wisp-agents 0.14.0 → 0.16.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 +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -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-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- 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 +56 -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 +339 -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 +87 -36
- 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 +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- 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 +62 -0
- package/web/css/feed.css +208 -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 +16 -1
- package/web/js/app.js +28 -5232
- 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 +339 -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 +104 -0
- package/web/js/features/composer.js +311 -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 +559 -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/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 +161 -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 +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -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 -1901
package/server/runner.js
CHANGED
|
@@ -4,23 +4,23 @@
|
|
|
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 { bad, MODES, newId } from './util.js';
|
|
14
15
|
|
|
15
16
|
const MAX_ACTIVITY = 400;
|
|
16
|
-
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
17
17
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
18
18
|
const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its background tasks
|
|
19
19
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
20
20
|
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
21
21
|
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
22
22
|
|
|
23
|
-
export
|
|
23
|
+
export { newId };
|
|
24
24
|
|
|
25
25
|
// Where the agent actually works: its own worktree when it has one, else the project folder.
|
|
26
26
|
export const workdir = (s) => s.worktree?.cwd || s.cwd;
|
|
@@ -32,6 +32,16 @@ function workspaceNote(s) {
|
|
|
32
32
|
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
33
|
}
|
|
34
34
|
|
|
35
|
+
// A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
|
|
36
|
+
// Fields left out keep the session's; a new harness without a model gets its default.
|
|
37
|
+
function changedRun(session, run) {
|
|
38
|
+
if (!run || typeof run !== 'object') return null;
|
|
39
|
+
const agent = run.agent || session.agent;
|
|
40
|
+
const same = agent === session.agent;
|
|
41
|
+
const next = { agent, model: String(run.model ?? (same ? session.model : '') ?? ''), effort: String(run.effort ?? (same ? session.effort : '') ?? '') };
|
|
42
|
+
return same && next.model === (session.model || '') && next.effort === (session.effort || '') ? null : next;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
function autoTitle(prompt) {
|
|
36
46
|
const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
37
47
|
return line.length > 64 ? `${line.slice(0, 63)}…` : line;
|
|
@@ -82,7 +92,7 @@ export function summarize(s, store) {
|
|
|
82
92
|
),
|
|
83
93
|
...(s.parentId && { parentId: s.parentId, member: s.member }),
|
|
84
94
|
// 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,
|
|
95
|
+
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
96
|
};
|
|
87
97
|
}
|
|
88
98
|
|
|
@@ -129,6 +139,7 @@ export class Runner extends EventEmitter {
|
|
|
129
139
|
createdAt: now,
|
|
130
140
|
updatedAt: now,
|
|
131
141
|
agentSessionId: null,
|
|
142
|
+
agentSessions: {}, // harness -> its own conversation id, so switching back resumes it
|
|
132
143
|
queue: [],
|
|
133
144
|
turns: [],
|
|
134
145
|
...(worktree && { worktree }),
|
|
@@ -145,10 +156,13 @@ export class Runner extends EventEmitter {
|
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
// 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
|
-
|
|
159
|
+
// follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
|
|
160
|
+
// switches the harness, model and thinking level the same way (see #switchRun).
|
|
161
|
+
reply(session, text, attachments = [], { queue = false, mode, run } = {}) {
|
|
150
162
|
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
151
|
-
|
|
163
|
+
run = changedRun(session, run);
|
|
164
|
+
const extra = { ...(mode && { mode }), ...(run && { run }) };
|
|
165
|
+
if (session.ask && !mode && !run) {
|
|
152
166
|
this.answer(session, text);
|
|
153
167
|
return { answered: true };
|
|
154
168
|
}
|
|
@@ -158,27 +172,46 @@ export class Runner extends EventEmitter {
|
|
|
158
172
|
// The run already produced its result and the CLI is only lingering on its way out:
|
|
159
173
|
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
160
174
|
if (turn?.status !== 'running') {
|
|
161
|
-
session.queue.push({ id: newId(), text, attachments, ...
|
|
175
|
+
session.queue.push({ id: newId(), text, attachments, ...extra });
|
|
162
176
|
handle.release();
|
|
163
177
|
return { queued: session.queue.length > 0 };
|
|
164
178
|
}
|
|
165
179
|
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
166
180
|
if (queue || !session.agentSessionId) {
|
|
167
|
-
session.queue.push({ id: newId(), text, attachments, ...
|
|
181
|
+
session.queue.push({ id: newId(), text, attachments, ...extra });
|
|
168
182
|
this.#touch(session);
|
|
169
183
|
return { queued: true, ahead: session.queue.length - 1 };
|
|
170
184
|
}
|
|
171
185
|
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
172
186
|
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, ...
|
|
187
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...extra });
|
|
174
188
|
this.#interrupt(session);
|
|
175
189
|
return { interrupted: true };
|
|
176
190
|
}
|
|
177
191
|
if (mode) session.mode = mode;
|
|
192
|
+
if (run) this.#switchRun(session, run);
|
|
178
193
|
this.#startTurn(session, text, attachments);
|
|
179
194
|
return { queued: false };
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
// A follow-up on another harness, model or thinking level. A model or level change resumes the
|
|
198
|
+
// same conversation; a harness change resumes that harness's own one (if it ran earlier in this
|
|
199
|
+
// task) or starts it fresh, and #startTurn hands it the turns it missed.
|
|
200
|
+
#switchRun(session, { agent, model, effort }) {
|
|
201
|
+
session.agentSessions ||= {};
|
|
202
|
+
// Sessions from before agentSessions: their conversation belongs to the harness they ran on.
|
|
203
|
+
if (session.agentSessionId) session.agentSessions[session.agent] = session.agentSessionId;
|
|
204
|
+
for (const t of session.turns) t.agent ||= session.agent;
|
|
205
|
+
// Auto's pick was overruled: the card stops crediting it, and the router learns (services/auto.js).
|
|
206
|
+
if (session.route && (agent !== session.agent || model !== session.model)) session.route.switched = true;
|
|
207
|
+
if (agent !== session.agent) {
|
|
208
|
+
session.agent = agent;
|
|
209
|
+
session.agentSessionId = session.agentSessions[agent] || null;
|
|
210
|
+
}
|
|
211
|
+
session.model = model;
|
|
212
|
+
session.effort = effort;
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
// Drop a follow-up that hasn't gone out yet.
|
|
183
216
|
unqueue(session, itemId) {
|
|
184
217
|
const at = session.queue.findIndex((q) => q.id === itemId);
|
|
@@ -234,9 +267,9 @@ export class Runner extends EventEmitter {
|
|
|
234
267
|
// created on first use and resumed after that. Returns one record (or { error }) per task.
|
|
235
268
|
delegate(lead, tasks) {
|
|
236
269
|
const turn = lead.turns.at(-1);
|
|
237
|
-
if (turn?.status !== 'running') throw
|
|
270
|
+
if (turn?.status !== 'running') throw bad('The lead agent is not running.', 409);
|
|
238
271
|
const team = lead.subagents || [];
|
|
239
|
-
if (!team.length) throw
|
|
272
|
+
if (!team.length) throw bad('This session has no subagents. The user can add them when launching.');
|
|
240
273
|
const out = [];
|
|
241
274
|
const claimed = new Set(); // copies handed a task in this call
|
|
242
275
|
const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
|
|
@@ -320,7 +353,8 @@ export class Runner extends EventEmitter {
|
|
|
320
353
|
this.live.get(session.id).stop();
|
|
321
354
|
}
|
|
322
355
|
|
|
323
|
-
|
|
356
|
+
// The record of a new turn, with the checkpoint taken before the agent touches anything.
|
|
357
|
+
#newTurn(session, prompt, attachments) {
|
|
324
358
|
const id = newId();
|
|
325
359
|
// A subagent's edits land in the lead's turn, whose checkpoint already covers them.
|
|
326
360
|
const checkpoint =
|
|
@@ -329,7 +363,7 @@ export class Runner extends EventEmitter {
|
|
|
329
363
|
: !session.turns.length && session.worktree?.head
|
|
330
364
|
? freshCheckpoint(session.worktree)
|
|
331
365
|
: captureCheckpoint(workdir(session), session.id, id);
|
|
332
|
-
|
|
366
|
+
return {
|
|
333
367
|
id,
|
|
334
368
|
prompt,
|
|
335
369
|
agent: session.agent,
|
|
@@ -346,6 +380,38 @@ export class Runner extends EventEmitter {
|
|
|
346
380
|
presentation: null,
|
|
347
381
|
stats: null,
|
|
348
382
|
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The wisp protocol for this run: a lead hears about its team, a subagent that it can't ask.
|
|
386
|
+
#systemPrompt(session, adapter, team) {
|
|
387
|
+
const base = session.parentId
|
|
388
|
+
? systemPrompt({ ask: SUBAGENT_ASK })
|
|
389
|
+
: systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
|
|
390
|
+
return base + workspaceNote(session) + this.promptExtra(session);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// wisp's own MCP server (ask_user, delegate), for harnesses that take one. Subagents get none.
|
|
394
|
+
#mcpServers(session, adapter, team) {
|
|
395
|
+
if (!this.bridge || adapter.askTool === null || session.parentId) return {};
|
|
396
|
+
return {
|
|
397
|
+
wisp: {
|
|
398
|
+
command: process.execPath,
|
|
399
|
+
args: [ASK_SERVER],
|
|
400
|
+
// Inside the desktop app execPath is the Electron binary; without
|
|
401
|
+
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
402
|
+
env: {
|
|
403
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
404
|
+
WISP_URL: this.bridge.url,
|
|
405
|
+
WISP_TOKEN: this.bridge.token,
|
|
406
|
+
WISP_SESSION: session.id,
|
|
407
|
+
...(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
|
+
},
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#startTurn(session, prompt, attachments = []) {
|
|
414
|
+
const turn = this.#newTurn(session, prompt, attachments);
|
|
349
415
|
session.turns.push(turn);
|
|
350
416
|
session.state = 'running';
|
|
351
417
|
session.unread = false;
|
|
@@ -359,37 +425,20 @@ export class Runner extends EventEmitter {
|
|
|
359
425
|
const adapter = adapters[session.agent];
|
|
360
426
|
// Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
|
|
361
427
|
const team = adapter.askTool !== null ? session.subagents || [] : [];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
: systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
|
|
428
|
+
// A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
|
|
429
|
+
const handoff = session.parentId ? '' : handoffNote(session, session.agent, session.agentSessionId, (t) => adapters[t.agent]?.name || t.agent);
|
|
365
430
|
const handle = adapter.run({
|
|
366
|
-
prompt,
|
|
431
|
+
prompt: handoff ? `${handoff}\n\n${prompt}` : prompt,
|
|
367
432
|
attachments,
|
|
368
433
|
cwd: workdir(session),
|
|
369
434
|
resumeId: session.agentSessionId,
|
|
370
435
|
mode: session.mode,
|
|
371
436
|
model: session.model,
|
|
372
437
|
effort: session.effort || '',
|
|
373
|
-
systemPrompt:
|
|
438
|
+
systemPrompt: this.#systemPrompt(session, adapter, team),
|
|
374
439
|
// A lead with a team delegates through wisp, so the harness's own subagent tool is switched off.
|
|
375
440
|
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
|
-
: {},
|
|
441
|
+
mcpServers: this.#mcpServers(session, adapter, team),
|
|
393
442
|
onEvent: (ev) => {
|
|
394
443
|
if (released) return; // a lingering process we already let go of
|
|
395
444
|
const waiting = this.#onEvent(session, turn, ev) === 'waiting';
|
|
@@ -417,6 +466,7 @@ export class Runner extends EventEmitter {
|
|
|
417
466
|
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
418
467
|
const next = session.queue.shift();
|
|
419
468
|
if (next?.mode) session.mode = next.mode;
|
|
469
|
+
if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
|
|
420
470
|
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
421
471
|
else this.#touch(session);
|
|
422
472
|
};
|
|
@@ -429,6 +479,7 @@ export class Runner extends EventEmitter {
|
|
|
429
479
|
switch (ev.type) {
|
|
430
480
|
case 'session':
|
|
431
481
|
session.agentSessionId = ev.id;
|
|
482
|
+
(session.agentSessions ||= {})[session.agent] = ev.id;
|
|
432
483
|
return;
|
|
433
484
|
case 'tool':
|
|
434
485
|
case 'tool_error':
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// One-click setup for people with no harness yet. opencode is the one wisp installs: its free
|
|
2
|
+
// models work without an account, so a fresh computer can run a task right away. It goes into
|
|
3
|
+
// wisp's own folder (~/.wisp/tools) through npm, which every wisp install already has: no sudo,
|
|
4
|
+
// no shell profile edits, the same on Windows. The opencode adapter finds it there.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { spawn } from '../proc.js';
|
|
8
|
+
import { bad, toolsDir } from '../util.js';
|
|
9
|
+
|
|
10
|
+
export const INSTALLABLE = {
|
|
11
|
+
opencode: { pkg: 'opencode-ai', name: 'opencode' },
|
|
12
|
+
};
|
|
13
|
+
const TIMEOUT_MS = 5 * 60_000;
|
|
14
|
+
|
|
15
|
+
// npm install --prefix <dir> <pkg>@latest. Resolves when it worked, rejects with the tail of
|
|
16
|
+
// npm's output when it didn't: a 424, since offline or a broken npm isn't a wisp bug (it's
|
|
17
|
+
// counted in the harness_install event instead of reported as an exception).
|
|
18
|
+
export function npmInstall(pkg, dir, { npm = process.env.WISP_NPM || 'npm', timeout = TIMEOUT_MS } = {}) {
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
const env = { ...process.env };
|
|
21
|
+
delete env.ELECTRON_RUN_AS_NODE; // the desktop app's node mode must not leak into npm
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(npm, ['install', '--prefix', dir, '--no-audit', '--no-fund', '--loglevel=error', `${pkg}@latest`], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
24
|
+
let out = '';
|
|
25
|
+
const keep = (d) => (out = (out + d).slice(-4000));
|
|
26
|
+
child.stdout.on('data', keep);
|
|
27
|
+
child.stderr.on('data', keep);
|
|
28
|
+
const timer = setTimeout(() => child.kill(), timeout);
|
|
29
|
+
child.on('error', (err) => (clearTimeout(timer), reject(bad(err.code === 'ENOENT' ? 'npm isn’t on PATH, so wisp can’t install it. Install Node.js (it comes with npm), then try again.' : err.message, 424))));
|
|
30
|
+
child.on('close', (code, signal) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
if (code === 0) return resolve();
|
|
33
|
+
const tail = out.trim().split('\n').filter(Boolean).slice(-3).join(' ');
|
|
34
|
+
reject(bad(`npm install ${pkg} ${signal ? 'timed out' : 'failed'}${tail ? `: ${tail}` : ''}`, 424));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createInstaller(app, { install = npmInstall } = {}) {
|
|
40
|
+
const running = new Map(); // id -> promise: a second click (or tab) waits for the same install
|
|
41
|
+
|
|
42
|
+
// Installs the harness, detects again and returns the harness list. Throws when the install
|
|
43
|
+
// failed or the harness still isn't found afterwards.
|
|
44
|
+
async function run(id) {
|
|
45
|
+
const spec = INSTALLABLE[id];
|
|
46
|
+
if (!spec) throw bad(`wisp can’t install ${id}. Install it yourself, then re-check.`);
|
|
47
|
+
if (!running.has(id)) {
|
|
48
|
+
const job = (async () => {
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
try {
|
|
51
|
+
await install(spec.pkg, toolsDir());
|
|
52
|
+
} catch (err) {
|
|
53
|
+
app.telemetry?.track('harness_install', { agent: id, ok: false, ms: Date.now() - started });
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
const agents = await app.auto.detect({ fresh: true });
|
|
57
|
+
const ok = agents.find((a) => a.id === id)?.available;
|
|
58
|
+
app.telemetry?.track('harness_install', { agent: id, ok: !!ok, ms: Date.now() - started });
|
|
59
|
+
if (!ok) throw bad(`${spec.name} was installed but doesn’t start: ${agents.find((a) => a.id === id)?.error || 'unknown error'}`, 500);
|
|
60
|
+
app.broadcast('agents', {});
|
|
61
|
+
return agents;
|
|
62
|
+
})().finally(() => running.delete(id));
|
|
63
|
+
running.set(id, job);
|
|
64
|
+
}
|
|
65
|
+
return running.get(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { run, installing: (id) => running.has(id) };
|
|
69
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// One way in for every launch (the launcher, the CLI, recipes, triggers, the phone and compare
|
|
2
|
+
// runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { adapters } from '../adapters/index.js';
|
|
6
|
+
import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
|
|
7
|
+
import { qualify } from '../machines.js';
|
|
8
|
+
import { fill, positional } from '../recipes.js';
|
|
9
|
+
import { summarize } from '../runner.js';
|
|
10
|
+
import { normalizeTeam } from '../subagents.js';
|
|
11
|
+
import { bad, expand, MODES, newId } from '../util.js';
|
|
12
|
+
|
|
13
|
+
export const MAX_COMPARE = 4;
|
|
14
|
+
|
|
15
|
+
export function createLaunch(app) {
|
|
16
|
+
const { store, runner, machines } = app;
|
|
17
|
+
|
|
18
|
+
// body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
19
|
+
// subagents: [{ name, agent, model, effort, use }] | preset: teamId }
|
|
20
|
+
async function launch(body, origin = null) {
|
|
21
|
+
if (!String(body.prompt || '').trim()) throw bad('prompt required');
|
|
22
|
+
const cwd = expand(body.cwd || process.cwd());
|
|
23
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
24
|
+
const d = store.config.defaults;
|
|
25
|
+
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
26
|
+
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
27
|
+
const subagents = normalizeTeam(body.subagents ?? preset?.members, adapters);
|
|
28
|
+
// compare: more { agent, model, effort } to run the same task on, side by side. 'auto' is
|
|
29
|
+
// resolved here: the router picks a harness and model (a different one per compare run).
|
|
30
|
+
const resolved = await app.auto.resolve(body, await app.auto.agents(), { team: subagents.length > 0 });
|
|
31
|
+
const runners = [{ agent: resolved.agent, model: resolved.model, effort: resolved.effort, route: resolved.route }, ...(resolved.compare || [])].slice(0, MAX_COMPARE).map((r, i) => {
|
|
32
|
+
const agent = r.agent || (i ? d.agent : '') || 'claude';
|
|
33
|
+
if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
|
|
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
|
+
});
|
|
36
|
+
const mode = MODES.has(body.mode) ? body.mode : 'edits';
|
|
37
|
+
// Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
|
|
38
|
+
// runs need one each, or the harnesses would edit the same files.
|
|
39
|
+
const comparing = runners.length > 1;
|
|
40
|
+
const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
|
|
41
|
+
// groupId: one launch sent to several machines (or here and there) shows as one task.
|
|
42
|
+
const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
|
|
43
|
+
const attachments = app.uploads.resolve(body.attachments);
|
|
44
|
+
const ids = runners.map(() => newId());
|
|
45
|
+
let trees = ids.map(() => null);
|
|
46
|
+
if (wantTree) {
|
|
47
|
+
const must = body.worktree === true || (comparing && mode !== 'plan');
|
|
48
|
+
// One fetch for the whole launch, then the checkouts side by side: compare runs used to
|
|
49
|
+
// fetch and check out one after the other before anything showed up in the feed.
|
|
50
|
+
const results = await worktreeSource(cwd).then(
|
|
51
|
+
(source) => source && Promise.allSettled(ids.map((id) => createWorktree({ cwd, home: store.home, id, title: body.prompt, source }))),
|
|
52
|
+
(err) => [{ status: 'rejected', reason: err }],
|
|
53
|
+
);
|
|
54
|
+
const failed = results?.find((x) => x.status === 'rejected')?.reason;
|
|
55
|
+
trees = ids.map((_, i) => (results?.[i]?.status === 'fulfilled' ? results[i].value : null));
|
|
56
|
+
if (must && (failed || trees.some((t) => !t))) {
|
|
57
|
+
await Promise.all(trees.map((t) => t && removeWorktree(t, { force: true, branch: 'force' }).catch(() => {})));
|
|
58
|
+
if (failed) throw bad(`Could not create a worktree: ${failed.message}`);
|
|
59
|
+
throw bad(comparing ? 'Compare needs a git repo with at least one commit, so each harness gets its own worktree.' : 'Not a git repo with commits, so there is nothing to make a worktree from.');
|
|
60
|
+
}
|
|
61
|
+
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
62
|
+
}
|
|
63
|
+
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Where a launch runs: body.machine, else a project folder that only exists on a machine
|
|
67
|
+
// (recipes and schedules keep just the path). null = here.
|
|
68
|
+
function machineFor(body) {
|
|
69
|
+
if (body.machine) return machines.get(String(body.machine)).id;
|
|
70
|
+
const cwd = String(body.cwd || '');
|
|
71
|
+
if (!cwd || fs.existsSync(expand(cwd))) return null;
|
|
72
|
+
return store.config.projects.find((p) => p.machine && (cwd === p.path || cwd.startsWith(`${p.path}/`)))?.machine || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Attachments are uploaded here; a machine gets its own copies before a launch or reply.
|
|
76
|
+
async function pushUploads(mid, ids) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const u of app.uploads.resolve(ids)) out.push((await machines.upload(mid, u)).id);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// launch(), here or on a machine. Resolves with summaries (a compare launch has several).
|
|
83
|
+
async function launchAny(body, origin = null) {
|
|
84
|
+
const mid = machineFor(body);
|
|
85
|
+
if (!mid) return (await launch(body, origin)).map((x) => summarize(x, store));
|
|
86
|
+
// Auto for a machine: routed here, with the harnesses that machine has; its outcomes come
|
|
87
|
+
// back through the summaries it streams (auto.signals), so one router learns from every place.
|
|
88
|
+
const routed = await app.auto.resolve(body, machines.agentsOf(mid) || [], { team: !!(body.subagents?.length || body.preset) });
|
|
89
|
+
const routes = [routed.route, ...(routed.compare || []).map((c) => c.route)];
|
|
90
|
+
const sendBody = { ...routed, compare: routed.compare?.map(({ route, ...c }) => c) };
|
|
91
|
+
delete sendBody.route;
|
|
92
|
+
if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
|
|
93
|
+
const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
|
|
94
|
+
(out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
|
|
95
|
+
const known = store.projectFor(out.cwd, mid);
|
|
96
|
+
(known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
|
|
97
|
+
store.saveConfig();
|
|
98
|
+
if (!known) app.broadcast('config', {});
|
|
99
|
+
return (out.group || [out]).map((x) => machines.summary(mid, x));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
103
|
+
async function replyTo(s, text, attachments = [], queue, mode, run) {
|
|
104
|
+
await ensureWorktree(s.worktree);
|
|
105
|
+
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function markDone(s) {
|
|
109
|
+
if (s.state === 'running' || s.state === 'waiting') return false;
|
|
110
|
+
s.state = 'done';
|
|
111
|
+
s.unread = false;
|
|
112
|
+
app.retro.schedule(s, 0);
|
|
113
|
+
app.sessionChanged(s);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runRecipe(r, { params = {}, words, cwd, machine, agent, model, effort, mode } = {}) {
|
|
118
|
+
const values = words ? { ...positional(r.prompt, words), ...params } : params;
|
|
119
|
+
const [s] = await launchAny({ prompt: fill(r.prompt, values), cwd: r.cwd || cwd, machine, agent: agent || r.agent || undefined, model: model ?? (r.model || undefined), effort: effort || r.effort || undefined, mode: mode || r.mode || undefined }, { kind: 'recipe', id: r.id, name: r.name });
|
|
120
|
+
r.runs = (r.runs || 0) + 1;
|
|
121
|
+
r.lastRunAt = Date.now();
|
|
122
|
+
app.configChanged();
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { launch, launchAny, machineFor, pushUploads, replyTo, markDone, runRecipe };
|
|
127
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Phone (ntfy / Telegram): what a tap or a reply there does to a session, here or on a machine.
|
|
2
|
+
|
|
3
|
+
import { splitId } from '../machines.js';
|
|
4
|
+
|
|
5
|
+
export function phoneActions(app) {
|
|
6
|
+
const { store, runner, machines } = app;
|
|
7
|
+
|
|
8
|
+
// What the phone can do to a session, here or on a machine: { s, answer, reply, done } or null.
|
|
9
|
+
async function sessionOps(sessionId) {
|
|
10
|
+
const at = splitId(sessionId);
|
|
11
|
+
if (!at) {
|
|
12
|
+
const s = store.get(sessionId);
|
|
13
|
+
return s && { s, answer: (a) => runner.answer(s, a), reply: (t) => app.launcher.replyTo(s, t), done: () => app.launcher.markDone(s) };
|
|
14
|
+
}
|
|
15
|
+
const call = (method, p, body) => machines.json(at.machine, method, `/sessions/${at.id}${p}`, body);
|
|
16
|
+
const s = await call('GET', '').catch((err) => {
|
|
17
|
+
if (err.status === 404) return null;
|
|
18
|
+
throw err;
|
|
19
|
+
});
|
|
20
|
+
const live = s && (s.state === 'running' || s.state === 'waiting');
|
|
21
|
+
return s && { s: machines.session(at.machine, s), answer: (a) => call('POST', '/answer', { text: a }), reply: (t) => call('POST', '/reply', { text: t }), done: async () => !live && !!(await call('PATCH', '', { state: 'done' })) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
// /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
|
|
26
|
+
launch: async ({ project, prompt }) => {
|
|
27
|
+
const d = store.config.defaults;
|
|
28
|
+
const agent = d.agent || 'claude';
|
|
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 || 'edits' }, { kind: 'telegram', name: 'Telegram' });
|
|
31
|
+
app.broadcast('config', {});
|
|
32
|
+
return s;
|
|
33
|
+
},
|
|
34
|
+
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
35
|
+
const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
|
|
36
|
+
if (ops?.error) return `Can’t reach it: ${ops.error}`;
|
|
37
|
+
if (!ops) return 'That session is gone.';
|
|
38
|
+
const { s } = ops;
|
|
39
|
+
if (kind === 'answer') {
|
|
40
|
+
if (!s.ask || s.ask.id !== ref) return 'That question was already answered.';
|
|
41
|
+
const answer = s.ask.options[index];
|
|
42
|
+
if (answer === undefined) return 'Unknown option.';
|
|
43
|
+
await ops.answer(answer);
|
|
44
|
+
return `✓ Answered: ${answer}`;
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'reply') {
|
|
47
|
+
const turn = s.turns.at(-1);
|
|
48
|
+
if (!turn || turn.id !== ref || s.state === 'running') return 'That was already answered.';
|
|
49
|
+
const q = turn.presentation?.blocks?.findLast((b) => b?.type === 'question');
|
|
50
|
+
const o = q?.options?.[index];
|
|
51
|
+
const msg = typeof o === 'string' ? o : o?.reply || o?.label;
|
|
52
|
+
if (!msg) return 'Unknown option.';
|
|
53
|
+
await ops.reply(msg);
|
|
54
|
+
return `✓ Sent: ${typeof o === 'string' ? o : o.label || msg}`;
|
|
55
|
+
}
|
|
56
|
+
if (kind === 'done') {
|
|
57
|
+
if (s.turns.at(-1)?.id !== ref && ref) return 'There is newer work on that session; open wisp to review it.';
|
|
58
|
+
return (await ops.done()) ? '✓ Marked done' : 'It is still working.';
|
|
59
|
+
}
|
|
60
|
+
if (s.agent === 'wisp') return 'That is the tour; it can’t take follow-ups.';
|
|
61
|
+
const r = await ops.reply(String(text || '').trim());
|
|
62
|
+
return r.answered ? '✓ Answered' : r.interrupted ? '✓ Sent; the agent picked it up mid-run' : r.queued ? '✓ Queued for when it finishes' : '✓ Sent';
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|