nuabase 3.0.1 → 3.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # nuabase
2
2
 
3
+ ## 3.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - dd9b28e: Update `localAgentSetupGuide()` to each agent's current install command for the machine it runs on, with links to each agent's install page. Pi's npm package is now `@earendil-works/pi-coding-agent`.
8
+
9
+ ## 3.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 8fdf585: `startAgentSession()` takes `env`, the environment `open` runs in on macOS, and `unsetEnv`, names the launcher unsets before starting the agent. A terminal app that `open` launches keeps `open`'s environment for every later window, so before this a server's own settings reached the user's other terminal sessions. It throws a `TypeError` for an `unsetEnv` entry that isn't a shell variable name.
14
+ - 3700318: `nua.get()` and `nua.list()` take `signal`, an `AbortSignal` that cancels the call. A local agent's process is stopped, SIGINT then SIGKILL 2 seconds later, and the call returns once the process has exited. A call still waiting for a free agent process leaves the queue without starting one. A provider request is aborted. A cancelled call is not retried and fails with `cancelled: true`, a new field of every failed result.
15
+
16
+ A local agent stopped by `timeoutMs` or `idleTimeoutMs` now also says how much it had printed and, for `timeoutMs`, how long before the stop it last printed, which tells a slow agent from a stuck one.
17
+ - a7054ac: Local agents: `onOutput`, an option of `nua.get()` and `nua.list()`, passes on everything the agent prints while that call runs, unparsed, so an app can show its progress as a log. `idleTimeoutMs` stops an agent that prints nothing for 2 minutes (Claude Code and Pi; Codex has no idle limit, since it is quiet while it reasons), and `timeoutMs` now defaults to 10 minutes instead of 3. A timeout says which limit was hit, after how long, how to raise it, and the end of the agent's stderr. Claude Code now runs with `--output-format stream-json`, so it prints while it works; only its final result line is read.
18
+
19
+ Also changed:
20
+
21
+ - `timeoutMs` and `idleTimeoutMs` must be from 1 to 2147483647 ms (or `null` for no idle limit); `localAgent()`, `findLocalAgent()` and `with()` throw a `TypeError` otherwise. Before, a value such as `0`, `NaN` or `Infinity` stopped the agent at once.
22
+ - In `agent.with()`, a `timeoutMs` given as `undefined` now resets to the default, as every other option does, instead of keeping the agent's.
23
+ - `findLocalAgent()` reports an invalid option only once it has found an agent, not before it looks.
24
+ - 36ce67b: Add `localAgentSetupGuide()` to `nuabase/local-agent`: plain text an app shows a user with no coding agent set up, saying the agent must be the CLI, how to install and sign in to each supported one, and a prompt that has a desktop or browser agent install one.
25
+
26
+ ### Patch Changes
27
+
28
+ - de8f585: A headless Codex call turns off Codex's shell, its ChatGPT apps connector, plugins, image viewing and web search. In the read-only sandbox the shell could still read any file and the caller's environment, and the apps connector could browse the web and change the account, so a prompt carrying an injected instruction could reach them. Codex 0.154 accepts these feature names; an older Codex that lacks one fails the call with "Unknown feature flag".
29
+
3
30
  ## 3.0.1
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -110,6 +110,8 @@ if (address.success) console.log(address.data); // z.infer<typeof AddressSchema>
110
110
  * `input?: unknown` – Context or data to include with the prompt.
111
111
  * `output: { name: string; schema: ZodSchema }` – Identifier and Zod schema for the output.
112
112
  * `model?: ModelInput` – Override the client's default model for this call.
113
+ * `onOutput?: (chunk, stream) => void` – On a local agent, gets everything the agent prints while this call runs, unparsed, as it arrives (`stream` is `'stdout'` or `'stderr'`), to show as a log. Chunks don't follow line boundaries, and a retry's output follows the earlier attempt's. Provider APIs print nothing, so it is never called.
114
+ * `signal?: AbortSignal` – Cancels the call: a local agent's process is stopped (SIGINT, then SIGKILL 2 seconds later) and a provider request is aborted. A call waiting for a free agent process leaves the queue. The call then fails with `cancelled: true` and is not retried.
113
115
 
114
116
  ### `nua.list(prompt, options)`
115
117
 
@@ -133,6 +135,8 @@ if (result.success) {
133
135
  * `primaryKey: string` – Unique key on each input object. Each output row carries it, so you can match rows to inputs.
134
136
  * `output: { name: string; schema: ZodSchema }` – Property name and Zod schema attached to each returned row.
135
137
  * `model?: ModelInput` – Override the client's default model for this call.
138
+ * `onOutput?: (chunk, stream) => void` – As for `get()`.
139
+ * `signal?: AbortSignal` – As for `get()`.
136
140
 
137
141
  ---
138
142
 
@@ -210,12 +214,13 @@ const agents = await detectLocalAgents();
210
214
  | `binaryPath` | `string` | Looked up on `PATH` | Path to executable (`localAgent` only). On Windows, point to a native executable (`.cmd` shims are not supported). |
211
215
  | `model` | `string` | Agent's default | The agent's own model name for calls that name no model. Claude Code accepts aliases (`'haiku'`, `'sonnet'`, `'opus'`), and so does a per-call `model: { alias }`. Codex and Pi have no aliases: Codex uses its own model names; Pi uses `'provider/id'` (e.g., `'openrouter/z-ai/glm-5.3-flash'`). |
212
216
  | `effort` | `'low' \| 'medium' \| 'high'` | Agent's default | How hard the model thinks, as the CLI's own setting: Claude Code `--effort`, Codex `model_reasoning_effort`, Pi `--thinking`. |
213
- | `timeoutMs` | `number` | `180000` | Maximum execution time per call in milliseconds. |
217
+ | `timeoutMs` | `number` | `600000` | Maximum execution time per call in milliseconds, from 1 to 2147483647. |
218
+ | `idleTimeoutMs` | `number \| null` | `120000`; none for Codex | Stops the agent when it prints nothing for this many milliseconds (1 to 2147483647), so a stuck agent fails early and a slow one still finishes. `null` for no limit. Codex has none by default: it prints nothing while it reasons. |
214
219
  | `concurrency` | `number` | `2` | Maximum concurrent agent processes. |
215
220
  | `auth` | `'subscription' \| 'inherit'` | `'subscription'` | `'subscription'` strips env vars like `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` so calls bill your logged-in plan instead of API keys. |
216
221
  | `logger` | object | `console` | Object with `debug`, `info`, `warn`, and `error` methods for call and retry logs. |
217
222
 
218
- `agent.with({ model, effort, timeoutMs })` returns the same agent with other call options. It shares the agent's executable, environment, logger and `concurrency` limit, so one `localAgent()` per CLI can serve casts on several of its models without running more processes at once than the limit allows. Options left out keep the agent's; a `model` or `effort` given as `undefined` is the CLI's default.
223
+ `agent.with({ model, effort, timeoutMs, idleTimeoutMs })` returns the same agent with other call options. It shares the agent's executable, environment, logger and `concurrency` limit, so one `localAgent()` per CLI can serve casts on several of its models without running more processes at once than the limit allows. Options left out keep the agent's; an option given as `undefined` takes its default.
219
224
 
220
225
  ```ts
221
226
  const codex = localAgent({ agent: 'codex', concurrency: 2 });
@@ -224,6 +229,16 @@ const careful = Nua.direct({ localAgent: codex.with({ model: 'gpt-5.6-sol', time
224
229
  // Both run on the same two processes at most.
225
230
  ```
226
231
 
232
+ To show a call's progress, pass `onOutput` with the call. What the agent prints is its own JSON lines, which nuabase passes on as they are:
233
+
234
+ ```ts
235
+ const result = await nua.get(prompt, { output: { name: 'chart', schema }, onOutput: (chunk) => res.write(chunk) });
236
+ ```
237
+
238
+ > **Note:** This sends the agent's output to the browser unfiltered: its reasoning, the prompt and input data it echoes, and details of the machine it runs on, such as working directories and how it is logged in. That suits a local-first app, where the server, the agent and the browser are all on the user's own machine and the user is the only one watching. A server that others reach should filter the output, or show only progress such as a count of characters, before passing it on.
239
+
240
+ When a call is stopped by `timeoutMs` or `idleTimeoutMs`, its error says which limit stopped it, how much the agent had printed and when it last printed, so a slow agent can be told from a stuck one. A call stopped by either limit is not retried, so `timeoutMs` bounds how long it runs. A transient failure is retried, and each attempt may take up to `timeoutMs`.
241
+
227
242
  > **Note:** Local agent calls count against your personal subscription limits. Check each vendor's terms before exposing local-agent execution to end users.
228
243
 
229
244
  ### Interactive Agent Sessions (Node.js only)
@@ -258,7 +273,9 @@ switch (session.kind) {
258
273
 
259
274
  It writes the prompt to its own file (`0600`) and a launcher (`0700`) that passes it to the agent as one argument after `--`. Quotes, backticks and `$NAMES` in the prompt reach the agent as written, and the prompt can't set the agent's flags or run commands. Neither file is ever overwritten or deleted. On macOS the launcher opens in a new window of the user's terminal app (`open`, which needs no Automation permission).
260
275
 
261
- Expected failures come back as `kind: 'refused'`, with nothing written: Windows, which can't run the launcher (`unsupported_platform`); a prompt of 128 KiB or more on Linux, which caps a single argument at that size (`prompt_too_long`); and an agent that isn't on `PATH` when no `binaryPath` is given (`agent_not_found`). It throws only for an unknown `agent` or a `name` already used.
276
+ Expected failures come back as `kind: 'refused'`, with nothing written: Windows, which can't run the launcher (`unsupported_platform`); a prompt of 128 KiB or more on Linux, which caps a single argument at that size (`prompt_too_long`); and an agent that isn't on `PATH` when no `binaryPath` is given (`agent_not_found`). It throws only for an unknown `agent`, an `unsetEnv` entry that isn't a shell variable name, or a `name` already used.
277
+
278
+ On macOS, `open` passes its environment to a terminal app it launches, and every later window of that app inherits it. A server whose environment holds its own settings (ports, secrets, a data directory) passes `env` without them, so they don't reach the user's other terminal sessions, and `unsetEnv` with their names, so the launcher unsets them in a terminal that already holds them.
262
279
 
263
280
  `agentSessionMode(platform?)` says beforehand how a session would start, for showing the right button: `'terminal'` on macOS, `'command'` on other POSIX systems, `'none'` on Windows.
264
281
 
@@ -273,6 +290,8 @@ Expected failures come back as `kind: 'refused'`, with nothing written: Windows,
273
290
  | `dir` | `string` | Required | Where both files are written. Created if missing. |
274
291
  | `name` | `string` | `<UTC time>-<agent>-<4 hex digits>` | The files' name without extension. Neither file may exist yet. |
275
292
  | `platform` | `NodeJS.Platform` | `process.platform` | The platform the launcher is for. |
293
+ | `env` | `NodeJS.ProcessEnv` | `process.env` | The environment `open` runs in on macOS, which a terminal app it launches keeps for every window. |
294
+ | `unsetEnv` | `string[]` | None | Variables the launcher unsets before starting the agent. |
276
295
 
277
296
  ---
278
297
 
@@ -311,6 +330,7 @@ The call rejects (throws) only for invalid arguments, before anything is sent: a
311
330
  | --- | --- | --- |
312
331
  | `success` | `false` | Request failed (see retries below). |
313
332
  | `error` | `string` | Error description. |
333
+ | `cancelled` | `boolean` | `true` when the call's `signal` cancelled it. |
314
334
  | `source` | `'direct'` | Indicates the request executed in-process. |
315
335
  | `latencyMs` | `number` | Time elapsed until failure in milliseconds. |
316
336
 
@@ -76,8 +76,9 @@ type AttemptFailure = {
76
76
  * - transient: another attempt may succeed.
77
77
  * - fatal: every attempt will fail the same way (bad API key, not logged in, out of quota, timed out).
78
78
  * - schema-rejected: the engine refused the native schema; the in-prompt prompt may still work.
79
+ * - cancelled: the caller's signal aborted it.
79
80
  */
80
- kind: "transient" | "fatal" | "schema-rejected";
81
+ kind: "transient" | "fatal" | "schema-rejected" | "cancelled";
81
82
  message: string;
82
83
  };
83
84
  type AttemptOutcome = {
@@ -89,6 +90,20 @@ type AttemptOutcome = {
89
90
  kind: "failed";
90
91
  failure: AttemptFailure;
91
92
  };
93
+ /**
94
+ * Gets what an engine prints while it answers, unparsed, as it arrives: a
95
+ * coding agent's own output, to show as a log while a call runs. Chunks are
96
+ * UTF-8 text that doesn't follow line boundaries; a retry's output follows the
97
+ * earlier attempt's. Engines that print nothing, such as LLM providers' APIs,
98
+ * never call it.
99
+ */
100
+ type EngineOutputListener = (chunk: string, stream: "stdout" | "stderr") => void;
101
+ /** What one cast's caller hands each of its attempts. */
102
+ type AttemptControls = {
103
+ onOutput?: EngineOutputListener;
104
+ /** Cancels the attempt: an agent process is stopped, an HTTP request aborted. */
105
+ signal?: AbortSignal;
106
+ };
92
107
  /**
93
108
  * Answers prompts on one model: an LLM provider's HTTP API, or a coding-agent
94
109
  * CLI on this machine. Expected failures come back as outcomes, not exceptions.
@@ -96,7 +111,7 @@ type AttemptOutcome = {
96
111
  interface LlmEngine {
97
112
  /** The envelope schema in the form this engine enforces natively, or null if it cannot. */
98
113
  nativeSchema(envelopeSchema: JsonSchema): JsonSchema | null;
99
- attempt(request: AttemptRequest): Promise<AttemptOutcome>;
114
+ attempt(request: AttemptRequest, controls?: AttemptControls): Promise<AttemptOutcome>;
100
115
  }
101
116
 
102
117
  type ProviderModel = {
@@ -145,10 +160,27 @@ type AgentProcessResult = {
145
160
  exitCode: number | null;
146
161
  stdout: string;
147
162
  stderr: string;
148
- timedOut: boolean;
163
+ /** Why the process was stopped before it finished; null when it wasn't. */
164
+ stopped: AgentProcessStop | null;
149
165
  /** Contents of the requested output files; undefined when a file was not written. */
150
166
  outputFiles: Record<string, string | undefined>;
151
167
  };
168
+ /**
169
+ * What stopped a process: the limit on the whole run ("total") or on going
170
+ * quiet ("idle"), which was `limitMs`, or the caller's AbortSignal ("signal").
171
+ */
172
+ type AgentProcessStopCause = {
173
+ by: "total" | "idle";
174
+ limitMs: number;
175
+ } | {
176
+ by: "signal";
177
+ };
178
+ /** What stopped a process, and when, in milliseconds from its start. */
179
+ type AgentProcessStop = AgentProcessStopCause & {
180
+ afterMs: number;
181
+ /** When it last printed; null when it printed nothing. */
182
+ lastOutputAtMs: number | null;
183
+ };
152
184
  /** What an agent's output says about one attempt. */
153
185
  type AgentReply = {
154
186
  kind: "answered";
@@ -192,6 +224,11 @@ interface AgentAdapter {
192
224
  binaryName: string;
193
225
  /** Env vars that would make the CLI bill an API key instead of the user's subscription. */
194
226
  apiKeyEnvVars: string[];
227
+ /**
228
+ * How long the CLI may print nothing before it counts as stuck, or null when
229
+ * it can be quiet for long stretches while working, as Codex is while it reasons.
230
+ */
231
+ idleTimeoutMs: number | null;
195
232
  /** The CLI's own model name for each model alias it has an equivalent for. */
196
233
  modelAliases: Partial<Record<ModelAliasName, string>>;
197
234
  /** The envelope schema in the form this CLI accepts, or null if it can't enforce it. */
@@ -214,7 +251,10 @@ declare class Semaphore {
214
251
  private active;
215
252
  private readonly waiting;
216
253
  constructor(limit: number);
217
- run<T>(task: () => Promise<T>): Promise<T>;
254
+ /** Rejects with the signal's reason, without running `task`, when `signal` aborts while it waits. */
255
+ run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
256
+ /** Resolves once a finishing task hands over its place. */
257
+ private waitForTurn;
218
258
  }
219
259
 
220
260
  /** How to run one installed agent's processes. Set up once and shared by all its engines. */
@@ -227,16 +267,21 @@ type AgentRunner = {
227
267
  limiter: Semaphore;
228
268
  logger: Logger;
229
269
  };
230
- /** How one engine's attempts run: the CLI's options and how long each may take. */
231
- type AgentCall = AgentCallOptions & {
232
- /** Per-attempt limit. */
233
- timeoutMs: number;
234
- };
235
270
 
236
- /** What `LocalAgent.with()` changes: how its casts run, not which processes run them. */
271
+ /**
272
+ * How a local agent's casts run, not which processes run them: what
273
+ * localAgent() sets and `LocalAgent.with()` changes. An option left out, or
274
+ * given as undefined, takes its default.
275
+ */
237
276
  type LocalAgentCallOptions = AgentCallOptions & {
238
- /** Per-call limit in milliseconds. */
277
+ /** Per-call limit in milliseconds. Defaults to 600000. */
239
278
  timeoutMs?: number;
279
+ /**
280
+ * How long, in milliseconds, the agent may print nothing before it is
281
+ * stopped as stuck; null for no limit. Defaults to the agent's own limit,
282
+ * which is none for an agent that is quiet while it works.
283
+ */
284
+ idleTimeoutMs?: number | null;
240
285
  };
241
286
  /**
242
287
  * A coding-agent CLI on this machine, set up once and ready to run casts.
@@ -244,18 +289,15 @@ type LocalAgentCallOptions = AgentCallOptions & {
244
289
  */
245
290
  declare class LocalAgent implements EngineRouter {
246
291
  private readonly runner;
247
- /** How calls that name no model run. */
248
- private readonly call;
249
292
  readonly agent: LocalAgentId;
250
293
  readonly logger: Logger;
251
- constructor(runner: AgentRunner,
252
294
  /** How calls that name no model run. */
253
- call: AgentCall);
295
+ private readonly call;
296
+ constructor(runner: AgentRunner, options: LocalAgentCallOptions);
254
297
  /**
255
298
  * The same agent with other call options, sharing this one's executable,
256
299
  * environment, logger and limit on processes running at once. Options left
257
- * out keep this agent's; a model or effort given as undefined is the CLI's
258
- * default.
300
+ * out keep this agent's; an option given as undefined takes its default.
259
301
  */
260
302
  with(options: LocalAgentCallOptions): LocalAgent;
261
303
  /**
@@ -266,5 +308,5 @@ declare class LocalAgent implements EngineRouter {
266
308
  private routeTo;
267
309
  }
268
310
 
269
- export { LocalAgent as a, LOCAL_AGENT_IDS as f, isLocalAgentId as i };
270
- export type { AnswerOrigin as A, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, AgentEffort as c, Logger as d, SessionApproval as e, LocalAgentCallOptions as g };
311
+ export { LocalAgent as a, LOCAL_AGENT_IDS as g, isLocalAgentId as i };
312
+ export type { AnswerOrigin as A, EngineOutputListener as E, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, LocalAgentCallOptions as c, Logger as d, SessionApproval as e, AgentEffort as f };
@@ -76,8 +76,9 @@ type AttemptFailure = {
76
76
  * - transient: another attempt may succeed.
77
77
  * - fatal: every attempt will fail the same way (bad API key, not logged in, out of quota, timed out).
78
78
  * - schema-rejected: the engine refused the native schema; the in-prompt prompt may still work.
79
+ * - cancelled: the caller's signal aborted it.
79
80
  */
80
- kind: "transient" | "fatal" | "schema-rejected";
81
+ kind: "transient" | "fatal" | "schema-rejected" | "cancelled";
81
82
  message: string;
82
83
  };
83
84
  type AttemptOutcome = {
@@ -89,6 +90,20 @@ type AttemptOutcome = {
89
90
  kind: "failed";
90
91
  failure: AttemptFailure;
91
92
  };
93
+ /**
94
+ * Gets what an engine prints while it answers, unparsed, as it arrives: a
95
+ * coding agent's own output, to show as a log while a call runs. Chunks are
96
+ * UTF-8 text that doesn't follow line boundaries; a retry's output follows the
97
+ * earlier attempt's. Engines that print nothing, such as LLM providers' APIs,
98
+ * never call it.
99
+ */
100
+ type EngineOutputListener = (chunk: string, stream: "stdout" | "stderr") => void;
101
+ /** What one cast's caller hands each of its attempts. */
102
+ type AttemptControls = {
103
+ onOutput?: EngineOutputListener;
104
+ /** Cancels the attempt: an agent process is stopped, an HTTP request aborted. */
105
+ signal?: AbortSignal;
106
+ };
92
107
  /**
93
108
  * Answers prompts on one model: an LLM provider's HTTP API, or a coding-agent
94
109
  * CLI on this machine. Expected failures come back as outcomes, not exceptions.
@@ -96,7 +111,7 @@ type AttemptOutcome = {
96
111
  interface LlmEngine {
97
112
  /** The envelope schema in the form this engine enforces natively, or null if it cannot. */
98
113
  nativeSchema(envelopeSchema: JsonSchema): JsonSchema | null;
99
- attempt(request: AttemptRequest): Promise<AttemptOutcome>;
114
+ attempt(request: AttemptRequest, controls?: AttemptControls): Promise<AttemptOutcome>;
100
115
  }
101
116
 
102
117
  type ProviderModel = {
@@ -145,10 +160,27 @@ type AgentProcessResult = {
145
160
  exitCode: number | null;
146
161
  stdout: string;
147
162
  stderr: string;
148
- timedOut: boolean;
163
+ /** Why the process was stopped before it finished; null when it wasn't. */
164
+ stopped: AgentProcessStop | null;
149
165
  /** Contents of the requested output files; undefined when a file was not written. */
150
166
  outputFiles: Record<string, string | undefined>;
151
167
  };
168
+ /**
169
+ * What stopped a process: the limit on the whole run ("total") or on going
170
+ * quiet ("idle"), which was `limitMs`, or the caller's AbortSignal ("signal").
171
+ */
172
+ type AgentProcessStopCause = {
173
+ by: "total" | "idle";
174
+ limitMs: number;
175
+ } | {
176
+ by: "signal";
177
+ };
178
+ /** What stopped a process, and when, in milliseconds from its start. */
179
+ type AgentProcessStop = AgentProcessStopCause & {
180
+ afterMs: number;
181
+ /** When it last printed; null when it printed nothing. */
182
+ lastOutputAtMs: number | null;
183
+ };
152
184
  /** What an agent's output says about one attempt. */
153
185
  type AgentReply = {
154
186
  kind: "answered";
@@ -192,6 +224,11 @@ interface AgentAdapter {
192
224
  binaryName: string;
193
225
  /** Env vars that would make the CLI bill an API key instead of the user's subscription. */
194
226
  apiKeyEnvVars: string[];
227
+ /**
228
+ * How long the CLI may print nothing before it counts as stuck, or null when
229
+ * it can be quiet for long stretches while working, as Codex is while it reasons.
230
+ */
231
+ idleTimeoutMs: number | null;
195
232
  /** The CLI's own model name for each model alias it has an equivalent for. */
196
233
  modelAliases: Partial<Record<ModelAliasName, string>>;
197
234
  /** The envelope schema in the form this CLI accepts, or null if it can't enforce it. */
@@ -214,7 +251,10 @@ declare class Semaphore {
214
251
  private active;
215
252
  private readonly waiting;
216
253
  constructor(limit: number);
217
- run<T>(task: () => Promise<T>): Promise<T>;
254
+ /** Rejects with the signal's reason, without running `task`, when `signal` aborts while it waits. */
255
+ run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
256
+ /** Resolves once a finishing task hands over its place. */
257
+ private waitForTurn;
218
258
  }
219
259
 
220
260
  /** How to run one installed agent's processes. Set up once and shared by all its engines. */
@@ -227,16 +267,21 @@ type AgentRunner = {
227
267
  limiter: Semaphore;
228
268
  logger: Logger;
229
269
  };
230
- /** How one engine's attempts run: the CLI's options and how long each may take. */
231
- type AgentCall = AgentCallOptions & {
232
- /** Per-attempt limit. */
233
- timeoutMs: number;
234
- };
235
270
 
236
- /** What `LocalAgent.with()` changes: how its casts run, not which processes run them. */
271
+ /**
272
+ * How a local agent's casts run, not which processes run them: what
273
+ * localAgent() sets and `LocalAgent.with()` changes. An option left out, or
274
+ * given as undefined, takes its default.
275
+ */
237
276
  type LocalAgentCallOptions = AgentCallOptions & {
238
- /** Per-call limit in milliseconds. */
277
+ /** Per-call limit in milliseconds. Defaults to 600000. */
239
278
  timeoutMs?: number;
279
+ /**
280
+ * How long, in milliseconds, the agent may print nothing before it is
281
+ * stopped as stuck; null for no limit. Defaults to the agent's own limit,
282
+ * which is none for an agent that is quiet while it works.
283
+ */
284
+ idleTimeoutMs?: number | null;
240
285
  };
241
286
  /**
242
287
  * A coding-agent CLI on this machine, set up once and ready to run casts.
@@ -244,18 +289,15 @@ type LocalAgentCallOptions = AgentCallOptions & {
244
289
  */
245
290
  declare class LocalAgent implements EngineRouter {
246
291
  private readonly runner;
247
- /** How calls that name no model run. */
248
- private readonly call;
249
292
  readonly agent: LocalAgentId;
250
293
  readonly logger: Logger;
251
- constructor(runner: AgentRunner,
252
294
  /** How calls that name no model run. */
253
- call: AgentCall);
295
+ private readonly call;
296
+ constructor(runner: AgentRunner, options: LocalAgentCallOptions);
254
297
  /**
255
298
  * The same agent with other call options, sharing this one's executable,
256
299
  * environment, logger and limit on processes running at once. Options left
257
- * out keep this agent's; a model or effort given as undefined is the CLI's
258
- * default.
300
+ * out keep this agent's; an option given as undefined takes its default.
259
301
  */
260
302
  with(options: LocalAgentCallOptions): LocalAgent;
261
303
  /**
@@ -266,5 +308,5 @@ declare class LocalAgent implements EngineRouter {
266
308
  private routeTo;
267
309
  }
268
310
 
269
- export { LocalAgent as a, LOCAL_AGENT_IDS as f, isLocalAgentId as i };
270
- export type { AnswerOrigin as A, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, AgentEffort as c, Logger as d, SessionApproval as e, LocalAgentCallOptions as g };
311
+ export { LocalAgent as a, LOCAL_AGENT_IDS as g, isLocalAgentId as i };
312
+ export type { AnswerOrigin as A, EngineOutputListener as E, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, LocalAgentCallOptions as c, Logger as d, SessionApproval as e, AgentEffort as f };
@@ -294,21 +294,27 @@ function renderPrompt(cast, enforcement) {
294
294
  }
295
295
 
296
296
  function refusedCast(reason) {
297
- return { success: false, error: reason, reason, attempts: 0 };
297
+ return { success: false, error: reason, reason, attempts: 0, cancelled: false };
298
298
  }
299
299
  const RETRY_DELAYS_MS = [1e3, 2e3];
300
300
  const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1;
301
- async function runCast(engine, request, logger) {
301
+ async function runCast(engine, request, logger, controls = {}) {
302
302
  const validate = validatorFor(request.schema);
303
303
  let plan = planAttempt(request, chooseEnforcement(engine, request.jsonSchema));
304
304
  let lastFailure = { error: "", prompt: plan.prompt };
305
305
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
306
- const outcome = await engine.attempt({
307
- prompt: plan.prompt.full,
308
- reusablePrefix: plan.prompt.reusablePrefix,
309
- maxTokens: request.maxTokens,
310
- enforcement: plan.enforcement
311
- });
306
+ if (controls.signal?.aborted) {
307
+ return cancelledCast(attempt - 1, attempt > 1 ? lastFailure.prompt : void 0);
308
+ }
309
+ const outcome = await engine.attempt(
310
+ {
311
+ prompt: plan.prompt.full,
312
+ reusablePrefix: plan.prompt.reusablePrefix,
313
+ maxTokens: request.maxTokens,
314
+ enforcement: plan.enforcement
315
+ },
316
+ controls
317
+ );
312
318
  const verdict = judgeOutcome(outcome, validate);
313
319
  if (verdict.kind === "accept") {
314
320
  return {
@@ -321,7 +327,17 @@ async function runCast(engine, request, logger) {
321
327
  };
322
328
  }
323
329
  if (verdict.kind === "give-up") {
324
- return { success: false, error: verdict.error, reason: verdict.error, attempts: attempt, prompt: plan.prompt };
330
+ return {
331
+ success: false,
332
+ error: verdict.error,
333
+ reason: verdict.error,
334
+ attempts: attempt,
335
+ cancelled: false,
336
+ prompt: plan.prompt
337
+ };
338
+ }
339
+ if (verdict.kind === "cancelled") {
340
+ return cancelledCast(attempt, plan.prompt);
325
341
  }
326
342
  lastFailure = { error: verdict.error, prompt: plan.prompt };
327
343
  if (verdict.kind === "retry-in-prompt") {
@@ -335,7 +351,7 @@ async function runCast(engine, request, logger) {
335
351
  if (attempt < MAX_ATTEMPTS) {
336
352
  const delayMs = RETRY_DELAYS_MS[attempt - 1];
337
353
  logger.warn("LLM call attempt failed; retrying", { attempt, delayMs, error: verdict.error });
338
- await delay(delayMs);
354
+ await delay(delayMs, controls.signal);
339
355
  }
340
356
  }
341
357
  return {
@@ -343,6 +359,7 @@ async function runCast(engine, request, logger) {
343
359
  error: `LLM call failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastFailure.error}`,
344
360
  reason: lastFailure.error,
345
361
  attempts: MAX_ATTEMPTS,
362
+ cancelled: false,
346
363
  prompt: lastFailure.prompt
347
364
  };
348
365
  }
@@ -387,6 +404,8 @@ function verdictForFailure(failure) {
387
404
  return { kind: "retry-in-prompt", error: failure.message };
388
405
  case "fatal":
389
406
  return { kind: "give-up", error: failure.message };
407
+ case "cancelled":
408
+ return { kind: "cancelled" };
390
409
  }
391
410
  }
392
411
  function readAnswer(answer) {
@@ -422,8 +441,20 @@ function readStructuredAnswer(envelope) {
422
441
  function errorMessage(error) {
423
442
  return error instanceof Error ? error.message : String(error);
424
443
  }
425
- function delay(ms) {
426
- return new Promise((resolve) => setTimeout(resolve, ms));
444
+ function cancelledCast(attempts, prompt) {
445
+ const reason = "The call was cancelled.";
446
+ return { success: false, error: reason, reason, attempts, cancelled: true, prompt };
447
+ }
448
+ function delay(ms, signal) {
449
+ return new Promise((resolve) => {
450
+ const done = () => {
451
+ clearTimeout(timer);
452
+ signal?.removeEventListener("abort", done);
453
+ resolve();
454
+ };
455
+ const timer = setTimeout(done, ms);
456
+ signal?.addEventListener("abort", done, { once: true });
457
+ });
427
458
  }
428
459
 
429
460
  const DEFAULT_MAX_TOKENS = 4096;
@@ -446,7 +477,10 @@ class NuaLlmClient {
446
477
  jsonSchema,
447
478
  maxTokens: params.maxTokens ?? DEFAULT_MAX_TOKENS
448
479
  };
449
- return runCast(route.engine, request, this.engines.logger);
480
+ return runCast(route.engine, request, this.engines.logger, {
481
+ onOutput: params.onOutput,
482
+ signal: params.signal
483
+ });
450
484
  }
451
485
  async castArray(params) {
452
486
  const { primaryKey, outputName, schema } = params.output;
@@ -469,7 +503,10 @@ class NuaLlmClient {
469
503
  jsonSchema,
470
504
  maxTokens: params.maxTokens ?? DEFAULT_MAX_TOKENS
471
505
  };
472
- return runCast(route.engine, request, this.engines.logger);
506
+ return runCast(route.engine, request, this.engines.logger, {
507
+ onOutput: params.onOutput,
508
+ signal: params.signal
509
+ });
473
510
  }
474
511
  }
475
512
 
@@ -904,7 +941,8 @@ async function callProvider(call, logger) {
904
941
  const response = await fetch(request.url, {
905
942
  method,
906
943
  headers: request.headers ?? {},
907
- body: toFetchableBody(request.body)
944
+ body: toFetchableBody(request.body),
945
+ signal: call.signal
908
946
  });
909
947
  status = response.status;
910
948
  const reply = await call.parseResponse(response);
@@ -950,7 +988,7 @@ class HttpEngine {
950
988
  if (!providerConfigs[provider].strictSchemaModels?.has(model)) return null;
951
989
  return strictJsonSchema.toStrictSchema(envelopeSchema);
952
990
  }
953
- async attempt(request) {
991
+ async attempt(request, { signal } = {}) {
954
992
  const { provider, model, apiKey } = this.target;
955
993
  const config = providerConfigs[provider];
956
994
  const responseSchema = request.enforcement.kind === "native" ? request.enforcement.schema : void 0;
@@ -971,11 +1009,15 @@ class HttpEngine {
971
1009
  ),
972
1010
  parseResponse: config.parseResponse,
973
1011
  responseLength: (reply) => reply.text.length,
974
- usage: (reply) => reply.usage
1012
+ usage: (reply) => reply.usage,
1013
+ signal
975
1014
  },
976
1015
  this.logger
977
1016
  );
978
1017
  if (call.kind === "failed") {
1018
+ if (signal?.aborted) {
1019
+ return { kind: "failed", failure: { kind: "cancelled", message: "The call was cancelled." } };
1020
+ }
979
1021
  return { kind: "failed", failure: failureForStatus(call.status, call.message, responseSchema !== void 0) };
980
1022
  }
981
1023
  const answer = responseSchema ? readEnvelope(call.reply.text) : { kind: "text", text: call.reply.text };
@@ -1078,7 +1120,9 @@ class Nua {
1078
1120
  const result = await this.client.castValue({
1079
1121
  model: options?.model,
1080
1122
  input: { prompt, data: options?.input ?? null },
1081
- output: { name: outputName, schema: outputSchema }
1123
+ output: { name: outputName, schema: outputSchema },
1124
+ onOutput: options?.onOutput,
1125
+ signal: options?.signal
1082
1126
  });
1083
1127
  return toDirectResult(result, Date.now() - startTime);
1084
1128
  }
@@ -1101,7 +1145,9 @@ class Nua {
1101
1145
  const result = await this.client.castArray({
1102
1146
  model: options.model,
1103
1147
  input: { prompt, data: options.input },
1104
- output
1148
+ output,
1149
+ onOutput: options.onOutput,
1150
+ signal: options.signal
1105
1151
  });
1106
1152
  return toDirectResult(result, Date.now() - startTime);
1107
1153
  }
@@ -1114,8 +1160,8 @@ function enginesFor(config) {
1114
1160
  }
1115
1161
  function toDirectResult(result, latencyMs) {
1116
1162
  if (!result.success) {
1117
- const { error, reason, attempts } = result;
1118
- return { success: false, error, reason, attempts, source: "direct", latencyMs };
1163
+ const { error, reason, attempts, cancelled } = result;
1164
+ return { success: false, error, reason, attempts, cancelled, source: "direct", latencyMs };
1119
1165
  }
1120
1166
  return {
1121
1167
  success: true,