agent.libx.js 0.92.3 → 0.92.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.
package/cli/cli.ts CHANGED
@@ -358,27 +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?.();
375
383
  },
376
384
  onToolOutput(_call, chunk) {
377
- if (!verboseOutput) return; // Ctrl+O verbose: live-tail streaming tool output (default chrome stays calm)
385
+ if (!verboseOutput || !on()) return; // Ctrl+O verbose: live-tail streaming tool output (default chrome stays calm)
386
+ if (bg) err('\r\x1b[0J');
378
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?.();
379
389
  },
380
390
  async postToolUse(call, result) {
381
- spinner.stop();
391
+ if (!on()) return;
392
+ if (bg) err('\r\x1b[0J'); else spinner.stop();
382
393
  try {
383
394
  if (EDIT.has(call.name)) {
384
395
  const path = String(call.args?.path);
@@ -405,7 +416,8 @@ function displayHooks(fs?: IFilesystem): Hooks {
405
416
  err(dim(' ⎿ (no output)\n')); // empty result → confirm completion so it doesn't look hung
406
417
  }
407
418
  } finally {
408
- 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
409
421
  }
410
422
  },
411
423
  };
@@ -965,13 +977,17 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
965
977
  let voiceIO: VoiceIO | undefined; // real voice I/O (--voice + keys): mic→STT in, text_delta→TTS out
966
978
  let editorRef: LineEditor | undefined; // bound once the line editor exists — async chrome repaints the prompt via it
967
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';
968
983
  let duplexPersist: () => void = () => {}; // bound once the session exists (re-voice fires async)
969
984
  let duplexAccount: (data: any) => void = () => {}; // worker cost → session meta (bound below)
970
985
  // Workers are non-interactive: a permission 'ask' can't pop a menu mid-conversation (it would fight
971
986
  // the line editor for raw stdin). Auto-deny with a narratable reason — the worker adapts or reports
972
987
  // back and the voice narrates it. Allow/deny rules (config, --allowedTools, --yes) apply as-is.
973
988
  const duplexAsk = async (call: ToolUse): Promise<{ decision: 'allow' | 'deny' }> => {
974
- err(yellow(` ⊘ worker asked to run ${call.name} — auto-denied (no interactive approval in duplex; use --yes or --allowedTools)\n`));
989
+ err('\r\x1b[0J' + yellow(` ⊘ worker asked to run ${call.name} — auto-denied (no interactive approval in duplex; use --yes or --allowedTools)\n`));
990
+ editorRef?.redrawNow(); // background event at a live prompt — repaint below the notice
975
991
  return { decision: 'deny' };
976
992
  };
977
993
  if (duplex) {
@@ -982,6 +998,11 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
982
998
  if (workerOptions.permissions)
983
999
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: undefined, ask: duplexAsk });
984
1000
  workerOptions.planMode = false; // a worker's plan could never be approved (no host) — it would stall to maxSteps
1001
+ // Workers are BACKGROUND agents: rebuild their display hooks so chrome never drives the foreground
1002
+ // spinner (the 'agentx › '/'⠹ thinking…' flicker) and repaints the live prompt after each print.
1003
+ workerChrome = args.voice ? 'minimal' : (cfg.workerChrome ?? 'full');
1004
+ const workerDisplay = displayHooks(agent.options.fs, { gate: () => workerChrome === 'full', background: () => editorRef?.redrawNow() });
1005
+ workerOptions.hooks = cfg.hooks ? composeHooks(workerDisplay, hooksFromConfig(cfg.hooks)) : workerDisplay;
985
1006
  // The single voice: markdown-rendered deltas on stdout = the SPOKEN channel. Everything else
986
1007
  // (⚙ tool chrome, task events, worker results) is VISUAL chrome on stderr — when wired to a
987
1008
  // real voice API, only text_delta becomes speech; the rest feeds the screen.
@@ -1019,6 +1040,15 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1019
1040
  editorRef?.redrawNow();
1020
1041
  return;
1021
1042
  }
1043
+ // Remaining task_* events (started/progress/cancelled/error) land ASYNC at a live prompt —
1044
+ // same treatment as task_done: clear the prompt line, print, repaint (bare err() leaves
1045
+ // residue glued to 'agentx › '). task_done returned above; everything else falls through.
1046
+ if (typeof e.kind === 'string' && e.kind.startsWith('task_')) {
1047
+ spinner.stop();
1048
+ err('\r\x1b[0J' + dim(` · ${e.message}\n`));
1049
+ editorRef?.redrawNow();
1050
+ return;
1051
+ }
1022
1052
  base.notify!(e); // makeHost always provides notify (the HostBridge type just marks it optional)
1023
1053
  },
1024
1054
  };
@@ -1399,7 +1429,13 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1399
1429
  else err(dim(' ' + (duplex ? `voice ${dx!.options.voiceModel} · worker ${work.model}` : work.model) + '\n'));
1400
1430
  },
1401
1431
  },
1402
- ...(duplex ? { 'voice-model': {
1432
+ ...(duplex ? { workers: {
1433
+ desc: 'duplex worker chrome — /workers <full|minimal>: per-step ⚙ tool activity vs task events only',
1434
+ run: async (a: string[]) => {
1435
+ if (a[0] === 'full' || a[0] === 'minimal') { workerChrome = a[0]; err(green(` ✓ worker chrome → ${a[0]}\n`)); return; }
1436
+ err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)\n`));
1437
+ },
1438
+ }, 'voice-model': {
1403
1439
  desc: 'switch the duplex voice (fast) model — /voice-model <id>, or alone for a picker',
1404
1440
  run: async (a: string[]) => {
1405
1441
  const apply = (id: string) => {
package/dist/cli.js CHANGED
@@ -7128,10 +7128,12 @@ function makeHost(format = "text", opts) {
7128
7128
  }
7129
7129
  };
7130
7130
  }
7131
- function displayHooks(fs) {
7131
+ function displayHooks(fs, opts) {
7132
7132
  const EDIT = /* @__PURE__ */ new Set(["Edit", "MultiEdit", "Write"]);
7133
7133
  const before = /* @__PURE__ */ new Map();
7134
7134
  const MAX = 64 * 1024;
7135
+ const bg = opts?.background;
7136
+ const on = () => !opts?.gate || opts.gate();
7135
7137
  const read = async (p) => {
7136
7138
  if (!fs || typeof p !== "string") return "";
7137
7139
  try {
@@ -7142,18 +7144,25 @@ function displayHooks(fs) {
7142
7144
  };
7143
7145
  return {
7144
7146
  async preToolUse(call) {
7145
- spinner.stop();
7147
+ if (!on()) return;
7148
+ if (bg) err("\r\x1B[0J");
7149
+ else spinner.stop();
7146
7150
  err(cyan(`
7147
7151
  \u2699 ${call.name}`) + dim(" " + summarizeCall(call.name, call.args)) + "\n");
7148
7152
  if (EDIT.has(call.name)) before.set(String(call.args?.path), await read(call.args?.path));
7153
+ bg?.();
7149
7154
  },
7150
7155
  onToolOutput(_call, chunk) {
7151
- if (!verboseOutput) return;
7156
+ if (!verboseOutput || !on()) return;
7157
+ if (bg) err("\r\x1B[0J");
7152
7158
  for (const ln of String(chunk).split("\n")) if (ln.trim()) err(dim(` \u22EE ${ln.length > 200 ? ln.slice(0, 200) + "\u2026" : ln}
7153
7159
  `));
7160
+ bg?.();
7154
7161
  },
7155
7162
  async postToolUse(call, result) {
7156
- spinner.stop();
7163
+ if (!on()) return;
7164
+ if (bg) err("\r\x1B[0J");
7165
+ else spinner.stop();
7157
7166
  try {
7158
7167
  if (EDIT.has(call.name)) {
7159
7168
  const path = String(call.args?.path);
@@ -7182,7 +7191,8 @@ function displayHooks(fs) {
7182
7191
  err(dim(" \u23BF (no output)\n"));
7183
7192
  }
7184
7193
  } finally {
7185
- spinner.start();
7194
+ if (bg) bg();
7195
+ else spinner.start();
7186
7196
  }
7187
7197
  }
7188
7198
  };
@@ -7694,13 +7704,15 @@ async function repl(args, ai, cfg, cwd) {
7694
7704
  let voiceIO;
7695
7705
  let editorRef;
7696
7706
  let workerOptions;
7707
+ let workerChrome = "full";
7697
7708
  let duplexPersist = () => {
7698
7709
  };
7699
7710
  let duplexAccount = () => {
7700
7711
  };
7701
7712
  const duplexAsk = async (call) => {
7702
- err(yellow(` \u2298 worker asked to run ${call.name} \u2014 auto-denied (no interactive approval in duplex; use --yes or --allowedTools)
7713
+ err("\r\x1B[0J" + yellow(` \u2298 worker asked to run ${call.name} \u2014 auto-denied (no interactive approval in duplex; use --yes or --allowedTools)
7703
7714
  `));
7715
+ editorRef?.redrawNow();
7704
7716
  return { decision: "deny" };
7705
7717
  };
7706
7718
  if (duplex) {
@@ -7709,6 +7721,9 @@ async function repl(args, ai, cfg, cwd) {
7709
7721
  if (workerOptions.permissions)
7710
7722
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: void 0, ask: duplexAsk });
7711
7723
  workerOptions.planMode = false;
7724
+ workerChrome = args.voice ? "minimal" : cfg.workerChrome ?? "full";
7725
+ const workerDisplay = displayHooks(agent.options.fs, { gate: () => workerChrome === "full", background: () => editorRef?.redrawNow() });
7726
+ workerOptions.hooks = cfg.hooks ? composeHooks(workerDisplay, hooksFromConfig(cfg.hooks)) : workerDisplay;
7712
7727
  const base = makeHost("text", { stream: true });
7713
7728
  const host = {
7714
7729
  ...base,
@@ -7738,6 +7753,13 @@ async function repl(args, ai, cfg, cwd) {
7738
7753
  editorRef?.redrawNow();
7739
7754
  return;
7740
7755
  }
7756
+ if (typeof e.kind === "string" && e.kind.startsWith("task_")) {
7757
+ spinner.stop();
7758
+ err("\r\x1B[0J" + dim(` \xB7 ${e.message}
7759
+ `));
7760
+ editorRef?.redrawNow();
7761
+ return;
7762
+ }
7741
7763
  base.notify(e);
7742
7764
  }
7743
7765
  };
@@ -8167,7 +8189,19 @@ ${extra}` : body);
8167
8189
  else err(dim(" " + (duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model) + "\n"));
8168
8190
  }
8169
8191
  },
8170
- ...duplex ? { "voice-model": {
8192
+ ...duplex ? { workers: {
8193
+ desc: "duplex worker chrome \u2014 /workers <full|minimal>: per-step \u2699 tool activity vs task events only",
8194
+ run: async (a) => {
8195
+ if (a[0] === "full" || a[0] === "minimal") {
8196
+ workerChrome = a[0];
8197
+ err(green(` \u2713 worker chrome \u2192 ${a[0]}
8198
+ `));
8199
+ return;
8200
+ }
8201
+ err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)
8202
+ `));
8203
+ }
8204
+ }, "voice-model": {
8171
8205
  desc: "switch the duplex voice (fast) model \u2014 /voice-model <id>, or alone for a picker",
8172
8206
  run: async (a) => {
8173
8207
  const apply = (id) => {