agentlas 1.0.21 → 1.0.23

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.
@@ -48,7 +48,6 @@ class Renderer {
48
48
  }
49
49
  const lines = text.trim().split(/\r?\n/).slice(-12);
50
50
  for (const l of lines) this.ui.line(" " + l);
51
- if (session.lastError) this.ui.error(session.lastError);
52
51
  }
53
52
 
54
53
  _render(ev) {
@@ -57,7 +56,6 @@ class Renderer {
57
56
  case "turn-start": this._beginChrome(); return;
58
57
  case "turn-end":
59
58
  ui.endTurn();
60
- if (!ev.ok && ev.error) ui.error(ev.error);
61
59
  return;
62
60
  case "status": ui.status(ev.text); return;
63
61
  case "warn": ui.warn(ev.text); return;
@@ -65,7 +63,6 @@ class Renderer {
65
63
  // 사용자가 의도적으로 중단(kill)한 턴의 종료 에러(SIGTERM exit 등)는 소음 —
66
64
  // 실사용 테스트에서 확인된 UX 결함. 중단 안내는 REPL이 이미 출력했다.
67
65
  if (this.session && this.session.status === "killed") return;
68
- ui.error(ev.text);
69
66
  return;
70
67
  case "line": ui.line(ev.text); return;
71
68
  case "tool": ui.tool(ev.name, ev.summary); return;
@@ -104,12 +101,12 @@ class Renderer {
104
101
  ui.line(ui.c.dim(` ↳ agentlas automation list · agentlas automation off ${String(ev.id || "").slice(0, 8)}`));
105
102
  return;
106
103
  }
107
- case "automation-refused": ui.warn(`automation refused: ${ev.name} — ${ev.reason}`); return;
104
+ case "automation-refused": return;
108
105
  case "delegate-spawned":
109
106
  ui.ok(`delegate spawned: ${ev.target} → ${ev.key}`);
110
107
  return;
111
- case "delegate-refused": ui.warn(`delegate refused: ${ev.target} — ${ev.reason}`); return;
112
- case "fence-error": ui.error(`fence: ${ev.text}`); return;
108
+ case "delegate-refused": return;
109
+ case "fence-error": return;
113
110
  case "memory-curated":
114
111
  ui.line(ui.c.dim(` ↳ memory: ${ev.written}/${ev.candidates} written (permission: ${ev.permission})`));
115
112
  return;
@@ -1,32 +1,29 @@
1
1
  "use strict";
2
2
  /*
3
- * ui/repl — v2 REPL (Claude Code 방식 + 오르카 멀티세션).
3
+ * ui/repl — 프로젝트 Work 컨트롤러와 실행 트리.
4
4
  *
5
5
  * 원칙:
6
6
  * - 실행 경로는 orchestrator 하나. 포그라운드 턴도 세션이다.
7
7
  * - 화면은 활성 세션 하나만 스트리밍(Renderer). 백그라운드 턴 종료는 한 줄 notice.
8
8
  * - 실행 중 입력: 텍스트는 다음 턴 스티어링 큐로, ctrl-c는 현재 턴 중단.
9
9
  *
10
- * 세션 조종(오르카):
11
- * /spawn <agent> [task] 백그라운드 서브세션 생성(+실행)
10
+ * 실행 관찰:
12
11
  * /sessions · /tree 세션 표 / 부모-자식 트리
13
12
  * /s <n> · /switch <n> 활성 세션 전환 (tail 재생 + 라이브 구독)
14
- * /steer <n> <msg> 해당 세션 다음 턴 큐잉
15
13
  * /kill <n> · /rm <n> 턴 중단 / 세션 제거
16
- * /broadcast <msg> 전 세션에 동일 지시
14
+ * 서브에이전트 배정과 지시는 프로젝트 컨트롤러만 수행한다.
17
15
  */
18
16
  const readline = require("node:readline");
19
17
  const { renderBanner, readVersion } = require("../agentlas-banner.cjs");
20
18
  const { Orchestrator, maxParallel } = require("../sessions/orchestrator.cjs");
21
19
  const { Renderer } = require("./renderer.cjs");
22
- const { findAgent, listAgents } = require("../agents/registry.cjs");
20
+ const { listAgents } = require("../agents/registry.cjs");
23
21
  const { resolveRuntimeForAgent } = require("../runtimes/overrides.cjs");
24
22
  const { EFFORTS } = require("../agentlas-workload-routing.cjs");
25
23
  const permissions = require("../agentlas-permissions.cjs");
26
24
  const i18n = require("../agentlas-i18n.cjs");
27
25
  const { tokenizeCommandLine } = require("../agentlas-input.cjs");
28
-
29
- const DEFAULT_AGENT_SLUG = "agentlas-orchestrator";
26
+ const { resolveProjectController, withProjectControllerContext } = require("../project/controller.cjs");
30
27
 
31
28
  /*
32
29
  * Shift-Tab 권한 순환 — 배너(banner.location)와 permCycleHint/permFullArm/
@@ -83,12 +80,9 @@ function createPermissionShortcut(opts = {}) {
83
80
  };
84
81
  }
85
82
 
86
- function pickDefaultAgent(db) {
87
- const visible = listAgents(db);
88
- if (visible.length) return visible[0];
89
- const builtin = findAgent(db, DEFAULT_AGENT_SLUG);
90
- if (builtin) return builtin;
91
- return null;
83
+ function pickProjectController(db, cwd = process.cwd()) {
84
+ const resolved = resolveProjectController(db, cwd);
85
+ return withProjectControllerContext(resolved.controller, resolved.project);
92
86
  }
93
87
 
94
88
  async function startRepl(ctx, opts = {}) {
@@ -124,7 +118,7 @@ async function startRepl(ctx, opts = {}) {
124
118
  } catch (e) {
125
119
  // 온보딩 실패는 REPL 진입을 막지 않지만, 조용히 삼키면 마법사 프롬프트와
126
120
  // REPL이 stdin을 경합하는 사고(실사용 테스트에서 실증)가 위장된다 — 표시한다.
127
- ctx.err(ui.c.dim((en ? "setup wizard failed: " : "설정 마법사 실패: ") + String((e && e.message) || e)));
121
+ ctx.err(ui.c.dim(en ? "One is recovering setup." : "One이 설정을 복구하고 있습니다."));
128
122
  } finally {
129
123
  wizardRl.close();
130
124
  }
@@ -137,6 +131,21 @@ async function startRepl(ctx, opts = {}) {
137
131
  let modelOverride = opts.model || null;
138
132
  let effortOverride = opts.effort || null;
139
133
 
134
+ const recoverPresentation = (operation, error) => {
135
+ const active = orch.active();
136
+ const evidence = String((error && error.message) || error || "").slice(0, 8000);
137
+ if (active && !active.isBusy()) {
138
+ orch.sendTo(active.key, [
139
+ `The user operation “${operation}” did not complete.`,
140
+ "Private evidence follows. Never quote codes, paths, provider text, or stack details to the user.",
141
+ evidence,
142
+ "Judge the situation, perform safe reversible recovery within current authority, verify the original outcome, then give only a concise useful result. Ask only when identity or an irreversible choice is required.",
143
+ ].join("\n")).catch(() => {});
144
+ return;
145
+ }
146
+ ui.line(ui.c.dim(en ? "One is checking and recovering this operation." : "One이 상태를 확인하고 복구하고 있습니다."));
147
+ };
148
+
140
149
  const resolveRt = (agentId = null) => resolveRuntimeForAgent({
141
150
  db,
142
151
  prefs: ctx.prefs,
@@ -148,12 +157,12 @@ async function startRepl(ctx, opts = {}) {
148
157
  });
149
158
 
150
159
  let resumeChatId = opts.chatId || null;
151
- const ensureMainSession = (agentToken) => {
152
- const agent = agentToken ? findAgent(db, agentToken) : (orch.active() ? orch.active().agent : pickDefaultAgent(db));
160
+ const ensureMainSession = () => {
161
+ const agent = orch.active() ? orch.active().agent : pickProjectController(db);
153
162
  if (!agent) {
154
163
  throw new Error(en
155
- ? (agentToken ? `agent not found: ${agentToken}` : "no installed agent (agentlas search/install first)")
156
- : (agentToken ? `에이전트를 찾을 없음: ${agentToken}` : "설치된 에이전트가 없습니다 (agentlas search/install 먼저)"));
164
+ ? "This project has no available controller. Configure its ordered team in Desktop Work."
165
+ : "이 프로젝트에 실행 가능한 컨트롤러가 없습니다. Desktop Work에서 순서가 있는 팀을 설정하세요.");
157
166
  }
158
167
  const active = orch.active();
159
168
  if (active && active.agent.id === agent.id) return active;
@@ -181,13 +190,13 @@ async function startRepl(ctx, opts = {}) {
181
190
  try { runtimeLabel = resolveRt().kind; } catch { /* no_runtime: 첫 턴에서 정직 정지 */ }
182
191
  let subjectLabel;
183
192
  try {
184
- const subject = opts.agent ? findAgent(db, opts.agent) : pickDefaultAgent(db);
193
+ const subject = pickProjectController(db);
185
194
  if (subject) subjectLabel = subject.slug;
186
195
  } catch { /* 표시용 — 못 정해도 배너는 그린다 */ }
187
196
  renderBanner({ ui, version: readVersion(), runtimeLabel, subjectLabel, permission, cwd: process.cwd() });
188
197
  } catch (e) {
189
198
  ctx.out(`agentlas ${readVersion()}`);
190
- ctx.err(ui.c.dim(`banner failed: ${(e && e.message) || e}`));
199
+ void e;
191
200
  }
192
201
 
193
202
  // 배너 카드가 런타임·권한·작업 폴더와 명령 메뉴를 이미 보여준다 — 남은 것만.
@@ -195,15 +204,6 @@ async function startRepl(ctx, opts = {}) {
195
204
  ? `v2 engine · parallel ≤${maxParallel()}`
196
205
  : `v2 엔진 · 동시 ≤${maxParallel()}`));
197
206
 
198
- if (opts.agent) {
199
- try {
200
- const session = ensureMainSession(opts.agent);
201
- ctx.out(ui.c.dim(`agent: ${session.agent.slug} · ${session.runtime.kind}`));
202
- } catch (e) {
203
- ctx.err(String((e && e.message) || e));
204
- }
205
- }
206
-
207
207
  return new Promise((resolve) => {
208
208
  // 히스토리는 v1 input 모듈 재사용, 완성기는 v2 팔레트(ui/palette)가 정본이다.
209
209
  const input = require("../agentlas-input.cjs");
@@ -325,7 +325,7 @@ async function startRepl(ctx, opts = {}) {
325
325
  const runForeground = (session, text) => {
326
326
  const p = orch.sendTo(session.key, text);
327
327
  if (p && typeof p.then === "function") {
328
- p.then(() => prompt(), (e) => { ui.error(String((e && e.message) || e)); prompt(); });
328
+ p.then(() => prompt(), (e) => { recoverPresentation("project task", e); prompt(); });
329
329
  }
330
330
  };
331
331
 
@@ -350,14 +350,14 @@ async function startRepl(ctx, opts = {}) {
350
350
  });
351
351
  if (quit === "quit") { rl.close(); return; }
352
352
  } catch (e) {
353
- ui.error(String((e && e.message) || e));
353
+ recoverPresentation("command", e);
354
354
  }
355
355
  prompt();
356
356
  return;
357
357
  }
358
358
 
359
359
  if (input.startsWith("!")) {
360
- runShell(ctx, input.slice(1).trim(), permission).then(prompt, (e) => { ui.error(String((e && e.message) || e)); prompt(); });
360
+ runShell(ctx, input.slice(1).trim(), permission).then(prompt, (e) => { recoverPresentation("shell action", e); prompt(); });
361
361
  return;
362
362
  }
363
363
 
@@ -366,7 +366,7 @@ async function startRepl(ctx, opts = {}) {
366
366
  session = orch.active() || ensureMainSession(null);
367
367
  if (!renderer.session) renderer.attach(session, { replay: false });
368
368
  } catch (e) {
369
- ui.error(String((e && e.message) || e));
369
+ recoverPresentation("project startup", e);
370
370
  prompt();
371
371
  return;
372
372
  }
@@ -379,7 +379,7 @@ async function startRepl(ctx, opts = {}) {
379
379
  try {
380
380
  runForeground(session, input);
381
381
  } catch (e) {
382
- ui.error(String((e && e.message) || e));
382
+ recoverPresentation("project task", e);
383
383
  prompt();
384
384
  }
385
385
  });
@@ -473,7 +473,7 @@ async function runShell(ctx, cmd, permission) {
473
473
  ui.streamStart(true);
474
474
  child.stdout.on("data", onData);
475
475
  child.stderr.on("data", onData);
476
- child.on("error", (e) => { ui.streamEnd(); ui.error(e.message); resolve(); });
476
+ child.on("error", () => { ui.streamEnd(); ui.error(); resolve(); });
477
477
  child.on("close", (code) => {
478
478
  ui.streamEnd();
479
479
  if (code !== 0 && !capped) ui.line(ui.c.dim(`exit ${code}`));
@@ -499,7 +499,7 @@ function printSessions(ctx, orch) {
499
499
  const ui = ctx.uiInstance;
500
500
  const rows = orch.list();
501
501
  if (!rows.length) {
502
- ctx.out(ui.c.dim(ctx.lang === "en" ? "no sessions yet — type a task or /spawn <agent> <task>" : "세션 없음 — 작업을 입력하거나 /spawn <agent> <task>"));
502
+ ctx.out(ui.c.dim(ctx.lang === "en" ? "no project run yet — type a task" : "아직 프로젝트 실행이 없습니다 — 작업을 입력하세요"));
503
503
  return;
504
504
  }
505
505
  for (const r of rows) {
@@ -532,7 +532,7 @@ function handleSlash(ctx, cmdline, api) {
532
532
  * 인자는 따옴표를 인식해 쪼갠다. 공백 분해는 따옴표를 인자 안에 그대로 남겨,
533
533
  * 팔레트가 안내하는 그대로 `/search "무엇이 필요한지"` 를 치면 따옴표째 검색어가 됐다.
534
534
  * 최상위 CLI 와 같은 토크나이저를 쓴다. restStr 은 원문 꼬리를 그대로 넘기는 자리
535
- * (/spawn·/steer·/broadcast)라 계속 원문에서 자른다.
535
+ * 일부 패스스루 명령은 원문 꼬리를 그대로 전달한다.
536
536
  */
537
537
  const rawCmd = cmdline.split(/\s+/)[0] || "";
538
538
  const rest = tokenizeCommandLine(cmdline).slice(1);
@@ -546,7 +546,7 @@ function handleSlash(ctx, cmdline, api) {
546
546
  case "help": {
547
547
  require("../commands/help.cjs").run(ctx, rest);
548
548
  ctx.out("");
549
- ctx.out(ui.c.bold(en ? "In-REPL session control (Orca)" : "REPL 세션 조종 (오르카)"));
549
+ ctx.out(ui.c.bold(en ? "Project Work runs" : "프로젝트 Work 실행"));
550
550
  // 팔레트는 Tab 완성과 같은 정본(ui/palette)에서 렌더한다 — 목록 드리프트 금지.
551
551
  ctx.out(require("./palette.cjs").renderPalette(ctx.lang));
552
552
  ctx.out("");
@@ -558,7 +558,6 @@ function handleSlash(ctx, cmdline, api) {
558
558
  return;
559
559
  }
560
560
  case "agents": case "list": require("../commands/list.cjs").run(ctx, rest); return;
561
- case "chats": require("../commands/chats.cjs").run(ctx, rest); return;
562
561
  case "doctor": require("../commands/doctor.cjs").run(ctx, rest); return;
563
562
  case "mcp": require("../commands/mcp.cjs").run(ctx, rest); return;
564
563
 
@@ -571,36 +570,6 @@ function handleSlash(ctx, cmdline, api) {
571
570
  return;
572
571
  }
573
572
 
574
- case "spawn": {
575
- const agentToken = rest[0];
576
- if (!agentToken) throw new Error("Usage: /spawn <agent> [task]");
577
- const agent = findAgent(ctx.db(), agentToken);
578
- if (!agent) throw new Error((en ? "agent not found: " : "에이전트를 찾을 수 없음: ") + agentToken);
579
- const task = restStr.slice(agentToken.length).trim();
580
- const parent = orch.active();
581
- const session = orch.spawn({
582
- agent,
583
- runtime: parent ? parent.runtime : api.resolveRt(),
584
- permission: api.getPermission(),
585
- cwd: process.cwd(),
586
- parentKey: parent ? parent.key : null,
587
- activate: false,
588
- title: task ? `sub: ${task.slice(0, 60)}` : undefined,
589
- });
590
- if (task) orch.sendTo(session.key, task);
591
- ctx.out(ui.c.dim(`${session.key} ${agent.slug} ${task ? (en ? "started" : "시작됨") : (en ? "ready (use /steer)" : "대기 (—/steer 로 지시)")}`));
592
- return;
593
- }
594
-
595
- case "steer": {
596
- const key = sessionKeyArg(rest, `Usage: /${cmd} <n> <message>`);
597
- const msg = restStr.slice(rest[0].length).trim();
598
- if (!msg) throw new Error("Usage: /steer <n> <message>");
599
- orch.sendTo(key, msg);
600
- ctx.out(ui.c.dim(`→ ${key}`));
601
- return;
602
- }
603
-
604
573
  case "kill": {
605
574
  const key = sessionKeyArg(rest, `Usage: /${cmd} <n>`);
606
575
  orch.kill(key);
@@ -614,27 +583,6 @@ function handleSlash(ctx, cmdline, api) {
614
583
  return;
615
584
  }
616
585
 
617
- case "broadcast": {
618
- if (!restStr) throw new Error("Usage: /broadcast <message>");
619
- /*
620
- * broadcast 는 {sent, skipped} 를 준다. 전달된 목록을 먼저 찍고 못 보낸 세션은
621
- * 사유와 함께 경고로 덧붙인다 — 예전엔 상한 throw 가 이 catch 로 떨어져 에러 한
622
- * 줄만 보였고, 이미 지시를 받아 돌기 시작한 세션이 화면에 전혀 안 나왔다.
623
- */
624
- const { sent, skipped } = orch.broadcast(restStr);
625
- ctx.out(ui.c.dim(`→ ${sent.join(", ") || "(none)"}`));
626
- for (const s of skipped) ui.warn(`${s.key} ${en ? "not sent" : "미전송"}: ${s.error}`);
627
- return;
628
- }
629
-
630
- case "use": {
631
- if (!rest[0]) throw new Error("Usage: /use <agent>");
632
- const session = ensureMainSession(rest[0]);
633
- renderer.attach(session, { replay: false });
634
- ctx.out(ui.c.dim(`agent: ${session.agent.slug}`));
635
- return;
636
- }
637
-
638
586
  case "runtime": {
639
587
  if (!rest[0]) throw new Error("Usage: /runtime claude-code|codex|gemini");
640
588
  api.setRuntime(rest[0]);
@@ -679,8 +627,8 @@ function handleSlash(ctx, cmdline, api) {
679
627
  * 제외: 자기 자신을 다시 여는 대화형 명령(chat/open/firm/setup)과 run
680
628
  * (REPL의 평문 입력이 곧 run이다).
681
629
  */
682
- // help/agents/list/chats/mcp/doctor 등은 위 케이스에서 이미 처리된다.
683
- const REPL_EXCLUDED = new Set(["chat", "open", "firm", "setup", "run"]);
630
+ // help/agents/list/mcp/doctor 등은 위 케이스에서 이미 처리된다.
631
+ const REPL_EXCLUDED = new Set(["firm", "setup", "run"]);
684
632
  if (!REPL_EXCLUDED.has(cmd) && commands.COMMANDS[cmd]) {
685
633
  const result = commands.COMMANDS[cmd]().run(ctx, rest);
686
634
  if (result && typeof result.then === "function") {
@@ -32,7 +32,7 @@ const path = require("node:path");
32
32
  const { userDataDir } = require("../core/paths.cjs");
33
33
  const hubClient = require("../cloud/hub-client.cjs");
34
34
  const detect = require("../runtimes/detect.cjs");
35
- const { resolvedModelRole } = require("../runtimes/roles.cjs");
35
+ const { roleMembers } = require("../runtimes/roles.cjs");
36
36
  const capture = require("./capture.cjs");
37
37
 
38
38
  // ── 런타임 해석 (v1 resolveRuntime의 워크포스 어댑터) ─────────────────────
@@ -115,8 +115,7 @@ function legacyWorkforceRuntime(db, override) {
115
115
  throw error;
116
116
  }
117
117
 
118
- // 사다리: 명시 override > model_roles[role] > 레거시 prefs/active/PATH.
119
- // worker가 비어 있거나 inherit=1이면 roles.cjs가 orchestrator로만 승격한다.
118
+ // 명시 override 또는 Dashboard에 저장된 role priority만 실행 권위다.
120
119
  function resolveWorkforceRuntime(db, override) {
121
120
  if (override) {
122
121
  const exact = legacyWorkforceRuntime(db, override);
@@ -129,9 +128,13 @@ function resolveWorkforceRuntime(db, override) {
129
128
  },
130
129
  };
131
130
  }
132
- const fallback = legacyWorkforceRuntime(db, null);
133
- const orchestrator = runtimeFromSelection(resolvedModelRole(db, "orchestrator")) || fallback;
134
- const worker = runtimeFromSelection(resolvedModelRole(db, "worker")) || orchestrator;
131
+ const orchestrator = runtimeFromSelection(roleMembers(db, "orchestrator")[0] || null);
132
+ const worker = runtimeFromSelection(roleMembers(db, "worker")[0] || null);
133
+ if (!orchestrator || !worker) {
134
+ const error = new Error("Dashboard orchestrator and worker priorities are required for Workforce execution.");
135
+ error.code = "no_runtime_role_priority";
136
+ throw error;
137
+ }
135
138
  return {
136
139
  ...orchestrator,
137
140
  role: "orchestrator",
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "1.0.21",
4
- "description": "Agentlas agent terminal — chat with your installed AI agents and teams from the terminal, Claude Code style. Standalone: no desktop app required.",
3
+ "version": "1.0.23",
4
+ "description": "Agentlas project terminal — run project controllers and task-scoped agent teams from the terminal. Standalone: no desktop app process required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"
7
7
  },
8
8
  "scripts": {
9
9
  "smoke": "sh test/smoke.sh",
10
- "test:release-contracts": "npm run smoke"
10
+ "test:release-contracts": "npm run smoke",
11
+ "sync:architecture": "node scripts/sync-architecture-from-desktop.cjs"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=20"
@@ -1,224 +0,0 @@
1
- "use strict";
2
- /*
3
- * agentlas-doctor: 런타임 CLI 실패의 "시스템 원인"을 결정론적으로 진단·수리한다.
4
- *
5
- * 데스크탑 앱 electron/system-agents/runtime-doctor.ts 와 로직 패리티를 유지해야 한다
6
- * (3제품 싱크: 데스크탑 TS ↔ 터미널 CJS ↔ system-optimizer 패키지 플레이북).
7
- * 패리티는 Agentlas_F/scripts/sync-runtime-doctor.sh 가 공유 픽스처로 검증한다 —
8
- * 이 파일의 분류/수리 규칙을 바꾸면 반드시 그 스크립트를 PASS 시켜라.
9
- *
10
- * 사례(2026-07-08): codex CLI 업데이트가 openai-curated 플러그인(notion/figma)을 자동
11
- * 활성화 → 미인증 OAuth 원격 MCP가 매 실행 AuthRequired fatal → codex exit 1.
12
- * 수리: 에러 stderr의 호스트와 플러그인 캐시 .mcp.json url 호스트를 대조해 "정확히 그
13
- * 플러그인만" config.toml에서 enabled=false (백업 필수, 인증돼 잘 도는 플러그인 오폭 금지).
14
- */
15
- const fs = require("node:fs");
16
- const os = require("node:os");
17
- const path = require("node:path");
18
-
19
- function codexHome() {
20
- return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
21
- }
22
-
23
- /**
24
- * 에러 텍스트에서 OAuth 자원 메타데이터로 명시된 호스트만 추출한다.
25
- * stderr의 모든 URL을 수집하면 도움말·문서 링크와 관련된 정상 플러그인까지
26
- * 수리 대상이 될 수 있다. 자동 수리는 구조화된 증거가 있을 때만 실행한다.
27
- */
28
- function extractHosts(error) {
29
- const hosts = new Set();
30
- const addUrlHost = (rawUrl) => {
31
- try {
32
- hosts.add(new URL(rawUrl.replace(/[\]}>),.;]+$/g, "")).hostname.toLowerCase());
33
- } catch {
34
- /* 잘못된 메타데이터 URL은 자동 수리 증거로 쓰지 않음 */
35
- }
36
- };
37
- const patterns = [
38
- /resource_metadata(?:_url)?\s*[:=]\s*\\?["']?(https?:\/\/[^\s"'\\)>,]+)/gi,
39
- /(https?:\/\/[^\s"'\\)>,]+\/\.well-known\/oauth-protected-resource(?:[/?#][^\s"'\\)>,]*)?)/gi,
40
- ];
41
- for (const re of patterns) {
42
- let m;
43
- while ((m = re.exec(error || "")) !== null) addUrlHost(m[1]);
44
- }
45
- return [...hosts];
46
- }
47
-
48
- /** 에러 텍스트에서 config.toml 스키마 위반이 지목한 mcp_servers 이름을 뽑는다. */
49
- function extractBadMcpServer(error) {
50
- // 예: Error loading config.toml: url is not supported for stdio in "mcp_servers.agentlas"
51
- const m = /mcp_servers\.([a-z0-9_.-]+)/i.exec(error || "");
52
- return m ? m[1] : null;
53
- }
54
-
55
- /** kind: mcp-oauth-unauthenticated | codex-config-invalid | timeout | cli-exit | unknown (데스크탑 TS와 동일 규칙) */
56
- function classifyFailure(error) {
57
- const text = error || "";
58
- if (/authrequired|invalid_token|oauth-protected-resource|www_authenticate/i.test(text)) {
59
- return { kind: "mcp-oauth-unauthenticated", hosts: extractHosts(text) };
60
- }
61
- // codex config.toml 파싱 실패(예: stdio 서버에 url 키) → CLI가 아예 기동 못 함.
62
- if (/error loading config\.toml|url is not supported for stdio|invalid config/i.test(text)) {
63
- return { kind: "codex-config-invalid", hosts: [], badServer: extractBadMcpServer(text) };
64
- }
65
- if (/no response for \d+s|auto-aborted/i.test(text)) return { kind: "timeout", hosts: [] };
66
- if (/(?:CLI exit|exited with code)\s+[1-9]\d*/i.test(text)) return { kind: "cli-exit", hosts: extractHosts(text) };
67
- return { kind: "unknown", hosts: [] };
68
- }
69
-
70
- /** 실패 호스트와 일치하는 OAuth MCP를 실은 플러그인 찾기(호스트가 에러에 등장한 것만). */
71
- function findOauthPluginsByHost(hosts) {
72
- if (!hosts.length) return [];
73
- const cacheRoot = path.join(codexHome(), "plugins", "cache");
74
- const hits = [];
75
- let marketplaces = [];
76
- try {
77
- marketplaces = fs.readdirSync(cacheRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
78
- } catch {
79
- return [];
80
- }
81
- for (const marketplace of marketplaces) {
82
- let plugins = [];
83
- try {
84
- plugins = fs.readdirSync(path.join(cacheRoot, marketplace), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
85
- } catch {
86
- continue;
87
- }
88
- for (const plugin of plugins) {
89
- let versions = [];
90
- try {
91
- versions = fs.readdirSync(path.join(cacheRoot, marketplace, plugin), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
92
- } catch {
93
- continue;
94
- }
95
- for (const ver of versions) {
96
- const mcpJson = path.join(cacheRoot, marketplace, plugin, ver, ".mcp.json");
97
- if (!fs.existsSync(mcpJson)) continue;
98
- try {
99
- const parsed = JSON.parse(fs.readFileSync(mcpJson, "utf8"));
100
- for (const server of Object.values(parsed.mcpServers || {})) {
101
- if (!server || !server.url) continue;
102
- let host = "";
103
- try {
104
- host = new URL(server.url).hostname.toLowerCase();
105
- } catch {
106
- continue;
107
- }
108
- // 호스트가 정확히 같을 때만 자동 수리한다. 부모/자식 도메인 관계만으로는
109
- // 어느 플러그인이 실패했는지 증명할 수 없다.
110
- if (hosts.includes(host)) {
111
- // cache 디렉토리 "openai-curated-remote"는 config 키에선 "openai-curated".
112
- hits.push({ pluginKey: `${plugin}@${marketplace.replace(/-remote$/, "")}`, host });
113
- }
114
- }
115
- } catch {
116
- /* 손상된 .mcp.json은 건너뜀 */
117
- }
118
- }
119
- }
120
- }
121
- const seen = new Set();
122
- return hits.filter((h) => (seen.has(h.pluginKey) ? false : (seen.add(h.pluginKey), true)));
123
- }
124
-
125
- /** config.toml에서 해당 플러그인을 enabled=false로 내린다(백업 필수). 반환: 실제 변경 여부. */
126
- function disableCodexPlugin(pluginKey) {
127
- const configPath = path.join(codexHome(), "config.toml");
128
- if (!fs.existsSync(configPath)) return false;
129
- const original = fs.readFileSync(configPath, "utf8");
130
- const header = `[plugins."${pluginKey}"]`;
131
- let next;
132
- const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
- const headerMatch = new RegExp(`^[ \\t]*${escapeRegExp(header)}[ \\t]*(?:#.*)?\\r?$`, "m").exec(original);
134
- if (headerMatch) {
135
- const headerLineEnd = headerMatch.index + headerMatch[0].length;
136
- const newlineIndex = original.indexOf("\n", headerLineEnd);
137
- const bodyStart = newlineIndex === -1 ? original.length : newlineIndex + 1;
138
- const remaining = original.slice(bodyStart);
139
- const nextSection = /^[ \t]*\[[^\r\n]+\][ \t]*(?:#.*)?\r?$/m.exec(remaining);
140
- const sectionEnd = nextSection ? bodyStart + nextSection.index : original.length;
141
- const section = original.slice(headerMatch.index, sectionEnd);
142
- const replacedSection = section.replace(
143
- /^([ \t]*enabled[ \t]*=[ \t]*)true([ \t]*(?:#.*)?)(?=\r?$)/m,
144
- "$1false$2",
145
- );
146
- if (replacedSection === section) return false; // 이미 false거나 해당 섹션에 enabled=true가 없음
147
- next = original.slice(0, headerMatch.index) + replacedSection + original.slice(sectionEnd);
148
- } else {
149
- next = `${original.trimEnd()}\n\n${header}\nenabled = false\n`;
150
- }
151
- const backup = `${configPath}.bak-doctor-${new Date().toISOString().replace(/[:.]/g, "-")}`;
152
- fs.copyFileSync(configPath, backup);
153
- fs.writeFileSync(configPath, next);
154
- return true;
155
- }
156
-
157
- /**
158
- * 진단 + (아는 계열이면) 즉시 수리. 반환: { kind, summary, repaired, actions[] }
159
- * 데스크탑 runtime-doctor.ts 의 runRuntimeDoctor 와 동일 계약.
160
- */
161
- function runRuntimeDoctor(errorMessage) {
162
- const { kind, hosts, badServer } = classifyFailure(errorMessage);
163
- const actions = [];
164
-
165
- if (kind === "codex-config-invalid") {
166
- // 진단만 한다(자동 수리 안 함): 원격 MCP 항목이라 안전한 무손실 수리가 없고,
167
- // 사용자가 어느 항목을 어떻게 고칠지 알아야 한다. 명확한 조치를 안내한다.
168
- const where = badServer ? `[mcp_servers.${badServer}]` : "일부 [mcp_servers.*] 항목";
169
- return {
170
- kind,
171
- summary: `codex의 ~/.codex/config.toml ${where} 이(가) 잘못돼 codex가 기동하지 못합니다(예: stdio 서버에 url 키). 다른 런타임(--runtime claude-code)으로 우회하거나 그 항목을 고치세요.`,
172
- repaired: false,
173
- actions,
174
- };
175
- }
176
-
177
- if (kind === "mcp-oauth-unauthenticated") {
178
- const hitList = findOauthPluginsByHost(hosts);
179
- let repairedAny = false;
180
- for (const hit of hitList) {
181
- try {
182
- if (disableCodexPlugin(hit.pluginKey)) {
183
- repairedAny = true;
184
- actions.push({
185
- title: `codex plugin disabled: ${hit.pluginKey}`,
186
- detail: `미인증 OAuth MCP(${hit.host})가 런타임을 죽여서 ~/.codex/config.toml에서 비활성화했습니다(백업 생성). 이 서비스를 쓰려면 인증 후 다시 켜세요.`,
187
- });
188
- }
189
- } catch (err) {
190
- actions.push({ title: `repair failed: ${hit.pluginKey}`, detail: err && err.message ? err.message : String(err) });
191
- }
192
- }
193
- return {
194
- kind,
195
- summary: hitList.length
196
- ? `런타임에 미인증 OAuth MCP 플러그인(${hitList.map((h) => h.pluginKey).join(", ")})이 붙어 있어 CLI가 죽었습니다.`
197
- : `An unauthenticated OAuth MCP (${hosts.join(", ") || "unknown host"}) crashed the runtime, but the responsible plugin could not be identified.`,
198
- repaired: repairedAny,
199
- actions,
200
- };
201
- }
202
-
203
- if (kind === "timeout") {
204
- return {
205
- kind,
206
- summary: "실행이 장시간 무응답이라 자동 중단됐습니다. 대화형 인증 대기·stdin 블록·원격 MCP 행이 흔한 원인입니다.",
207
- repaired: false,
208
- actions,
209
- };
210
- }
211
-
212
- if (kind === "cli-exit") {
213
- return {
214
- kind,
215
- summary: "런타임 CLI가 비정상 종료했지만 아는 수리 계열이 아닙니다.",
216
- repaired: false,
217
- actions,
218
- };
219
- }
220
-
221
- return { kind: "unknown", summary: "", repaired: false, actions };
222
- }
223
-
224
- module.exports = { classifyFailure, extractHosts, findOauthPluginsByHost, disableCodexPlugin, runRuntimeDoctor };
@@ -1,12 +0,0 @@
1
- "use strict";
2
- /* chat — 에이전트 지정 REPL 진입 별칭: agentlas chat <agent> (= agentlas <agent>) */
3
- function run(ctx, args) {
4
- const ko = ctx.lang === "ko";
5
- if (!args[0]) {
6
- ctx.err(ko ? "사용법: agentlas chat <agent>" : "Usage: agentlas chat <agent>");
7
- return 1;
8
- }
9
- const { startRepl } = require("../ui/repl.cjs");
10
- return startRepl(ctx, { agent: args[0] });
11
- }
12
- module.exports = { run };
@@ -1,33 +0,0 @@
1
- "use strict";
2
- /* chats [n] — 최근 대화 목록 (공유 DB, 데스크탑과 동일 데이터). */
3
-
4
- function run(ctx, args) {
5
- const n = Math.max(1, Math.min(100, Number(args[0]) || 15));
6
- const db = ctx.db();
7
- if (!ctx.tableExists(db, "chats")) {
8
- ctx.out(ctx.lang === "en" ? "No chats yet." : "대화가 아직 없습니다.");
9
- return 0;
10
- }
11
- const rows = db.prepare(
12
- `SELECT c.id, c.title, c.updated_at, a.slug AS agent_slug
13
- FROM chats c LEFT JOIN installed_agents a ON a.id = c.agent_id
14
- WHERE c.archived_at IS NULL AND (c.kind IS NULL OR c.kind = 'user')
15
- ORDER BY c.updated_at DESC LIMIT ?`,
16
- ).all(n);
17
- if (!rows.length) {
18
- ctx.out(ctx.lang === "en" ? "No chats yet." : "대화가 아직 없습니다.");
19
- return 0;
20
- }
21
- for (const r of rows) {
22
- const when = String(r.updated_at || "").replace("T", " ").slice(0, 16);
23
- // id 앞 8자를 먼저 찍는다 — `open <id 앞부분>`이 유일한 재개 경로인데
24
- // 목록에 id가 없으면 SQLite를 직접 열지 않는 한 재개가 불가능했다.
25
- // 8자는 open.cjs의 모호성 안내(id.slice(0,8))와 같은 규약.
26
- const id = String(r.id || "").slice(0, 8);
27
- ctx.out(` ${ctx.ui.bold(id)} ${ctx.ui.dim(when)} ${ctx.ui.accent((r.agent_slug || "?").padEnd(20))} ${r.title}`);
28
- }
29
- ctx.out(ctx.ui.dim(ctx.lang === "en" ? " resume: agentlas open <id>" : " 재개: agentlas open <id>"));
30
- return 0;
31
- }
32
-
33
- module.exports = { run };