wisp-agents 0.16.0 → 0.17.0
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 +17 -1
- package/README.md +5 -5
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/handoff.js +2 -1
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +3 -2
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/sessions.js +12 -0
- package/server/runner.js +136 -20
- package/server/services/launch.js +1 -1
- package/server/services/phone.js +1 -1
- package/server/store.js +8 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/ship.css +2 -2
- package/web/index.html +1 -0
- package/web/js/blocks.js +46 -0
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/launcher.js +11 -9
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/runners.js +3 -1
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
package/server/openapi.js
CHANGED
|
@@ -226,7 +226,8 @@ export function openapi({ version, port }) {
|
|
|
226
226
|
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
|
|
227
227
|
},
|
|
228
228
|
},
|
|
229
|
-
'/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
|
|
229
|
+
'/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
|
|
230
|
+
'/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
|
|
230
231
|
'/sessions/{id}/revert': {
|
|
231
232
|
parameters: [id],
|
|
232
233
|
post: {
|
|
@@ -292,7 +293,7 @@ export function openapi({ version, port }) {
|
|
|
292
293
|
cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
|
|
293
294
|
agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
|
|
294
295
|
important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
|
|
295
|
-
mode: { enum: ['plan', 'edits', 'auto'], default: '
|
|
296
|
+
mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
|
|
296
297
|
model: { type: 'string' },
|
|
297
298
|
effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
|
|
298
299
|
attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
|
package/server/protocol.js
CHANGED
|
@@ -69,6 +69,9 @@ diff, the test run, the command output — over prose. Use file paths relative t
|
|
|
69
69
|
A screenshot or generated image on disk.
|
|
70
70
|
- {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
|
|
71
71
|
When you need a decision. The user's click resumes you with the reply.
|
|
72
|
+
- {"type":"blocked","items":[{"tool":"shell","detail":"npm publish","why":"needs network access"}]}
|
|
73
|
+
Actions your permissions or sandbox refused (a denied tool call, a command that failed for lack of
|
|
74
|
+
network or write access). wisp shows them with an Allow & retry button. Not for ordinary failures.
|
|
72
75
|
- {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
|
|
73
76
|
One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
|
|
74
77
|
- {"type":"next","items":["Suggested follow-up the user might send", "…"]}
|
|
@@ -89,7 +92,11 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
|
|
|
89
92
|
|
|
90
93
|
const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
91
94
|
two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
|
|
92
|
-
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options
|
|
95
|
+
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
|
|
96
|
+
- If the user asks about their past wisp tasks (which tools or harnesses they use, cost, success rate),
|
|
97
|
+
call \`wisp_stats\` (${tool.replace(/ask_user$/, 'wisp_stats')}) instead of digging through files.
|
|
98
|
+
- If the prompt mentions another wisp task ("@Title (wisp task <id>)") or the user asks what another task
|
|
99
|
+
did, read it with \`wisp_session\` (${tool.replace(/ask_user$/, 'wisp_session')}); \`wisp_sessions\` lists them.`;
|
|
93
100
|
const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
94
101
|
two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
|
|
95
102
|
The user's answer comes back as a follow-up in this same session.`;
|
package/server/render.js
CHANGED
|
@@ -80,6 +80,8 @@ const R = {
|
|
|
80
80
|
callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
|
|
81
81
|
image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
|
|
82
82
|
question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
|
|
83
|
+
blocked: (b, turn) =>
|
|
84
|
+
`**🔒 Blocked by permissions**${turn?.approval ? ` _(${turn.approval})_` : ''}\n${list(b.items).map((i) => `- ${str(i.tool)}${i.detail ? `: \`${str(i.detail)}\`` : ''}${i.why ? ` — ${i.why}` : ''}`).join('\n')}`,
|
|
83
85
|
actions: (b) =>
|
|
84
86
|
`${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
|
|
85
87
|
.map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
|
|
@@ -109,7 +111,7 @@ export function sessionToMarkdown(session) {
|
|
|
109
111
|
if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
|
|
110
112
|
else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
|
|
111
113
|
if (session.ask) {
|
|
112
|
-
out.push(
|
|
114
|
+
out.push(`**${session.ask.permission ? '🔒 The agent needs your approval' : '❓ The agent is waiting for your answer'}:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
|
|
113
115
|
if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
|
|
114
116
|
}
|
|
115
117
|
// Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
// Bridge for the MCP servers running inside an agent process (ask_user, delegate
|
|
1
|
+
// Bridge for the MCP servers running inside an agent process (ask_user, approve, delegate, wisp_stats,
|
|
2
|
+
// wisp_sessions, wisp_session). Every call
|
|
2
3
|
// carries the per-run token wisp handed the agent; nothing else may use these.
|
|
3
4
|
|
|
4
5
|
import { adapters } from '../adapters/index.js';
|
|
6
|
+
import { plainMentions } from '../mentions.js';
|
|
7
|
+
import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
|
|
8
|
+
import { summarize } from '../runner.js';
|
|
5
9
|
import { delegationReport } from '../subagents.js';
|
|
10
|
+
import { toolStats, toolStatsText } from '../usage.js';
|
|
11
|
+
import { clip } from '../util.js';
|
|
6
12
|
|
|
7
13
|
export function register(r, app) {
|
|
8
14
|
const { store, runner, telemetry } = app;
|
|
@@ -25,6 +31,22 @@ export function register(r, app) {
|
|
|
25
31
|
);
|
|
26
32
|
r.route('GET', '/internal/ask/:id', guarded((c) => c.send(200, runner.pollAnswer(c.params.id))), opts);
|
|
27
33
|
|
|
34
|
+
// A live approval (the harness's permission prompt): { decision } at once, or an { id } to poll
|
|
35
|
+
// like ask_user, whose answer carries the decision.
|
|
36
|
+
r.route(
|
|
37
|
+
'POST',
|
|
38
|
+
'/internal/approve',
|
|
39
|
+
guarded(async (c) => {
|
|
40
|
+
const body = await c.body();
|
|
41
|
+
const s = store.get(body.session);
|
|
42
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
43
|
+
const out = runner.requestApproval(s, body);
|
|
44
|
+
if (out.id) telemetry.track('approval', { agent: s.agent });
|
|
45
|
+
c.send(200, out);
|
|
46
|
+
}),
|
|
47
|
+
opts,
|
|
48
|
+
);
|
|
49
|
+
|
|
28
50
|
// delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
|
|
29
51
|
r.route(
|
|
30
52
|
'POST',
|
|
@@ -53,4 +75,102 @@ export function register(r, app) {
|
|
|
53
75
|
}),
|
|
54
76
|
opts,
|
|
55
77
|
);
|
|
78
|
+
|
|
79
|
+
// wisp_stats: read-only tool counts, spend and success across sessions. project=current is the
|
|
80
|
+
// asking session's project.
|
|
81
|
+
r.route(
|
|
82
|
+
'GET',
|
|
83
|
+
'/internal/stats/:id',
|
|
84
|
+
guarded((c) => {
|
|
85
|
+
const s = lead(c);
|
|
86
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
87
|
+
const projectFor = (x) => store.projectFor(x.cwd);
|
|
88
|
+
let project = c.query.get('project') || '';
|
|
89
|
+
if (project === 'current') project = projectFor(s)?.id || String(s.cwd || '').split(/[\\/]/).pop();
|
|
90
|
+
const stats = toolStats(store.all(), { days: Number(c.query.get('days')) || 30, project: projectFor, filter: { agent: c.query.get('agent') || '', project } });
|
|
91
|
+
telemetry.track('wisp_stats', { agent: s.agent });
|
|
92
|
+
c.send(200, { stats, text: toolStatsText(stats) });
|
|
93
|
+
}),
|
|
94
|
+
opts,
|
|
95
|
+
);
|
|
96
|
+
// wisp_sessions: the user's other tasks (not the asker, its subagents or the tour), newest first.
|
|
97
|
+
r.route(
|
|
98
|
+
'GET',
|
|
99
|
+
'/internal/sessions/:id',
|
|
100
|
+
guarded((c) => {
|
|
101
|
+
const s = lead(c);
|
|
102
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
103
|
+
const q = Object.fromEntries(c.query);
|
|
104
|
+
let project = q.project || '';
|
|
105
|
+
if (project === 'current') project = store.projectFor(s.cwd)?.id || String(s.cwd || '').split(/[\\/]/).pop();
|
|
106
|
+
const states = q.state ? q.state.split(',').map((x) => x.trim()) : null;
|
|
107
|
+
const words = String(q.query || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
108
|
+
const all = store
|
|
109
|
+
.all()
|
|
110
|
+
.filter((x) => x.id !== s.id && !x.parentId && x.agent !== 'wisp' && x.id !== s.parentId)
|
|
111
|
+
.map((x) => summarize(x, store))
|
|
112
|
+
.filter((x) => (q.archived === 'true' || !x.archived) && (!states || states.includes(x.state)))
|
|
113
|
+
.filter((x) => !project || x.projectId === project || x.project === project)
|
|
114
|
+
.filter((x) => words.every((w) => `${x.title} ${x.preview}`.toLowerCase().includes(w)))
|
|
115
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
116
|
+
const list = all.slice(0, Math.min(Math.max(Number(q.limit) || 20, 1), 100));
|
|
117
|
+
telemetry.track('wisp_sessions', { agent: s.agent });
|
|
118
|
+
const text = list.length
|
|
119
|
+
? `${all.length} task${all.length === 1 ? '' : 's'}${all.length > list.length ? `, the newest ${list.length}` : ''}. Read one with wisp_session.\n\n${list.map(sessionLine).join('\n')}`
|
|
120
|
+
: 'No tasks match.';
|
|
121
|
+
c.send(200, { text });
|
|
122
|
+
}),
|
|
123
|
+
opts,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// wisp_session: one task's requests and a turn's report (?of=<id>&turn=<1-based>).
|
|
127
|
+
r.route(
|
|
128
|
+
'GET',
|
|
129
|
+
'/internal/session/:id',
|
|
130
|
+
guarded((c) => {
|
|
131
|
+
const s = lead(c);
|
|
132
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
133
|
+
const id = String(c.query.get('of') || '').trim().replace(/^wisp:/, '');
|
|
134
|
+
const x = store.get(id);
|
|
135
|
+
telemetry.track('wisp_session', { agent: s.agent });
|
|
136
|
+
if (!x) return c.send(200, { text: `No wisp task \`${id}\` on this computer. It may have been deleted, or it ran on another machine. List tasks with wisp_sessions.` });
|
|
137
|
+
c.send(200, { text: sessionText(x, store, c.query.get('turn')) });
|
|
138
|
+
}),
|
|
139
|
+
opts,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const age = (t) => {
|
|
144
|
+
const m = Math.round((Date.now() - t) / 60_000);
|
|
145
|
+
return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const sessionLine = (x) =>
|
|
149
|
+
`- \`${x.id}\` **${x.title}** — ${x.archived ? 'archived, ' : ''}${x.state}, ${x.agent}${x.model ? ` (${x.model})` : ''}, ${x.project}, ${age(x.updatedAt)}${x.state === 'running' && x.live ? `: ${x.live}` : x.preview ? `: ${clip(x.preview, 160)}` : ''}`;
|
|
150
|
+
|
|
151
|
+
export function sessionText(x, store, turnArg) {
|
|
152
|
+
const sum = summarize(x, store);
|
|
153
|
+
const n = Number(turnArg);
|
|
154
|
+
const turn = turnArg === null || turnArg === undefined || turnArg === '' || !n ? x.turns.at(-1) : x.turns[n - 1];
|
|
155
|
+
const head = [
|
|
156
|
+
`# ${x.title}`,
|
|
157
|
+
`Task \`${x.id}\` · ${sum.archived ? 'archived · ' : ''}${x.state} · ${x.agent}${x.model ? ` (${x.model})` : ''} · project ${sum.project} · updated ${age(x.updatedAt)}`,
|
|
158
|
+
`Folder: \`${x.worktree?.path || x.cwd}\`${x.worktree ? ` (worktree, branch \`${x.worktree.branch}\`)` : ''}${x.pr ? ` · PR #${x.pr.number} (${String(x.pr.state).toLowerCase()}): ${x.pr.url}` : ''}`,
|
|
159
|
+
];
|
|
160
|
+
const asks = x.turns.map((t, i) => `${i + 1}. ${clip(plainMentions(t.prompt).replace(/\s+/g, ' '), 300)}${t.status === 'running' ? ' _(running)_' : t.status === 'error' ? ' _(failed)_' : t.status === 'stopped' ? ' _(stopped)_' : ''}`);
|
|
161
|
+
if (!turn) return [...head, '', `Turn ${turnArg} doesn't exist; this task has ${x.turns.length}.`, '', '## Requests', ...asks].join('\n');
|
|
162
|
+
const i = x.turns.indexOf(turn);
|
|
163
|
+
const report = turn === x.turns.at(-1) ? sessionToMarkdown(x) : blocksToMarkdown(turn.presentation?.blocks, turn);
|
|
164
|
+
const files = (turn.changes || []).filter((ch) => !ch.reverted).map((ch) => `\`${ch.file}\``);
|
|
165
|
+
return [
|
|
166
|
+
...head,
|
|
167
|
+
'',
|
|
168
|
+
`## Requests (${x.turns.length})`,
|
|
169
|
+
...asks,
|
|
170
|
+
'',
|
|
171
|
+
`## Report for turn ${i + 1}${x.turns.length > 1 ? ` of ${x.turns.length} (pass turn to read another)` : ''}`,
|
|
172
|
+
'',
|
|
173
|
+
report || '_No report yet._',
|
|
174
|
+
...(files.length ? ['', `Files edited in this turn: ${files.join(', ')}`] : []),
|
|
175
|
+
].join('\n');
|
|
56
176
|
}
|
|
@@ -206,6 +206,18 @@ export function register(r, app) {
|
|
|
206
206
|
}),
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
// The card under a turn whose permissions blocked something: allow retries it, deny closes it.
|
|
210
|
+
r.route(
|
|
211
|
+
'POST',
|
|
212
|
+
'/sessions/:id/approve',
|
|
213
|
+
one(async (c, s) => {
|
|
214
|
+
const { turnId, allow } = await c.body();
|
|
215
|
+
notTour(s, 'approve');
|
|
216
|
+
await ensureWorktree(s.worktree);
|
|
217
|
+
c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
209
221
|
// Revert (or re-apply) a single file's edits from a turn, or roll back (or re-apply) the
|
|
210
222
|
// project's working tree to the checkpoint before a turn. Both note it on the turn and tell
|
|
211
223
|
// the cards; Ship it may have something new (or nothing left) to ship.
|
package/server/runner.js
CHANGED
|
@@ -11,6 +11,7 @@ import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
|
|
|
11
11
|
import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
|
|
12
12
|
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
13
13
|
import { handoffNote } from './handoff.js';
|
|
14
|
+
import { agentPrompt, plainMentions } from './mentions.js';
|
|
14
15
|
import { bad, MODES, newId } from './util.js';
|
|
15
16
|
|
|
16
17
|
const MAX_ACTIVITY = 400;
|
|
@@ -19,6 +20,26 @@ const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its backgr
|
|
|
19
20
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
20
21
|
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
21
22
|
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
23
|
+
// The answers a live approval offers. Typed text instead is a "no, do this instead".
|
|
24
|
+
export const APPROVAL = { allow: 'Allow', always: 'Always allow in this task', deny: 'Deny' };
|
|
25
|
+
const sameAction = (a, b) => a.tool === b.tool && a.detail === b.detail;
|
|
26
|
+
|
|
27
|
+
// A live approval's answer: the button the user clicked, a yes/no typed out, or anything else,
|
|
28
|
+
// which denies the call and hands the agent the user's words.
|
|
29
|
+
export function approvalDecision(text) {
|
|
30
|
+
const t = String(text || '').trim();
|
|
31
|
+
if (t === APPROVAL.always || /^(always\b|2$)/i.test(t)) return { decision: 'allow', always: true };
|
|
32
|
+
if (t === APPROVAL.allow || /^(y|yes|ok|allow|approve|go ahead|1)$/i.test(t)) return { decision: 'allow' };
|
|
33
|
+
if (t === APPROVAL.deny || /^(n|no|deny|reject|3)$/i.test(t)) return { decision: 'deny' };
|
|
34
|
+
return { decision: 'deny', message: t };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The follow-up that retries what a turn's permissions refused, once the user allowed it.
|
|
38
|
+
function retryPrompt(items, scoped) {
|
|
39
|
+
const list = items.map((b) => `- ${b.tool}${b.detail ? `: \`${b.detail}\`` : ''}`).join('\n');
|
|
40
|
+
const how = scoped ? 'I allowed exactly these for this follow-up' : 'This follow-up runs in Full auto, so nothing is blocked';
|
|
41
|
+
return `These actions were blocked by your permissions last turn:\n${list}\n\n${how}. Run them now and finish the task.`;
|
|
42
|
+
}
|
|
22
43
|
|
|
23
44
|
export { newId };
|
|
24
45
|
|
|
@@ -43,7 +64,7 @@ function changedRun(session, run) {
|
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
function autoTitle(prompt) {
|
|
46
|
-
const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
67
|
+
const line = plainMentions(prompt).trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
47
68
|
return line.length > 64 ? `${line.slice(0, 63)}…` : line;
|
|
48
69
|
}
|
|
49
70
|
|
|
@@ -78,6 +99,7 @@ export function summarize(s, store) {
|
|
|
78
99
|
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
|
|
79
100
|
asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
|
|
80
101
|
question: s.ask?.question || '',
|
|
102
|
+
approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
|
|
81
103
|
branch: s.worktree?.branch || '',
|
|
82
104
|
toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
|
|
83
105
|
pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
|
|
@@ -122,7 +144,7 @@ export class Runner extends EventEmitter {
|
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
125
|
-
create({ id = newId(), prompt, cwd, agent = 'claude', mode = '
|
|
147
|
+
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
|
|
126
148
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
127
149
|
const now = Date.now();
|
|
128
150
|
const session = {
|
|
@@ -158,11 +180,12 @@ export class Runner extends EventEmitter {
|
|
|
158
180
|
// mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
|
|
159
181
|
// follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
|
|
160
182
|
// switches the harness, model and thinking level the same way (see #switchRun).
|
|
161
|
-
|
|
183
|
+
// grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
|
|
184
|
+
reply(session, text, attachments = [], { queue = false, mode, run, grants, once } = {}) {
|
|
162
185
|
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
163
186
|
run = changedRun(session, run);
|
|
164
|
-
const extra = { ...(mode && { mode }), ...(run && { run }) };
|
|
165
|
-
if (session.ask && !
|
|
187
|
+
const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
|
|
188
|
+
if (session.ask && !Object.keys(extra).length) {
|
|
166
189
|
this.answer(session, text);
|
|
167
190
|
return { answered: true };
|
|
168
191
|
}
|
|
@@ -190,10 +213,29 @@ export class Runner extends EventEmitter {
|
|
|
190
213
|
}
|
|
191
214
|
if (mode) session.mode = mode;
|
|
192
215
|
if (run) this.#switchRun(session, run);
|
|
193
|
-
this.#startTurn(session, text, attachments);
|
|
216
|
+
this.#startTurn(session, text, attachments, extra);
|
|
194
217
|
return { queued: false };
|
|
195
218
|
}
|
|
196
219
|
|
|
220
|
+
// The card under a turn whose permissions refused something: allow retries it as a follow-up,
|
|
221
|
+
// with exactly those actions allowed where the harness can scope a grant, else in Full auto for
|
|
222
|
+
// that one follow-up. Deny just closes the card.
|
|
223
|
+
approveBlocked(session, { turnId, allow }) {
|
|
224
|
+
const turn = turnId ? session.turns.find((t) => t.id === turnId) : session.turns.at(-1);
|
|
225
|
+
if (!turn?.blocked?.length) throw bad('Nothing on that turn is waiting for approval.', 404);
|
|
226
|
+
if (turn.approval) throw bad(`Already ${turn.approval}.`, 409);
|
|
227
|
+
if (turn !== session.turns.at(-1) || this.live.has(session.id)) throw bad('The task has moved on since; send a follow-up instead.', 409);
|
|
228
|
+
turn.approval = allow ? 'allowed' : 'denied';
|
|
229
|
+
if (!allow) {
|
|
230
|
+
this.#touch(session, { turn: turn.id });
|
|
231
|
+
return { denied: true };
|
|
232
|
+
}
|
|
233
|
+
const scoped = turn.blocked.every((b) => b.rule !== undefined);
|
|
234
|
+
const out = this.reply(session, retryPrompt(turn.blocked, scoped), [], scoped ? { grants: turn.blocked.map((b) => b.rule) } : { once: 'auto' });
|
|
235
|
+
this.#touch(session, { turn: turn.id });
|
|
236
|
+
return { ...out, scoped };
|
|
237
|
+
}
|
|
238
|
+
|
|
197
239
|
// A follow-up on another harness, model or thinking level. A model or level change resumes the
|
|
198
240
|
// same conversation; a harness change resumes that harness's own one (if it ran earlier in this
|
|
199
241
|
// task) or starts it fresh, and #startTurn hands it the turns it missed.
|
|
@@ -222,7 +264,8 @@ export class Runner extends EventEmitter {
|
|
|
222
264
|
}
|
|
223
265
|
|
|
224
266
|
// ask_user: the agent is blocked inside a tool call until answer() is called.
|
|
225
|
-
|
|
267
|
+
// permission: set for a live approval ({ tool, action, detail }), see requestApproval().
|
|
268
|
+
ask(session, { question, context, options, permission }) {
|
|
226
269
|
const turn = session.turns.at(-1);
|
|
227
270
|
session.ask = {
|
|
228
271
|
id: newId(),
|
|
@@ -230,34 +273,66 @@ export class Runner extends EventEmitter {
|
|
|
230
273
|
context: context ? String(context) : '',
|
|
231
274
|
options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
|
|
232
275
|
askedAt: Date.now(),
|
|
276
|
+
...(permission && { permission }),
|
|
233
277
|
};
|
|
234
278
|
session.state = 'waiting';
|
|
235
279
|
session.unread = true;
|
|
236
280
|
delete session.archived; // a question needs the user, so it comes back to the feed
|
|
237
|
-
|
|
281
|
+
const label = permission ? `Needs your OK to ${permission.action}` : `Asked you: ${session.ask.question}`;
|
|
282
|
+
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label });
|
|
238
283
|
this.#touch(session, { turn: turn?.id });
|
|
239
284
|
this.emit('finished', { session: summarize(session, this.store) });
|
|
240
285
|
return session.ask.id;
|
|
241
286
|
}
|
|
242
287
|
|
|
288
|
+
// Live approval: the harness asks before a tool call its permission mode doesn't allow on its
|
|
289
|
+
// own (Claude's --permission-prompt-tool). It waits on the answer like ask_user; something the
|
|
290
|
+
// user said "always" to earlier in this task goes through at once.
|
|
291
|
+
requestApproval(session, { tool, input }) {
|
|
292
|
+
const adapter = adapters[session.agent];
|
|
293
|
+
const p = adapter.permission?.(String(tool || ''), input && typeof input === 'object' ? input : {}, workdir(session)) || { tool: String(tool || 'tool'), action: `use ${tool}`, detail: '' };
|
|
294
|
+
const turn = session.turns.at(-1);
|
|
295
|
+
if ((session.grants || []).some((g) => sameAction(g, p))) {
|
|
296
|
+
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `Allowed (always in this task): ${p.action}` });
|
|
297
|
+
return { decision: 'allow' };
|
|
298
|
+
}
|
|
299
|
+
const id = this.ask(session, {
|
|
300
|
+
question: `Allow ${adapter.name} to ${p.action}?`,
|
|
301
|
+
context: p.why || '',
|
|
302
|
+
options: [APPROVAL.allow, APPROVAL.always, APPROVAL.deny],
|
|
303
|
+
permission: { tool: p.tool, action: p.action, detail: p.detail },
|
|
304
|
+
});
|
|
305
|
+
return { id };
|
|
306
|
+
}
|
|
307
|
+
|
|
243
308
|
answer(session, text) {
|
|
244
309
|
const ask = session.ask;
|
|
245
310
|
if (!ask) return false;
|
|
246
311
|
ask.answer = text;
|
|
247
|
-
|
|
312
|
+
const turn = session.turns.at(-1);
|
|
313
|
+
let label = `You answered: ${text}`;
|
|
314
|
+
if (ask.permission) {
|
|
315
|
+
const d = approvalDecision(text);
|
|
316
|
+
this.answers.set(ask.id, { answer: text, ...d });
|
|
317
|
+
const p = ask.permission;
|
|
318
|
+
if (d.always) (session.grants ||= []).push({ tool: p.tool, detail: p.detail });
|
|
319
|
+
// Denied here, so the turn's "blocked" card shouldn't offer it again.
|
|
320
|
+
if (d.decision === 'deny' && turn) (turn.denied ||= []).push({ tool: p.tool, detail: p.detail });
|
|
321
|
+
label = d.decision === 'allow' ? `You allowed${d.always ? ' (always in this task)' : ''}: ${p.action}` : `You denied: ${p.action}${d.message ? ` (“${d.message}”)` : ''}`;
|
|
322
|
+
} else this.answers.set(ask.id, { answer: text });
|
|
248
323
|
delete session.ask;
|
|
249
324
|
session.state = this.live.has(session.id) ? 'running' : 'review';
|
|
250
|
-
|
|
251
|
-
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `You answered: ${text}` });
|
|
325
|
+
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label });
|
|
252
326
|
this.#touch(session, { turn: turn?.id });
|
|
253
327
|
return true;
|
|
254
328
|
}
|
|
255
329
|
|
|
330
|
+
// { answer } once answered (plus { decision, message? } for an approval), { pending }, or { cancelled }.
|
|
256
331
|
pollAnswer(askId) {
|
|
257
332
|
if (this.answers.has(askId)) {
|
|
258
|
-
const
|
|
333
|
+
const out = this.answers.get(askId);
|
|
259
334
|
this.answers.delete(askId);
|
|
260
|
-
return
|
|
335
|
+
return out;
|
|
261
336
|
}
|
|
262
337
|
const open = this.store.all().some((s) => s.ask?.id === askId);
|
|
263
338
|
return open ? { pending: true } : { cancelled: true };
|
|
@@ -354,7 +429,7 @@ export class Runner extends EventEmitter {
|
|
|
354
429
|
}
|
|
355
430
|
|
|
356
431
|
// The record of a new turn, with the checkpoint taken before the agent touches anything.
|
|
357
|
-
#newTurn(session, prompt, attachments) {
|
|
432
|
+
#newTurn(session, prompt, attachments, mode = session.mode) {
|
|
358
433
|
const id = newId();
|
|
359
434
|
// A subagent's edits land in the lead's turn, whose checkpoint already covers them.
|
|
360
435
|
const checkpoint =
|
|
@@ -369,7 +444,7 @@ export class Runner extends EventEmitter {
|
|
|
369
444
|
agent: session.agent,
|
|
370
445
|
model: session.model,
|
|
371
446
|
effort: session.effort || '',
|
|
372
|
-
mode
|
|
447
|
+
mode,
|
|
373
448
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
374
449
|
startedAt: Date.now(),
|
|
375
450
|
endedAt: null,
|
|
@@ -404,14 +479,18 @@ export class Runner extends EventEmitter {
|
|
|
404
479
|
WISP_URL: this.bridge.url,
|
|
405
480
|
WISP_TOKEN: this.bridge.token,
|
|
406
481
|
WISP_SESSION: session.id,
|
|
482
|
+
...(adapter.approveTool && { WISP_APPROVE: '1' }),
|
|
407
483
|
...(team.length && { WISP_TEAM: JSON.stringify(team.map((m) => ({ name: m.name, agent: adapters[m.agent]?.name || m.agent, model: m.model, effort: m.effort, use: m.use, count: m.count || 1, copies: copies(m) }))) }),
|
|
408
484
|
},
|
|
409
485
|
},
|
|
410
486
|
};
|
|
411
487
|
}
|
|
412
488
|
|
|
413
|
-
|
|
414
|
-
|
|
489
|
+
// grants: harness rules allowing what the user approved, for this turn only. once: a mode for
|
|
490
|
+
// this turn only (an approved retry the harness can't scope runs in Full auto).
|
|
491
|
+
#startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
|
|
492
|
+
const mode = MODES.has(once) ? once : session.mode;
|
|
493
|
+
const turn = this.#newTurn(session, prompt, attachments, mode);
|
|
415
494
|
session.turns.push(turn);
|
|
416
495
|
session.state = 'running';
|
|
417
496
|
session.unread = false;
|
|
@@ -427,12 +506,16 @@ export class Runner extends EventEmitter {
|
|
|
427
506
|
const team = adapter.askTool !== null ? session.subagents || [] : [];
|
|
428
507
|
// A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
|
|
429
508
|
const handoff = session.parentId ? '' : handoffNote(session, session.agent, session.agentSessionId, (t) => adapters[t.agent]?.name || t.agent);
|
|
509
|
+
// Mentioned tasks: a lead reads them with wisp_session; a run without wisp's tools gets their reports.
|
|
510
|
+
const tools = !session.parentId && adapter.askTool !== null && this.bridge;
|
|
511
|
+
const text = agentPrompt(prompt, this.store, { tool: tools ? (adapter.askTool ?? 'mcp__wisp__ask_user').replace(/ask_user$/, 'wisp_session') : null, self: session.id });
|
|
430
512
|
const handle = adapter.run({
|
|
431
|
-
prompt: handoff ? `${handoff}\n\n${
|
|
513
|
+
prompt: handoff ? `${handoff}\n\n${text}` : text,
|
|
432
514
|
attachments,
|
|
433
515
|
cwd: workdir(session),
|
|
434
516
|
resumeId: session.agentSessionId,
|
|
435
|
-
mode
|
|
517
|
+
mode,
|
|
518
|
+
grants,
|
|
436
519
|
model: session.model,
|
|
437
520
|
effort: session.effort || '',
|
|
438
521
|
systemPrompt: this.#systemPrompt(session, adapter, team),
|
|
@@ -467,7 +550,7 @@ export class Runner extends EventEmitter {
|
|
|
467
550
|
const next = session.queue.shift();
|
|
468
551
|
if (next?.mode) session.mode = next.mode;
|
|
469
552
|
if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
|
|
470
|
-
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
553
|
+
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments, next);
|
|
471
554
|
else this.#touch(session);
|
|
472
555
|
};
|
|
473
556
|
handle.release = release;
|
|
@@ -516,6 +599,14 @@ export class Runner extends EventEmitter {
|
|
|
516
599
|
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
517
600
|
return;
|
|
518
601
|
}
|
|
602
|
+
case 'blocked': {
|
|
603
|
+
// A tool call the harness's permissions refused. The card after the turn offers to retry it.
|
|
604
|
+
const item = { tool: String(ev.tool || 'tool'), detail: String(ev.detail ?? ''), ...(ev.rule !== undefined && { rule: ev.rule }) };
|
|
605
|
+
const list = (turn.blocked ||= []);
|
|
606
|
+
if (!list.some((b) => sameAction(b, item))) list.push(item);
|
|
607
|
+
this.store.save(session);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
519
610
|
case 'background': {
|
|
520
611
|
turn.background = ev.tasks;
|
|
521
612
|
if (turn.interim) this.#waitItem(session, turn);
|
|
@@ -583,6 +674,7 @@ export class Runner extends EventEmitter {
|
|
|
583
674
|
// Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
|
|
584
675
|
if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
|
|
585
676
|
}
|
|
677
|
+
if (!ev.stopped) blockedCard(turn);
|
|
586
678
|
session.state = 'review';
|
|
587
679
|
session.unread = !interrupted;
|
|
588
680
|
this.store.save(session, { now: true });
|
|
@@ -601,3 +693,27 @@ export class Runner extends EventEmitter {
|
|
|
601
693
|
if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
|
|
602
694
|
}
|
|
603
695
|
}
|
|
696
|
+
|
|
697
|
+
// What the turn's permissions refused, as one "blocked" block after the hero: the harness's own
|
|
698
|
+
// reports (which can carry a rule to allow exactly that) merged with the agent's "blocked" block,
|
|
699
|
+
// minus anything the user already denied. turn.blocked keeps the list for the retry.
|
|
700
|
+
function blockedCard(turn) {
|
|
701
|
+
const blocks = turn.presentation?.blocks || [];
|
|
702
|
+
const own = blocks.filter((b) => b.type === 'blocked').flatMap((b) => (Array.isArray(b.items) ? b.items : []));
|
|
703
|
+
const items = [...(turn.blocked || [])];
|
|
704
|
+
for (const i of own) {
|
|
705
|
+
if (!i || typeof i !== 'object') continue;
|
|
706
|
+
const item = { tool: String(i.tool || 'tool'), detail: String(i.detail ?? i.command ?? i.path ?? ''), ...(i.why && { why: String(i.why) }) };
|
|
707
|
+
const known = items.find((b) => sameAction(b, item) || (item.detail && b.detail === item.detail));
|
|
708
|
+
if (known) known.why ||= item.why;
|
|
709
|
+
else items.push(item);
|
|
710
|
+
}
|
|
711
|
+
// The agent may name a tool its own way (shell for Bash): the same command still counts.
|
|
712
|
+
const left = items.filter((b) => !(turn.denied || []).some((d) => sameAction(d, b) || (b.detail && d.detail === b.detail))).slice(0, 12);
|
|
713
|
+
if (left.length) turn.blocked = left;
|
|
714
|
+
else delete turn.blocked;
|
|
715
|
+
if (!turn.presentation) return;
|
|
716
|
+
const rest = blocks.filter((b) => b.type !== 'blocked');
|
|
717
|
+
if (left.length) rest.splice(rest[0]?.type === 'hero' ? 1 : 0, 0, { type: 'blocked', items: left.map(({ rule, ...b }) => b) });
|
|
718
|
+
turn.presentation.blocks = rest;
|
|
719
|
+
}
|
|
@@ -33,7 +33,7 @@ export function createLaunch(app) {
|
|
|
33
33
|
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
34
34
|
return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
|
|
35
35
|
});
|
|
36
|
-
const mode = MODES.has(body.mode) ? body.mode : '
|
|
36
|
+
const mode = MODES.has(body.mode) ? body.mode : 'auto';
|
|
37
37
|
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
38
38
|
// runs need one each, or the harnesses would edit the same files.
|
|
39
39
|
const comparing = runners.length > 1;
|
package/server/services/phone.js
CHANGED
|
@@ -27,7 +27,7 @@ export function phoneActions(app) {
|
|
|
27
27
|
const d = store.config.defaults;
|
|
28
28
|
const agent = d.agent || 'claude';
|
|
29
29
|
const model = d.models?.[agent] ?? d.model ?? '';
|
|
30
|
-
const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || '
|
|
30
|
+
const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'auto' }, { kind: 'telegram', name: 'Telegram' });
|
|
31
31
|
app.broadcast('config', {});
|
|
32
32
|
return s;
|
|
33
33
|
},
|
package/server/store.js
CHANGED
|
@@ -9,7 +9,7 @@ import { writeJsonAtomic } from './util.js';
|
|
|
9
9
|
|
|
10
10
|
const DEFAULT_CONFIG = {
|
|
11
11
|
projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
|
|
12
|
-
defaults: { agent: 'auto', mode: '
|
|
12
|
+
defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
|
|
13
13
|
recipes: [], // saved prompts with {params}: server/recipes.js
|
|
14
14
|
triggers: [], // schedules and GitHub watchers: server/triggers.js
|
|
15
15
|
teams: [], // saved subagent presets: server/subagents.js
|
|
@@ -66,9 +66,15 @@ export class Store {
|
|
|
66
66
|
cfg.defaults.effort = '';
|
|
67
67
|
cfg.autoIntroduced = true;
|
|
68
68
|
}
|
|
69
|
+
// Full auto became the default mode: switch once from Edits (the old default); Plan and
|
|
70
|
+
// any later pick stick.
|
|
71
|
+
if (!cfg.fullAutoIntroduced) {
|
|
72
|
+
if (cfg.defaults.mode === 'edits') cfg.defaults.mode = 'auto';
|
|
73
|
+
cfg.fullAutoIntroduced = true;
|
|
74
|
+
}
|
|
69
75
|
return cfg;
|
|
70
76
|
} catch {
|
|
71
|
-
return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
|
|
77
|
+
return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true, fullAutoIntroduced: true };
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
|
package/server/triggers.js
CHANGED
|
@@ -100,7 +100,7 @@ export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
|
|
|
100
100
|
if (kind === 'schedule') parseCron(cron);
|
|
101
101
|
const label = kind === 'issues' ? String(v('label', 'wisp')).trim() : '';
|
|
102
102
|
if (kind === 'issues' && !label) throw bad('Pick the issue label to watch.');
|
|
103
|
-
const mode = String(v('mode', kind === 'issues' ? 'plan' : '
|
|
103
|
+
const mode = String(v('mode', kind === 'issues' ? 'plan' : 'auto'));
|
|
104
104
|
if (!MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
|
|
105
105
|
const changedSource = prev && (kind !== prev.kind || cwd !== prev.cwd || label !== prev.label || String(v('branch')) !== prev.branch);
|
|
106
106
|
return {
|