agentlas 1.0.36 → 1.0.38

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.
@@ -36,7 +36,13 @@ function create(deps) {
36
36
  const D = deps;
37
37
  const { swarmRun } = require("./swarm.cjs").create(D);
38
38
 
39
+ /*
40
+ * pi-tui 이행 정지작업 (D3 Phase 1-2, 2026-08-11): ctx가 준 Ui가 있으면 그것을
41
+ * 쓴다. 자체 생성 Ui는 렌더러 교체 시 구 코드가 stdout에 직접 써 프레임을
42
+ * 찢는 병렬 경로였다. 생성은 주입이 없을 때의 폴백으로만 남긴다.
43
+ */
39
44
  function newUi(lang) {
45
+ if (D.uiInstance) return D.uiInstance;
40
46
  return new Ui({ lang: lang || D.prefsLang() });
41
47
  }
42
48
 
@@ -78,7 +84,7 @@ function create(deps) {
78
84
  return { ok: false };
79
85
  }
80
86
  if (goal.startsWith("-")) {
81
- ui.error("goal cannot start with '-'.");
87
+ ui.error("goal cannot start with '-'.", { reveal: true });
82
88
  return { ok: false };
83
89
  }
84
90
  const cwd = ctx.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd());
@@ -86,7 +92,7 @@ function create(deps) {
86
92
  try {
87
93
  executionHarness = await loadCoreStormbreakerHarness(cwd);
88
94
  } catch (error) {
89
- ui.error(`Stormbreaker Core harness unavailable: ${String((error && error.message) || error).slice(0, 400)}`);
95
+ ui.error(`Stormbreaker Core harness unavailable: ${String((error && error.message) || error).slice(0, 400)}`, { reveal: true });
90
96
  return { ok: false, error: "stormbreaker-core-harness-unavailable" };
91
97
  }
92
98
  const args = ["route", goal, "--project", cwd, "--runtime", "terminal"];
@@ -127,7 +127,13 @@ function parseSwarmOutput(text) {
127
127
  function create(deps) {
128
128
  const D = deps;
129
129
 
130
+ /*
131
+ * pi-tui 이행 정지작업 (D3 Phase 1-2, 2026-08-11): ctx가 준 Ui가 있으면 그것을
132
+ * 쓴다. 자체 생성 Ui는 렌더러 교체 시 구 코드가 stdout에 직접 써 프레임을
133
+ * 찢는 병렬 경로였다. 생성은 주입이 없을 때의 폴백으로만 남긴다.
134
+ */
130
135
  function newUi(lang) {
136
+ if (D.uiInstance) return D.uiInstance;
131
137
  return new Ui({ lang: lang || D.prefsLang() });
132
138
  }
133
139
 
@@ -146,7 +152,7 @@ function create(deps) {
146
152
  // Core 하네스 없이 stormbreaker 모드 진입 금지 — 로컬 모조 프롬프트로 대체하는
147
153
  // 것은 계약 위반이다(모델 실행 전에 실패해야 한다).
148
154
  if (stormbreaker && (!executionHarness || typeof executionHarness.system_prompt !== "string")) {
149
- ui.error("Stormbreaker requires the canonical Goal + UltraCode harness from Agentlas Core.");
155
+ ui.error("Stormbreaker requires the canonical Goal + UltraCode harness from Agentlas Core.", { reveal: true });
150
156
  return { ok: false, error: "stormbreaker-core-harness-unavailable" };
151
157
  }
152
158
  const coreHarnessPrompt = executionHarness && executionHarness.system_prompt;
@@ -307,7 +313,7 @@ function create(deps) {
307
313
  if (!planned) {
308
314
  ui.error(ui.lang === "ko"
309
315
  ? "플래너가 유효한 실행 계획(JSON)을 내지 못했습니다 — 정지합니다 (조용한 폴백 금지)."
310
- : "The planner did not produce a valid plan JSON — stopping (no silent fallback).");
316
+ : "The planner did not produce a valid plan JSON — stopping (no silent fallback).", { reveal: true });
311
317
  return { ok: false, reason: "invalid_plan_json" };
312
318
  }
313
319
  if (planned) {
@@ -376,7 +382,7 @@ function create(deps) {
376
382
  ui.line("");
377
383
  ui.info(`tasks: ${tasks.length} · done: ${done.length} · failed: ${failed}`);
378
384
  if (!done.length) {
379
- ui.error(ui.lang === "ko" ? "스웜이 완료한 작업이 없습니다." : "The swarm completed no work.");
385
+ ui.error(ui.lang === "ko" ? "스웜이 완료한 작업이 없습니다." : "The swarm completed no work.", { reveal: true });
380
386
  return { ok: false };
381
387
  }
382
388
 
@@ -411,7 +417,7 @@ function create(deps) {
411
417
  );
412
418
  } catch (e) {
413
419
  ui.stopSpinner();
414
- ui.error("Synthesis failed: " + String((e && e.message) || e).slice(0, 200));
420
+ ui.error("Synthesis failed: " + String((e && e.message) || e).slice(0, 200), { reveal: true });
415
421
  finalText = pieces;
416
422
  }
417
423
  ui.stopSpinner();
@@ -435,7 +441,7 @@ function create(deps) {
435
441
  const strayFlag = rest.find((token) => String(token).startsWith("-"));
436
442
  if (strayFlag) {
437
443
  const ui = executionContext.ui || newUi();
438
- ui.error(`unknown option ${strayFlag} — swarm accepts: --parallel N | -n N, --runtime <kind>`);
444
+ ui.error(`unknown option ${strayFlag} — swarm accepts: --parallel N | -n N, --runtime <kind>`, { reveal: true });
439
445
  process.exitCode = 1;
440
446
  return { ok: false, error: "unknown-option" };
441
447
  }
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ /*
3
+ * ui/pitui-shell — pi-tui 기반 실험 셸 (D3 Phase 2 증분 1, 2026-08-11).
4
+ *
5
+ * 켜는 법: AGENTLAS_TUI=pi agentlas (TTY 필수 · 기본 REPL은 그대로 정본)
6
+ *
7
+ * 설계 (D2 위험 5의 해법이 이 파일의 구조다):
8
+ * - PiUi 는 기존 Ui 를 상속하되 write() 초크포인트만 pi-tui 트랜스크립트로 돌린다.
9
+ * line/_message/tool/rule 등 기존 메서드는 전부 write 로 수렴하므로 그대로 산다.
10
+ * - 스트리밍 3종은 Markdown 누적으로 교체 — 표·코드블록이 실시간 재렌더된다.
11
+ * - 스피너는 pi-tui Loader 로 교체 (기존 \r 기반 페인트는 dummy 스트림으로 무해화).
12
+ * - ctx.out/err 55파일의 직출력은 shellCtx 재지정으로 전부 프레임 안에 들어온다.
13
+ * - 자동완성은 ui/palette 정본(SLASH_COMMANDS)을 pi-tui SlashCommand 로 변환 — 목록 드리프트 금지.
14
+ *
15
+ * 증분 1 범위 밖(기본 REPL로): Shift-Tab 권한 순환 · ! 셸 · 세션 전환(/s) ·
16
+ * 히스토리 디스크 영속 · 스티어링 큐 표시. 이 항목들은 D3 Phase 2-2에서 이전한다.
17
+ */
18
+ const { PassThrough } = require("node:stream");
19
+ const { Ui } = require("../agentlas-ui.cjs");
20
+ const { Orchestrator, maxParallel } = require("../sessions/orchestrator.cjs");
21
+ const { Renderer } = require("./renderer.cjs");
22
+ const { resolveRuntimeForAgent } = require("../runtimes/overrides.cjs");
23
+ const permissions = require("../agentlas-permissions.cjs");
24
+ const palette = require("./palette.cjs");
25
+ const { readVersion } = require("../agentlas-banner.cjs");
26
+ const { resolveProjectController, withProjectControllerContext } = require("../project/controller.cjs");
27
+
28
+ function loadPiTui() {
29
+ try {
30
+ // ESM 패키지 — Node >=20.19 의 require(esm). engines 가 이 최소선을 선언한다.
31
+ return require("@earendil-works/pi-tui");
32
+ } catch (cause) {
33
+ throw Object.assign(
34
+ new Error("pi-tui shell needs Node >=20.19 (require(esm)). Run without AGENTLAS_TUI=pi, or upgrade Node."),
35
+ { code: "pitui_unavailable", cause },
36
+ );
37
+ }
38
+ }
39
+
40
+ /* Ui 를 상속해 write 초크포인트만 pi-tui 로 돌린다. */
41
+ class PiUi extends Ui {
42
+ constructor(opts, pi, tui, transcript) {
43
+ // 실터미널 대신 dummy 스트림 — 놓친 직접 out.write 가 프레임을 찢는 대신 소멸한다.
44
+ super({ ...opts, stream: new PassThrough(), color: true });
45
+ this._pi = pi;
46
+ this._tui = tui;
47
+ this._transcript = transcript;
48
+ this._lineBuf = "";
49
+ this._md = null; // 스트리밍 중 Markdown 누적 컴포넌트
50
+ this._mdText = "";
51
+ this._loader = null;
52
+ }
53
+ _appendText(text) {
54
+ this._transcript.addChild(new this._pi.Text(String(text), 1, 0));
55
+ this._tui.requestRender();
56
+ }
57
+ write(s) {
58
+ // line()/tool()/rule()/_message() 전부 여기로 수렴한다.
59
+ this._lineBuf += String(s);
60
+ let idx;
61
+ while ((idx = this._lineBuf.indexOf("\n")) !== -1) {
62
+ this._appendText(this._lineBuf.slice(0, idx));
63
+ this._lineBuf = this._lineBuf.slice(idx + 1);
64
+ }
65
+ this._atLineStart = this._lineBuf === "";
66
+ }
67
+ ensureNl() {
68
+ if (this._lineBuf) { this._appendText(this._lineBuf); this._lineBuf = ""; }
69
+ this._atLineStart = true;
70
+ }
71
+ // ── 스피너 → Loader ──
72
+ updateSpinner(text) {
73
+ this._spinText = String(text || this._spinText || "");
74
+ if (!this._loader) {
75
+ this._loader = new this._pi.Loader(this._tui, this.c.emerald, this.c.dim, this._spinText);
76
+ this._transcript.addChild(this._loader);
77
+ } else {
78
+ this._loader.setMessage(this._spinText);
79
+ }
80
+ this._tui.requestRender();
81
+ }
82
+ stopSpinner() {
83
+ if (this._loader) {
84
+ this._loader.stop();
85
+ this._transcript.removeChild(this._loader);
86
+ this._loader = null;
87
+ this._tui.requestRender();
88
+ }
89
+ this._spinText = "";
90
+ }
91
+ // ── 스트리밍 → Markdown 누적 (표·코드블록 실시간 재렌더) ──
92
+ streamStart() {
93
+ this.stopSpinner();
94
+ this.ensureNl();
95
+ this._mdText = "";
96
+ this._md = new this._pi.Markdown("", 3, 0, this._mdTheme());
97
+ this._transcript.addChild(this._md);
98
+ this._streaming = true;
99
+ }
100
+ streamDelta(text) {
101
+ if (!text) return;
102
+ if (!this._md) this.streamStart();
103
+ this._mdText += String(text);
104
+ /*
105
+ * Memory Events 봉투는 런타임 계약(펜스 파이프라인이 수확)이지 사용자용이 아니다.
106
+ * append-only 기본 REPL은 이미 찍힌 봉투를 지울 수 없지만, 누적 재렌더는
107
+ * 표시만 잘라낼 수 있다 — 수확 경로(st.text/fences)는 건드리지 않는다.
108
+ */
109
+ const visible = this._mdText.replace(/\n#{1,3} Memory Events\b[\s\S]*$/, "\n");
110
+ this._md.setText(visible);
111
+ this._tui.requestRender();
112
+ this._streaming = true;
113
+ }
114
+ streamEnd() {
115
+ this._md = null;
116
+ this._mdText = "";
117
+ this._streaming = false;
118
+ }
119
+ _mdTheme() {
120
+ const c = this.c;
121
+ return {
122
+ heading: (s) => c.bold(c.emerald(s)), link: c.blue, linkUrl: c.dim,
123
+ code: c.amber, codeBlock: c.green, codeBlockBorder: c.faint,
124
+ quote: c.dim, quoteBorder: c.faint, hr: c.faint, listBullet: c.emerald,
125
+ bold: c.bold, italic: c.italic, strikethrough: c.dim, underline: c.underline,
126
+ };
127
+ }
128
+ }
129
+
130
+ function toSlashCommands(lang) {
131
+ // 팔레트 정본 → pi-tui SlashCommand. "/" 접두는 pi-tui 가 관리하므로 벗긴다.
132
+ return palette.SLASH_COMMANDS.map((cmd) => ({
133
+ name: cmd.command.slice(1),
134
+ description: lang === "ko" ? cmd.ko : cmd.en,
135
+ argumentHint: cmd.args || undefined,
136
+ }));
137
+ }
138
+
139
+ async function startPiShell(ctx, opts = {}) {
140
+ const pi = loadPiTui();
141
+ const en = ctx.lang === "en";
142
+ const db = ctx.db();
143
+
144
+ const terminal = new pi.ProcessTerminal();
145
+ const tui = new pi.TuiMainScreen(terminal);
146
+ const ui = new PiUi({ lang: ctx.lang }, pi, tui, tui);
147
+
148
+ // ctx 초크포인트 재지정 — 55파일의 ctx.out 직출력이 전부 프레임 안으로 들어온다.
149
+ const shellCtx = {
150
+ ...ctx,
151
+ uiInstance: ui,
152
+ out: (s = "") => ui.line(String(s)),
153
+ err: (s = "") => ui.line(ui.c.amber(String(s))),
154
+ };
155
+
156
+ const orch = new Orchestrator({ db, lang: ctx.lang });
157
+ const renderer = new Renderer(ui);
158
+ let permission = permissions.normalize(opts.permission || (ctx.prefs && ctx.prefs.permission) || "write");
159
+
160
+ const resolveRt = (agentId = null) => resolveRuntimeForAgent({
161
+ db, prefs: ctx.prefs, explicit: opts.runtime || null,
162
+ model: opts.model || null, effort: opts.effort || null,
163
+ role: "orchestrator", agentId,
164
+ });
165
+ const pickController = () => {
166
+ const resolved = resolveProjectController(db, process.cwd());
167
+ return withProjectControllerContext(resolved.controller, resolved.project);
168
+ };
169
+ const ensureMainSession = () => {
170
+ const agent = orch.active() ? orch.active().agent : pickController();
171
+ if (!agent) {
172
+ throw Object.assign(new Error(en
173
+ ? "This project has no available controller — connect with: agentlas project use <agent>"
174
+ : "이 프로젝트에 실행 가능한 컨트롤러가 없습니다 — agentlas project use <에이전트>로 연결하세요"),
175
+ { code: "project_not_connected", honestStop: true });
176
+ }
177
+ const active = orch.active();
178
+ if (active && active.agent.id === agent.id) return active;
179
+ const session = orch.spawn({
180
+ agent, runtime: resolveRt(agent.id), permission,
181
+ cwd: process.cwd(), activate: true, chatId: opts.chatId || null,
182
+ });
183
+ renderer.attach(session, { replay: false });
184
+ return session;
185
+ };
186
+
187
+ // ── 헤더 ──
188
+ ui.line(`${ui.c.paw("▞▖")} ${ui.c.bold("AGENTLAS")} ${ui.c.dim(`${readVersion()} · pi-tui shell (experimental) · parallel ≤${maxParallel()}`)}`);
189
+ ui.line(ui.c.dim(en
190
+ ? "plain words run a task · / commands · Esc interrupts · Ctrl+C quits"
191
+ : "문장을 치면 실행 · / 명령 · Esc 중단 · Ctrl+C 종료"));
192
+ ui.line("");
193
+
194
+ // ── 입력면 ──
195
+ const editorTheme = {
196
+ borderColor: ui.c.faint,
197
+ selectList: {
198
+ selectedPrefix: ui.c.emerald, selectedText: ui.c.bold,
199
+ description: ui.c.dim, scrollInfo: ui.c.faint, noMatch: ui.c.dim,
200
+ },
201
+ };
202
+ const editor = new pi.Editor(tui, editorTheme, { autocompleteMaxVisible: 8 });
203
+ editor.setAutocompleteProvider(new pi.CombinedAutocompleteProvider(toSlashCommands(ctx.lang), process.cwd()));
204
+
205
+ const commands = require("../commands/index.cjs");
206
+ const handleSlash = async (cmdline) => {
207
+ const raw = cmdline.split(/\s+/)[0] || "";
208
+ const rest = cmdline.slice(raw.length).trim().split(/\s+/).filter(Boolean);
209
+ const cmd = commands.resolveCommandName(raw);
210
+ if (cmd === "quit" || cmd === "exit") return "quit";
211
+ if (cmd === "help") {
212
+ ui.line(palette.renderPalette(ctx.lang));
213
+ return;
214
+ }
215
+ const EXCLUDED = new Set(["firm", "setup", "run"]);
216
+ if (!EXCLUDED.has(cmd) && commands.COMMANDS[cmd]) {
217
+ await commands.COMMANDS[cmd]().run(shellCtx, rest);
218
+ return;
219
+ }
220
+ if (commands.DESKTOP_ONLY_SURFACES && commands.DESKTOP_ONLY_SURFACES[cmd]) {
221
+ ui.line(ui.c.dim(commands.DESKTOP_ONLY_SURFACES[cmd]));
222
+ return;
223
+ }
224
+ ui.line(ui.c.dim(en ? `not in the pi shell yet: /${cmd} — use the classic REPL` : `pi 셸에는 아직 없음: /${cmd} — 기본 REPL을 쓰세요`));
225
+ };
226
+
227
+ let busy = false;
228
+ editor.onSubmit = (text) => {
229
+ const input = String(text || "").trim();
230
+ if (!input) return;
231
+ editor.addToHistory(input);
232
+ editor.setText("");
233
+ ui.ensureNl();
234
+ ui.line(ui.c.emerald("› ") + ui.c.text(input));
235
+ (async () => {
236
+ if (input.startsWith("/")) {
237
+ const verdict = await handleSlash(input.slice(1)).catch((e) => {
238
+ if (e && (e.code || e.honestStop)) ui.error(e);
239
+ else ui.error();
240
+ return null;
241
+ });
242
+ if (verdict === "quit") shutdown(0);
243
+ return;
244
+ }
245
+ if (busy) { orch.active()?.send(input).catch(() => {}); return; } // 스티어링 큐
246
+ busy = true;
247
+ try {
248
+ const session = ensureMainSession();
249
+ await orch.sendTo(session.key, input);
250
+ } catch (e) {
251
+ if (e && (e.code || e.honestStop)) ui.error(e);
252
+ else ui.error();
253
+ } finally {
254
+ busy = false;
255
+ }
256
+ })();
257
+ };
258
+ tui.addChild(editor);
259
+ tui.setFocus(editor);
260
+
261
+ const shutdown = (code) => {
262
+ try { renderer.detach(); } catch { /* 종료 경로 */ }
263
+ try { tui.stop(); } catch { /* 종료 경로 */ }
264
+ Promise.resolve(orch.shutdown && orch.shutdown()).finally(() => process.exit(code));
265
+ };
266
+
267
+ tui.addInputListener((data) => {
268
+ if (pi.matchesKey(data, "ctrl+c")) {
269
+ const active = orch.active();
270
+ if (active && active.isBusy()) {
271
+ active.kill();
272
+ ui.ensureNl();
273
+ ui.line(ui.c.dim(en ? "(turn interrupted)" : "(턴 중단됨)"));
274
+ return { handled: true };
275
+ }
276
+ shutdown(0);
277
+ return { handled: true };
278
+ }
279
+ if (pi.matchesKey(data, "escape")) {
280
+ const active = orch.active();
281
+ if (active && active.isBusy()) {
282
+ active.kill();
283
+ ui.ensureNl();
284
+ ui.line(ui.c.dim(en ? "(turn interrupted — Esc)" : "(턴 중단됨 — Esc)"));
285
+ return { handled: true };
286
+ }
287
+ }
288
+ });
289
+
290
+ tui.start();
291
+ // pi-tui 가 프로세스를 잡고 있는 동안 살아 있는 프라미스
292
+ return new Promise(() => {});
293
+ }
294
+
295
+ module.exports = { startPiShell };
@@ -86,6 +86,14 @@ function pickProjectController(db, cwd = process.cwd()) {
86
86
  }
87
87
 
88
88
  async function startRepl(ctx, opts = {}) {
89
+ /*
90
+ * 실험 셸 opt-in (D3 Phase 2 증분 1): AGENTLAS_TUI=pi + TTY 일 때만.
91
+ * 기본 REPL 이 정본이며, 온보딩 전(prefs.onboarded=false)에는 켜지 않는다 —
92
+ * 마법사는 아직 readline 계약이다(Phase 2-2 에서 이전).
93
+ */
94
+ if (process.env.AGENTLAS_TUI === "pi" && process.stdin.isTTY && ctx.prefs && ctx.prefs.onboarded) {
95
+ return require("./pitui-shell.cjs").startPiShell(ctx, opts);
96
+ }
89
97
  // 마법사가 언어를 바꾸면 이 뒤의 문구도 따라가야 한다 — 아래 온보딩 블록에서 갱신한다.
90
98
  let en = ctx.lang === "en";
91
99
  const ui = ctx.uiInstance;
@@ -140,6 +148,11 @@ async function startRepl(ctx, opts = {}) {
140
148
  * 그대로 보여주고, 어떻게 실행하는지까지 덧붙인다. One 복구는 예상 못 한
141
149
  * 실패에만 쓴다. (사람용 문장과 기계 판단은 다른 필드 — 스케줄러와 같은 원칙.)
142
150
  */
151
+ if (error && error.code === "usage") {
152
+ // 사용법은 복구 대상이 아니라 안내다 — 그대로 보여주고 끝낸다.
153
+ ui.line(ui.c.dim(String(error.message || error)));
154
+ return;
155
+ }
143
156
  if (error && (error.honestStop || error.code)) {
144
157
  // 공유 controller 모듈은 lang을 모른다 — 기계 code로 여기서 현지화한다.
145
158
  const KO_REASON = {
@@ -290,7 +303,7 @@ async function startRepl(ctx, opts = {}) {
290
303
  * 눌리는 순간 커서-뒤 지우기(CSI K)로 걷어내므로 readline 의 echo·팔레트
291
304
  * 오버레이와 겹치지 않는다. 비TTY·바쁜 세션에서는 그리지 않는다.
292
305
  */
293
- const { visWidth } = require("../agentlas-composer.cjs");
306
+ const { visWidth } = require("./width.cjs");
294
307
  const GHOST_HINT = en
295
308
  ? "type a task · / commands · @ files · ? shortcuts"
296
309
  : "할 일을 문장으로 · / 명령 · @ 파일 · ? 단축키";
@@ -628,9 +641,18 @@ function printSessions(ctx, orch) {
628
641
  * 날 TypeError 를 냈다. 팔레트가 `/steer <n> <msg>` 라고 안내하므로 인자 없이 Enter 를
629
642
  * 눌러 사용법을 보려는 것은 정상적인 탐색이다.
630
643
  */
644
+ /*
645
+ * 사용법 에러는 기계 코드를 단다 — 코드 없는 Error 는 recoverPresentation 이
646
+ * "One이 복구 중" 으로 치환해 사용법이 통째로 사라졌다(2026-08-11 존폐 판단 결함 2:
647
+ * /runtime /model /effort /permission /s /switch /kill /rm 여덟 명령).
648
+ */
649
+ function usageError(text) {
650
+ return Object.assign(new Error(text), { code: "usage", honestStop: true });
651
+ }
652
+
631
653
  function sessionKeyArg(rest, usage) {
632
654
  const token = rest[0];
633
- if (!token) throw new Error(usage);
655
+ if (!token) throw usageError(usage);
634
656
  return String(token).startsWith("s") ? token : `s${token}`;
635
657
  }
636
658
 
@@ -669,7 +691,8 @@ function handleSlash(ctx, cmdline, api) {
669
691
  return;
670
692
  }
671
693
  case "agents": case "list": require("../commands/list.cjs").run(ctx, rest); return;
672
- case "doctor": require("../commands/doctor.cjs").run(ctx, rest); return;
694
+ // doctor 는 세션 검증(네트워크)으로 async 가 됐다 — 특례로 fire-and-forget 하면
695
+ // REPL 종료가 출력을 잘라먹는다. promise 를 track 하는 default 폴스루로 보낸다.
673
696
  case "mcp": require("../commands/mcp.cjs").run(ctx, rest); return;
674
697
 
675
698
  case "sessions": case "tree": printSessions(ctx, orch); return;
@@ -695,7 +718,7 @@ function handleSlash(ctx, cmdline, api) {
695
718
  }
696
719
 
697
720
  case "runtime": {
698
- if (!rest[0]) throw new Error("Usage: /runtime claude-code|codex|gemini");
721
+ if (!rest[0]) throw usageError("Usage: /runtime claude-code|codex|gemini");
699
722
  // 세션 오버라이드는 저장되지 않는다 — 고지 없이는 사용자가 영구 설정으로
700
723
  // 믿는다(2026-08-05 감사 결함 C). 영구 경로를 같은 줄에서 알려준다.
701
724
  api.setRuntime(rest[0]);
@@ -706,7 +729,7 @@ function handleSlash(ctx, cmdline, api) {
706
729
  }
707
730
  case "model": {
708
731
  const model = String(rest[0] || "").trim();
709
- if (!model) throw new Error("Usage: /model <provider-model-id|default>");
732
+ if (!model) throw usageError("Usage: /model <provider-model-id|default>");
710
733
  const next = ["default", "inherit"].includes(model.toLowerCase()) ? null : model;
711
734
  api.setModel(next);
712
735
  ctx.out(ui.c.dim(
@@ -719,7 +742,7 @@ function handleSlash(ctx, cmdline, api) {
719
742
  case "effort": {
720
743
  const effort = String(rest[0] || "").trim().toLowerCase();
721
744
  if (!EFFORTS.includes(effort)) {
722
- throw new Error(`Usage: /effort ${EFFORTS.join("|")}`);
745
+ throw usageError(`Usage: /effort ${EFFORTS.join("|")}`);
723
746
  }
724
747
  api.setEffort(effort === "none" ? null : effort);
725
748
  ctx.out(ui.c.dim(
@@ -729,7 +752,7 @@ function handleSlash(ctx, cmdline, api) {
729
752
  }
730
753
  case "permission": {
731
754
  if (!["read", "write", "full"].includes(String(rest[0] || ""))) {
732
- throw new Error("Usage: /permission read|write|full");
755
+ throw usageError("Usage: /permission read|write|full");
733
756
  }
734
757
  const level = permissions.normalize(rest[0]);
735
758
  api.setPermission(level);
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ /*
3
+ * ui/width: 터미널 셀 폭·그래핌 유틸 정본.
4
+ * agentlas-composer.cjs(데드코드가 된 바텀 입력 박스)에서 2026-08-11 추출 — 함수는
5
+ * 바이트 동일. 소비자: agentlas-ui / agentlas-banner / agentlas-onboard /
6
+ * ui/repl / hephaestus/runtime. pi-tui 이행(D3 Phase 1-3) 정지작업.
7
+ */
8
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9
+ const MARK_RE = /\p{Mark}/u;
10
+ const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
11
+
12
+ // East-Asian width: CJK / Hangul / Kana / fullwidth glyphs occupy 2 terminal cells.
13
+ function isWide(cp) {
14
+ return (
15
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
16
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals … symbols
17
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana … CJK compat
18
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK ext A
19
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified
20
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
21
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
22
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
23
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compat forms
24
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
25
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
26
+ (cp >= 0x1f300 && cp <= 0x1faff) // emoji / pictographs
27
+ );
28
+ }
29
+ function charWidth(ch) {
30
+ const cp = ch.codePointAt(0);
31
+ if (cp < 0x20) return 0;
32
+ if (
33
+ cp === 0x200d || // zero-width joiner
34
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
35
+ (cp >= 0xe0100 && cp <= 0xe01ef) ||
36
+ (cp >= 0x1f3fb && cp <= 0x1f3ff) || // emoji skin tones
37
+ MARK_RE.test(ch)
38
+ ) return 0;
39
+ return isWide(cp) ? 2 : 1;
40
+ }
41
+ function graphemeSegments(value) {
42
+ return [...GRAPHEME_SEGMENTER.segment(String(value || ""))];
43
+ }
44
+ function graphemeWidth(segment) {
45
+ const text = String(segment || "");
46
+ if (
47
+ EXTENDED_PICTOGRAPHIC_RE.test(text) ||
48
+ /[\u{1f1e6}-\u{1f1ff}]/u.test(text) ||
49
+ text.includes("\u20e3")
50
+ ) return 2;
51
+ let width = 0;
52
+ for (const ch of text) width += charWidth(ch);
53
+ return width;
54
+ }
55
+ function previousGraphemeIndex(value, index) {
56
+ const text = String(value || "");
57
+ const cursor = Math.max(0, Math.min(Number(index) || 0, text.length));
58
+ let previous = 0;
59
+ for (const entry of GRAPHEME_SEGMENTER.segment(text)) {
60
+ const end = entry.index + entry.segment.length;
61
+ if (cursor <= entry.index) return previous;
62
+ if (cursor <= end) return entry.index;
63
+ previous = entry.index;
64
+ }
65
+ return previous;
66
+ }
67
+ function nextGraphemeIndex(value, index) {
68
+ const text = String(value || "");
69
+ const cursor = Math.max(0, Math.min(Number(index) || 0, text.length));
70
+ for (const entry of GRAPHEME_SEGMENTER.segment(text)) {
71
+ const end = entry.index + entry.segment.length;
72
+ if (cursor < end) return end;
73
+ }
74
+ return text.length;
75
+ }
76
+ function visWidth(s) {
77
+ const clean = String(s).replace(/\x1b\[[0-9;]*m/g, "");
78
+ let n = 0;
79
+ for (const entry of GRAPHEME_SEGMENTER.segment(clean)) n += graphemeWidth(entry.segment);
80
+ return n;
81
+ }
82
+
83
+ function truncateWidth(value, max) {
84
+ const text = String(value || "");
85
+ if (visWidth(text) <= max) return text;
86
+ let out = "";
87
+ let width = 0;
88
+ const room = Math.max(0, max - 1);
89
+ for (const entry of GRAPHEME_SEGMENTER.segment(text)) {
90
+ const cells = graphemeWidth(entry.segment);
91
+ if (width + cells > room) break;
92
+ out += entry.segment;
93
+ width += cells;
94
+ }
95
+ return out + "…";
96
+ }
97
+
98
+ function splitWidth(value, max) {
99
+ const text = String(value || "");
100
+ const limit = Math.max(1, Math.floor(Number(max) || 1));
101
+ const lines = [];
102
+ let line = "";
103
+ let width = 0;
104
+ for (const entry of GRAPHEME_SEGMENTER.segment(text)) {
105
+ const cells = graphemeWidth(entry.segment);
106
+ if (line && width + cells > limit) {
107
+ lines.push(line);
108
+ line = "";
109
+ width = 0;
110
+ }
111
+ line += entry.segment;
112
+ width += cells;
113
+ }
114
+ if (line || !lines.length) lines.push(line);
115
+ return lines;
116
+ }
117
+
118
+ function wrapWidth(value, max) {
119
+ const limit = Math.max(2, Math.floor(Number(max) || 2));
120
+ const lines = [];
121
+ const pushWord = (word, state) => {
122
+ if (!word) return;
123
+ const cells = visWidth(word);
124
+ if (state.line && state.width + 1 + cells <= limit) {
125
+ state.line += " " + word;
126
+ state.width += 1 + cells;
127
+ return;
128
+ }
129
+ if (state.line) {
130
+ lines.push(state.line);
131
+ state.line = "";
132
+ state.width = 0;
133
+ }
134
+ if (cells <= limit) {
135
+ state.line = word;
136
+ state.width = cells;
137
+ return;
138
+ }
139
+ let chunk = "";
140
+ let chunkWidth = 0;
141
+ for (const entry of GRAPHEME_SEGMENTER.segment(word)) {
142
+ const width = graphemeWidth(entry.segment);
143
+ if (chunk && chunkWidth + width > limit) {
144
+ lines.push(chunk);
145
+ chunk = "";
146
+ chunkWidth = 0;
147
+ }
148
+ chunk += entry.segment;
149
+ chunkWidth += width;
150
+ }
151
+ state.line = chunk;
152
+ state.width = chunkWidth;
153
+ };
154
+ const paragraphs = String(value || "").split(/\r?\n/);
155
+ paragraphs.forEach((paragraph) => {
156
+ const state = { line: "", width: 0 };
157
+ for (const word of paragraph.trim().split(/\s+/u).filter(Boolean)) pushWord(word, state);
158
+ if (state.line) lines.push(state.line);
159
+ else if (!paragraph.trim()) lines.push("");
160
+ });
161
+ return lines.length ? lines : [""];
162
+ }
163
+
164
+
165
+ module.exports = {
166
+ visWidth,
167
+ truncateWidth,
168
+ splitWidth,
169
+ wrapWidth,
170
+ previousGraphemeIndex,
171
+ nextGraphemeIndex,
172
+ };