wisp-agents 0.15.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 +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
package/server/runner.js
CHANGED
|
@@ -4,23 +4,44 @@
|
|
|
4
4
|
// setting they wait until the run finishes.
|
|
5
5
|
|
|
6
6
|
import { EventEmitter } from 'node:events';
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
7
|
import path from 'node:path';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
10
9
|
import { adapters } from './adapters/index.js';
|
|
11
10
|
import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
|
|
12
11
|
import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
|
|
13
12
|
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
13
|
+
import { handoffNote } from './handoff.js';
|
|
14
|
+
import { agentPrompt, plainMentions } from './mentions.js';
|
|
15
|
+
import { bad, MODES, newId } from './util.js';
|
|
14
16
|
|
|
15
17
|
const MAX_ACTIVITY = 400;
|
|
16
|
-
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
17
18
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
18
19
|
const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
|
|
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;
|
|
22
26
|
|
|
23
|
-
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
export { newId };
|
|
24
45
|
|
|
25
46
|
// Where the agent actually works: its own worktree when it has one, else the project folder.
|
|
26
47
|
export const workdir = (s) => s.worktree?.cwd || s.cwd;
|
|
@@ -32,8 +53,18 @@ function workspaceNote(s) {
|
|
|
32
53
|
return `\n\n## Workspace\n\nYou are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`;
|
|
33
54
|
}
|
|
34
55
|
|
|
56
|
+
// A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
|
|
57
|
+
// Fields left out keep the session's; a new harness without a model gets its default.
|
|
58
|
+
function changedRun(session, run) {
|
|
59
|
+
if (!run || typeof run !== 'object') return null;
|
|
60
|
+
const agent = run.agent || session.agent;
|
|
61
|
+
const same = agent === session.agent;
|
|
62
|
+
const next = { agent, model: String(run.model ?? (same ? session.model : '') ?? ''), effort: String(run.effort ?? (same ? session.effort : '') ?? '') };
|
|
63
|
+
return same && next.model === (session.model || '') && next.effort === (session.effort || '') ? null : next;
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
function autoTitle(prompt) {
|
|
36
|
-
const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
67
|
+
const line = plainMentions(prompt).trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
37
68
|
return line.length > 64 ? `${line.slice(0, 63)}…` : line;
|
|
38
69
|
}
|
|
39
70
|
|
|
@@ -68,6 +99,7 @@ export function summarize(s, store) {
|
|
|
68
99
|
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
|
|
69
100
|
asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
|
|
70
101
|
question: s.ask?.question || '',
|
|
102
|
+
approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
|
|
71
103
|
branch: s.worktree?.branch || '',
|
|
72
104
|
toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
|
|
73
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,
|
|
@@ -82,7 +114,7 @@ export function summarize(s, store) {
|
|
|
82
114
|
),
|
|
83
115
|
...(s.parentId && { parentId: s.parentId, member: s.member }),
|
|
84
116
|
// Auto: what it picked and why, for the card's tooltip.
|
|
85
|
-
route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives } : null,
|
|
117
|
+
route: s.route ? { goal: s.route.goal, type: s.route.type, label: s.route.label, chance: s.route.chance, n: s.route.n, usd: s.route.usd, explored: s.route.explored, trying: s.route.trying, alternatives: s.route.alternatives, ...(s.route.switched && { switched: true }) } : null,
|
|
86
118
|
};
|
|
87
119
|
}
|
|
88
120
|
|
|
@@ -112,7 +144,7 @@ export class Runner extends EventEmitter {
|
|
|
112
144
|
}
|
|
113
145
|
|
|
114
146
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
115
|
-
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 }) {
|
|
116
148
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
117
149
|
const now = Date.now();
|
|
118
150
|
const session = {
|
|
@@ -129,6 +161,7 @@ export class Runner extends EventEmitter {
|
|
|
129
161
|
createdAt: now,
|
|
130
162
|
updatedAt: now,
|
|
131
163
|
agentSessionId: null,
|
|
164
|
+
agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
|
|
132
165
|
queue: [],
|
|
133
166
|
turns: [],
|
|
134
167
|
...(worktree && { worktree }),
|
|
@@ -145,10 +178,14 @@ export class Runner extends EventEmitter {
|
|
|
145
178
|
}
|
|
146
179
|
|
|
147
180
|
// mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
|
|
148
|
-
// follow-up keeps its own, so the run before it isn't affected.
|
|
149
|
-
|
|
181
|
+
// follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
|
|
182
|
+
// switches the harness, model and thinking level the same way (see #switchRun).
|
|
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 } = {}) {
|
|
150
185
|
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
151
|
-
|
|
186
|
+
run = changedRun(session, run);
|
|
187
|
+
const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
|
|
188
|
+
if (session.ask && !Object.keys(extra).length) {
|
|
152
189
|
this.answer(session, text);
|
|
153
190
|
return { answered: true };
|
|
154
191
|
}
|
|
@@ -158,27 +195,65 @@ export class Runner extends EventEmitter {
|
|
|
158
195
|
// The run already produced its result and the CLI is only lingering on its way out:
|
|
159
196
|
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
160
197
|
if (turn?.status !== 'running') {
|
|
161
|
-
session.queue.push({ id: newId(), text, attachments, ...
|
|
198
|
+
session.queue.push({ id: newId(), text, attachments, ...extra });
|
|
162
199
|
handle.release();
|
|
163
200
|
return { queued: session.queue.length > 0 };
|
|
164
201
|
}
|
|
165
202
|
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
166
203
|
if (queue || !session.agentSessionId) {
|
|
167
|
-
session.queue.push({ id: newId(), text, attachments, ...
|
|
204
|
+
session.queue.push({ id: newId(), text, attachments, ...extra });
|
|
168
205
|
this.#touch(session);
|
|
169
206
|
return { queued: true, ahead: session.queue.length - 1 };
|
|
170
207
|
}
|
|
171
208
|
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
172
209
|
const at = session.queue.findIndex((q) => !q.now);
|
|
173
|
-
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...
|
|
210
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...extra });
|
|
174
211
|
this.#interrupt(session);
|
|
175
212
|
return { interrupted: true };
|
|
176
213
|
}
|
|
177
214
|
if (mode) session.mode = mode;
|
|
178
|
-
this.#
|
|
215
|
+
if (run) this.#switchRun(session, run);
|
|
216
|
+
this.#startTurn(session, text, attachments, extra);
|
|
179
217
|
return { queued: false };
|
|
180
218
|
}
|
|
181
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
|
+
|
|
239
|
+
// A follow-up on another harness, model or thinking level. A model or level change resumes the
|
|
240
|
+
// same conversation; a harness change resumes that harness's own one (if it ran earlier in this
|
|
241
|
+
// task) or starts it fresh, and #startTurn hands it the turns it missed.
|
|
242
|
+
#switchRun(session, { agent, model, effort }) {
|
|
243
|
+
session.agentSessions ||= {};
|
|
244
|
+
// Sessions from before agentSessions: their conversation belongs to the harness they ran on.
|
|
245
|
+
if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
|
|
246
|
+
for (const t of session.turns) t.agent ||= session.agent;
|
|
247
|
+
// Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
|
|
248
|
+
if (session.route && (agent !== session.agent || model !== session.model)) session.route.switched = true;
|
|
249
|
+
if (agent !== session.agent) {
|
|
250
|
+
session.agent = agent;
|
|
251
|
+
session.agentSessionId = session.agentSessions[agent] || null;
|
|
252
|
+
}
|
|
253
|
+
session.model = model;
|
|
254
|
+
session.effort = effort;
|
|
255
|
+
}
|
|
256
|
+
|
|
182
257
|
// Drop a follow-up that hasn't gone out yet.
|
|
183
258
|
unqueue(session, itemId) {
|
|
184
259
|
const at = session.queue.findIndex((q) => q.id === itemId);
|
|
@@ -189,7 +264,8 @@ export class Runner extends EventEmitter {
|
|
|
189
264
|
}
|
|
190
265
|
|
|
191
266
|
// ask_user: the agent is blocked inside a tool call until answer() is called.
|
|
192
|
-
|
|
267
|
+
// permission: set for a live approval ({ tool, action, detail }), see requestApproval().
|
|
268
|
+
ask(session, { question, context, options, permission }) {
|
|
193
269
|
const turn = session.turns.at(-1);
|
|
194
270
|
session.ask = {
|
|
195
271
|
id: newId(),
|
|
@@ -197,34 +273,66 @@ export class Runner extends EventEmitter {
|
|
|
197
273
|
context: context ? String(context) : '',
|
|
198
274
|
options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
|
|
199
275
|
askedAt: Date.now(),
|
|
276
|
+
...(permission && { permission }),
|
|
200
277
|
};
|
|
201
278
|
session.state = 'waiting';
|
|
202
279
|
session.unread = true;
|
|
203
280
|
delete session.archived; // a question needs the user, so it comes back to the feed
|
|
204
|
-
|
|
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 });
|
|
205
283
|
this.#touch(session, { turn: turn?.id });
|
|
206
284
|
this.emit('finished', { session: summarize(session, this.store) });
|
|
207
285
|
return session.ask.id;
|
|
208
286
|
}
|
|
209
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
|
+
|
|
210
308
|
answer(session, text) {
|
|
211
309
|
const ask = session.ask;
|
|
212
310
|
if (!ask) return false;
|
|
213
311
|
ask.answer = text;
|
|
214
|
-
|
|
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 });
|
|
215
323
|
delete session.ask;
|
|
216
324
|
session.state = this.live.has(session.id) ? 'running' : 'review';
|
|
217
|
-
|
|
218
|
-
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 });
|
|
219
326
|
this.#touch(session, { turn: turn?.id });
|
|
220
327
|
return true;
|
|
221
328
|
}
|
|
222
329
|
|
|
330
|
+
// { answer } once answered (plus { decision, message? } for an approval), { pending }, or { cancelled }.
|
|
223
331
|
pollAnswer(askId) {
|
|
224
332
|
if (this.answers.has(askId)) {
|
|
225
|
-
const
|
|
333
|
+
const out = this.answers.get(askId);
|
|
226
334
|
this.answers.delete(askId);
|
|
227
|
-
return
|
|
335
|
+
return out;
|
|
228
336
|
}
|
|
229
337
|
const open = this.store.all().some((s) => s.ask?.id === askId);
|
|
230
338
|
return open ? { pending: true } : { cancelled: true };
|
|
@@ -234,9 +342,9 @@ export class Runner extends EventEmitter {
|
|
|
234
342
|
// created on first use and resumed after that. Returns one record (or { error }) per task.
|
|
235
343
|
delegate(lead, tasks) {
|
|
236
344
|
const turn = lead.turns.at(-1);
|
|
237
|
-
if (turn?.status !== 'running') throw
|
|
345
|
+
if (turn?.status !== 'running') throw bad('The lead agent is not running.', 409);
|
|
238
346
|
const team = lead.subagents || [];
|
|
239
|
-
if (!team.length) throw
|
|
347
|
+
if (!team.length) throw bad('This session has no subagents. The user can add them when launching.');
|
|
240
348
|
const out = [];
|
|
241
349
|
const claimed = new Set(); // copies handed a task in this call
|
|
242
350
|
const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
|
|
@@ -320,7 +428,8 @@ export class Runner extends EventEmitter {
|
|
|
320
428
|
this.live.get(session.id).stop();
|
|
321
429
|
}
|
|
322
430
|
|
|
323
|
-
|
|
431
|
+
// The record of a new turn, with the checkpoint taken before the agent touches anything.
|
|
432
|
+
#newTurn(session, prompt, attachments, mode = session.mode) {
|
|
324
433
|
const id = newId();
|
|
325
434
|
// A subagent's edits land in the lead's turn, whose checkpoint already covers them.
|
|
326
435
|
const checkpoint =
|
|
@@ -329,13 +438,13 @@ export class Runner extends EventEmitter {
|
|
|
329
438
|
: !session.turns.length && session.worktree?.head
|
|
330
439
|
? freshCheckpoint(session.worktree)
|
|
331
440
|
: captureCheckpoint(workdir(session), session.id, id);
|
|
332
|
-
|
|
441
|
+
return {
|
|
333
442
|
id,
|
|
334
443
|
prompt,
|
|
335
444
|
agent: session.agent,
|
|
336
445
|
model: session.model,
|
|
337
446
|
effort: session.effort || '',
|
|
338
|
-
mode
|
|
447
|
+
mode,
|
|
339
448
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
340
449
|
startedAt: Date.now(),
|
|
341
450
|
endedAt: null,
|
|
@@ -346,6 +455,42 @@ export class Runner extends EventEmitter {
|
|
|
346
455
|
presentation: null,
|
|
347
456
|
stats: null,
|
|
348
457
|
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The wisp protocol for this run: a lead hears about its team, a subagent that it can't ask.
|
|
461
|
+
#systemPrompt(session, adapter, team) {
|
|
462
|
+
const base = session.parentId
|
|
463
|
+
? systemPrompt({ ask: SUBAGENT_ASK })
|
|
464
|
+
: systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
|
|
465
|
+
return base + workspaceNote(session) + this.promptExtra(session);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// wisp's own MCP server (ask_user, delegate), for harnesses that take one. Subagents get none.
|
|
469
|
+
#mcpServers(session, adapter, team) {
|
|
470
|
+
if (!this.bridge || adapter.askTool === null || session.parentId) return {};
|
|
471
|
+
return {
|
|
472
|
+
wisp: {
|
|
473
|
+
command: process.execPath,
|
|
474
|
+
args: [ASK_SERVER],
|
|
475
|
+
// Inside the desktop app execPath is the Electron binary; without
|
|
476
|
+
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
477
|
+
env: {
|
|
478
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
479
|
+
WISP_URL: this.bridge.url,
|
|
480
|
+
WISP_TOKEN: this.bridge.token,
|
|
481
|
+
WISP_SESSION: session.id,
|
|
482
|
+
...(adapter.approveTool && { WISP_APPROVE: '1' }),
|
|
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) }))) }),
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
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);
|
|
349
494
|
session.turns.push(turn);
|
|
350
495
|
session.state = 'running';
|
|
351
496
|
session.unread = false;
|
|
@@ -359,37 +504,24 @@ export class Runner extends EventEmitter {
|
|
|
359
504
|
const adapter = adapters[session.agent];
|
|
360
505
|
// Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
|
|
361
506
|
const team = adapter.askTool !== null ? session.subagents || [] : [];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
507
|
+
// A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
|
|
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 });
|
|
365
512
|
const handle = adapter.run({
|
|
366
|
-
prompt,
|
|
513
|
+
prompt: handoff ? `${handoff}\n\n${text}` : text,
|
|
367
514
|
attachments,
|
|
368
515
|
cwd: workdir(session),
|
|
369
516
|
resumeId: session.agentSessionId,
|
|
370
|
-
mode
|
|
517
|
+
mode,
|
|
518
|
+
grants,
|
|
371
519
|
model: session.model,
|
|
372
520
|
effort: session.effort || '',
|
|
373
|
-
systemPrompt:
|
|
521
|
+
systemPrompt: this.#systemPrompt(session, adapter, team),
|
|
374
522
|
// A lead with a team delegates through wisp, so the harness's own subagent tool is switched off.
|
|
375
523
|
delegating: team.length > 0 && !!this.bridge,
|
|
376
|
-
mcpServers: this
|
|
377
|
-
? {
|
|
378
|
-
wisp: {
|
|
379
|
-
command: process.execPath,
|
|
380
|
-
args: [ASK_SERVER],
|
|
381
|
-
// Inside the desktop app execPath is the Electron binary; without
|
|
382
|
-
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
383
|
-
env: {
|
|
384
|
-
ELECTRON_RUN_AS_NODE: '1',
|
|
385
|
-
WISP_URL: this.bridge.url,
|
|
386
|
-
WISP_TOKEN: this.bridge.token,
|
|
387
|
-
WISP_SESSION: session.id,
|
|
388
|
-
...(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) }))) }),
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
}
|
|
392
|
-
: {},
|
|
524
|
+
mcpServers: this.#mcpServers(session, adapter, team),
|
|
393
525
|
onEvent: (ev) => {
|
|
394
526
|
if (released) return; // a lingering process we already let go of
|
|
395
527
|
const waiting = this.#onEvent(session, turn, ev) === 'waiting';
|
|
@@ -417,7 +549,8 @@ export class Runner extends EventEmitter {
|
|
|
417
549
|
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
418
550
|
const next = session.queue.shift();
|
|
419
551
|
if (next?.mode) session.mode = next.mode;
|
|
420
|
-
if (next &&
|
|
552
|
+
if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
|
|
553
|
+
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments, next);
|
|
421
554
|
else this.#touch(session);
|
|
422
555
|
};
|
|
423
556
|
handle.release = release;
|
|
@@ -429,6 +562,7 @@ export class Runner extends EventEmitter {
|
|
|
429
562
|
switch (ev.type) {
|
|
430
563
|
case 'session':
|
|
431
564
|
session.agentSessionId = ev.id;
|
|
565
|
+
(session.agentSessions ||= {})[session.agent] = ev.id;
|
|
432
566
|
return;
|
|
433
567
|
case 'tool':
|
|
434
568
|
case 'tool_error':
|
|
@@ -465,6 +599,14 @@ export class Runner extends EventEmitter {
|
|
|
465
599
|
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
466
600
|
return;
|
|
467
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
|
+
}
|
|
468
610
|
case 'background': {
|
|
469
611
|
turn.background = ev.tasks;
|
|
470
612
|
if (turn.interim) this.#waitItem(session, turn);
|
|
@@ -532,6 +674,7 @@ export class Runner extends EventEmitter {
|
|
|
532
674
|
// Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
|
|
533
675
|
if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
|
|
534
676
|
}
|
|
677
|
+
if (!ev.stopped) blockedCard(turn);
|
|
535
678
|
session.state = 'review';
|
|
536
679
|
session.unread = !interrupted;
|
|
537
680
|
this.store.save(session, { now: true });
|
|
@@ -550,3 +693,27 @@ export class Runner extends EventEmitter {
|
|
|
550
693
|
if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
|
|
551
694
|
}
|
|
552
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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Auto: wisp picks harness and model per task and learns from outcomes (server/router.js).
|
|
2
|
+
// This wires the router to the rest of the server: which harnesses are installed here, the
|
|
3
|
+
// user's Auto settings, and the outcome signals that come back from summaries.
|
|
4
|
+
|
|
5
|
+
import { listAgents } from '../adapters/index.js';
|
|
6
|
+
import { GOALS } from '../router.js';
|
|
7
|
+
import { bad } from '../util.js';
|
|
8
|
+
|
|
9
|
+
export function createAuto(app) {
|
|
10
|
+
const { store, router } = app;
|
|
11
|
+
let agentsHere = [];
|
|
12
|
+
|
|
13
|
+
const detect = async (opts) => {
|
|
14
|
+
const agents = await listAgents(opts);
|
|
15
|
+
agentsHere = agents;
|
|
16
|
+
const added = router.sync(agents);
|
|
17
|
+
if (added.length) app.broadcast('router', { added });
|
|
18
|
+
return agents;
|
|
19
|
+
};
|
|
20
|
+
// This computer's harnesses: the last ones detected, else detected now.
|
|
21
|
+
const agents = async () => (agentsHere.length ? agentsHere : await detect());
|
|
22
|
+
|
|
23
|
+
const opts = () => {
|
|
24
|
+
const r = store.config.defaults.router || {};
|
|
25
|
+
return { explore: r.explore !== 'off', allowed: Array.isArray(r.allowed) && r.allowed.length ? r.allowed : null };
|
|
26
|
+
};
|
|
27
|
+
const goalOf = (model) => (GOALS[model] ? model : GOALS[store.config.defaults.models?.auto] ? store.config.defaults.models.auto : 'balanced');
|
|
28
|
+
|
|
29
|
+
// Subagents set to Auto are routed on their first task, from this computer's harnesses.
|
|
30
|
+
app.runner.routeAuto = (prompt) => router.choose({ prompt, agents: agentsHere, goal: goalOf(), ...opts() });
|
|
31
|
+
|
|
32
|
+
// A routed session's outcome so far, from its summary: works the same for sessions on machines.
|
|
33
|
+
// Follow-ups after the first turn count as corrections; a merged or closed PR settles it, unless
|
|
34
|
+
// the user moved the task to another harness or model first.
|
|
35
|
+
const signals = (sum) => {
|
|
36
|
+
if (!sum?.id || !router.route(sum.id) || sum.status === 'running' || sum.state === 'running') return;
|
|
37
|
+
const pr = sum.pr?.state;
|
|
38
|
+
router.signal(sum.id, {
|
|
39
|
+
hero: ['done', 'partial', 'blocked', 'info'].includes(sum.status) ? sum.status : undefined,
|
|
40
|
+
followups: Math.max(0, (sum.turnCount || 1) - 1),
|
|
41
|
+
merged: pr === 'MERGED' || undefined,
|
|
42
|
+
closed: pr === 'CLOSED' || undefined,
|
|
43
|
+
switched: sum.route?.switched || undefined,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// agent 'auto' (and compare entries set to it) → a harness and model picked by the router from
|
|
48
|
+
// `agents`: this computer's, or the machine's the launch goes to. Model is the goal for Auto.
|
|
49
|
+
const resolve = async (body, agents, { team = false } = {}) => {
|
|
50
|
+
const want = [{ agent: body.agent, model: body.model, effort: body.effort }, ...(Array.isArray(body.compare) ? body.compare : [])];
|
|
51
|
+
if (!want.some((r) => r.agent === 'auto')) return body;
|
|
52
|
+
const taken = [];
|
|
53
|
+
const out = want.map((r, i) => {
|
|
54
|
+
if (r.agent !== 'auto') return r;
|
|
55
|
+
const pick = router.choose({ prompt: body.prompt, attachments: (body.attachments || []).length, agents, goal: goalOf(r.model), important: !!body.important, exclude: taken, needDelegate: i === 0 && team, ...opts() });
|
|
56
|
+
if (!pick) throw bad('Auto found no installed harness to run this on.');
|
|
57
|
+
taken.push(pick.key);
|
|
58
|
+
return { agent: pick.agent, model: pick.model, effort: '', route: pick.route };
|
|
59
|
+
});
|
|
60
|
+
return { ...body, ...out[0], compare: out.slice(1) };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// What the Auto view shows: goals, settings, and what the router learned.
|
|
64
|
+
const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed }, ...router.report(await agents()) });
|
|
65
|
+
|
|
66
|
+
return { detect, agents, opts, goalOf, signals, resolve, view };
|
|
67
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Feedback for the wisp team: thumbs on a turn and/or a note.
|
|
2
|
+
// A rating sticks to its turn (it shows on the card and can be taken back). What goes out is
|
|
3
|
+
// the rating, the user's own words, and how the run was set up — never the prompt or the result.
|
|
4
|
+
|
|
5
|
+
import { splitId } from '../machines.js';
|
|
6
|
+
import { feedbackAvailable } from '../telemetry.js';
|
|
7
|
+
import { bad } from '../util.js';
|
|
8
|
+
import { runProps } from './telemetry.js';
|
|
9
|
+
|
|
10
|
+
export function createFeedback(app) {
|
|
11
|
+
const { store, machines, telemetry } = app;
|
|
12
|
+
|
|
13
|
+
return async function sendFeedback({ rating, text, sessionId, turnId, source } = {}) {
|
|
14
|
+
if (rating !== undefined && rating !== null && rating !== 'up' && rating !== 'down') throw bad('rating must be up or down');
|
|
15
|
+
text = String(text || '').trim();
|
|
16
|
+
if (rating === undefined && !text) throw bad('Write something, or pick 👍 / 👎.');
|
|
17
|
+
// A session on a machine: that wisp keeps the rating on its turn and sends it.
|
|
18
|
+
const at = sessionId && splitId(sessionId);
|
|
19
|
+
if (sessionId && turnId && rating !== undefined) app.router.signal(sessionId, { feedback: rating });
|
|
20
|
+
if (at) {
|
|
21
|
+
try {
|
|
22
|
+
return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.status === 400) throw err; // offline, or an older wisp there: send it from here, without the run
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const s = sessionId && !at ? store.get(sessionId) : null;
|
|
28
|
+
if (sessionId && !at && !s) throw bad('no such session', 404);
|
|
29
|
+
const turn = s && (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
30
|
+
if (turnId && s && !turn) throw bad('no such turn', 404);
|
|
31
|
+
// Only thumbs on a turn stick to it; a rating in the feedback dialog is about wisp as a whole.
|
|
32
|
+
if (turn && turnId && rating !== undefined) {
|
|
33
|
+
turn.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
34
|
+
store.save(s);
|
|
35
|
+
app.broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
36
|
+
}
|
|
37
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
38
|
+
const context = turn && {
|
|
39
|
+
...runProps(s, turn),
|
|
40
|
+
status: turn.status,
|
|
41
|
+
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
42
|
+
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
43
|
+
durationMs: turn.endedAt ? turn.endedAt - turn.startedAt : undefined,
|
|
44
|
+
tour: s.agent === 'wisp' || undefined,
|
|
45
|
+
};
|
|
46
|
+
const props = { ...context, rating: rating || undefined, source: String(source || (turn ? 'turn' : 'app')).slice(0, 20), machine: at ? true : undefined };
|
|
47
|
+
// Thumbs on a turn count as a rating; anything written, or sent from the dialog, is 'feedback'.
|
|
48
|
+
const event = text || !turnId ? 'feedback' : 'turn_rated';
|
|
49
|
+
if (event === 'turn_rated' && rating === null) props.cleared = true;
|
|
50
|
+
const sent = await telemetry.feedback(event, { ...props, text: text || undefined });
|
|
51
|
+
return { ok: true, sent, available: feedbackAvailable() };
|
|
52
|
+
};
|
|
53
|
+
}
|