wisp-agents 0.12.0 → 0.13.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 +22 -1
- package/README.md +14 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/client.js +1 -1
- package/server/index.js +63 -8
- package/server/mcp-wisp.js +3 -3
- package/server/openapi.js +18 -2
- package/server/remote.js +103 -17
- package/server/runner.js +14 -6
- package/server/telemetry.js +58 -7
- package/web/css/app.css +53 -7
- package/web/index.html +2 -0
- package/web/js/app.js +299 -37
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-10-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Start tasks from Telegram.** In **Phone → Telegram**, switch on **Start tasks from Telegram**, then send your bot `/new fix the flaky login test`. The task runs in your default project (the last used, or the one you pick in that section) with the launcher's harness, model and mode. `/new @api add rate limiting` picks a project by name, and `/projects` lists them. The bot replies **▶ Started**, and replying to that message sends a follow-up. Its card shows **Telegram** as where it came from. It's off by default, only the linked chat can start tasks, and a `/new` sent while wisp was offline is not run later. API: `PUT /api/remote` with `telegram.allowStart` and `telegram.project`.
|
|
13
|
+
|
|
14
|
+
- **Feedback for the wisp team.** Each finished turn has a small 👍 / 👎 at the end of its trail. It stays faint until you hover the turn. Click again to take the rating back. 👎 opens one optional line for what went wrong (↵ sends, Esc skips). You can also type `/feedback <note>` in the launcher or any follow-up box, or use `⌘K` → **Send feedback…** for a short dialog. Bare `/feedback` opens the dialog too. It thanks you right away; offline, the feedback is kept and sent later. It goes to PostHog with the harness, model and status of the run, never your prompt, output or code. It's sent even with usage stats off, and `WISP_FEEDBACK=0` turns it off. API: `POST /api/feedback`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Switch modes in a follow-up.** Each task's follow-up box now has the same mode button as the launcher (Plan, Edits, Full auto; Shift+Tab cycles). Start in Plan, read the plan, then pick **Edits** and say "go ahead": that follow-up and the ones after it run in the new mode. Any mode can switch to any other. The pick only takes effect when you send, and the follow-up shows the new mode next to its model. A follow-up queued while the agent works keeps its own mode, so the run already going isn't affected. A task started in Plan has no worktree, so edits after switching land in the project folder. API: `mode` on `POST /api/sessions/{id}/reply`; CLI: `wisp reply <id> "…" --mode edits`; MCP: `mode` on `wisp_reply`.
|
|
19
|
+
|
|
20
|
+
- **Compare more than two harnesses.** Clicking **vs** in the launcher adds a second harness as before, and a **+** at the end of the pill now adds a third and a fourth (four side by side, the server's limit). Each one sits behind its own **vs**: hover it for **×** to drop just that harness. A new one starts on a harness that isn't in the pill yet. With three or four, the pill shows logos only (hover a logo for harness, model and thinking level), and the mode and Subagents labels fold to icons so the bar stays on one line.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Changing a Telegram setting through the API without a `token` no longer clears the bot token and unlinks the chat. After a settings change, Telegram no longer re-delivers messages wisp already handled.
|
|
25
|
+
|
|
26
|
+
- Following up on a task now keeps it on screen as it moves to **Working**, and again when it lands back in **Needs you**. On a long card, the previous answer folding away used to pull the whole card off the top of the screen, so you lost the task you had just replied to. The composer now holds still and the new exchange appears where you were typing.
|
|
27
|
+
|
|
8
28
|
## [0.12.0] - 2026-10-18
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -220,7 +240,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
220
240
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
221
241
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
222
242
|
|
|
223
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
243
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.13.0...HEAD
|
|
244
|
+
[0.13.0]: https://github.com/M0xoo/wisp/compare/v0.12.0...v0.13.0
|
|
224
245
|
[0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
|
|
225
246
|
[0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
|
|
226
247
|
[0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
|
package/README.md
CHANGED
|
@@ -53,9 +53,9 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
53
53
|
| 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
|
|
54
54
|
| ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
|
|
55
55
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
56
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
56
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.13.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
57
57
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
58
|
-
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there
|
|
58
|
+
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
59
59
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
60
60
|
| 🔌 **Integrations** | An MCP server (`wisp mcp`), an agent skill, a scriptable CLI, a JS SDK, an OpenAPI-described HTTP API, and webhooks. Hand tasks to wisp from Claude Code, Codex, opencode, Cursor, scripts or CI. |
|
|
61
61
|
| 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
|
|
@@ -121,11 +121,11 @@ wisp "add a test" --wait # block until it finishes, print the presentat
|
|
|
121
121
|
wisp ls --state running,waiting # list sessions
|
|
122
122
|
wisp show <id> # state + latest presentation
|
|
123
123
|
wisp wait <id> # until it finishes or asks you something
|
|
124
|
-
wisp reply <id> "also do X" # follow up (answers a pending question)
|
|
124
|
+
wisp reply <id> "also do X" # follow up (answers a pending question; --mode to switch)
|
|
125
125
|
wisp stop <id>
|
|
126
126
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
127
127
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
128
|
-
wisp run release 0.
|
|
128
|
+
wisp run release 0.13.0 # run a recipe; values fill its {params} (or version=0.13.0)
|
|
129
129
|
wisp recipes # list recipes
|
|
130
130
|
wisp usage [--days 7] # spend and outcomes
|
|
131
131
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -305,7 +305,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
305
305
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
-
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.
|
|
308
|
+
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.13.0` fills the fields in order, `wisp run release version=0.13.0` by name.
|
|
309
309
|
|
|
310
310
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
311
311
|
|
|
@@ -327,6 +327,7 @@ Settings → *Phone* sends questions and finished tasks to your phone and takes
|
|
|
327
327
|
|
|
328
328
|
- **[ntfy](https://ntfy.sh)**: subscribe to the topic in the ntfy app. Notifications carry up to three buttons (the question's options, or *Mark done*); a tap sends a signed answer back. Use a long random topic (wisp suggests one): anyone who knows it can read the notifications, but only wisp can make answers that count.
|
|
329
329
|
- **Telegram**: create a bot with @BotFather, paste its token, then send `/link <code>` to the bot. Tap a button to answer, or reply to a message to answer in your own words or send a follow-up.
|
|
330
|
+
Switch on **Start tasks from Telegram** to launch from the chat: `/new <task>` runs in the default project (the last used, or the one you pick), `/new @project <task>` picks one by name and `/projects` lists them. Tasks use the launcher's harness, model and mode. It's off by default, and only the linked chat can start tasks.
|
|
330
331
|
|
|
331
332
|
They receive titles, summaries and questions (with their context), never code or diffs.
|
|
332
333
|
|
|
@@ -370,6 +371,14 @@ wisp sends anonymous usage stats and error reports to [PostHog](https://posthog.
|
|
|
370
371
|
|
|
371
372
|
It is also off whenever `CI` is set.
|
|
372
373
|
|
|
374
|
+
### Feedback
|
|
375
|
+
|
|
376
|
+
Rate any result with 👍 / 👎 at the end of the turn (👎 asks for an optional line on what went wrong), type `/feedback <your note>` in any prompt box, or use `⌘K` → *Send feedback*. It goes to the wisp team on PostHog as a `turn_rated` or `feedback` event.
|
|
377
|
+
|
|
378
|
+
**What is sent:** your rating and note (with your home directory replaced by `~`), the wisp version and OS. For a rated turn it also sends the run's harness, model, mode, status, duration and presentation block types. Your prompt, the agent's output and your code are **never sent**.
|
|
379
|
+
|
|
380
|
+
You send feedback on purpose, so it goes out even with usage stats off, under a one-off id. Offline, it waits in `~/.wisp/feedback-outbox.json` and is sent later. `WISP_FEEDBACK=0` turns it off and hides the thumbs.
|
|
381
|
+
|
|
373
382
|
## Contributing
|
|
374
383
|
|
|
375
384
|
Issues and PRs are welcome. wisp has no build step: edit `web/` or `server/`, then run `npm run dev` (or `npm run dev:web`) to see your changes.
|
package/bin/wisp.js
CHANGED
|
@@ -24,7 +24,7 @@ const HELP = `usage:
|
|
|
24
24
|
wisp ls list sessions (--state running,waiting --limit N)
|
|
25
25
|
wisp show <id> state + latest presentation as markdown
|
|
26
26
|
wisp wait <id> block until it finishes or asks you something
|
|
27
|
-
wisp reply <id> "text" follow up (answers a pending question) (--queue)
|
|
27
|
+
wisp reply <id> "text" follow up (answers a pending question) (--queue --mode plan|edits|auto)
|
|
28
28
|
wisp stop <id> stop a run
|
|
29
29
|
wisp ship <id> commit, push and open a PR for a worktree session (--draft)
|
|
30
30
|
wisp merge <id> squash-merge its PR and sync your local base branch
|
|
@@ -132,7 +132,7 @@ async function scripted(cmd, args) {
|
|
|
132
132
|
}
|
|
133
133
|
case 'reply': {
|
|
134
134
|
const id = need(args[0], 'session id');
|
|
135
|
-
const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue });
|
|
135
|
+
const r = await wisp.reply(id, need(args.slice(1).join(' ').trim(), 'text'), { queue: flags.queue, mode: flags.mode || undefined });
|
|
136
136
|
if (!flags.wait) return out(r, r.answered ? ' ✓ answered' : r.interrupted ? ' ✓ interrupted; resuming with your message' : r.queued ? ' ✓ queued' : ' ✓ sent');
|
|
137
137
|
return show(id, await wisp.wait(id));
|
|
138
138
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"app": "node bin/wisp.js",
|
|
21
21
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
22
22
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
23
|
-
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js",
|
|
23
|
+
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js",
|
|
24
24
|
"test:real": "node test/real-clis.mjs",
|
|
25
25
|
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
|
|
26
26
|
},
|
package/server/client.js
CHANGED
|
@@ -155,7 +155,7 @@ export class WispClient {
|
|
|
155
155
|
|
|
156
156
|
// A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
|
|
157
157
|
// current run instead of interrupting it (default: the user's setting).
|
|
158
|
-
reply = (id, text, { attachments, queue } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue });
|
|
158
|
+
reply = (id, text, { attachments, queue, mode } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode });
|
|
159
159
|
|
|
160
160
|
// Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
|
|
161
161
|
async upload(file, { name, type } = {}) {
|
package/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Runner, summarize, newId, workdir } from './runner.js';
|
|
|
11
11
|
import { adapters, listAgents } from './adapters/index.js';
|
|
12
12
|
import { welcomeSession } from './welcome.js';
|
|
13
13
|
import { openInTerminal, TERMINALS, shq } from './terminal.js';
|
|
14
|
-
import { Telemetry, envDisabled } from './telemetry.js';
|
|
14
|
+
import { Telemetry, envDisabled, feedbackAvailable } from './telemetry.js';
|
|
15
15
|
import { blocksToMarkdown, sessionToMarkdown } from './render.js';
|
|
16
16
|
import { openapi } from './openapi.js';
|
|
17
17
|
import { Updater } from './update.js';
|
|
@@ -226,9 +226,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
229
|
-
async function replyTo(s, text, attachments = [], queue) {
|
|
229
|
+
async function replyTo(s, text, attachments = [], queue, mode) {
|
|
230
230
|
await ensureWorktree(s.worktree);
|
|
231
|
-
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue' });
|
|
231
|
+
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode });
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
const markDone = (s) => {
|
|
@@ -257,6 +257,15 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
257
257
|
const remote = new Remote({
|
|
258
258
|
store,
|
|
259
259
|
onChange: () => broadcast('remote', {}),
|
|
260
|
+
// /new from Telegram: the launcher's remembered harness, model and mode, like `wisp "…"`.
|
|
261
|
+
launch: async ({ project, prompt }) => {
|
|
262
|
+
const d = store.config.defaults;
|
|
263
|
+
const agent = d.agent || 'claude';
|
|
264
|
+
const model = d.models?.[agent] ?? d.model ?? '';
|
|
265
|
+
const [s] = await launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'edits' }, { kind: 'telegram', name: 'Telegram' });
|
|
266
|
+
broadcast('config', {});
|
|
267
|
+
return s;
|
|
268
|
+
},
|
|
260
269
|
act: async ({ sessionId, kind, ref, index, text }) => {
|
|
261
270
|
const ops = await sessionOps(sessionId).catch((err) => ({ error: err.message }));
|
|
262
271
|
if (ops?.error) return `Can’t reach it: ${ops.error}`;
|
|
@@ -507,7 +516,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
507
516
|
}
|
|
508
517
|
|
|
509
518
|
if (root === 'config') {
|
|
510
|
-
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
519
|
+
if (m === 'GET') return send(res, 200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() });
|
|
511
520
|
if (m === 'PATCH') {
|
|
512
521
|
const body = await readJson(req);
|
|
513
522
|
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
@@ -722,6 +731,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
722
731
|
|
|
723
732
|
if (root === 'machines') return machinesApi(req, res, url, id, action);
|
|
724
733
|
|
|
734
|
+
// Feedback for the wisp team: thumbs on a turn and/or a note. { rating?, text?, sessionId?, turnId? }
|
|
735
|
+
if (root === 'feedback' && m === 'POST') return send(res, 200, await sendFeedback(await readJson(req)));
|
|
736
|
+
|
|
725
737
|
if (root === 'sessions') {
|
|
726
738
|
if (id && splitId(id)) return machineSessionApi(req, res, url, splitId(id), action);
|
|
727
739
|
if (!id) {
|
|
@@ -742,9 +754,9 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
742
754
|
}
|
|
743
755
|
if (m === 'POST') {
|
|
744
756
|
const body = await readJson(req);
|
|
745
|
-
// A machine's wisp hands on where a recipe or
|
|
757
|
+
// A machine's wisp hands on where a recipe, trigger or Telegram launch came from.
|
|
746
758
|
const o = body.origin;
|
|
747
|
-
const origin = o &&
|
|
759
|
+
const origin = o && ['recipe', 'trigger', 'telegram'].includes(o.kind) ? { kind: o.kind, id: String(o.id || ''), name: String(o.name || '') } : null;
|
|
748
760
|
const out = await launchAny(body, origin);
|
|
749
761
|
return send(res, 201, out.length > 1 ? { ...out[0], group: out } : out[0]);
|
|
750
762
|
}
|
|
@@ -836,10 +848,11 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
836
848
|
}
|
|
837
849
|
|
|
838
850
|
if (action === 'reply' && m === 'POST') {
|
|
839
|
-
const { text, attachments, queue } = await readJson(req);
|
|
851
|
+
const { text, attachments, queue, mode } = await readJson(req);
|
|
840
852
|
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
841
853
|
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
842
|
-
return send(res,
|
|
854
|
+
if (mode !== undefined && !MODES.has(mode)) return send(res, 400, { error: 'mode must be plan, edits or auto.' });
|
|
855
|
+
return send(res, 200, await replyTo(s, text.trim(), resolveAttachments(attachments), queue, mode));
|
|
843
856
|
}
|
|
844
857
|
|
|
845
858
|
if (action === 'queue' && m === 'DELETE') {
|
|
@@ -1030,6 +1043,48 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
1030
1043
|
}
|
|
1031
1044
|
|
|
1032
1045
|
// /api/sessions/<id>@<machine>/…: passed through to that machine's wisp, ids rewritten.
|
|
1046
|
+
// A rating sticks to its turn (it shows on the card and can be taken back). What goes out is
|
|
1047
|
+
// the rating, the user's own words, and how the run was set up — never the prompt or the result.
|
|
1048
|
+
async function sendFeedback({ rating, text, sessionId, turnId, source } = {}) {
|
|
1049
|
+
if (rating !== undefined && rating !== null && rating !== 'up' && rating !== 'down') throw Object.assign(new Error('rating must be up or down'), { status: 400 });
|
|
1050
|
+
text = String(text || '').trim();
|
|
1051
|
+
if (rating === undefined && !text) throw Object.assign(new Error('Write something, or pick 👍 / 👎.'), { status: 400 });
|
|
1052
|
+
// A session on a machine: that wisp keeps the rating on its turn and sends it.
|
|
1053
|
+
const at = sessionId && splitId(sessionId);
|
|
1054
|
+
if (at) {
|
|
1055
|
+
try {
|
|
1056
|
+
return await machines.json(at.machine, 'POST', '/feedback', { rating, text, sessionId: at.id, turnId, source });
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
if (err.status === 400) throw err; // offline, or an older wisp there: send it from here, without the run
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const s = sessionId && !at ? store.get(sessionId) : null;
|
|
1062
|
+
if (sessionId && !at && !s) throw Object.assign(new Error('no such session'), { status: 404 });
|
|
1063
|
+
const turn = s && (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
1064
|
+
if (turnId && s && !turn) throw Object.assign(new Error('no such turn'), { status: 404 });
|
|
1065
|
+
// Only thumbs on a turn stick to it; a rating in the feedback dialog is about wisp as a whole.
|
|
1066
|
+
if (turn && turnId && rating !== undefined) {
|
|
1067
|
+
turn.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
1068
|
+
store.save(s);
|
|
1069
|
+
broadcast('turn', { sessionId: s.id, turnId: turn.id });
|
|
1070
|
+
}
|
|
1071
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
1072
|
+
const context = turn && {
|
|
1073
|
+
...run(s, turn),
|
|
1074
|
+
status: turn.status,
|
|
1075
|
+
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
1076
|
+
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
1077
|
+
durationMs: turn.endedAt ? turn.endedAt - turn.startedAt : undefined,
|
|
1078
|
+
tour: s.agent === 'wisp' || undefined,
|
|
1079
|
+
};
|
|
1080
|
+
const props = { ...context, rating: rating || undefined, source: String(source || (turn ? 'turn' : 'app')).slice(0, 20), machine: at ? true : undefined };
|
|
1081
|
+
// Thumbs on a turn count as a rating; anything written, or sent from the dialog, is 'feedback'.
|
|
1082
|
+
const event = text || !turnId ? 'feedback' : 'turn_rated';
|
|
1083
|
+
if (event === 'turn_rated' && rating === null) props.cleared = true;
|
|
1084
|
+
const sent = await telemetry.feedback(event, { ...props, text: text || undefined });
|
|
1085
|
+
return { ok: true, sent, available: feedbackAvailable() };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1033
1088
|
async function machineSessionApi(req, res, url, at, action) {
|
|
1034
1089
|
const m = req.method;
|
|
1035
1090
|
const mid = at.machine;
|
package/server/mcp-wisp.js
CHANGED
|
@@ -66,8 +66,8 @@ const TOOLS = [
|
|
|
66
66
|
name: 'wisp_reply',
|
|
67
67
|
description:
|
|
68
68
|
'Send a follow-up to a session; it resumes the same agent conversation. If the agent is waiting on a question, this answers it. ' +
|
|
69
|
-
'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead).',
|
|
70
|
-
inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' } }, required: ['id', 'text'] },
|
|
69
|
+
'A follow-up sent while it runs interrupts it (queue=true waits for the run to finish instead). mode switches the session’s mode from this follow-up on.',
|
|
70
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' }, queue: { type: 'boolean' }, mode: { type: 'string', enum: ['plan', 'edits', 'auto'] } }, required: ['id', 'text'] },
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
name: 'wisp_stop',
|
|
@@ -118,7 +118,7 @@ async function callTool(name, a = {}) {
|
|
|
118
118
|
return waitFor(a.id);
|
|
119
119
|
case 'wisp_reply': {
|
|
120
120
|
if (!a.text?.trim()) throw new Error('text is required');
|
|
121
|
-
const r = await c.reply(a.id, a.text.trim(), { queue: a.queue });
|
|
121
|
+
const r = await c.reply(a.id, a.text.trim(), { queue: a.queue, mode: a.mode });
|
|
122
122
|
return r.answered ? 'Answered the agent’s question; it is continuing.' : r.interrupted ? 'Interrupted the run; it resumes with your message.' : r.queued ? 'Queued; it goes out when the current run finishes.' : 'Sent; the agent is working on it.';
|
|
123
123
|
}
|
|
124
124
|
case 'wisp_stop':
|
package/server/openapi.js
CHANGED
|
@@ -46,6 +46,22 @@ export function openapi({ version, port }) {
|
|
|
46
46
|
},
|
|
47
47
|
'/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
48
48
|
'/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
49
|
+
'/feedback': {
|
|
50
|
+
post: {
|
|
51
|
+
summary: 'Send feedback to the wisp team: thumbs on a turn and/or a note. Sent even with usage stats off; WISP_FEEDBACK=0 turns it off',
|
|
52
|
+
requestBody: body({
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
rating: { type: ['string', 'null'], enum: ['up', 'down', null], description: 'With turnId: kept on that turn; null takes it back' },
|
|
56
|
+
text: { type: 'string', maxLength: 4000 },
|
|
57
|
+
sessionId: { type: 'string', description: 'Adds that run\'s harness, model and status (never the prompt or result)' },
|
|
58
|
+
turnId: { type: 'string' },
|
|
59
|
+
source: { type: 'string', description: 'turn | command | dialog' },
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
responses: { 200: json({ type: 'object', properties: { ok: { type: 'boolean' }, sent: { type: 'boolean', description: 'false: kept in ~/.wisp/feedback-outbox.json and retried' }, available: { type: 'boolean' } } }), 400: err[400] },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
49
65
|
'/config': {
|
|
50
66
|
get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
|
|
51
67
|
patch: {
|
|
@@ -140,7 +156,7 @@ export function openapi({ version, port }) {
|
|
|
140
156
|
get: { summary: 'Phone channels (ntfy, Telegram) with secrets masked, and their status', responses: { 200: json({ type: 'object' }) } },
|
|
141
157
|
put: {
|
|
142
158
|
summary: 'Configure phone channels. A masked token (••••1234) means unchanged',
|
|
143
|
-
requestBody: body({ type: 'object', properties: { events: { type: 'array', items: { enum: ['waiting', 'finished'] } }, ntfy: { type: 'object', properties: { enabled: { type: 'boolean' }, server: { type: 'string' }, topic: { type: 'string' }, token: { type: 'string' } } }, telegram: { type: 'object', properties: { enabled: { type: 'boolean' }, token: { type: 'string' }, unlink: { type: 'boolean' } } } } }),
|
|
159
|
+
requestBody: body({ type: 'object', properties: { events: { type: 'array', items: { enum: ['waiting', 'finished'] } }, ntfy: { type: 'object', properties: { enabled: { type: 'boolean' }, server: { type: 'string' }, topic: { type: 'string' }, token: { type: 'string' } } }, telegram: { type: 'object', properties: { enabled: { type: 'boolean' }, token: { type: 'string' }, unlink: { type: 'boolean' }, allowStart: { type: 'boolean', description: '/new in the linked chat starts tasks' }, project: { type: 'string', description: 'Project id /new uses without @name; empty = last used' } } } } }),
|
|
144
160
|
responses: { 200: json({ type: 'object' }), 400: err[400] },
|
|
145
161
|
},
|
|
146
162
|
},
|
|
@@ -184,7 +200,7 @@ export function openapi({ version, port }) {
|
|
|
184
200
|
parameters: [id],
|
|
185
201
|
post: {
|
|
186
202
|
summary: 'Follow up (answers a pending ask_user question if there is one)',
|
|
187
|
-
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' } } }),
|
|
203
|
+
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' } } }),
|
|
188
204
|
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
|
|
189
205
|
},
|
|
190
206
|
},
|
package/server/remote.js
CHANGED
|
@@ -4,26 +4,37 @@
|
|
|
4
4
|
// ntfy notification with up to 3 buttons. A tap makes the ntfy app POST a signed answer to
|
|
5
5
|
// <topic>-reply, which wisp polls. No free text (ntfy has no reply box).
|
|
6
6
|
// Telegram a bot message with buttons; reply to the message to answer in your own words or
|
|
7
|
-
// send a follow-up. Linked to one chat with a one-time code.
|
|
7
|
+
// send a follow-up. Linked to one chat with a one-time code. With allowStart on,
|
|
8
|
+
// /new [@project] <task> starts a task and /projects lists where it can run.
|
|
8
9
|
|
|
9
10
|
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
|
10
11
|
|
|
11
12
|
const KIND = { a: 'answer', r: 'reply', d: 'done' };
|
|
12
13
|
const CODE = { answer: 'a', reply: 'r', done: 'd' };
|
|
13
14
|
const MAX_TG_IDS = 20;
|
|
15
|
+
const STALE_S = 10 * 60;
|
|
14
16
|
const sleep = (ms, signal) => new Promise((r) => (signal?.aborted ? r() : (setTimeout(r, ms).unref?.(), signal?.addEventListener('abort', r, { once: true }))));
|
|
15
17
|
const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
|
|
18
|
+
// /new @name: exact name, then name prefix, then anywhere in the name or path.
|
|
19
|
+
export function findProject(projects, q) {
|
|
20
|
+
const n = String(q || '').toLowerCase();
|
|
21
|
+
if (!n) return null;
|
|
22
|
+
const name = (p) => String(p.name || '').toLowerCase();
|
|
23
|
+
return projects.find((p) => name(p) === n) || projects.find((p) => name(p).startsWith(n)) || projects.find((p) => name(p).includes(n) || String(p.path || '').toLowerCase().includes(n)) || null;
|
|
24
|
+
}
|
|
16
25
|
export const mask = (t) => (t ? `••••${String(t).slice(-4)}` : '');
|
|
17
26
|
|
|
18
27
|
export class Remote {
|
|
19
28
|
// act({ sessionId, kind: answer|reply|done|text, ref, index, text }) → a short result line.
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
// launch({ project, prompt }) → the new session's summary (a task started with /new).
|
|
30
|
+
constructor({ store, act, launch = async () => null, onChange = () => {} }) {
|
|
31
|
+
Object.assign(this, { store, act, launch, onChange });
|
|
22
32
|
this.status = { ntfy: null, telegram: null };
|
|
23
33
|
this.tgElsewhere = new Map(); // Telegram message id -> session id, for sessions on a machine (server/machines.js)
|
|
24
34
|
this.ac = null;
|
|
25
35
|
this.ntfyPollMs = 4000;
|
|
26
36
|
this.tgLongPoll = 25;
|
|
37
|
+
this.tgOffset = 0;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
get cfg() {
|
|
@@ -189,23 +200,19 @@ export class Remote {
|
|
|
189
200
|
text: clip(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
|
|
190
201
|
...(buttons.length && { reply_markup: { inline_keyboard: buttons } }),
|
|
191
202
|
});
|
|
192
|
-
if (s && sent?.message_id
|
|
193
|
-
s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), sent.message_id].slice(-MAX_TG_IDS) };
|
|
194
|
-
this.store.save(s);
|
|
195
|
-
} else if (s && sent?.message_id) {
|
|
196
|
-
this.tgElsewhere.set(sent.message_id, s.id);
|
|
197
|
-
if (this.tgElsewhere.size > 200) this.tgElsewhere.delete(this.tgElsewhere.keys().next().value);
|
|
198
|
-
}
|
|
203
|
+
if (s && sent?.message_id) this.#tgLink(s.id, sent.message_id);
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
// The offset outlives restarts (every settings change restarts the loop), or Telegram would
|
|
207
|
+
// hand back updates already handled and a /new would start its task twice.
|
|
201
208
|
async #tgLoop(signal) {
|
|
202
|
-
let offset = 0;
|
|
203
209
|
while (!signal.aborted) {
|
|
204
210
|
try {
|
|
205
|
-
const updates = await this.#tg('getUpdates', { offset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
|
|
211
|
+
const updates = await this.#tg('getUpdates', { offset: this.tgOffset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
|
|
206
212
|
if (this.status.telegram?.error) this.#mark('telegram', { ok: true, error: '' });
|
|
207
213
|
for (const u of updates) {
|
|
208
|
-
|
|
214
|
+
if (u.update_id < this.tgOffset) continue;
|
|
215
|
+
this.tgOffset = u.update_id + 1;
|
|
209
216
|
await this.#fromTelegram(u).catch((err) => console.warn(`wisp: telegram: ${err.message}`));
|
|
210
217
|
}
|
|
211
218
|
} catch (err) {
|
|
@@ -241,19 +248,88 @@ export class Remote {
|
|
|
241
248
|
return;
|
|
242
249
|
}
|
|
243
250
|
if (String(m.chat.id) !== String(t.chatId)) return;
|
|
251
|
+
const cmd = m.text.match(/^\/(\w+)(?:@\w+)?\s*([\s\S]*)$/);
|
|
252
|
+
if (cmd && ['new', 'projects', 'help', 'start'].includes(cmd[1].toLowerCase())) return this.#tgCommand(cmd[1].toLowerCase(), cmd[2].trim(), m, say);
|
|
244
253
|
const replyTo = m.reply_to_message?.message_id;
|
|
245
254
|
const sessionId = replyTo && (this.store.all().find((x) => x.remote?.tg?.includes(replyTo))?.id || this.tgElsewhere.get(replyTo));
|
|
246
|
-
if (!sessionId) return say('Reply to one of wisp’s messages to answer it or send a follow-up.');
|
|
255
|
+
if (!sessionId) return say(t.allowStart ? 'Reply to one of wisp’s messages to answer it or follow up, or send /new <task> to start one.' : 'Reply to one of wisp’s messages to answer it or send a follow-up.');
|
|
247
256
|
await say(await this.act({ sessionId, kind: 'text', text: m.text }), { reply_to_message_id: m.message_id });
|
|
248
257
|
}
|
|
249
258
|
|
|
259
|
+
async #tgCommand(name, arg, m, say) {
|
|
260
|
+
const t = this.cfg.telegram;
|
|
261
|
+
const off = 'Starting tasks from Telegram is off. Turn it on in wisp → Phone → Telegram.';
|
|
262
|
+
const projects = this.store.config.projects;
|
|
263
|
+
if (name === 'help' || name === 'start')
|
|
264
|
+
return say(
|
|
265
|
+
t.allowStart
|
|
266
|
+
? 'Send /new <task> to start a task in your default project, or /new @project <task> to pick one (/projects lists them). Reply to wisp’s messages to answer or follow up.'
|
|
267
|
+
: `Reply to wisp’s messages to answer or follow up. ${off}`,
|
|
268
|
+
);
|
|
269
|
+
if (!t.allowStart) return say(off);
|
|
270
|
+
// Telegram keeps updates for a day: don't start what was sent while wisp was off.
|
|
271
|
+
if (name === 'new' && m.date && Date.now() / 1000 - m.date > STALE_S) return say('That /new arrived while wisp was offline, so it didn’t start. Send it again to run it now.', { reply_to_message_id: m.message_id });
|
|
272
|
+
if (name === 'projects') {
|
|
273
|
+
if (!projects.length) return say('No projects yet. Open a folder in wisp first.');
|
|
274
|
+
const def = this.defaultProject();
|
|
275
|
+
const where = (id) => (this.store.config.machines || []).find((x) => x.id === id)?.name || id;
|
|
276
|
+
const lines = projects.map((p) => `• ${p.name}${p.machine ? ` (on ${where(p.machine)})` : ''}${p === def ? ' ← default' : ''}`);
|
|
277
|
+
return say(clip(`Projects:\n${lines.join('\n')}\n\n/new @name <task> runs in one of them.`, 4000));
|
|
278
|
+
}
|
|
279
|
+
let prompt = arg;
|
|
280
|
+
let project = this.defaultProject();
|
|
281
|
+
const at = arg.match(/^@(\S+)\s*([\s\S]*)$/);
|
|
282
|
+
if (at) {
|
|
283
|
+
project = findProject(projects, at[1]);
|
|
284
|
+
if (!project) return say(`No project matches “${clip(at[1], 60)}”. Send /projects to see them.`);
|
|
285
|
+
prompt = at[2].trim();
|
|
286
|
+
}
|
|
287
|
+
if (!prompt) return say('What should it do? Send /new <task>, or /new @project <task>.');
|
|
288
|
+
if (!project) return say('No projects yet. Open a folder in wisp first.');
|
|
289
|
+
let s;
|
|
290
|
+
try {
|
|
291
|
+
s = await this.launch({ project, prompt });
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return say(`Couldn’t start it: ${clip(err.message, 300)}`, { reply_to_message_id: m.message_id });
|
|
294
|
+
}
|
|
295
|
+
const sent = await say(clip(`▶ Started in ${project.name}: ${s?.title || prompt}\n\nwisp will message you when it needs you or is done. Reply to this message to follow up.`, 4000), { reply_to_message_id: m.message_id });
|
|
296
|
+
if (s?.id && sent?.message_id) this.#tgLink(s.id, sent.message_id);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The project /new uses without an @name: the one picked in settings, else the last used.
|
|
300
|
+
defaultProject() {
|
|
301
|
+
const projects = this.store.config.projects;
|
|
302
|
+
const id = this.cfg.telegram?.project;
|
|
303
|
+
return (id && projects.find((p) => p.id === id)) || [...projects].sort((a, b) => (b.lastUsedAt || b.addedAt || 0) - (a.lastUsedAt || a.addedAt || 0))[0] || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Replies to a Telegram message reach the session it belongs to.
|
|
307
|
+
#tgLink(sessionId, messageId) {
|
|
308
|
+
const s = this.store.get(sessionId);
|
|
309
|
+
if (s) {
|
|
310
|
+
s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), messageId].slice(-MAX_TG_IDS) };
|
|
311
|
+
this.store.save(s);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.tgElsewhere.set(messageId, sessionId);
|
|
315
|
+
if (this.tgElsewhere.size > 200) this.tgElsewhere.delete(this.tgElsewhere.keys().next().value);
|
|
316
|
+
}
|
|
317
|
+
|
|
250
318
|
// For the settings view: config with secrets masked, plus channel health.
|
|
251
319
|
view() {
|
|
252
320
|
const c = this.cfg;
|
|
253
321
|
return {
|
|
254
322
|
events: c.events || ['waiting', 'finished'],
|
|
255
323
|
ntfy: { enabled: !!c.ntfy?.enabled, server: c.ntfy?.server || 'https://ntfy.sh', topic: c.ntfy?.topic || '', token: mask(c.ntfy?.token) },
|
|
256
|
-
telegram: {
|
|
324
|
+
telegram: {
|
|
325
|
+
enabled: !!c.telegram?.enabled,
|
|
326
|
+
token: mask(c.telegram?.token),
|
|
327
|
+
linked: !!c.telegram?.chatId,
|
|
328
|
+
chatName: c.telegram?.chatName || '',
|
|
329
|
+
linkCode: c.telegram?.chatId ? '' : c.telegram?.linkCode || '',
|
|
330
|
+
allowStart: !!c.telegram?.allowStart,
|
|
331
|
+
project: c.telegram?.project || '',
|
|
332
|
+
},
|
|
257
333
|
status: this.status,
|
|
258
334
|
};
|
|
259
335
|
}
|
|
@@ -272,9 +348,19 @@ export class Remote {
|
|
|
272
348
|
}
|
|
273
349
|
if (body.telegram) {
|
|
274
350
|
const prev = c.telegram || {};
|
|
275
|
-
const token = keep(body.telegram.token, prev.token) || '';
|
|
351
|
+
const token = (body.telegram.token === undefined ? prev.token : keep(body.telegram.token, prev.token)) || ''; // missing = unchanged
|
|
276
352
|
const relink = body.telegram.unlink || token !== prev.token;
|
|
277
|
-
|
|
353
|
+
if (token !== prev.token) this.tgOffset = 0; // another bot, another update counter
|
|
354
|
+
const project = body.telegram.project ?? prev.project ?? '';
|
|
355
|
+
if (project && !this.store.config.projects.some((p) => p.id === project)) throw Object.assign(new Error('Unknown project'), { status: 400 });
|
|
356
|
+
c.telegram = {
|
|
357
|
+
...prev,
|
|
358
|
+
enabled: !!(body.telegram.enabled ?? prev.enabled),
|
|
359
|
+
token,
|
|
360
|
+
allowStart: !!(body.telegram.allowStart ?? prev.allowStart),
|
|
361
|
+
project,
|
|
362
|
+
...(relink && { chatId: null, chatName: '', linkCode: token ? String(randomInt(100000, 999999)) : '' }),
|
|
363
|
+
};
|
|
278
364
|
}
|
|
279
365
|
this.store.saveConfig();
|
|
280
366
|
this.start();
|
package/server/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js
|
|
|
13
13
|
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
14
14
|
|
|
15
15
|
const MAX_ACTIVITY = 400;
|
|
16
|
+
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
16
17
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
17
18
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
18
19
|
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
@@ -70,7 +71,7 @@ export function summarize(s, store) {
|
|
|
70
71
|
toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
|
|
71
72
|
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,
|
|
72
73
|
groupId: s.groupId || null,
|
|
73
|
-
origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
|
|
74
|
+
origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
|
|
74
75
|
// The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
|
|
75
76
|
team: (s.subagents || []).flatMap((m) =>
|
|
76
77
|
copies(m).map((name) => {
|
|
@@ -138,8 +139,11 @@ export class Runner extends EventEmitter {
|
|
|
138
139
|
return session;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
// mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
|
|
143
|
+
// follow-up keeps its own, so the run before it isn't affected.
|
|
144
|
+
reply(session, text, attachments = [], { queue = false, mode } = {}) {
|
|
145
|
+
if (!MODES.has(mode) || mode === session.mode) mode = undefined;
|
|
146
|
+
if (session.ask && !mode) {
|
|
143
147
|
this.answer(session, text);
|
|
144
148
|
return { answered: true };
|
|
145
149
|
}
|
|
@@ -149,22 +153,23 @@ export class Runner extends EventEmitter {
|
|
|
149
153
|
// The run already produced its result and the CLI is only lingering on its way out:
|
|
150
154
|
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
151
155
|
if (turn?.status !== 'running') {
|
|
152
|
-
session.queue.push({ id: newId(), text, attachments });
|
|
156
|
+
session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
|
|
153
157
|
handle.release();
|
|
154
158
|
return { queued: session.queue.length > 0 };
|
|
155
159
|
}
|
|
156
160
|
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
157
161
|
if (queue || !session.agentSessionId) {
|
|
158
|
-
session.queue.push({ id: newId(), text, attachments });
|
|
162
|
+
session.queue.push({ id: newId(), text, attachments, ...(mode && { mode }) });
|
|
159
163
|
this.#touch(session);
|
|
160
164
|
return { queued: true, ahead: session.queue.length - 1 };
|
|
161
165
|
}
|
|
162
166
|
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
163
167
|
const at = session.queue.findIndex((q) => !q.now);
|
|
164
|
-
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
|
|
168
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true, ...(mode && { mode }) });
|
|
165
169
|
this.#interrupt(session);
|
|
166
170
|
return { interrupted: true };
|
|
167
171
|
}
|
|
172
|
+
if (mode) session.mode = mode;
|
|
168
173
|
this.#startTurn(session, text, attachments);
|
|
169
174
|
return { queued: false };
|
|
170
175
|
}
|
|
@@ -252,6 +257,7 @@ export class Runner extends EventEmitter {
|
|
|
252
257
|
let child = this.store.get(lead.children?.[slot]);
|
|
253
258
|
if (child) {
|
|
254
259
|
child.cwd = workdir(lead); // follows the lead if its worktree moved
|
|
260
|
+
child.mode = lead.mode; // and its mode, if a follow-up switched it
|
|
255
261
|
this.#startTurn(child, prompt);
|
|
256
262
|
} else {
|
|
257
263
|
child = this.create({ prompt, cwd: workdir(lead), agent: m.agent, mode: lead.mode, model: m.model, effort: m.effort, parentId: lead.id, member: slot });
|
|
@@ -317,6 +323,7 @@ export class Runner extends EventEmitter {
|
|
|
317
323
|
agent: session.agent,
|
|
318
324
|
model: session.model,
|
|
319
325
|
effort: session.effort || '',
|
|
326
|
+
mode: session.mode,
|
|
320
327
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
321
328
|
startedAt: Date.now(),
|
|
322
329
|
endedAt: null,
|
|
@@ -391,6 +398,7 @@ export class Runner extends EventEmitter {
|
|
|
391
398
|
this.interrupting.delete(session.id);
|
|
392
399
|
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
393
400
|
const next = session.queue.shift();
|
|
401
|
+
if (next?.mode) session.mode = next.mode;
|
|
394
402
|
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
395
403
|
else this.#touch(session);
|
|
396
404
|
};
|