@livx.cc/agentx 0.99.20 → 0.99.21

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
@@ -114,6 +114,7 @@ agentx --resume <id> "…" # resume a specific session
114
114
  - **Project instructions** — `./AGENTS.md` (or `CLAUDE.md`) auto-loads into every run; `/init` scaffolds one.
115
115
  - **Any provider** — set `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY` / `GROQ_API_KEY`; choose with `-m provider/model`. Env files load with precedence: CWD `.env` (bun) → install-dir `.env` → `~/.agent/.env` (user-wide). **Bodify secrets**: set `BODIFY_API_KEY` + `BODIFY_APP_ID` (e.g. in `~/.agent/.env`) to pull provider keys from a Bodify app at startup — no local key management needed.
116
116
  - **@-file mentions & headless JSON** — reference files inline in a prompt with `@path` (e.g. `explain @src/Agent.ts`; `~/` expands to the home directory; quote paths with spaces as `@"…"` — drag-dropped files, e.g. macOS screenshots, quote themselves automatically); script with `-p --output-format json` to get one machine-readable result object on stdout (activity stays on stderr).
117
+ - **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.
117
118
  - **Tab-completion** — `Tab` completes `/<command>` names and `@<path>` file/dir references (descends subdirs, dotfiles hidden unless typed) straight from the working tree.
118
119
  - **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).
119
120
  - **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.
@@ -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-DKf3hN4M.js';
2
+ import { M as Message, H as HostBridge, A as AgentTool, C as ChatLike, e as MessageContent, U as UserQuestion } from './tools-ClgFZjUr.js';
3
3
 
4
4
  /**
5
5
  * Hooks — deterministic interception points around tool execution, run by the
@@ -180,9 +180,14 @@ declare function reasoningToChatFragment(model: string, effort?: ReasoningEffort
180
180
  interface RunResult {
181
181
  text: string;
182
182
  steps: number;
183
- /** Why the loop ended. The middle group are automatic kill-switches (budget/abuse guards). */
184
- finishReason: 'stop' | 'max_steps' | 'budget' | 'timeout' | 'loop' | 'max_tool_calls' | 'aborted' | 'error';
183
+ /** Why the loop ended. The middle group are automatic kill-switches (budget/abuse guards).
184
+ * `needs_input` is NOT a failure: the agent hit a decision that was the user's to make with no
185
+ * human reachable, so it stopped and raised the question (see `question`) instead of guessing.
186
+ * Answer it and resume the session to continue. */
187
+ finishReason: 'stop' | 'max_steps' | 'budget' | 'timeout' | 'loop' | 'max_tool_calls' | 'aborted' | 'error' | 'needs_input';
185
188
  messages: Message[];
189
+ /** The parked question — present iff `finishReason === 'needs_input'`. */
190
+ question?: UserQuestion;
186
191
  /** Accumulated token usage across all turns (non-stream path). With prompt caching,
187
192
  * promptTokens includes cached reads/writes; the cache splits ride along for exact pricing. */
188
193
  usage?: {
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { H as Hooks, h as RunResult, R as ReasoningEffort, A as Agent } from './Agent-Cn9BxS8h.js';
2
+ import { H as Hooks, h as RunResult, R as ReasoningEffort, A as Agent } from './Agent-CM7HL95r.js';
3
3
  import { IFilesystem } from '@livx.cc/wcli/core';
4
- import { M as Message, H as HostBridge, c as ContentPart, e as MessageContent } from './tools-DKf3hN4M.js';
4
+ import { M as Message, U as UserQuestion, H as HostBridge, c as ContentPart, e as MessageContent } from './tools-ClgFZjUr.js';
5
5
 
6
6
  /**
7
7
  * On-disk session store for the CLI: each conversation is one JSON file at
@@ -236,7 +236,8 @@ interface PermMode {
236
236
  * Default permission posture — CC-inspired: **ask when a human can answer, allow when unattended.**
237
237
  * Pure + exported so the decision is unit-testable without a TTY. Precedence:
238
238
  * --yes → allow (explicit trust, no prompts)
239
- * --ask → ask (explicit gate, even headless)
239
+ * --ask → ask (explicit gate; parseArgs rejects it up front when no terminal is attached,
240
+ * so this branch always has a human behind it)
240
241
  * interactive (TTY & not json) → ask (the safe default)
241
242
  * else (headless/piped) → allow, with a one-line notice so it's never silent.
242
243
  */
@@ -271,8 +272,9 @@ declare function expandMentions(fs: IFilesystem, line: string): Promise<{
271
272
  /** The headless `--output-format json` result object for a turn. */
272
273
  declare function jsonResult(res: RunResult, session: SessionData): {
273
274
  error?: any;
275
+ question?: UserQuestion | undefined;
274
276
  ok: boolean;
275
- finishReason: "error" | "budget" | "stop" | "max_steps" | "timeout" | "loop" | "max_tool_calls" | "aborted";
277
+ finishReason: "error" | "budget" | "stop" | "max_steps" | "timeout" | "loop" | "max_tool_calls" | "aborted" | "needs_input";
276
278
  text: string;
277
279
  steps: number;
278
280
  tools: number;
package/dist/cli.js CHANGED
@@ -2152,7 +2152,7 @@ ${out.replace(/\n+$/, "") || "(no output yet)"}`;
2152
2152
  // src/host.ts
2153
2153
  var askUserQuestionTool = {
2154
2154
  name: "AskUserQuestion",
2155
- description: "Ask the user a multiple-choice question when a decision is genuinely theirs to make (ambiguous requirement, a fork you cannot resolve from context). Returns their selection.",
2155
+ description: 'Ask the user a multiple-choice question when a decision is genuinely theirs to make: an ambiguous or under-specified requirement, a fork you cannot resolve from context, a premise of the task that the evidence contradicts, or a risky/irreversible next step. Prefer this over guessing, "proceeding with best judgment", or quietly widening scope to make an unclear brief fit \u2014 a wrong silent decision is far more costly than a question. Returns their selection if a human is reachable; otherwise the run pauses and the question is handed to whoever delegated the task (you do not need to know which \u2014 just ask).',
2156
2156
  parameters: {
2157
2157
  type: "object",
2158
2158
  required: ["question", "options"],
@@ -2168,8 +2168,8 @@ var askUserQuestionTool = {
2168
2168
  },
2169
2169
  async run(args, ctx) {
2170
2170
  if (!ctx.host?.ask) {
2171
- const fallback = args.options?.[0]?.label ?? "";
2172
- return `No interactive user is available \u2014 proceed using your best judgment.${fallback ? ` (Closest default: "${fallback}")` : ""}`;
2171
+ ctx.needsInput = args;
2172
+ return "PAUSED \u2014 no interactive user is reachable from this run, so your question has been raised to the caller that delegated this task. The run stops here; do not guess and do not continue. Their answer will arrive as the next user message when the run resumes.";
2173
2173
  }
2174
2174
  const answer = ctx.host.ask(args);
2175
2175
  return ctx.parkHuman ? ctx.parkHuman(answer) : answer;
@@ -3617,6 +3617,7 @@ var Agent = class _Agent {
3617
3617
  let usageEstimated = false;
3618
3618
  const start = Date.now();
3619
3619
  this.parkedMs = 0;
3620
+ this.ctx.needsInput = void 0;
3620
3621
  let toolCallsTotal = 0;
3621
3622
  let lastFp = "";
3622
3623
  let repeats = 0;
@@ -3750,6 +3751,17 @@ var Agent = class _Agent {
3750
3751
  }
3751
3752
  this.transcript.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content });
3752
3753
  }
3754
+ if (this.ctx.needsInput) {
3755
+ const question = this.ctx.needsInput;
3756
+ this.ctx.needsInput = void 0;
3757
+ log4.warn(`needs_input: ${question.question} (steps=${steps})`);
3758
+ await this.ctx.jobs?.drain();
3759
+ const text = [
3760
+ `OPEN QUESTION (run paused \u2014 no human was reachable): ${question.question}`,
3761
+ ...question.options.map((o2) => ` \u2022 ${o2.label}${o2.description ? ` \u2014 ${o2.description}` : ""}`)
3762
+ ].join("\n");
3763
+ return { text, steps, finishReason: "needs_input", messages: this.transcript, usage, usageEstimated, question };
3764
+ }
3753
3765
  this.drainInjections();
3754
3766
  }
3755
3767
  }
@@ -12070,6 +12082,8 @@ function parseArgs(argv) {
12070
12082
  if (a.duplex && a.plan) throw new Error("--plan is not supported in --duplex (workers are non-interactive; a plan could never be approved)");
12071
12083
  if (a.voiceModel && !a.duplex) throw new Error("--voice-model only applies with --duplex");
12072
12084
  if (a.thinkModel !== void 0 && !a.duplex) throw new Error("--think-model/--no-think only apply with --duplex");
12085
+ if (!canPrompt && (a.ask || a.plan))
12086
+ throw new Error(`${a.ask ? "--ask" : "--plan"} needs an interactive terminal to approve on \u2014 none is attached (stdin/stderr are not a TTY). For an unattended run use --yes, or pre-authorize with rules in .agent/permissions.json.`);
12073
12087
  return a;
12074
12088
  }
12075
12089
  var HELP = `agentx \u2014 agent runtime CLI
@@ -12097,7 +12111,8 @@ Flags:
12097
12111
  --harden OS-sandbox the real shell (sandbox-exec/bwrap): writes confined to cwd+tmp,
12098
12112
  outbound network blocked; --harden-net keeps network allowed
12099
12113
  --plan plan mode: edits blocked until you approve a plan
12100
- --ask confirm each mutating tool (bash/Shell/Write/Edit/\u2026)
12114
+ --ask confirm each mutating tool (bash/Shell/Write/Edit/\u2026) \u2014 needs a terminal to
12115
+ approve on; rejected up front when detached (use --yes / permissions rules)
12101
12116
  --yes, -y auto-approve mutating tools (no prompts) \u2014 for trusted/unattended runs
12102
12117
  --update check for updates and install if available (no keys needed)
12103
12118
  --no-update-check skip the automatic update check on startup
@@ -12129,6 +12144,10 @@ Flags:
12129
12144
 
12130
12145
  Prompts may reference files with @path (e.g. "explain @src/Agent.ts") \u2014 they're inlined.
12131
12146
 
12147
+ Exit codes (-p): 0 = done \xB7 1 = failed \xB7 2 = needs input \u2014 the agent hit a decision that was yours
12148
+ to make and paused rather than guess. The question is on stderr (and in --output-format json under
12149
+ "question"); answer it with: agentx --resume <sessionId> -p "<your answer>".
12150
+
12132
12151
  Providers: set any of ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY / GROQ_API_KEY.
12133
12152
  Env files: .env (CWD, bun auto-loads) > install-dir .env > ~/.agent/.env (user-wide).
12134
12153
  Bodify secrets: set BODIFY_API_KEY + BODIFY_APP_ID (in ~/.agent/.env) to pull keys from a Bodify app.
@@ -12330,21 +12349,28 @@ function makeHost(format = "text", opts) {
12330
12349
  io.close();
12331
12350
  }
12332
12351
  },
12333
- async ask(q2) {
12334
- const title = `? ${q2.header ? "[" + q2.header + "] " : ""}${q2.question}`;
12335
- const v = await selectMenu(process.stderr, { title, items: q2.options.map((o) => ({ label: o.label, value: o.label, desc: o.description })) });
12336
- if (v !== null) return v;
12337
- const io = createInterface({ input: keyInput, output: process.stderr });
12338
- try {
12339
- const lines = [yellow(" " + title)];
12340
- q2.options.forEach((o, i) => lines.push(` ${i + 1}) ${o.label}${o.description ? dim(" \u2014 " + o.description) : ""}`));
12341
- const ans = (await io.question(lines.join("\n") + "\n > ")).trim();
12342
- const n = Number(ans);
12343
- return Number.isInteger(n) && q2.options[n - 1] ? q2.options[n - 1].label : ans;
12344
- } finally {
12345
- io.close();
12352
+ // Only offer `ask` when a human can actually SEE the prompt. Headless/piped (`-p`, redirected,
12353
+ // no TTY) → omit it: AskUserQuestion then parks the question and ends the run with
12354
+ // `needs_input`, handing it to the caller. Prompting into a void instead blocks forever —
12355
+ // parkHuman excludes the wait from --timeout, so only an external timeout ever reaps it, and
12356
+ // the whole turn is lost. A question nobody can answer must travel UP, not stall.
12357
+ ...canPrompt ? {
12358
+ async ask(q2) {
12359
+ const title = `? ${q2.header ? "[" + q2.header + "] " : ""}${q2.question}`;
12360
+ const v = await selectMenu(process.stderr, { title, items: q2.options.map((o) => ({ label: o.label, value: o.label, desc: o.description })) });
12361
+ if (v !== null) return v;
12362
+ const io = createInterface({ input: keyInput, output: process.stderr });
12363
+ try {
12364
+ const lines = [yellow(" " + title)];
12365
+ q2.options.forEach((o, i) => lines.push(` ${i + 1}) ${o.label}${o.description ? dim(" \u2014 " + o.description) : ""}`));
12366
+ const ans = (await io.question(lines.join("\n") + "\n > ")).trim();
12367
+ const n = Number(ans);
12368
+ return Number.isInteger(n) && q2.options[n - 1] ? q2.options[n - 1].label : ans;
12369
+ } finally {
12370
+ io.close();
12371
+ }
12346
12372
  }
12347
- }
12373
+ } : {}
12348
12374
  };
12349
12375
  }
12350
12376
  function summarizeResult(name, text) {
@@ -12707,6 +12733,11 @@ function resolvePermMode(args, interactiveCapable) {
12707
12733
  }
12708
12734
  function makeAskResolver(cwd) {
12709
12735
  return async (call) => {
12736
+ if (!canPrompt) {
12737
+ err(dim(` \u26A0 ${call.name} needs approval but no terminal is attached \u2014 denied. Use --yes, or an allow rule in .agent/permissions.json.
12738
+ `));
12739
+ return { decision: "deny" };
12740
+ }
12710
12741
  const name = call.name === "McpCall" && typeof call.args?.name === "string" ? String(call.args.name) : call.name;
12711
12742
  const tgt = call.args?.path ? ` on ${call.args.path}` : call.args?.command ? `: ${String(call.args.command).slice(0, 60)}` : "";
12712
12743
  const interactive = !!(process.stderr.isTTY && process.stdin.isTTY);
@@ -12937,6 +12968,8 @@ function jsonResult(res, session) {
12937
12968
  tools: res.messages.slice(lastUser).filter((m) => m.role === "tool").length,
12938
12969
  usage: res.usage,
12939
12970
  sessionId: session.meta.id,
12971
+ // The parked question, machine-readable — the caller answers it and resumes `sessionId`.
12972
+ ...res.finishReason === "needs_input" && res.question ? { question: res.question } : {},
12940
12973
  ...res.finishReason === "error" && res.error ? { error: res.error?.message ?? String(res.error) } : {}
12941
12974
  };
12942
12975
  }
@@ -13024,7 +13057,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
13024
13057
  const didWork = agent.transcript.slice(msgsBefore).some((m2) => m2.role === "assistant" || m2.role === "tool");
13025
13058
  const silentAbort = res.finishReason === "aborted" && !res.usage?.totalTokens && !didWork;
13026
13059
  if (!silentAbort)
13027
- err("\n" + (process.stderr.isTTY ? "\r\x1B[0J" : "") + (ok ? green(" \u2713 done") : red(` \u2717 ${res.finishReason}`)) + dim(` \xB7 ${res.steps} steps \xB7 ${tools} tools \xB7 ${tok}${secs}s \xB7 ${shortId}
13060
+ err("\n" + (process.stderr.isTTY ? "\r\x1B[0J" : "") + (ok ? green(" \u2713 done") : res.finishReason === "needs_input" ? yellow(" ? needs input") : red(` \u2717 ${res.finishReason}`)) + dim(` \xB7 ${res.steps} steps \xB7 ${tools} tools \xB7 ${tok}${secs}s \xB7 ${shortId}
13028
13061
  `));
13029
13062
  if (!silentAbort && process.stderr.isTTY && Date.now() - t0 > 1e4) err("\x07");
13030
13063
  if (res.finishReason === "error" && res.error) {
@@ -15480,7 +15513,7 @@ async function main() {
15480
15513
  const session = startSession(args, store, agent, cwd);
15481
15514
  process.once("SIGINT", () => activeTurn?.abort());
15482
15515
  const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
15483
- if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
15516
+ if (cfg.reflectOnFailure && !ok && res?.finishReason !== "needs_input" && res && agent.options.memoryDir) {
15484
15517
  const _fsBase = agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd();
15485
15518
  const slug2 = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
15486
15519
  if (slug2) err(dim(` \u270E learned a lesson \u2192 ${slug2}
@@ -15492,7 +15525,7 @@ async function main() {
15492
15525
  process.stdout.write(JSON.stringify(args.outputFormat === "stream-json" ? { type: "result", ...obj } : obj) + "\n");
15493
15526
  }
15494
15527
  worktreeCleanup?.();
15495
- process.exit(ok ? 0 : 1);
15528
+ process.exit(ok ? 0 : res?.finishReason === "needs_input" ? 2 : 1);
15496
15529
  }
15497
15530
  await repl(args, ai, cfg, cwd);
15498
15531
  worktreeCleanup?.();