jaitus 0.3.24 → 0.3.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node tests/source-sanity.js && node tests/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/service-notices.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js && node tests/compaction.js && node tests/copy-clean.js && node tests/parallel-tools.js && node tests/plan-percent.js && node tests/orb.js && node tests/update-required.js && node tests/update-policy.js && node tests/select-scroll.js && node tests/project-identity.js",
19
+ "test": "node tests/source-sanity.js && node tests/ask-user.js && node tests/ad-slots.js && node tests/memory.js && node tests/mcp-remote.js && node tests/parallel-subagents.js && node tests/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/service-notices.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js && node tests/compaction.js && node tests/copy-clean.js && node tests/parallel-tools.js && node tests/plan-percent.js && node tests/orb.js && node tests/update-required.js && node tests/update-policy.js && node tests/select-scroll.js && node tests/project-identity.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/adSlots.js ADDED
@@ -0,0 +1,153 @@
1
+ // ─── Sponsored lines: which ad is up, and when it counts as seen ──────────────────
2
+ //
3
+ // The plan pays for itself on Free, Starter and Pro with one text line, always labelled
4
+ // "Sponsored", in the CLI's own chrome — never in the conversation. The server decides
5
+ // whether this person sees ads at all (Max never does); this file only asks, shows what
6
+ // it is given, and reports a view honestly:
7
+ //
8
+ // • A view is reported only after the line has been on screen for one CONTINUOUS
9
+ // second (the MRC viewable-impression rule advertisers pay by). Covered by a dialog
10
+ // or a panel, the clock starts again.
11
+ // • Each serve is reported at most once, and never retried into a double count (the
12
+ // server would refuse a second report anyway).
13
+ // • Nothing here can slow or break the CLI: every call has a short timeout, every
14
+ // failure is silent, and a placement with nothing to show is not asked again for a
15
+ // while.
16
+ //
17
+ // Placements: 'thinking' (under the work bar while the model works), 'idle' (above the
18
+ // prompt while waiting for you), 'usage' (inside /usage).
19
+
20
+ export const VIEWABLE_MS = 1000;
21
+ const TICK_MS = 200;
22
+ const ROTATE_THINKING_MS = 60_000; // a long turn shows a fresh ad each minute
23
+ const IDLE_REUSE_MS = 45_000; // a quick back-and-forth keeps the same idle ad
24
+ const EMPTY_BACKOFF_MS = 5 * 60_000; // nothing to show here: ask again in 5 minutes
25
+ const ERROR_BACKOFF_MS = 10 * 60_000; // the service failed: leave it alone for 10
26
+
27
+ /**
28
+ * source: { next(placement) → Promise<ad|null>, seen(token, shownMs) → Promise }
29
+ * isVisible(slot): is this slot's line on screen right now (drawn, not covered)?
30
+ * onChange(): redraw.
31
+ */
32
+ export function createAdSlots({ source, isVisible, onChange, now = () => Date.now(), timers = { setInterval, clearInterval } }) {
33
+ let current = null; // { placement, ad, at, visibleMs, reported }
34
+ let wanted = null;
35
+ let lastIdle = null; // { slot, at } — reused for a quick return to idle
36
+ let timer = null;
37
+ let lastTick = 0;
38
+ let stopped = false;
39
+ const backoff = new Map();
40
+ const inFlight = new Set();
41
+
42
+ const set = (slot) => { current = slot; onChange?.(); };
43
+
44
+ function ensureTimer() {
45
+ if (timer || stopped) return;
46
+ lastTick = now();
47
+ timer = timers.setInterval(tick, TICK_MS);
48
+ timer?.unref?.();
49
+ }
50
+ function stopTimer() { if (timer) { timers.clearInterval(timer); timer = null; } }
51
+
52
+ function tick() {
53
+ const t = now();
54
+ const prev = lastTick;
55
+ lastTick = t;
56
+ if (!current) { stopTimer(); return; }
57
+ // Only time since THIS line went up counts: the timer may have been running for an
58
+ // earlier one, and crediting the whole tick reported views after ~0.8 s on screen.
59
+ const elapsed = Math.max(0, t - Math.max(prev, current.countFrom ?? current.at));
60
+ if (!current.reported) {
61
+ if (isVisible(current)) {
62
+ // Uncovered somewhere since the last tick, but when exactly is unknowable from a
63
+ // poll: count from this tick on, never from before it (the advertiser's side).
64
+ if (current.countFrom === Infinity) current.countFrom = t;
65
+ else current.visibleMs += elapsed;
66
+ if (current.visibleMs >= VIEWABLE_MS) {
67
+ current.reported = true;
68
+ const shown = Math.round(current.visibleMs);
69
+ Promise.resolve().then(() => source.seen(current.ad.token, shown)).catch(() => {});
70
+ }
71
+ } else {
72
+ current.visibleMs = 0; // continuous, or it does not count
73
+ current.countFrom = Infinity; // and restarts at the first tick that sees it again
74
+ }
75
+ }
76
+ if (current.placement === 'thinking' && wanted === 'thinking' && t - current.at >= ROTATE_THINKING_MS) {
77
+ void fetchFor('thinking', { replace: true });
78
+ }
79
+ }
80
+
81
+ async function fetchFor(placement, { replace = false } = {}) {
82
+ if (stopped || inFlight.has(placement)) return;
83
+ if ((backoff.get(placement) || 0) > now()) { if (!replace) set(null); return; }
84
+ inFlight.add(placement);
85
+ let ad = null;
86
+ let failed = false;
87
+ try { ad = await source.next(placement); } catch { failed = true; }
88
+ inFlight.delete(placement);
89
+ if (stopped) return;
90
+ if (!ad || typeof ad.text !== 'string' || !ad.token) {
91
+ backoff.set(placement, now() + (failed ? ERROR_BACKOFF_MS : EMPTY_BACKOFF_MS));
92
+ if (wanted === placement && !replace) set(null);
93
+ return;
94
+ }
95
+ // The person moved on while it was being fetched (the turn ended): not shown.
96
+ if (wanted !== placement) return;
97
+ const slot = { placement, ad, at: now(), visibleMs: 0, reported: false };
98
+ if (placement === 'idle') lastIdle = { slot, at: slot.at };
99
+ set(slot);
100
+ ensureTimer();
101
+ }
102
+
103
+ return {
104
+ /** Which slot should have an ad now ('thinking' | 'idle' | 'usage' | null). */
105
+ want(placement) {
106
+ if (stopped) return;
107
+ wanted = placement || null;
108
+ if (!wanted) { set(null); return; }
109
+ if (current?.placement === wanted) return;
110
+ if (wanted === 'idle' && lastIdle && now() - lastIdle.at < IDLE_REUSE_MS) {
111
+ // Back to waiting moments after the last wait: the same line, not a new serve.
112
+ // Its clock starts again from now — it was off screen in between.
113
+ if (!lastIdle.slot.reported) { lastIdle.slot.visibleMs = 0; }
114
+ lastIdle.slot.countFrom = now();
115
+ set(lastIdle.slot);
116
+ ensureTimer();
117
+ return;
118
+ }
119
+ set(null);
120
+ void fetchFor(wanted);
121
+ },
122
+ current: () => current,
123
+ stop() { stopped = true; stopTimer(); current = null; },
124
+ _tick: tick,
125
+ };
126
+ }
127
+
128
+ /** The one line as it is drawn, fitted to `width` columns: label, text, domain. */
129
+ export function adLineParts(ad, width) {
130
+ const label = 'Sponsored';
131
+ const domain = String(ad.domain || '');
132
+ const room = Math.max(0, width - label.length - domain.length - 8);
133
+ const text = String(ad.text || '');
134
+ const shown = room <= 0 ? '' : (text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`);
135
+ return { label, text: shown, domain };
136
+ }
137
+
138
+ /** The ad source on Jaitus's API: short timeouts, and any failure is just "no ad". */
139
+ export function adSourceFor(client, { timeoutMs = 2500 } = {}) {
140
+ const call = (path, init = {}) => client.api(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
141
+ return {
142
+ async next(placement) {
143
+ const r = await call(`/ads/cli/next?placement=${encodeURIComponent(placement)}`);
144
+ return r.ok ? (r.body?.data?.ad || null) : null;
145
+ },
146
+ async seen(token, shownMs) {
147
+ // No content-type here: client.api already sends one, and a second made the header
148
+ // "application/json, application/json", which the server's JSON parser does not
149
+ // recognise — so in 0.3.25 every report arrived with no body and was refused.
150
+ await call('/ads/cli/seen', { method: 'POST', body: JSON.stringify({ token, shownMs }) });
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,161 @@
1
+ // The model asks the person a question and waits for the answer — multiple choice, with
2
+ // a way to answer in their own words. Without it a model that hit a real decision either
3
+ // guessed (and built the wrong thing) or ended its turn with a question in prose, which
4
+ // throws away everything it had in flight.
5
+ //
6
+ // The answer always comes back as text the model can act on: what was picked, what was
7
+ // typed, or that the person closed the question. A run with nobody to ask (a script, -p,
8
+ // a subagent) never pretends to ask.
9
+
10
+ export const MAX_QUESTIONS = 4;
11
+ export const MAX_OPTIONS = 6;
12
+ export const OTHER_VALUE = '__own_words__';
13
+ const OTHER_LABEL = 'Type my own answer';
14
+
15
+ /** True when the input already contains an answer the user can always pick. */
16
+ function isOtherLabel(label) {
17
+ return /^(other|something else|none of (the|these)|custom|type (my|your) own)/i.test(String(label).trim());
18
+ }
19
+
20
+ function text(value) {
21
+ return typeof value === 'string' ? value.trim() : (typeof value === 'number' ? String(value) : '');
22
+ }
23
+
24
+ /**
25
+ * Normalise what the model sent. Models trained on other agents send `question` alone,
26
+ * options as plain strings, `choices` for `options`, `multi_select` or `allowMultiple` —
27
+ * all of which mean the same thing and are taken as such rather than refused.
28
+ * Returns { questions } or { error }.
29
+ */
30
+ export function normaliseQuestions(input = {}) {
31
+ let list = Array.isArray(input.questions) ? input.questions
32
+ : (input.question || input.options || input.choices) ? [input] : [];
33
+ if (!list.length) return { error: 'Give questions: [{ question, options: [{ label, description }] }].' };
34
+ if (list.length > MAX_QUESTIONS) return { error: `Ask at most ${MAX_QUESTIONS} questions at a time.` };
35
+ const questions = [];
36
+ for (const [n, raw] of list.entries()) {
37
+ const q = typeof raw === 'string' ? { question: raw } : (raw || {});
38
+ const question = text(q.question) || text(q.prompt) || text(q.text);
39
+ if (!question) return { error: `Question ${n + 1} has no question text.` };
40
+ const rawOptions = Array.isArray(q.options) ? q.options : Array.isArray(q.choices) ? q.choices : [];
41
+ const options = [];
42
+ const seen = new Set();
43
+ for (const o of rawOptions) {
44
+ const opt = typeof o === 'string' || typeof o === 'number' ? { label: String(o) } : (o || {});
45
+ const label = text(opt.label) || text(opt.value) || text(opt.title) || text(opt.name);
46
+ // "Other" is always offered by the interface; one the model wrote itself would
47
+ // appear twice and only one of them would let the person type.
48
+ if (!label || isOtherLabel(label) || seen.has(label.toLowerCase())) continue;
49
+ seen.add(label.toLowerCase());
50
+ options.push({ label: label.slice(0, 120), description: text(opt.description).slice(0, 400) });
51
+ }
52
+ if (options.length < 2) return { error: `Question ${n + 1} needs at least 2 options (the person can always type their own answer as well).` };
53
+ if (options.length > MAX_OPTIONS) return { error: `Question ${n + 1} has ${options.length} options; give at most ${MAX_OPTIONS}.` };
54
+ const header = (text(q.header) || text(q.title)).slice(0, 24);
55
+ const multi = [q.multiSelect, q.multi_select, q.multiple, q.allowMultiple].some((v) => v === true);
56
+ questions.push({ question: question.slice(0, 600), header, options, multi });
57
+ }
58
+ return { questions };
59
+ }
60
+
61
+ /** Lines that describe the options when the dialog cannot show a description per row. */
62
+ function descriptionLines(options) {
63
+ return options.filter((o) => o.description).map((o) => `${o.label} — ${o.description}`);
64
+ }
65
+
66
+ /**
67
+ * Ask one question. `tui.richDialog` (the full-screen interface) shows descriptions,
68
+ * check boxes and a text field itself. Anything else gets the same answers through the
69
+ * plain numbered dialog every renderer has: one pick at a time for several choices, and
70
+ * `tui.askText` for words.
71
+ * Resolves { picked: [labels], text } or null when closed.
72
+ */
73
+ async function askOne(tui, q, { index, total, signal }) {
74
+ const title = `${total > 1 ? `Question ${index + 1} of ${total}` : 'Question'}${q.header ? ` · ${q.header}` : ''}`;
75
+ const choices = [
76
+ ...q.options.map((o) => ({ label: o.label, value: o.label, description: o.description })),
77
+ { label: OTHER_LABEL, value: OTHER_VALUE, text: true, description: 'Answer in your own words.' },
78
+ ];
79
+
80
+ if (tui.richDialog) {
81
+ const answer = await tui.askDialog(q.question, choices, [], { title, multi: q.multi, signal, hideComment: true, kind: 'question' });
82
+ if (!answer) return null;
83
+ const values = Array.isArray(answer.values) ? answer.values : [answer.value];
84
+ return { picked: values.filter((v) => v && v !== OTHER_VALUE), text: text(answer.text) };
85
+ }
86
+
87
+ const preview = descriptionLines(q.options);
88
+ // A renderer answers a closed dialog with null, or (the classic one) with a value that
89
+ // is none of the options — 'deny'. Neither may be read as a pick.
90
+ const known = new Set([...q.options.map((o) => o.label), OTHER_VALUE, '__done__']);
91
+ const closed = (answer) => !answer || signal?.aborted || !known.has(answer.value);
92
+ let picked = [];
93
+ if (q.multi) {
94
+ const chosen = new Set();
95
+ for (;;) {
96
+ const options = [
97
+ ...q.options.map((o) => ({ label: `[${chosen.has(o.label) ? 'x' : ' '}] ${o.label}`, value: o.label })),
98
+ { label: OTHER_LABEL, value: OTHER_VALUE },
99
+ { label: chosen.size ? `Done (${chosen.size} chosen)` : 'Done', value: '__done__' },
100
+ ];
101
+ const answer = await tui.askDialog(`${q.question} (pick each that applies, then Done)`, options, preview, { title, signal, hideComment: true });
102
+ if (closed(answer)) return null;
103
+ if (answer.value === '__done__') { if (chosen.size) break; continue; }
104
+ if (answer.value === OTHER_VALUE) {
105
+ const typed = await askWords(tui, q.question, { title, signal });
106
+ if (typed === null) continue;
107
+ return { picked: [...chosen], text: typed };
108
+ }
109
+ if (chosen.has(answer.value)) chosen.delete(answer.value); else chosen.add(answer.value);
110
+ }
111
+ picked = [...chosen];
112
+ return { picked, text: '' };
113
+ }
114
+ for (;;) {
115
+ const answer = await tui.askDialog(q.question, choices.map(({ label, value }) => ({ label, value })), preview, { title, signal, hideComment: true });
116
+ if (closed(answer)) return null;
117
+ if (answer.value !== OTHER_VALUE) return { picked: [answer.value], text: '' };
118
+ const typed = await askWords(tui, q.question, { title, signal });
119
+ if (typed !== null) return { picked: [], text: typed };
120
+ }
121
+ }
122
+
123
+ /** Free text from a renderer without a text field in its dialog. null = went back. */
124
+ async function askWords(tui, question, { title, signal }) {
125
+ if (!tui.askText) return null;
126
+ const typed = text(await tui.askText(`${question} — type your answer`, { title, signal }));
127
+ return typed || null;
128
+ }
129
+
130
+ function quote(s) { return `"${String(s).replace(/"/g, '\\"')}"`; }
131
+
132
+ /** The tool result: one line per question, in the person's own terms. */
133
+ export function formatAnswers(questions, answers) {
134
+ const lines = ['The user answered:'];
135
+ questions.forEach((q, i) => {
136
+ const a = answers[i];
137
+ const parts = [];
138
+ if (a.picked.length) parts.push(a.picked.join(', '));
139
+ if (a.text) parts.push(`in their own words: ${quote(a.text)}`);
140
+ lines.push(`${i + 1}. ${quote(q.question)} → ${parts.join('; ')}`);
141
+ });
142
+ lines.push('Act on these answers. Their own words take precedence over any option you offered.');
143
+ return lines.join('\n');
144
+ }
145
+
146
+ export const CLOSED_NOTE = 'The user closed the question without answering. Do not guess and do not ask it again: stop here, say briefly what you need decided, and wait for their next message.';
147
+ export const NOBODY_NOTE = 'Nobody can answer questions in this run (it is not interactive). Choose the most sensible option yourself, state it as an assumption in your reply, and continue.';
148
+
149
+ /** Run the whole exchange. Returns the text for the tool result. */
150
+ export async function askUser(input, { tui, signal, canAsk } = {}) {
151
+ const { questions, error } = normaliseQuestions(input);
152
+ if (error) return { error };
153
+ if (!canAsk || !tui?.askDialog) return { text: NOBODY_NOTE };
154
+ const answers = [];
155
+ for (const [index, q] of questions.entries()) {
156
+ const answer = await askOne(tui, q, { index, total: questions.length, signal });
157
+ if (!answer || (!answer.picked.length && !answer.text)) return { text: CLOSED_NOTE, closed: true };
158
+ answers.push(answer);
159
+ }
160
+ return { text: formatAnswers(questions, answers), answers };
161
+ }
package/src/agent/loop.js CHANGED
@@ -27,7 +27,7 @@ import { PermissionDenied } from './permissions.js';
27
27
  import path from 'node:path';
28
28
  import fs from 'node:fs';
29
29
  import os from 'node:os';
30
- import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
30
+ import { loadContext, loadUserContext, loadIgnore, CONTEXT_FILE } from '../project.js';
31
31
  import { c, out, write, spinner, glyph } from '../ui.js';
32
32
  import { discoverMcpTools } from '../mcp.js';
33
33
  import { unifiedDiff } from '../diff.js';
@@ -97,7 +97,7 @@ const TOOL_MARKS = {
97
97
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
98
98
  list_files: ['→', '>'], write_file: ['←', '<'], edit_file: ['←', '<'],
99
99
  search: ['✱', '*'], web_fetch: ['%', '%'], web_search: ['◈', 'o'],
100
- background_run: ['◉', 'o'], todo_write: ['⚙', '*'],
100
+ background_run: ['◉', 'o'], todo_write: ['⚙', '*'], ask_user: ['?', '?'],
101
101
  };
102
102
  const toolMark = (name) => {
103
103
  const [uni, ascii] = TOOL_MARKS[name] || ['⚙', '*'];
@@ -127,7 +127,9 @@ How to work:
127
127
  How to talk:
128
128
  - Be concise. This is a terminal, not a document.
129
129
  - Say what you did and what happened, not what you are about to do.
130
- - If something is blocked or you are missing information, say so plainly and ask.
130
+ - If something is blocked or you are missing information, say so plainly and ask. When a
131
+ decision is genuinely the user's (a preference, an unclear requirement, a real trade-off),
132
+ ask it with ask_user rather than guessing; decide everything else yourself.
131
133
  - No preamble, no restating the question back.`;
132
134
 
133
135
  /**
@@ -193,9 +195,20 @@ ${listing || '(empty)'}
193
195
 
194
196
  export function systemPrompt(cwd) {
195
197
  const project = cwd ? loadContext(cwd) : null;
196
- const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
197
- if (!project) return base;
198
- return `${base}
198
+ const personal = loadUserContext();
199
+ let out = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
200
+ if (personal) {
201
+ out += `
202
+
203
+ The user keeps personal notes for you that apply in every project: how they like to work.
204
+ Follow them unless this project's own notes say otherwise for this codebase.
205
+
206
+ <user-notes>
207
+ ${personal}
208
+ </user-notes>`;
209
+ }
210
+ if (project) {
211
+ out += `
199
212
 
200
213
  The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
201
214
  it applies, and prefer what it says over your own assumptions about this codebase.
@@ -203,6 +216,8 @@ it applies, and prefer what it says over your own assumptions about this codebas
203
216
  <${CONTEXT_FILE}>
204
217
  ${project}
205
218
  </${CONTEXT_FILE}>`;
219
+ }
220
+ return out;
206
221
  }
207
222
 
208
223
  /**
@@ -315,7 +330,10 @@ export function clearOldToolResults(messages, { at = TRIM_AT, keep, atLeast = TR
315
330
  }
316
331
 
317
332
  // Tools that change nothing and ask nothing: several in one batch run at the same time.
318
- const PARALLEL_SAFE = new Set(['read_file', 'glob', 'list_files', 'search', 'web_search', 'web_fetch', 'background_list', 'background_read', 'load_skill']);
333
+ // `task` (a read-only subagent) is here too: several investigations in one turn run side
334
+ // by side, two at a time (SUBAGENTS_AT_ONCE) so they fit any plan's concurrent requests.
335
+ const PARALLEL_SAFE = new Set(['read_file', 'glob', 'list_files', 'search', 'web_search', 'web_fetch', 'background_list', 'background_read', 'load_skill', 'task']);
336
+ export const SUBAGENTS_AT_ONCE = 2;
319
337
 
320
338
  export const IMAGE_BUDGET = 6_000_000;
321
339
  export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
@@ -528,8 +546,11 @@ export async function runAgent({
528
546
  // subagents is an unbounded fan-out on a metered gateway, and the depth that would
529
547
  // actually help — one — is the depth that is safe.
530
548
  const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
549
+ // ask_user is offered only when someone is there to answer: a model shown a question
550
+ // tool in a script or a subagent asks, gets "nobody can answer" and has wasted a turn.
551
+ const canAsk = !!tui?.askDialog && permissions?.canAsk !== false;
531
552
  const availableTools = [
532
- ...toolSchemas(),
553
+ ...toolSchemas().filter((t) => canAsk || t.name !== 'ask_user'),
533
554
  ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
534
555
  ...mcp.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema })),
535
556
  ];
@@ -828,7 +849,9 @@ export async function runAgent({
828
849
  // silent when animations are off — progress you can watch, not noise).
829
850
  let elapsedTimer = null;
830
851
  const stopElapsed = () => { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } };
831
- if (tui && tui.animationsOn?.()) {
852
+ if (tui && call.name === 'ask_user') {
853
+ tui.setStatus(`${c.brand(glyph('•', '*'))} Waiting for your answer`);
854
+ } else if (tui && tui.animationsOn?.()) {
832
855
  const toolStatus = () => `${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 60))} ${c.dim(`· ${((Date.now() - toolStarted) / 1000).toFixed(0)}s`)}`;
833
856
  tui.setStatus(toolStatus());
834
857
  elapsedTimer = setInterval(() => tui.setStatus(toolStatus()), 1000);
@@ -846,7 +869,7 @@ export async function runAgent({
846
869
  } else if (journal && call.name === 'run_command') {
847
870
  journal.snapshotTracked(cwd);
848
871
  }
849
- const output = await (early.get(call.id) ?? tool.run(call.input, { cwd, signal }));
872
+ const output = await (early.get(call.id) ?? tool.run(call.input, { cwd, signal, tui, canAsk }));
850
873
  spin2.stop(); stopElapsed();
851
874
  const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
852
875
  // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
@@ -950,14 +973,22 @@ export async function runAgent({
950
973
  * subagent the user never sees must not be able to change the disk.
951
974
  */
952
975
  function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
953
- return {
976
+ // At most SUBAGENTS_AT_ONCE run together; the rest wait their turn. Every plan allows
977
+ // at least two requests at once, and the main agent is idle while its tools run — so
978
+ // side-by-side subagents never trip the plan's limit and fail on a retry budget.
979
+ let running = 0;
980
+ const waiting = [];
981
+ const enter = () => (running < SUBAGENTS_AT_ONCE ? (running += 1, Promise.resolve()) : new Promise((r) => waiting.push(r)));
982
+ const leave = () => { const next = waiting.shift(); if (next) next(); else running -= 1; };
983
+ const tool = {
954
984
  name: 'task',
955
985
  needsPermission: false,
956
986
  description: 'Delegate a read-only investigation to a separate agent with its own context window. '
957
987
  + 'Use it when answering needs a lot of searching and reading whose intermediate output you do not need to keep — '
958
988
  + '"find every caller of X", "work out how Y is wired", "check whether Z is covered by tests". '
959
989
  + 'The subagent can read, list, search and fetch, but cannot write files or run commands. '
960
- + 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text.',
990
+ + 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text. '
991
+ + 'Several task calls in the same turn run at the same time, so split independent investigations into separate calls.',
961
992
  input_schema: {
962
993
  type: 'object',
963
994
  properties: {
@@ -967,7 +998,11 @@ function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null
967
998
  required: ['description', 'prompt'],
968
999
  },
969
1000
  summarise: (input) => String(input.description || 'subagent'),
970
- async run(input, { cwd: runCwd }) {
1001
+ async run(input, { cwd: runCwd } = {}) {
1002
+ await enter();
1003
+ try { return await tool.investigate(input, runCwd); } finally { leave(); }
1004
+ },
1005
+ async investigate(input, runCwd) {
971
1006
  const { createPermissions } = await import('./permissions.js');
972
1007
  const messages = [{ role: 'user', content: String(input.prompt || '') }];
973
1008
  let answer = '';
@@ -994,6 +1029,7 @@ function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null
994
1029
  return `${trimmed}\n\n[subagent: ${result.tools} tool calls, ${result.input + result.output} tokens]`;
995
1030
  },
996
1031
  };
1032
+ return tool;
997
1033
  }
998
1034
 
999
1035
  /**
@@ -46,6 +46,8 @@ export function createPermissions({
46
46
  get rules() { return { allow: [...savedAllow], deny: [...savedDeny], session: [...sessionAllow] }; },
47
47
  /** Saved rules that take a whole tool out of the mode's hands (see wideAllows). */
48
48
  get wide() { return wideAllows(savedAllow); },
49
+ /** Whether anyone is there to answer a question (false for -p, scripts and subagents). */
50
+ get canAsk() { return interactive && !!process.stdin.isTTY; },
49
51
 
50
52
  /** Add a rule. `persist` writes it to settings via onRulesChange. */
51
53
  addRule(rule, { persist = false, kind = 'allow' } = {}) {
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import { spawn, spawnSync } from 'node:child_process';
21
21
  import { loadIgnore, IGNORE_FILE } from '../project.js';
22
22
  import { searchWeb, formatResults } from './websearch.js';
23
+ import { askUser } from './askUser.js';
23
24
 
24
25
  const MAX_OUTPUT = 60_000; // characters returned to the model per tool call
25
26
  const MAX_READ_LINES = 2_000;
@@ -762,6 +763,57 @@ export const TOOLS = [
762
763
  },
763
764
  },
764
765
 
766
+ {
767
+ name: 'ask_user',
768
+ description:
769
+ 'Ask the user one to four multiple-choice questions and wait for the answers. Use it when you are blocked on a decision that is genuinely theirs to make: a preference, an ambiguous requirement, a choice between approaches with real trade-offs. Do not use it for things you can find out from the code or by running something, and never to ask permission to act (actions that need approval are confirmed separately). Give 2 to 4 short, distinct options per question, each with a one-line description of what it means or costs. If you recommend one, put it first and end its label with "(Recommended)". The user can always type their own answer instead, so never add an "Other" option. Set multiSelect when several options can apply together.',
770
+ needsPermission: false,
771
+ input_schema: {
772
+ type: 'object',
773
+ properties: {
774
+ questions: {
775
+ type: 'array',
776
+ minItems: 1,
777
+ maxItems: 4,
778
+ description: 'The questions, asked one after another.',
779
+ items: {
780
+ type: 'object',
781
+ properties: {
782
+ question: { type: 'string', description: 'The full question, ending with a question mark.' },
783
+ header: { type: 'string', description: 'A label of at most 12 characters shown above the question, e.g. "Database".' },
784
+ options: {
785
+ type: 'array',
786
+ minItems: 2,
787
+ maxItems: 4,
788
+ items: {
789
+ type: 'object',
790
+ properties: {
791
+ label: { type: 'string', description: 'The choice, in 1 to 5 words.' },
792
+ description: { type: 'string', description: 'What choosing it means, or its trade-off.' },
793
+ },
794
+ required: ['label'],
795
+ },
796
+ },
797
+ multiSelect: { type: 'boolean', description: 'true when several options may be chosen together.' },
798
+ },
799
+ required: ['question', 'options'],
800
+ },
801
+ },
802
+ },
803
+ required: ['questions'],
804
+ },
805
+ summarise: (i) => {
806
+ const first = Array.isArray(i.questions) ? i.questions[0] : i;
807
+ const q = String(first?.question || first?.prompt || 'question').replace(/\s+/g, ' ');
808
+ return q.length > 70 ? `${q.slice(0, 69)}…` : q;
809
+ },
810
+ async run(input, { tui, signal, canAsk } = {}) {
811
+ const answer = await askUser(input, { tui, signal, canAsk });
812
+ if (answer.error) throw new ToolError(answer.error);
813
+ return answer.text;
814
+ },
815
+ },
816
+
765
817
  {
766
818
  name: 'read_image',
767
819
  description: