agentlas 0.5.2 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +109 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +120 -12
  10. package/engine/agentlas-input.cjs +116 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +99 -45
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +348 -23
  18. package/engine/agentlas.cjs +2742 -338
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/run-api-regression.cjs +322 -0
  33. package/test/runtime-env-protection.cjs +45 -0
  34. package/test/semver-precedence.cjs +39 -0
  35. package/test/smoke.sh +19 -0
  36. package/test/sqlite-driver-probe.cjs +22 -0
  37. package/test/terminal-ui-regression.cjs +454 -0
  38. package/test/timeout-regression.cjs +218 -0
  39. package/test/tool-workspace-boundary.cjs +165 -0
  40. package/test/update-safety.cjs +376 -0
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  const i18n = require("./agentlas-i18n.cjs");
11
+ const readline = require("node:readline");
12
+ const taskEvents = require("./agentlas-tasks.cjs");
11
13
 
12
14
  const RESET = "\x1b[0m";
13
15
 
@@ -57,20 +59,132 @@ function makePalette(enabled) {
57
59
 
58
60
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
59
61
 
60
- // ANSI 시퀀스를 제거한 가시 폭 (대략) — wide char는 단순 1로 계산(충분).
62
+ function cellWidth(ch) {
63
+ const cp = ch.codePointAt(0);
64
+ if (cp < 0x20) return 0;
65
+ return (
66
+ (cp >= 0x1100 && cp <= 0x115f) ||
67
+ (cp >= 0x2e80 && cp <= 0x303e) ||
68
+ (cp >= 0x3041 && cp <= 0x33ff) ||
69
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
70
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
71
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
72
+ (cp >= 0xf900 && cp <= 0xfaff) ||
73
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
74
+ (cp >= 0xff00 && cp <= 0xff60) ||
75
+ (cp >= 0x1f300 && cp <= 0x1faff)
76
+ ) ? 2 : 1;
77
+ }
78
+
79
+ // ANSI 시퀀스를 제거한 실제 terminal cell 폭 (CJK/emoji 포함).
61
80
  function visibleWidth(s) {
62
- return stripAnsi(s).length;
81
+ let width = 0;
82
+ for (const ch of stripAnsi(s)) width += cellWidth(ch);
83
+ return width;
63
84
  }
64
85
  function stripAnsi(s) {
65
86
  // eslint-disable-next-line no-control-regex
66
87
  return String(s).replace(/\x1b\[[0-9;]*m/g, "");
67
88
  }
68
89
 
90
+ function oneLine(value) {
91
+ return stripAnsi(String(value || ""))
92
+ .replace(/\r?\n+/g, " ")
93
+ .replace(/\s+/g, " ")
94
+ .trim();
95
+ }
96
+
97
+ function truncateCells(value, max) {
98
+ const text = String(value || "");
99
+ if (visibleWidth(text) <= max) return text;
100
+ let out = "";
101
+ let width = 0;
102
+ const room = Math.max(0, max - 1);
103
+ for (const ch of text) {
104
+ const cells = cellWidth(ch);
105
+ if (width + cells > room) break;
106
+ out += ch;
107
+ width += cells;
108
+ }
109
+ return out + "…";
110
+ }
111
+
112
+ function compactHomePath(value) {
113
+ const home = process.env.HOME || "";
114
+ return home && value.startsWith(home + "/") ? "~/" + value.slice(home.length + 1) : value;
115
+ }
116
+
117
+ function redactCommandSecrets(value) {
118
+ return value
119
+ .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))=([^\s]+)/g, "$1=••••")
120
+ .replace(/\b(authorization\s*:\s*bearer)\s+[^\s]+/gi, "$1 ••••")
121
+ // Some remote MCPs put a bearer-like credential in the URL path instead of a header.
122
+ // Keep the provider/path useful while ensuring activity summaries never print the secret.
123
+ .replace(/(https?:\/\/[^\s]+\/)(ocm_[A-Za-z0-9_-]{12,})\b/gi, "$1[redacted]")
124
+ .replace(/\bocm_[A-Za-z0-9_-]{12,}\b/gi, "[redacted]");
125
+ }
126
+
127
+ // Runtime JSON often contains an entire heredoc or command chain. The terminal should show
128
+ // what is being done, not reproduce a second debug console inside the conversation.
129
+ function compactToolArg(name, value, max = 120) {
130
+ let text = redactCommandSecrets(oneLine(value));
131
+ if (!text) return "";
132
+ const tool = String(name || "").toLowerCase();
133
+ if (/bash|shell|command|terminal/.test(tool)) {
134
+ text = text.replace(/^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s*(?:&&|;)\s*/i, "");
135
+ const steps = text.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
136
+ const heredoc = steps[0] && steps[0].replace(/\s*<<['"]?[A-Za-z0-9_-]+['"]?.*$/i, " <<…");
137
+ text = heredoc || text;
138
+ if (steps.length > 1) text += ` · ${steps.length} steps`;
139
+ } else if (/read|write|edit|patch|file|glob|grep|search/.test(tool)) {
140
+ text = compactHomePath(text);
141
+ }
142
+ return truncateCells(text, Math.max(8, max));
143
+ }
144
+
145
+ function compactResult(text, ok, toolName, maxCells = 120) {
146
+ const clean = stripAnsi(String(text || "")).replace(/\r/g, "").trim();
147
+ const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean);
148
+ const count = lines.length;
149
+ if (!ok) {
150
+ if (!count) return { headline: "error", details: [] };
151
+ const first = oneLine(lines[0]);
152
+ const last = count > 1 ? oneLine(lines[count - 1]) : "";
153
+ return {
154
+ headline: truncateCells(first, maxCells),
155
+ details: last && last !== first ? [truncateCells(last, Math.max(8, maxCells - 6))] : [],
156
+ };
157
+ }
158
+
159
+ if (!count || (count === 1 && /^(?:done|ok|success)$/i.test(lines[0]))) {
160
+ return { headline: "done", details: [] };
161
+ }
162
+
163
+ const signalPatterns = [
164
+ /\b\d+\s+(?:passed|failed|skipped|tests?)\b/i,
165
+ /\b(?:PASS|FAIL|SUCCESS|ERROR)\b/i,
166
+ /\b(?:created|updated|written|wrote|saved|modified)\b/i,
167
+ /\bexit(?:ed)?\s+(?:code\s+)?\d+\b/i,
168
+ ];
169
+ let signal = "";
170
+ for (let i = lines.length - 1; i >= 0 && !signal; i--) {
171
+ if (signalPatterns.some((pattern) => pattern.test(lines[i]))) signal = oneLine(lines[i]);
172
+ }
173
+
174
+ const tool = String(toolName || "").toLowerCase();
175
+ if (signal) return { headline: truncateCells(signal, maxCells), details: count > 1 ? [`${count} output lines`] : [] };
176
+ if (/read|glob|grep|search|list/.test(tool)) return { headline: `${count} line${count === 1 ? "" : "s"} read`, details: [] };
177
+ if (/write|edit|patch/.test(tool)) return { headline: "updated", details: count > 1 ? [`${count} output lines`] : [] };
178
+ if (count === 1 && visibleWidth(oneLine(lines[0])) <= maxCells) return { headline: oneLine(lines[0]), details: [] };
179
+ return { headline: "done", details: [`${count} output lines`] };
180
+ }
181
+
69
182
  class Ui {
70
183
  constructor(opts = {}) {
71
184
  this.enabled = opts.color != null ? opts.color : colorEnabled();
72
185
  this.c = makePalette(this.enabled);
73
186
  this.out = opts.stream || process.stdout;
187
+ this.input = opts.input || process.stdin;
74
188
  this.lang = opts.lang || "en";
75
189
  this.t = (key, ...args) => i18n.t(this.lang, key, ...args);
76
190
  this._spinTimer = null;
@@ -81,14 +195,30 @@ class Ui {
81
195
  this._streaming = false;
82
196
  this._atLineStart = true;
83
197
  this._lastUsage = null; // last per-turn usage (for session /cost ledger)
198
+ this._turnActions = 0;
199
+ this._turnFailures = 0;
200
+ this._lastTool = null;
201
+ this._turnChrome = null;
202
+ this._footerDrawn = false;
203
+ this._footerDrawnRows = 0;
204
+ this._footerSuspended = false;
205
+ this._resumeSpinnerAfterStream = false;
206
+ this._streamKeepsFooter = false;
207
+ this._turnTasks = [];
208
+ this._tasksExpanded = true;
209
+ this._turnKeyHandler = null;
210
+ this._turnInputWasRaw = false;
84
211
  }
85
212
 
86
213
  write(s) {
214
+ const redrawFooter = !!(this._turnChrome && !this._footerSuspended);
215
+ if (redrawFooter) this._eraseFooter();
87
216
  this.out.write(s);
88
217
  if (s.length) this._atLineStart = s.endsWith("\n");
218
+ if (redrawFooter) this._drawFooter();
89
219
  }
90
220
  line(s = "") {
91
- this.stopSpinner();
221
+ if (!this._turnChrome) this.stopSpinner();
92
222
  this.write(s + "\n");
93
223
  }
94
224
  // 줄 시작이 아니면 개행을 보장 (스트리밍/스피너 뒤 깔끔한 블록 시작용).
@@ -107,8 +237,94 @@ class Ui {
107
237
  }
108
238
  }
109
239
 
240
+ _footerLines() {
241
+ if (!this._turnChrome) return [];
242
+ const cols = Math.max(30, this.out.columns || 80);
243
+ const width = cols - 1;
244
+ const rule = this.c.faint("─".repeat(width));
245
+ const prompt = this.c.text("› ");
246
+ const start = this._turnStart || this._spinStart || Date.now();
247
+ const secs = Math.max(0, Math.floor((Date.now() - start) / 1000));
248
+ const frame = SPINNER_FRAMES[this._spinFrame % SPINNER_FRAMES.length];
249
+ const status = this._spinText || (this.lang === "ko" ? "작업 중" : "Working");
250
+ const stop = this.lang === "ko" ? "ctrl-c로 중단" : "ctrl-c to interrupt";
251
+ const permission = this._turnChrome.permissionLabel || "";
252
+ const contextBits = [permission, this._turnChrome.status].filter(Boolean);
253
+ const meta = `(${secs}s · ${stop})${contextBits.length ? ` · ${contextBits.join(" · ")}` : ""}`;
254
+ const available = Math.max(12, width - visibleWidth(frame + " "));
255
+ const plain = truncateCells(`${status} ${meta}`, available);
256
+ const statusLine = this.c.emerald(frame + " ") + this.c.text(plain);
257
+ const taskLines = [];
258
+ if (this._turnTasks.length) {
259
+ const completed = this._turnTasks.filter((task) => task.status === "completed").length;
260
+ const toggle = this._tasksExpanded ? this.t("tasks.hide") : this.t("tasks.show");
261
+ taskLines.push(this.c.bold(this.c.text(`${this.t("tasks.title")} ${completed}/${this._turnTasks.length}`)) + this.c.faint(` · ${toggle}`));
262
+ if (this._tasksExpanded) {
263
+ const visible = this._turnTasks.slice(-8);
264
+ for (let index = 0; index < visible.length; index++) {
265
+ const task = visible[index];
266
+ const last = index === visible.length - 1;
267
+ const branch = last ? "└" : "├";
268
+ const icon = task.status === "completed" ? "✓" : task.status === "failed" ? "!" : task.status === "in_progress" ? "■" : "□";
269
+ const statusKey = task.status === "completed"
270
+ ? "tasks.done"
271
+ : task.status === "failed"
272
+ ? "tasks.failed"
273
+ : task.status === "in_progress"
274
+ ? "tasks.progress"
275
+ : "tasks.pending";
276
+ const labelRoom = Math.max(8, width - visibleWidth(`${branch} ${icon} ${this.t(statusKey)}`) - 4);
277
+ const label = truncateCells(task.label, labelRoom);
278
+ const paint = task.status === "completed" ? this.c.green : task.status === "failed" ? this.c.paw : task.status === "in_progress" ? this.c.emerald : this.c.dim;
279
+ taskLines.push(this.c.faint(`${branch} `) + paint(`${icon} ${label}`) + this.c.faint(` ${this.t(statusKey)}`));
280
+ }
281
+ }
282
+ }
283
+ return [...taskLines, rule, prompt, rule, statusLine];
284
+ }
285
+
286
+ _eraseFooter() {
287
+ if (!this._footerDrawn) return;
288
+ const rows = this._footerDrawnRows;
289
+ let seq = "\r";
290
+ if (rows > 1) seq += `\x1b[${rows - 1}A`;
291
+ seq += "\x1b[0J";
292
+ this.out.write(seq);
293
+ this._footerDrawn = false;
294
+ this._footerDrawnRows = 0;
295
+ }
296
+
297
+ _drawFooter() {
298
+ if (!this._turnChrome || this._footerSuspended || this._footerDrawn || !this.out.isTTY) return;
299
+ const lines = this._footerLines();
300
+ if (!lines.length) return;
301
+ if (!this._atLineStart) this.out.write("\n");
302
+ this.out.write(lines.join("\n"));
303
+ this._footerDrawn = true;
304
+ this._footerDrawnRows = lines.length;
305
+ }
306
+
307
+ _redrawFooter() {
308
+ if (!this._turnChrome || this._footerSuspended) return;
309
+ this._eraseFooter();
310
+ this._drawFooter();
311
+ }
312
+
110
313
  // ── 스피너 (stderr가 아닌 메인 스트림에, 같은 줄을 갱신) ──
111
314
  startSpinner(text) {
315
+ if (this._turnChrome) {
316
+ this._spinText = text || this._spinText || "";
317
+ if (this._spinTimer) return;
318
+ this._spinStart = Date.now();
319
+ const tick = () => {
320
+ this._spinFrame++;
321
+ this._redrawFooter();
322
+ };
323
+ tick();
324
+ this._spinTimer = setInterval(tick, 120);
325
+ if (this._spinTimer.unref) this._spinTimer.unref();
326
+ return;
327
+ }
112
328
  if (!this.enabled || !this.out.isTTY) {
113
329
  // 폴백: 한 번만 상태 출력
114
330
  if (text && text !== this._spinText) this.line(this.c.dim(" " + text));
@@ -134,22 +350,90 @@ class Ui {
134
350
  }
135
351
 
136
352
  // 턴 시작/끝 — 스피너가 (툴 사이에 멈췄다 다시 떠도) 총 턴 경과시간을 보여주도록.
137
- beginTurn() {
353
+ beginTurn(chrome) {
138
354
  this._turnStart = Date.now();
355
+ this._turnActions = 0;
356
+ this._turnFailures = 0;
357
+ this._lastTool = null;
358
+ this._turnTasks = [];
359
+ this._tasksExpanded = true;
360
+ if (chrome && this.out.isTTY) {
361
+ this._turnChrome = typeof chrome === "string" ? { status: chrome } : { ...chrome };
362
+ this._drawFooter();
363
+ this._attachTurnKeys();
364
+ }
139
365
  }
140
366
  endTurn() {
367
+ this.stopSpinner(true);
368
+ this._eraseFooter();
369
+ this._detachTurnKeys();
370
+ this._turnChrome = null;
371
+ this._footerSuspended = false;
141
372
  this._turnStart = null;
373
+ this._turnTasks = [];
374
+ }
375
+
376
+ _attachTurnKeys() {
377
+ const input = this.input;
378
+ if (this._turnKeyHandler || !input || !input.isTTY || !this.out.isTTY) return;
379
+ this._turnInputWasRaw = !!input.isRaw;
380
+ try { if (input.setRawMode) input.setRawMode(true); } catch { /* fallback to SIGINT/canonical input */ }
381
+ readline.emitKeypressEvents(input);
382
+ const handler = (_str, key = {}) => {
383
+ if (key.ctrl && String(key.name || "").toLowerCase() === "t") {
384
+ this._tasksExpanded = !this._tasksExpanded;
385
+ this._redrawFooter();
386
+ return;
387
+ }
388
+ if (key.ctrl && String(key.name || "").toLowerCase() === "c" && this._turnChrome?.onInterrupt) {
389
+ this._turnChrome.onInterrupt();
390
+ }
391
+ };
392
+ this._turnKeyHandler = handler;
393
+ input.prependListener("keypress", handler);
394
+ try { input.resume?.(); } catch { /* ignore */ }
395
+ }
396
+
397
+ _detachTurnKeys() {
398
+ const input = this.input;
399
+ if (this._turnKeyHandler && input) input.removeListener("keypress", this._turnKeyHandler);
400
+ this._turnKeyHandler = null;
401
+ try { if (input?.setRawMode) input.setRawMode(this._turnInputWasRaw); } catch { /* ignore */ }
402
+ }
403
+
404
+ replaceTasks(payload, source) {
405
+ const normalized = taskEvents.normalizeTaskList(payload, source);
406
+ if (!normalized.length && !Array.isArray(payload) && !Array.isArray(payload?.todos) && !Array.isArray(payload?.items) && !Array.isArray(payload?.tasks)) return;
407
+ this._turnTasks = normalized;
408
+ this._redrawFooter();
409
+ }
410
+
411
+ applyTaskTool(name, payload, toolId) {
412
+ const next = taskEvents.applyTaskTool(this._turnTasks, name, payload, toolId);
413
+ if (next === this._turnTasks) return;
414
+ this._turnTasks = next;
415
+ this._redrawFooter();
416
+ }
417
+
418
+ applyTaskResult(name, payload, toolId) {
419
+ const next = taskEvents.applyTaskResult(this._turnTasks, name, payload, toolId);
420
+ if (next === this._turnTasks) return;
421
+ this._turnTasks = next;
422
+ this._redrawFooter();
142
423
  }
143
424
  updateSpinner(text) {
144
425
  this._spinText = text || "";
145
- if (!this._spinTimer && this.enabled && this.out.isTTY) this.startSpinner(text);
426
+ if (!this._spinTimer && this.out.isTTY && (this.enabled || this._turnChrome)) this.startSpinner(text);
146
427
  }
147
- stopSpinner() {
428
+ stopSpinner(force = false) {
429
+ if (this._turnChrome && !force) return;
148
430
  if (this._spinTimer) {
149
431
  clearInterval(this._spinTimer);
150
432
  this._spinTimer = null;
151
- this.out.write("\r\x1b[2K");
152
- this._atLineStart = true;
433
+ if (!this._turnChrome) {
434
+ this.out.write("\r\x1b[2K");
435
+ this._atLineStart = true;
436
+ }
153
437
  }
154
438
  }
155
439
 
@@ -164,8 +448,19 @@ class Ui {
164
448
  }
165
449
 
166
450
  // ── 스트리밍 텍스트 ──
167
- streamStart() {
168
- this.stopSpinner();
451
+ streamStart(keepFooter = false) {
452
+ if (this._turnChrome && keepFooter) {
453
+ this._streamKeepsFooter = true;
454
+ this.ensureNl();
455
+ this._streaming = true;
456
+ return;
457
+ }
458
+ this._resumeSpinnerAfterStream = !!this._spinTimer;
459
+ this.stopSpinner(true);
460
+ if (this._turnChrome) {
461
+ this._eraseFooter();
462
+ this._footerSuspended = true;
463
+ }
169
464
  this.ensureNl();
170
465
  this._streaming = true;
171
466
  }
@@ -180,27 +475,57 @@ class Ui {
180
475
  this.ensureNl();
181
476
  this._streaming = false;
182
477
  }
478
+ if (this._streamKeepsFooter) {
479
+ this._streamKeepsFooter = false;
480
+ return;
481
+ }
482
+ if (this._turnChrome) {
483
+ this._footerSuspended = false;
484
+ this._drawFooter();
485
+ if (this._resumeSpinnerAfterStream) this.startSpinner(this._spinText);
486
+ }
487
+ this._resumeSpinnerAfterStream = false;
183
488
  }
184
489
 
185
490
  // ── 툴 호출/결과 라인 (claude/codex 스타일) ──
186
491
  tool(name, arg) {
187
- this.stopSpinner();
188
492
  this.ensureNl();
189
- const head = this.c.green("⏺ ") + this.c.bold(this.c.text(name));
190
- this.line(arg ? head + " " + this.c.dim(truncate(String(arg), 200)) : head);
493
+ this._turnActions += 1;
494
+ this._lastTool = { name: String(name || "tool"), arg: String(arg || "") };
495
+ const columns = Math.max(24, this.out.columns || 100);
496
+ const displayName = truncateCells(String(name || "tool"), Math.max(8, Math.min(28, columns - 12)));
497
+ const headWidth = visibleWidth(`● ${displayName} `);
498
+ const room = Math.max(8, Math.min(140, columns - headWidth - 1));
499
+ const summary = compactToolArg(name, arg, room);
500
+ const head = this.c.green("● ") + this.c.bold(this.c.text(displayName));
501
+ this.line(summary ? head + " " + this.c.dim(summary) : head);
502
+ this._spinText = this.lang === "ko" ? `${name} 실행 중` : `Running ${name}`;
191
503
  }
192
- toolResult(text, ok = true) {
193
- this.stopSpinner();
194
- const body = truncate(String(text || "").trim(), 600);
195
- if (!body) {
196
- this.line(" " + (ok ? this.c.dim("done") : this.c.paw("error")));
504
+ toolResult(text, ok = true, options = {}) {
505
+ if (!ok) this._turnFailures += 1;
506
+ if (options.verbose) {
507
+ const body = truncate(stripAnsi(String(text || "")).trim(), options.maxChars || 4_000);
508
+ const lines = body ? body.split("\n") : [ok ? "done" : "error"];
509
+ const marker = ok ? this.c.green(" └ ") : this.c.paw(" └ ");
510
+ for (let index = 0; index < lines.length; index++) {
511
+ this.line((index === 0 ? marker : " ") + this.c.dim(lines[index]));
512
+ }
197
513
  return;
198
514
  }
199
- const lines = body.split("\n");
200
- const marker = ok ? this.c.dim(" └ ") : this.c.paw(" └ ");
201
- for (let i = 0; i < lines.length; i++) {
202
- this.line((i === 0 ? marker : " ") + this.c.dim(lines[i]));
203
- }
515
+ const marker = ok ? this.c.green(" └ ✓ ") : this.c.paw(" └ ✗ ");
516
+ const columns = Math.max(24, this.out.columns || 100);
517
+ const summary = compactResult(
518
+ text,
519
+ ok,
520
+ this._lastTool && this._lastTool.name,
521
+ Math.max(8, columns - visibleWidth(stripAnsi(marker)) - 1),
522
+ );
523
+ this.line(marker + this.c.dim(summary.headline));
524
+ for (const detail of summary.details) this.line(this.c.faint(" " + detail));
525
+ const count = this._turnActions;
526
+ this._spinText = this._turnFailures
527
+ ? (this.lang === "ko" ? `${count}개 작업 · ${this._turnFailures}개 확인 필요` : `${count} actions · ${this._turnFailures} need attention`)
528
+ : (this.lang === "ko" ? `${count}개 작업 완료 · 계속 진행 중` : `${count} action${count === 1 ? "" : "s"} complete · working`);
204
529
  }
205
530
 
206
531
  status(msg) {