agent.libx.js 0.92.2 → 0.92.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/cli.ts CHANGED
@@ -358,23 +358,38 @@ function makeHost(format: 'text' | 'json' | 'stream-json' = 'text', opts?: { str
358
358
  };
359
359
  }
360
360
 
361
- /** Hooks that render tool activity to stderr: a preToolUse header, and for edits a colorized diff. */
362
- function displayHooks(fs?: IFilesystem): Hooks {
361
+ /** Hooks that render tool activity to stderr: a preToolUse header, and for edits a colorized diff.
362
+ * `opts.background` marks the agent as a BACKGROUND one (duplex workers): its chrome lands async on
363
+ * top of a live prompt, so it must never drive the foreground spinner (the 'agentx › '/'⠹ thinking…'
364
+ * flicker), clears the prompt line before printing, and repaints via the callback after. `opts.gate`
365
+ * is evaluated per call — false renders nothing (minimal mode: task events only). */
366
+ function displayHooks(fs?: IFilesystem, opts?: { gate?: () => boolean; background?: () => void }): Hooks {
363
367
  const EDIT = new Set(['Edit', 'MultiEdit', 'Write']);
364
368
  const before = new Map<string, string>();
365
369
  const MAX = 64 * 1024; // skip diffing very large files
370
+ const bg = opts?.background;
371
+ const on = () => !opts?.gate || opts.gate();
366
372
  const read = async (p: unknown): Promise<string> => {
367
373
  if (!fs || typeof p !== 'string') return '';
368
374
  try { return await fs.readFile(p); } catch { return ''; }
369
375
  };
370
376
  return {
371
377
  async preToolUse(call) {
372
- spinner.stop(); // a tool is about to run → stop "thinking…"
378
+ if (!on()) return;
379
+ if (bg) err('\r\x1b[0J'); else spinner.stop(); // foreground: a tool is about to run → stop "thinking…"
373
380
  err(cyan(`\n ⚙ ${call.name}`) + dim(' ' + summarizeCall(call.name, call.args)) + '\n');
374
381
  if (EDIT.has(call.name)) before.set(String(call.args?.path), await read(call.args?.path));
382
+ bg?.();
383
+ },
384
+ onToolOutput(_call, chunk) {
385
+ if (!verboseOutput || !on()) return; // Ctrl+O verbose: live-tail streaming tool output (default chrome stays calm)
386
+ if (bg) err('\r\x1b[0J');
387
+ for (const ln of String(chunk).split('\n')) if (ln.trim()) err(dim(` ⋮ ${ln.length > 200 ? ln.slice(0, 200) + '…' : ln}\n`));
388
+ bg?.();
375
389
  },
376
390
  async postToolUse(call, result) {
377
- spinner.stop();
391
+ if (!on()) return;
392
+ if (bg) err('\r\x1b[0J'); else spinner.stop();
378
393
  try {
379
394
  if (EDIT.has(call.name)) {
380
395
  const path = String(call.args?.path);
@@ -401,7 +416,8 @@ function displayHooks(fs?: IFilesystem): Hooks {
401
416
  err(dim(' ⎿ (no output)\n')); // empty result → confirm completion so it doesn't look hung
402
417
  }
403
418
  } finally {
404
- spinner.start(); // tool done the model is thinking about the next step
419
+ if (bg) bg(); // background: repaint the live prompt below the chrome NEVER the spinner
420
+ else spinner.start(); // foreground: tool done → the model is thinking about the next step
405
421
  }
406
422
  },
407
423
  };
@@ -961,6 +977,9 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
961
977
  let voiceIO: VoiceIO | undefined; // real voice I/O (--voice + keys): mic→STT in, text_delta→TTS out
962
978
  let editorRef: LineEditor | undefined; // bound once the line editor exists — async chrome repaints the prompt via it
963
979
  let workerOptions: AgentOptions | undefined;
980
+ // Worker UI verbosity: 'full' = ⚙ tool chrome per worker step; 'minimal' = task events only
981
+ // (started/progress/⦿ done). Voice defaults minimal (chrome is noise next to speech); /workers toggles live.
982
+ let workerChrome: 'full' | 'minimal' = 'full';
964
983
  let duplexPersist: () => void = () => {}; // bound once the session exists (re-voice fires async)
965
984
  let duplexAccount: (data: any) => void = () => {}; // worker cost → session meta (bound below)
966
985
  // Workers are non-interactive: a permission 'ask' can't pop a menu mid-conversation (it would fight
@@ -978,6 +997,11 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
978
997
  if (workerOptions.permissions)
979
998
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: undefined, ask: duplexAsk });
980
999
  workerOptions.planMode = false; // a worker's plan could never be approved (no host) — it would stall to maxSteps
1000
+ // Workers are BACKGROUND agents: rebuild their display hooks so chrome never drives the foreground
1001
+ // spinner (the 'agentx › '/'⠹ thinking…' flicker) and repaints the live prompt after each print.
1002
+ workerChrome = args.voice ? 'minimal' : (cfg.workerChrome ?? 'full');
1003
+ const workerDisplay = displayHooks(agent.options.fs, { gate: () => workerChrome === 'full', background: () => editorRef?.redrawNow() });
1004
+ workerOptions.hooks = cfg.hooks ? composeHooks(workerDisplay, hooksFromConfig(cfg.hooks)) : workerDisplay;
981
1005
  // The single voice: markdown-rendered deltas on stdout = the SPOKEN channel. Everything else
982
1006
  // (⚙ tool chrome, task events, worker results) is VISUAL chrome on stderr — when wired to a
983
1007
  // real voice API, only text_delta becomes speech; the rest feeds the screen.
@@ -1395,7 +1419,13 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1395
1419
  else err(dim(' ' + (duplex ? `voice ${dx!.options.voiceModel} · worker ${work.model}` : work.model) + '\n'));
1396
1420
  },
1397
1421
  },
1398
- ...(duplex ? { 'voice-model': {
1422
+ ...(duplex ? { workers: {
1423
+ desc: 'duplex worker chrome — /workers <full|minimal>: per-step ⚙ tool activity vs task events only',
1424
+ run: async (a: string[]) => {
1425
+ if (a[0] === 'full' || a[0] === 'minimal') { workerChrome = a[0]; err(green(` ✓ worker chrome → ${a[0]}\n`)); return; }
1426
+ err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)\n`));
1427
+ },
1428
+ }, 'voice-model': {
1399
1429
  desc: 'switch the duplex voice (fast) model — /voice-model <id>, or alone for a picker',
1400
1430
  run: async (a: string[]) => {
1401
1431
  const apply = (id: string) => {
@@ -1,5 +1,5 @@
1
1
  import { IFilesystem } from '@livx.cc/wcli/core';
2
- import { M as Message, H as HostBridge, A as AgentTool, C as ChatLike, e as MessageContent } from './tools-CeK5AquG.js';
2
+ import { M as Message, H as HostBridge, A as AgentTool, C as ChatLike, e as MessageContent } from './tools-GPWp7oXq.js';
3
3
 
4
4
  /**
5
5
  * Hooks — deterministic interception points around tool execution, run by the
@@ -31,6 +31,9 @@ interface Hooks {
31
31
  preToolUse?(call: ToolUse, meta?: ToolUseMeta): Promise<PreToolUseDecision | void> | PreToolUseDecision | void;
32
32
  /** Observe a tool's result after it ran (audit, metrics, side-channel). */
33
33
  postToolUse?(call: ToolUse, result: string, meta?: ToolUseMeta): void | Promise<void>;
34
+ /** Observe a tool's INCREMENTAL output while it runs (only tools that stream, e.g. the real
35
+ * Shell). Fire-and-forget — sync, never awaited, never alters the result. */
36
+ onToolOutput?(call: ToolUse, chunk: string, meta?: ToolUseMeta): void;
34
37
  /** Fired once when the agent loop stops cleanly with the model's final text. */
35
38
  onStop?(finalText: string): void;
36
39
  /** Fired once at session start (a fresh `run()`, or the first `send()`). Return a string to inject
@@ -59,11 +62,17 @@ declare class RecordingHooks implements Hooks {
59
62
  result: string;
60
63
  meta?: ToolUseMeta;
61
64
  }>;
65
+ outputs: Array<{
66
+ call: ToolUse;
67
+ chunk: string;
68
+ meta?: ToolUseMeta;
69
+ }>;
62
70
  stops: string[];
63
71
  /** tool name -> reason; a matching preToolUse call is blocked with that reason. */
64
72
  constructor(blocks?: Record<string, string>);
65
73
  preToolUse(call: ToolUse, meta?: ToolUseMeta): PreToolUseDecision | void;
66
74
  postToolUse(call: ToolUse, result: string, meta?: ToolUseMeta): void;
75
+ onToolOutput(call: ToolUse, chunk: string, meta?: ToolUseMeta): void;
67
76
  onStop(finalText: string): void;
68
77
  }
69
78
  /** Recording lifecycle hooks for tests: capture session-start/prompt-submit/pre-compact + script transforms. */
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { h as RunResult, R as ReasoningEffort } from './Agent-BzwprwHr.js';
2
+ import { h as RunResult, R as ReasoningEffort } from './Agent-QwBA0wu6.js';
3
3
  import { IFilesystem } from '@livx.cc/wcli/core';
4
- import { M as Message, c as ContentPart } from './tools-CeK5AquG.js';
4
+ import { M as Message, c as ContentPart } from './tools-GPWp7oXq.js';
5
5
 
6
6
  /**
7
7
  * On-disk session store for the CLI: each conversation is one JSON file at
package/dist/cli.js CHANGED
@@ -1386,6 +1386,18 @@ function makeRealShellTool(options) {
1386
1386
  timedOut = true;
1387
1387
  ctl.abort();
1388
1388
  }, timeoutMs);
1389
+ let pend = "";
1390
+ let flushTimer = null;
1391
+ const flushEmit = (ctx2) => {
1392
+ if (flushTimer) {
1393
+ clearTimeout(flushTimer);
1394
+ flushTimer = null;
1395
+ }
1396
+ if (pend) {
1397
+ ctx2.emit?.(redactSecrets(pend));
1398
+ pend = "";
1399
+ }
1400
+ };
1389
1401
  try {
1390
1402
  return await new Promise((resolve4) => {
1391
1403
  let out = "";
@@ -1402,7 +1414,13 @@ function makeRealShellTool(options) {
1402
1414
  return finish(`[exit 1] failed to spawn shell: ${e?.message ?? e}`);
1403
1415
  }
1404
1416
  const collect = (chunk) => {
1405
- out += typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
1417
+ const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
1418
+ out += s;
1419
+ if (ctx.emit && !settled) {
1420
+ pend += s;
1421
+ if (pend.length >= 1024) flushEmit(ctx);
1422
+ else flushTimer ??= setTimeout(() => flushEmit(ctx), 250);
1423
+ }
1406
1424
  };
1407
1425
  proc.stdout?.on("data", collect);
1408
1426
  proc.stderr?.on("data", collect);
@@ -1412,6 +1430,7 @@ function makeRealShellTool(options) {
1412
1430
  finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean(out) : ""}`);
1413
1431
  });
1414
1432
  proc.on("close", (code) => {
1433
+ flushEmit(ctx);
1415
1434
  if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
1416
1435
  const body = clean(out);
1417
1436
  if (code && code !== 0) return finish(`[exit ${code}]${body ? "\n" + body : ""}`);
@@ -1420,6 +1439,7 @@ function makeRealShellTool(options) {
1420
1439
  });
1421
1440
  } finally {
1422
1441
  clearTimeout(timer);
1442
+ if (flushTimer) clearTimeout(flushTimer);
1423
1443
  ctx.signal?.removeEventListener("abort", onAbort);
1424
1444
  }
1425
1445
  }
@@ -2409,6 +2429,9 @@ function composeHooks(...list) {
2409
2429
  async postToolUse(call, result, meta) {
2410
2430
  for (const h of hooks) await h.postToolUse?.(call, result, meta);
2411
2431
  },
2432
+ onToolOutput(call, chunk, meta) {
2433
+ for (const h of hooks) h.onToolOutput?.(call, chunk, meta);
2434
+ },
2412
2435
  onStop(text) {
2413
2436
  for (const h of hooks) h.onStop?.(text);
2414
2437
  },
@@ -2919,6 +2942,13 @@ var Agent = class _Agent {
2919
2942
  let threw = false;
2920
2943
  try {
2921
2944
  log3.debug(`${tc.function.name}(${tc.function.arguments})`);
2945
+ this.ctx.emit = hooks?.onToolOutput ? (chunk) => {
2946
+ try {
2947
+ hooks.onToolOutput(call, chunk, meta);
2948
+ } catch (e) {
2949
+ log3.debug(`onToolOutput hook error: ${e}`);
2950
+ }
2951
+ } : void 0;
2922
2952
  const raw = await tool.run(args, this.ctx);
2923
2953
  if (typeof raw === "string") {
2924
2954
  result = raw;
@@ -2931,6 +2961,8 @@ var Agent = class _Agent {
2931
2961
  log3.debug(`${tc.function.name} -> error: ${msg}`);
2932
2962
  result = `Error: ${msg}`;
2933
2963
  threw = true;
2964
+ } finally {
2965
+ this.ctx.emit = void 0;
2934
2966
  }
2935
2967
  if (!threw) result = await this.maybeAutoTest(tc.function.name, result);
2936
2968
  await hooks?.postToolUse?.(call, result, meta);
@@ -3514,6 +3546,10 @@ ${recent}` : brief;
3514
3546
  postToolUse: async (call, result, meta) => {
3515
3547
  await base?.postToolUse?.(call, result, meta);
3516
3548
  report.post(call);
3549
+ },
3550
+ onToolOutput: (call, chunk, meta) => {
3551
+ base?.onToolOutput?.(call, chunk, meta);
3552
+ report.output(chunk);
3517
3553
  }
3518
3554
  } : base;
3519
3555
  const worker = new Agent({
@@ -3550,13 +3586,18 @@ ${recent}` : brief;
3550
3586
  const rec = this.tasks.get(id);
3551
3587
  if (!rec || rec.status !== "running") return clearInterval(timer);
3552
3588
  if (!inflight || !due()) return;
3553
- emit(rec, `still inside ${describeCall(inflight.call)} \u2014 ${Math.round((Date.now() - inflight.at) / 1e3)}s on this step`, inflight.call);
3589
+ const last = inflight.tail.trim().split("\n").filter(Boolean).pop()?.slice(-80);
3590
+ emit(rec, `still inside ${describeCall(inflight.call)} \u2014 ${Math.round((Date.now() - inflight.at) / 1e3)}s on this step${last ? `, last output: ${last}` : ""}`, inflight.call);
3554
3591
  }, Math.max(this.options.progressIntervalMs, 250));
3555
3592
  timer.unref?.();
3556
3593
  return {
3557
3594
  pre: (call) => {
3558
- inflight = { call, at: Date.now() };
3595
+ inflight = { call, at: Date.now(), tail: "" };
3596
+ },
3597
+ output: (chunk) => {
3598
+ if (inflight) inflight.tail = (inflight.tail + chunk).slice(-500);
3559
3599
  },
3600
+ // digest only — NEVER re-voices directly
3560
3601
  post: (call) => {
3561
3602
  steps++;
3562
3603
  inflight = null;
@@ -7087,10 +7128,12 @@ function makeHost(format = "text", opts) {
7087
7128
  }
7088
7129
  };
7089
7130
  }
7090
- function displayHooks(fs) {
7131
+ function displayHooks(fs, opts) {
7091
7132
  const EDIT = /* @__PURE__ */ new Set(["Edit", "MultiEdit", "Write"]);
7092
7133
  const before = /* @__PURE__ */ new Map();
7093
7134
  const MAX = 64 * 1024;
7135
+ const bg = opts?.background;
7136
+ const on = () => !opts?.gate || opts.gate();
7094
7137
  const read = async (p) => {
7095
7138
  if (!fs || typeof p !== "string") return "";
7096
7139
  try {
@@ -7101,13 +7144,25 @@ function displayHooks(fs) {
7101
7144
  };
7102
7145
  return {
7103
7146
  async preToolUse(call) {
7104
- spinner.stop();
7147
+ if (!on()) return;
7148
+ if (bg) err("\r\x1B[0J");
7149
+ else spinner.stop();
7105
7150
  err(cyan(`
7106
7151
  \u2699 ${call.name}`) + dim(" " + summarizeCall(call.name, call.args)) + "\n");
7107
7152
  if (EDIT.has(call.name)) before.set(String(call.args?.path), await read(call.args?.path));
7153
+ bg?.();
7154
+ },
7155
+ onToolOutput(_call, chunk) {
7156
+ if (!verboseOutput || !on()) return;
7157
+ if (bg) err("\r\x1B[0J");
7158
+ for (const ln of String(chunk).split("\n")) if (ln.trim()) err(dim(` \u22EE ${ln.length > 200 ? ln.slice(0, 200) + "\u2026" : ln}
7159
+ `));
7160
+ bg?.();
7108
7161
  },
7109
7162
  async postToolUse(call, result) {
7110
- spinner.stop();
7163
+ if (!on()) return;
7164
+ if (bg) err("\r\x1B[0J");
7165
+ else spinner.stop();
7111
7166
  try {
7112
7167
  if (EDIT.has(call.name)) {
7113
7168
  const path = String(call.args?.path);
@@ -7136,7 +7191,8 @@ function displayHooks(fs) {
7136
7191
  err(dim(" \u23BF (no output)\n"));
7137
7192
  }
7138
7193
  } finally {
7139
- spinner.start();
7194
+ if (bg) bg();
7195
+ else spinner.start();
7140
7196
  }
7141
7197
  }
7142
7198
  };
@@ -7648,6 +7704,7 @@ async function repl(args, ai, cfg, cwd) {
7648
7704
  let voiceIO;
7649
7705
  let editorRef;
7650
7706
  let workerOptions;
7707
+ let workerChrome = "full";
7651
7708
  let duplexPersist = () => {
7652
7709
  };
7653
7710
  let duplexAccount = () => {
@@ -7663,6 +7720,9 @@ async function repl(args, ai, cfg, cwd) {
7663
7720
  if (workerOptions.permissions)
7664
7721
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: void 0, ask: duplexAsk });
7665
7722
  workerOptions.planMode = false;
7723
+ workerChrome = args.voice ? "minimal" : cfg.workerChrome ?? "full";
7724
+ const workerDisplay = displayHooks(agent.options.fs, { gate: () => workerChrome === "full", background: () => editorRef?.redrawNow() });
7725
+ workerOptions.hooks = cfg.hooks ? composeHooks(workerDisplay, hooksFromConfig(cfg.hooks)) : workerDisplay;
7666
7726
  const base = makeHost("text", { stream: true });
7667
7727
  const host = {
7668
7728
  ...base,
@@ -8121,7 +8181,19 @@ ${extra}` : body);
8121
8181
  else err(dim(" " + (duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model) + "\n"));
8122
8182
  }
8123
8183
  },
8124
- ...duplex ? { "voice-model": {
8184
+ ...duplex ? { workers: {
8185
+ desc: "duplex worker chrome \u2014 /workers <full|minimal>: per-step \u2699 tool activity vs task events only",
8186
+ run: async (a) => {
8187
+ if (a[0] === "full" || a[0] === "minimal") {
8188
+ workerChrome = a[0];
8189
+ err(green(` \u2713 worker chrome \u2192 ${a[0]}
8190
+ `));
8191
+ return;
8192
+ }
8193
+ err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)
8194
+ `));
8195
+ }
8196
+ }, "voice-model": {
8125
8197
  desc: "switch the duplex voice (fast) model \u2014 /voice-model <id>, or alone for a picker",
8126
8198
  run: async (a) => {
8127
8199
  const apply = (id) => {