@livx.cc/agentx 0.99.60 → 0.99.62

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/README.md CHANGED
@@ -119,6 +119,7 @@ agentx --resume <id> "…" # resume a specific session
119
119
  - **Raise, don't guess** (`AskUserQuestion` + `finishReason: 'needs_input'`) — when a decision is genuinely the user's, the agent asks. Interactively that's an arrow-select prompt. **Headless** (`-p`, piped, no TTY) there's nobody to prompt, so instead of blocking on a prompt no one can see — or, worse, "proceeding with best judgment" — the run **stops and hands the question to its caller**: exit code `2`, a yellow `? needs input` footer, and `{finishReason:'needs_input', question:{…}, sessionId}` in `--output-format json`. The transcript is preserved, so the caller answers and continues (`--resume <id> -p "<answer>"`) instead of re-running. A delegated agent silently deciding something that was never its to decide is the failure this closes.
120
120
  - **Tab-completion** — `Tab` completes `/<command>` names and `@<path>` file/dir references (descends subdirs, dotfiles hidden unless typed) straight from the working tree.
121
121
  - **Duplex mode** — `agentx --duplex` runs the full standard REPL (slash commands, sessions, postures, rewind, MCP) with the three-tier engine driving turns: a fast voice model (`--voice-model`, default `groq/openai/gpt-oss-120b`) answers every line instantly and delegates real work to background workers built with the same wiring as a normal run (fs mode, permissions, MCP); worker activity shows as dim chrome and results are re-voiced when ready. Switch any tier live with `/model` (opens a reflex/act/think picker), or the `/voice-model` · `/think-model` shortcuts. `/tasks` lists background tasks, inspects a task's live output tail, and cancels a running one from a picker (Esc mid-turn cancels the foreground turn; Esc again at the idle prompt cancels running workers).
122
+ - **Headless duplex for host apps** — `agentx --voice --input-format stream-json --output-format stream-json` runs the same duplex engine behind an NDJSON pipe, for an app that owns its own mic/TTS (the Bare browser uses it). stdin: `{type:'user', text, id?, replaces?, cut?}` (`cut` = the host voice engine's `takeInterruptedReply()`, so "heard up to" / parked deliveries work exactly as in the CLI), `{type:'barge'}`, `{type:'cancel', task}`, `{type:'exit'}`. stdout: reflex `text`/`thinking` deltas, worker `speak` utterances, `filler`, `task` lifecycle events, `turn_done`, `revoice_done`, `exit`. `--append-system-prompt` goes to the workers, `--voice-system-prompt` to the voice model.
122
123
  - **MCP servers** — declare `mcpServers: { name: { command, args } | { url } }` in config and they're auto-mounted at startup (in parallel, with an optional `mountTimeoutMs` deadline so one slow/dead server never blocks the rest): the client does the JSON-RPC handshake (stdio or HTTP) + `tools/list`, and the discovered tools appear as `mcp__<name>__<tool>` in `/tools` (inspect with `/mcp`). A bad server is logged and skipped, never blocking the agent. For large tool sets, **deferred mode** (`makeMcpToolSearch` / `mountMcpDeferred`) exposes just two bounded tools (`ToolSearch` + `McpCall`) instead of N defs — dodging the provider tool-cap and improving selection accuracy; the CLI applies this automatically past 12 mounted tools (a 42-tool server was costing ~80k tok/turn in schema alone), and permission rules written against the real `mcp__<name>__<tool>` names still match through `McpCall`. **`mountMcpCatalog`** goes further: a cached, hash-keyed catalog + lazy connect means a turn that uses no MCP tool opens **zero** connections, and one that uses a tool connects exactly that server — latency scales with tools-used, not servers-configured. A down server is **negative-cached** (`failureCooldownMs`) so it never re-floors a later turn at the deadline. For zero turn-path latency even on a cold process, call **`warmMcpCatalog`** at boot + on a timer (off-turn discovery) and mount with **`{ discover: 'cache-only' }`** — the turn then never synchronously connects: it serves the warmed catalog and discovers any miss in the background. **Slow tools are jobs, not timeouts**: `timeoutMs` (default 30s) bounds protocol requests (`initialize`, `tools/list`) only; a `tools/call` still running after the soft foreground timeout (`foregroundMs.mcp`, 30s) returns `[still running] … background job job-N` while the request keeps going, and its result is delivered when it lands. The client sends a `progressToken` and reads progress over stdio and incrementally over HTTP/SSE; a call is cancelled (`notifications/cancelled`) only by `JobKill`, the stuck-job reaper, or its hard cap `callTimeoutMs` (per server, default 30 min).
123
124
 
124
125
  ## 🧬 It improves itself
package/dist/cli.d.ts CHANGED
@@ -141,9 +141,11 @@ interface Args {
141
141
  sessionId?: string;
142
142
  fork?: boolean;
143
143
  outputFormat: 'text' | 'json' | 'stream-json';
144
+ inputFormat?: 'stream-json';
144
145
  allowedTools?: string[];
145
146
  disallowedTools?: string[];
146
147
  appendSystemPrompt?: string;
148
+ voiceSystemPrompt?: string;
147
149
  addDirs?: string[];
148
150
  print?: boolean;
149
151
  debug?: boolean;
package/dist/cli.js CHANGED
@@ -10381,7 +10381,7 @@ Another agent just implemented the above. Independently check the CURRENT state
10381
10381
  const rec = this.tasks.get(id);
10382
10382
  if (res.finishReason === "aborted" || rec.status === "cancelled") {
10383
10383
  rec.status = "cancelled";
10384
- this.notify("task_cancelled", `task ${id} (${rec.label}) cancelled`);
10384
+ this.notify("task_cancelled", `task ${id} (${rec.label}) cancelled`, { id });
10385
10385
  return;
10386
10386
  }
10387
10387
  if (res.finishReason === "error") {
@@ -10433,7 +10433,7 @@ Another agent just implemented the above. Independently check the CURRENT state
10433
10433
  rec.status = "error";
10434
10434
  rec.result = msg;
10435
10435
  log20.warn(`task ${rec.id} failed: ${msg}`);
10436
- this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
10436
+ this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`, { id: rec.id });
10437
10437
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
10438
10438
  this.dropParked(rec);
10439
10439
  this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
@@ -15733,6 +15733,7 @@ function parseArgs(argv) {
15733
15733
  else if (x === "--allowedTools" || x === "--allowed-tools") a.allowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
15734
15734
  else if (x === "--disallowedTools" || x === "--disallowed-tools") a.disallowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
15735
15735
  else if (x === "--append-system-prompt") a.appendSystemPrompt = val(++i, x);
15736
+ else if (x === "--voice-system-prompt") a.voiceSystemPrompt = val(++i, x);
15736
15737
  else if (x === "--add-dir") (a.addDirs ??= []).push(val(++i, x));
15737
15738
  else if (x === "--reasoning") a.reasoning = parseReasoning(val(++i, x));
15738
15739
  else if (x === "--session-id") a.sessionId = val(++i, x);
@@ -15745,6 +15746,10 @@ function parseArgs(argv) {
15745
15746
  const f = argv[++i];
15746
15747
  if (f !== "text" && f !== "json" && f !== "stream-json") throw new Error(`invalid --output-format: ${f ?? "(missing)"} (use text|json|stream-json)`);
15747
15748
  a.outputFormat = f;
15749
+ } else if (x === "--input-format") {
15750
+ const f = argv[++i];
15751
+ if (f !== "stream-json") throw new Error(`invalid --input-format: ${f ?? "(missing)"} (use stream-json)`);
15752
+ a.inputFormat = f;
15748
15753
  } else if (x === "--") {
15749
15754
  rest.push(...argv.slice(i + 1));
15750
15755
  break;
@@ -15757,7 +15762,9 @@ function parseArgs(argv) {
15757
15762
  if (a.seed && !a.boddb) throw new Error("--seed only applies with --boddb (it seeds the database from cwd on first run)");
15758
15763
  if (a.worktree && (a.vfs || a.boddb)) throw new Error("--worktree is a real-disk concept \u2014 incompatible with --vfs/--boddb");
15759
15764
  if (a.duplex && (a.task || a.print)) throw new Error("--duplex is interactive-only (a conversational mode) \u2014 drop the task/-p");
15765
+ if (a.inputFormat && (!a.duplex || a.outputFormat !== "stream-json")) throw new Error("--input-format stream-json is headless duplex \u2014 use it with --duplex (or --voice) and --output-format stream-json");
15760
15766
  if (a.duplex && a.plan) throw new Error("--plan is not supported in --duplex (workers are non-interactive; a plan could never be approved)");
15767
+ if (a.voiceSystemPrompt && !a.duplex) throw new Error("--voice-system-prompt only applies with --duplex");
15761
15768
  if (a.voiceModel && !a.duplex) throw new Error("--voice-model only applies with --duplex");
15762
15769
  if (a.thinkModel !== void 0 && !a.duplex) throw new Error("--think-model/--no-think only apply with --duplex");
15763
15770
  if (!canPrompt && (a.ask || a.plan))
@@ -15807,6 +15814,7 @@ Flags:
15807
15814
  --voice-model <id> with --duplex: the fast voice model (default groq/openai/gpt-oss-120b)
15808
15815
  --think-model <id> with --duplex: the premium deep-reasoning model (default anthropic/claude-opus-4-6)
15809
15816
  --no-think with --duplex: disable the Think tier (Act handles everything)
15817
+ --voice-system-prompt <text> with --duplex: appended to the voice (reflex) prompt only (--append-system-prompt \u2192 workers)
15810
15818
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
15811
15819
  --subagents allow the Task tool (spawn child agents)
15812
15820
  --reasoning <e> extended thinking: off|low|medium|high or a token budget (anthropic/openai)
@@ -15816,6 +15824,8 @@ Flags:
15816
15824
  --max-steps <n> step budget (default 30)
15817
15825
  --max-tokens <n> token budget kill-switch (default 200000)
15818
15826
  --timeout <sec> wall-clock kill-switch (default 120)
15827
+ --input-format stream-json with --duplex|--voice + --output-format stream-json: headless duplex for a host app
15828
+ (NDJSON turns/barge-ins on stdin, reflex text + worker speech + task events on stdout)
15819
15829
  --output-format <f> headless output: text (default) | json (one result object) | stream-json (NDJSON events: {type:text|thinking}\u2026 then {type:result})
15820
15830
  -v, --version print version and exit
15821
15831
  -h, --help
@@ -17210,6 +17220,7 @@ async function repl(args, ai, cfg, cwd) {
17210
17220
  exitRequested = true;
17211
17221
  })] }
17212
17222
  });
17223
+ if (args.voiceSystemPrompt) dx.voice.options.systemPrompt += "\n\n" + args.voiceSystemPrompt;
17213
17224
  }
17214
17225
  const face = dx ? dx.voice : agent;
17215
17226
  const work = workerOptions ?? agent.options;
@@ -19208,6 +19219,88 @@ ${out}
19208
19219
  disposeClaudeCodeSessions();
19209
19220
  await closeMcp(mounted);
19210
19221
  }
19222
+ async function duplexServe(args, ai, cfg, cwd) {
19223
+ const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
19224
+ const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join14(cwd, ".agent", "mcp-auth.json") }));
19225
+ installCancelGuards(mounted);
19226
+ const agent = await makeAgent(args, ai, cfg, mcpAgentTools(mounted, { quiet: true }), mounted.map((m) => m.name));
19227
+ const { host: _h, stream: _s, signal: _g, providerOptions: _po, ...wo } = agent.options;
19228
+ const host = {
19229
+ notify(e) {
19230
+ const k = String(e.kind);
19231
+ if (k === "text_delta") out({ type: "text", text: e.message });
19232
+ else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
19233
+ else if (k === "speak_utterance") out({ type: "speak", text: e.message });
19234
+ else if (k === "hold_filler") out({ type: "filler", text: e.message });
19235
+ else if (k === "revoice_done") {
19236
+ out({ type: "revoice_done" });
19237
+ persist();
19238
+ } else if (k.startsWith("task_")) out({ type: "task", kind: k.slice(5), id: e.data?.id, message: e.message, data: e.data });
19239
+ }
19240
+ };
19241
+ let persist = () => {
19242
+ };
19243
+ const dx = new DuplexAgent({
19244
+ ai,
19245
+ fs: agent.options.fs,
19246
+ memoryDir: agent.options.memoryDir,
19247
+ memoryUserDir: agent.options.memoryUserDir,
19248
+ ...args.voiceModel ?? cfg.reflexModel ? { reflexModel: resolveModelOrNewest(args.voiceModel ?? cfg.reflexModel) } : {},
19249
+ actModel: agent.options.model,
19250
+ actOptions: { ...wo, planMode: false },
19251
+ providerOptionsFor: (m) => providerOptionsFor(m, cwd, cfg.mcpServers, mounted.map((x) => x.name)),
19252
+ ...(args.thinkModel ?? cfg.thinkModel) !== void 0 ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {},
19253
+ host,
19254
+ // --voice: the CLI voice register (progress asides, spoken budget). No emotion tags: the host's TTS may not speak them.
19255
+ ...args.voice ? { voiceStyle: "conversational", progressUpdates: true, spokenBudgetChars: 350 } : {},
19256
+ reflexOptions: { fs: agent.options.fs, tools: [exitSessionTool(() => out({ type: "exit" }))] }
19257
+ });
19258
+ if (args.voiceSystemPrompt) dx.voice.options.systemPrompt += "\n\n" + args.voiceSystemPrompt;
19259
+ const store = new SessionStore(cwd);
19260
+ const session = startSession(args, store, dx.voice, cwd);
19261
+ persist = () => {
19262
+ session.messages = dx.voice.transcript;
19263
+ session.meta.updated = Date.now();
19264
+ try {
19265
+ store.save(session);
19266
+ } catch (e) {
19267
+ log33.warn("duplex session save failed", e);
19268
+ }
19269
+ };
19270
+ out({ type: "ready", sessionId: session.meta.id, reflexModel: dx.options.reflexModel, actModel: dx.options.actModel });
19271
+ const done = new Promise((resolveDone) => {
19272
+ const rl = createInterface({ input: process.stdin });
19273
+ rl.on("line", (line) => {
19274
+ let m;
19275
+ try {
19276
+ m = JSON.parse(line);
19277
+ } catch {
19278
+ return;
19279
+ }
19280
+ if (m.type === "barge") {
19281
+ activeTurn?.abort();
19282
+ dx.parkInFlightDeliveries();
19283
+ } else if (m.type === "cancel") out({ type: "task", kind: "cancel_result", id: m.task, message: dx.cancelTask(String(m.task)) });
19284
+ else if (m.type === "exit") rl.close();
19285
+ else if (m.type === "user" && typeof m.text === "string" && m.text.trim()) {
19286
+ const { reply, delivery, heard } = splitCut(m.cut ?? null);
19287
+ if (delivery || reply) dx.parkCutDelivery(delivery, !!reply, heard);
19288
+ const note = reply && reply.full.length - reply.heard.length > 40 ? interruptionNote(reply) : "";
19289
+ const id = m.id ? String(m.id) : void 0;
19290
+ void runTurn(dx.voice, store, session, m.text + note, void 0, cwd, (c) => dx.send(c, { id, replaces: m.replaces ? String(m.replaces) : void 0 })).then(({ res }) => out({ type: "turn_done", id, finishReason: res?.finishReason ?? "error" })).catch((e) => {
19291
+ log33.error("duplex turn failed", e);
19292
+ out({ type: "turn_done", id, finishReason: "error", error: String(e?.message ?? e) });
19293
+ });
19294
+ }
19295
+ });
19296
+ rl.on("close", () => resolveDone());
19297
+ });
19298
+ await done;
19299
+ for (const t of dx.tasks.values()) if (t.status === "running") dx.cancelTask(t.id);
19300
+ activeTurn?.abort();
19301
+ persist();
19302
+ await closeMcp(mounted);
19303
+ }
19211
19304
  function readAllStdin() {
19212
19305
  return new Promise((res) => {
19213
19306
  let data = "";
@@ -19329,6 +19422,11 @@ async function main() {
19329
19422
  worktreeCleanup?.();
19330
19423
  process.exit(ok ? 0 : res?.finishReason === "needs_input" ? 2 : 1);
19331
19424
  }
19425
+ if (args.inputFormat) {
19426
+ await duplexServe(args, ai, cfg, cwd);
19427
+ worktreeCleanup?.();
19428
+ process.exit(0);
19429
+ }
19332
19430
  await repl(args, ai, cfg, cwd);
19333
19431
  worktreeCleanup?.();
19334
19432
  }