jaitus 0.3.24 → 0.3.25
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 +2 -2
- package/src/adSlots.js +140 -0
- package/src/agent/askUser.js +161 -0
- package/src/agent/loop.js +49 -13
- package/src/agent/permissions.js +2 -0
- package/src/agent/tools.js +52 -0
- package/src/index.js +100 -25
- package/src/mcp.js +251 -9
- package/src/project.js +33 -0
- package/src/tui-ink.js +187 -16
- package/src/tui.js +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
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,140 @@
|
|
|
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 elapsed = Math.max(0, t - lastTick);
|
|
55
|
+
lastTick = t;
|
|
56
|
+
if (!current) { stopTimer(); return; }
|
|
57
|
+
if (!current.reported) {
|
|
58
|
+
if (isVisible(current)) {
|
|
59
|
+
current.visibleMs += elapsed;
|
|
60
|
+
if (current.visibleMs >= VIEWABLE_MS) {
|
|
61
|
+
current.reported = true;
|
|
62
|
+
const shown = Math.round(current.visibleMs);
|
|
63
|
+
Promise.resolve().then(() => source.seen(current.ad.token, shown)).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
current.visibleMs = 0; // continuous, or it does not count
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (current.placement === 'thinking' && wanted === 'thinking' && t - current.at >= ROTATE_THINKING_MS) {
|
|
70
|
+
void fetchFor('thinking', { replace: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchFor(placement, { replace = false } = {}) {
|
|
75
|
+
if (stopped || inFlight.has(placement)) return;
|
|
76
|
+
if ((backoff.get(placement) || 0) > now()) { if (!replace) set(null); return; }
|
|
77
|
+
inFlight.add(placement);
|
|
78
|
+
let ad = null;
|
|
79
|
+
let failed = false;
|
|
80
|
+
try { ad = await source.next(placement); } catch { failed = true; }
|
|
81
|
+
inFlight.delete(placement);
|
|
82
|
+
if (stopped) return;
|
|
83
|
+
if (!ad || typeof ad.text !== 'string' || !ad.token) {
|
|
84
|
+
backoff.set(placement, now() + (failed ? ERROR_BACKOFF_MS : EMPTY_BACKOFF_MS));
|
|
85
|
+
if (wanted === placement && !replace) set(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// The person moved on while it was being fetched (the turn ended): not shown.
|
|
89
|
+
if (wanted !== placement) return;
|
|
90
|
+
const slot = { placement, ad, at: now(), visibleMs: 0, reported: false };
|
|
91
|
+
if (placement === 'idle') lastIdle = { slot, at: slot.at };
|
|
92
|
+
set(slot);
|
|
93
|
+
ensureTimer();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/** Which slot should have an ad now ('thinking' | 'idle' | 'usage' | null). */
|
|
98
|
+
want(placement) {
|
|
99
|
+
if (stopped) return;
|
|
100
|
+
wanted = placement || null;
|
|
101
|
+
if (!wanted) { set(null); return; }
|
|
102
|
+
if (current?.placement === wanted) return;
|
|
103
|
+
if (wanted === 'idle' && lastIdle && now() - lastIdle.at < IDLE_REUSE_MS) {
|
|
104
|
+
// Back to waiting moments after the last wait: the same line, not a new serve.
|
|
105
|
+
set(lastIdle.slot);
|
|
106
|
+
ensureTimer();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
set(null);
|
|
110
|
+
void fetchFor(wanted);
|
|
111
|
+
},
|
|
112
|
+
current: () => current,
|
|
113
|
+
stop() { stopped = true; stopTimer(); current = null; },
|
|
114
|
+
_tick: tick,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The one line as it is drawn, fitted to `width` columns: label, text, domain. */
|
|
119
|
+
export function adLineParts(ad, width) {
|
|
120
|
+
const label = 'Sponsored';
|
|
121
|
+
const domain = String(ad.domain || '');
|
|
122
|
+
const room = Math.max(0, width - label.length - domain.length - 8);
|
|
123
|
+
const text = String(ad.text || '');
|
|
124
|
+
const shown = room <= 0 ? '' : (text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`);
|
|
125
|
+
return { label, text: shown, domain };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The ad source on Jaitus's API: short timeouts, and any failure is just "no ad". */
|
|
129
|
+
export function adSourceFor(client, { timeoutMs = 2500 } = {}) {
|
|
130
|
+
const call = (path, init = {}) => client.api(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
131
|
+
return {
|
|
132
|
+
async next(placement) {
|
|
133
|
+
const r = await call(`/ads/cli/next?placement=${encodeURIComponent(placement)}`);
|
|
134
|
+
return r.ok ? (r.body?.data?.ad || null) : null;
|
|
135
|
+
},
|
|
136
|
+
async seen(token, shownMs) {
|
|
137
|
+
await call('/ads/cli/seen', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token, shownMs }) });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
/**
|
package/src/agent/permissions.js
CHANGED
|
@@ -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' } = {}) {
|
package/src/agent/tools.js
CHANGED
|
@@ -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:
|