agentlas 1.0.37 → 1.0.39

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.
@@ -0,0 +1,348 @@
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
+ // ── 히스토리 디스크 영속 (증분 2) — cli-history.json v2 계약을 그대로 재사용 ──
206
+ const input = require("../agentlas-input.cjs");
207
+ const historyLedger = (() => { try { return input.loadHistory(process.cwd()); } catch { return []; } })();
208
+ // readline 히스토리는 최신-우선 배열 — Editor에는 과거→최신 순으로 먹인다.
209
+ for (const entry of [...historyLedger].reverse()) editor.addToHistory(entry);
210
+ const recordHistory = (line) => {
211
+ historyLedger.unshift(line);
212
+ if (historyLedger.length > input.HISTORY_MAX) historyLedger.length = input.HISTORY_MAX;
213
+ try { input.saveHistory(historyLedger, process.cwd()); } catch { /* 히스토리는 최선노력 */ }
214
+ };
215
+
216
+ // ── Shift-Tab 권한 순환 (증분 2) — repl의 순수 상태기계를 그대로 재사용 ──
217
+ const { createPermissionShortcut } = require("./repl.cjs");
218
+ const permShortcut = createPermissionShortcut({
219
+ lang: ctx.lang,
220
+ getPermission: () => permission,
221
+ setPermission: (level) => { permission = level; },
222
+ onMessage: (msg) => { ui.ensureNl(); ui.line(ui.c.dim(msg.text)); },
223
+ });
224
+
225
+ const commands = require("../commands/index.cjs");
226
+ const handleSlash = async (cmdline) => {
227
+ const raw = cmdline.split(/\s+/)[0] || "";
228
+ const rest = cmdline.slice(raw.length).trim().split(/\s+/).filter(Boolean);
229
+ const cmd = commands.resolveCommandName(raw);
230
+ if (cmd === "quit" || cmd === "exit") return "quit";
231
+ if (cmd === "help") {
232
+ ui.line(palette.renderPalette(ctx.lang));
233
+ return;
234
+ }
235
+ // 세션 관찰/전환 (증분 2b) — 기본 REPL과 같은 orch/renderer 배선
236
+ if (cmd === "sessions" || cmd === "tree") {
237
+ require("./repl.cjs").printSessions(shellCtx, orch);
238
+ return;
239
+ }
240
+ if (cmd === "s" || cmd === "switch" || cmd === "kill" || cmd === "rm") {
241
+ const token = rest[0];
242
+ if (!token) { ui.line(ui.c.dim(`Usage: /${cmd} <n>`)); return; }
243
+ const key = String(token).startsWith("s") ? token : `s${token}`;
244
+ if (cmd === "kill") { orch.kill(key); return; }
245
+ if (cmd === "rm") {
246
+ orch.remove(key);
247
+ const act = orch.active();
248
+ if (act) renderer.attach(act, { replay: false });
249
+ return;
250
+ }
251
+ const session = orch.setActive(key);
252
+ renderer.attach(session, { replay: true });
253
+ return;
254
+ }
255
+ const EXCLUDED = new Set(["firm", "setup", "run"]);
256
+ if (!EXCLUDED.has(cmd) && commands.COMMANDS[cmd]) {
257
+ await commands.COMMANDS[cmd]().run(shellCtx, rest);
258
+ return;
259
+ }
260
+ if (commands.DESKTOP_ONLY_SURFACES && commands.DESKTOP_ONLY_SURFACES[cmd]) {
261
+ ui.line(ui.c.dim(commands.DESKTOP_ONLY_SURFACES[cmd]));
262
+ return;
263
+ }
264
+ ui.line(ui.c.dim(en ? `not in the pi shell yet: /${cmd} — use the classic REPL` : `pi 셸에는 아직 없음: /${cmd} — 기본 REPL을 쓰세요`));
265
+ };
266
+
267
+ let busy = false;
268
+ editor.onSubmit = (text) => {
269
+ const input = String(text || "").trim();
270
+ if (!input) return;
271
+ editor.addToHistory(input);
272
+ recordHistory(input);
273
+ editor.setText("");
274
+ ui.ensureNl();
275
+ ui.line(ui.c.emerald("› ") + ui.c.text(input));
276
+ (async () => {
277
+ if (input.startsWith("!")) {
278
+ await require("./repl.cjs").runShell(shellCtx, input.slice(1).trim(), permission)
279
+ .catch((e) => { if (e && (e.code || e.honestStop)) ui.error(e); else ui.error(); });
280
+ return;
281
+ }
282
+ if (input.startsWith("/")) {
283
+ const verdict = await handleSlash(input.slice(1)).catch((e) => {
284
+ if (e && (e.code || e.honestStop)) ui.error(e);
285
+ else ui.error();
286
+ return null;
287
+ });
288
+ if (verdict === "quit") shutdown(0);
289
+ return;
290
+ }
291
+ if (busy) { orch.active()?.send(input).catch(() => {}); return; } // 스티어링 큐
292
+ busy = true;
293
+ try {
294
+ const session = ensureMainSession();
295
+ await orch.sendTo(session.key, input);
296
+ } catch (e) {
297
+ if (e && (e.code || e.honestStop)) ui.error(e);
298
+ else ui.error();
299
+ } finally {
300
+ busy = false;
301
+ }
302
+ })();
303
+ };
304
+ tui.addChild(editor);
305
+ tui.setFocus(editor);
306
+
307
+ const shutdown = (code) => {
308
+ try { renderer.detach(); } catch { /* 종료 경로 */ }
309
+ try { tui.stop(); } catch { /* 종료 경로 */ }
310
+ Promise.resolve(orch.shutdown && orch.shutdown()).finally(() => process.exit(code));
311
+ };
312
+
313
+ tui.addInputListener((data) => {
314
+ // Shift-Tab 권한 순환 — pi-tui 가 raw mode 를 단독 소유하므로 readline 의
315
+ // swallowCompletion 우회 없이 여기서 직접 소비한다 (D2 위험 2의 해소 형태).
316
+ if (pi.matchesKey(data, "shift+tab")) {
317
+ permShortcut.handleKey("", { name: "tab", shift: true });
318
+ return { handled: true };
319
+ }
320
+ if (permShortcut.armed()) permShortcut.handleKey("", { name: "other" }); // 다른 키 = 무장 해제
321
+ if (pi.matchesKey(data, "ctrl+c")) {
322
+ const active = orch.active();
323
+ if (active && active.isBusy()) {
324
+ active.kill();
325
+ ui.ensureNl();
326
+ ui.line(ui.c.dim(en ? "(turn interrupted)" : "(턴 중단됨)"));
327
+ return { handled: true };
328
+ }
329
+ shutdown(0);
330
+ return { handled: true };
331
+ }
332
+ if (pi.matchesKey(data, "escape")) {
333
+ const active = orch.active();
334
+ if (active && active.isBusy()) {
335
+ active.kill();
336
+ ui.ensureNl();
337
+ ui.line(ui.c.dim(en ? "(turn interrupted — Esc)" : "(턴 중단됨 — Esc)"));
338
+ return { handled: true };
339
+ }
340
+ }
341
+ });
342
+
343
+ tui.start();
344
+ // pi-tui 가 프로세스를 잡고 있는 동안 살아 있는 프라미스
345
+ return new Promise(() => {});
346
+ }
347
+
348
+ 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);
@@ -763,4 +786,4 @@ function handleSlash(ctx, cmdline, api) {
763
786
  }
764
787
  }
765
788
 
766
- module.exports = { startRepl, printSessions, createPermissionShortcut };
789
+ module.exports = { startRepl, printSessions, createPermissionShortcut, runShell };
@@ -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
+ };
@@ -314,11 +314,30 @@ async function workforceAccountContext() {
314
314
  params: { name: "agentlas.account_context", arguments: {} },
315
315
  }),
316
316
  });
317
+ /*
318
+ * 서버의 로그인 안내를 먼저 중계한다 (2026-08-11 존폐 판단 결함 4).
319
+ * 실사고: 세션 만료 시 서버는 auth_required + 로그인 방법을 친절히 돌려줬는데,
320
+ * 클라이언트가 그 필드를 안 보고 스키마 검사부터 실패시켜 "유효하지 않은 연속성
321
+ * 영수증"으로 오진했고, 그 오진마저 표시 경계가 지웠다 — 안내가 세 번 소실됐다.
322
+ * 기계 code를 달아 표시 경계(usage/honestStop 통과 조건)를 지나게 한다.
323
+ */
324
+ const authRequiredError = (detail) => Object.assign(
325
+ new Error(detail || "Agentlas sign-in required — run `agentlas login`, then retry."),
326
+ { code: "auth_required", honestStop: true },
327
+ );
328
+ if (response.status === 401 || response.status === 403) throw authRequiredError();
317
329
  if (!response.ok) throw new Error(`Agentlas account context failed with HTTP ${response.status}.`);
318
330
  const rpc = hubClient.parseHubJson(response, "Agentlas account context");
319
331
  const text = rpc?.result?.content?.[0]?.text;
320
332
  let payload;
321
333
  try { payload = JSON.parse(String(text || "")); } catch { payload = null; }
334
+ const authMarker = [payload?.error, payload?.code, rpc?.error?.code, rpc?.error?.message]
335
+ .map((v) => String(v || ""))
336
+ .find((v) => /auth_required|unauthorized|not signed in/i.test(v));
337
+ if (authMarker) {
338
+ const hint = String(payload?.message || payload?.hint || rpc?.error?.message || "").slice(0, 400);
339
+ throw authRequiredError(hint ? `${hint} — run \`agentlas login\`, then retry.` : null);
340
+ }
322
341
  // 연속성 영수증은 스키마·계정 다이제스트·과금 권한을 정확히 검증한다 — 위조/구버전
323
342
  // 응답으로 goal 바인딩을 진행하면 안 된다.
324
343
  if (
@@ -524,6 +543,8 @@ function buildWorkforceDeps(ctx = {}) {
524
543
  return {
525
544
  now: () => new Date(),
526
545
  out: typeof ctx.out === "function" ? ctx.out : (s) => process.stdout.write(`${s}\n`),
546
+ // Phase 1-2: 엔진 ctx의 Ui를 관통시킨다 — 워크포스가 자체 Ui를 만들지 않게.
547
+ uiInstance: ctx.uiInstance || null,
527
548
  prefsLang: () => ctx.lang || "en",
528
549
  userDataDir,
529
550
  projectCwd: capture.projectCwd,