wisp-agents 0.10.0 → 0.12.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 +33 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/server/adapters/index.js +4 -1
- package/server/index.js +6 -1
- package/server/machines.js +24 -1
- package/server/openapi.js +2 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/css/app.css +21 -2
- package/web/favicon.svg +12 -4
- package/web/index.html +1 -0
- package/web/js/app.js +373 -111
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.12.0] - 2026-10-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Settings → Agents → Re-check** looks for harnesses and models again, on this computer and on every online machine. Install a CLI, sign in or add a provider (say a new opencode provider), come back to wisp and click it: the list, the model counts and the launcher's harness picker update without restarting wisp, and a toast says what changed ("opencode +12 models", "Codex found"). Each harness now shows how many models it has, and one that isn't installed says to install its CLI and sign in. Before, wisp kept the first detection until it restarted. API: `POST /api/agents/recheck`, or `GET /api/agents?fresh=1` for this computer only.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The calm feed (nothing needs you) looks tidier. **Nothing needs you** gets a green dot and lines up with the **Done** and **Archived** headings, and folded groups now sit close together instead of a full section gap apart. Folded headings highlight on hover so they read as buttons.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- In **Settings → Machines**, the add/edit machine form lays out properly again: labels sit left above their fields, and the **SSH host** and **Name** inputs are full height instead of squashed and centred. The Settings row style was leaking into it.
|
|
21
|
+
|
|
22
|
+
## [0.11.0] - 2026-10-18
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Machines shows **Update to X** next to the version when a connected machine runs an older wisp than this computer. One click restarts wisp there as this version (you're asked first if agents are working on it). Before, reconnecting kept the old wisp running, and you had to find **Restart wisp there** in the ⋯ menu. Machines with a custom start command don't show it, because that command decides the version. Machines carry it as `outdated: "<version>"`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The app responds right away instead of waiting for the server. A new task clears the prompt box and shows up in **Working** as a "Starting up" card while wisp makes its worktree. A follow-up or an answer clears the composer and moves the card to **Working** with your message as the live turn. **Mark done**, **Reopen**, **Archive**, **Delete**, **Stop**, **Rerun**, recipe runs and **Keep this** in Compare also act immediately. If the server refuses, the change is undone, your text and attachments come back, and an error toast says why.
|
|
31
|
+
- The web app uses the same icon as the site and the desktop app (the glowing orb on a dark tile) for its browser tab, home-screen shortcut and notifications.
|
|
32
|
+
- The launcher's **Run on** button sits right after the attach button, so the bar reads attachments, where it runs, then mode, harness, subagents and worktree.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- The launcher's harness picker now lists the harnesses installed on the places picked under **Run on**, not the ones on this computer. Any picked place that has a harness makes it pickable (a union), with its models taken from a place that has it; a harness missing on some of them says **Not on <machine>**, and launching names the place it's missing on. Before, running only on a machine still offered Claude when only this computer had it, and hid a harness only the machine had. Changing the places (or a machine coming online) moves the pick to an installed harness if the current one isn't there. Machines now report each harness's models, thinking levels and whether it can lead subagents.
|
|
37
|
+
|
|
8
38
|
## [0.10.0] - 2026-10-18
|
|
9
39
|
|
|
10
40
|
### Added
|
|
@@ -190,7 +220,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
190
220
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
191
221
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
192
222
|
|
|
193
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
223
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.12.0...HEAD
|
|
224
|
+
[0.12.0]: https://github.com/M0xoo/wisp/compare/v0.11.0...v0.12.0
|
|
225
|
+
[0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
|
|
194
226
|
[0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
|
|
195
227
|
[0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
|
|
196
228
|
[0.8.0]: https://github.com/M0xoo/wisp/compare/v0.7.0...v0.8.0
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ 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.12.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
58
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there. |
|
|
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. |
|
|
@@ -125,7 +125,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question)
|
|
|
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.12.0 # run a recipe; values fill its {params} (or version=0.12.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.12.0` fills the fields in order, `wisp run release version=0.12.0` by name.
|
|
309
309
|
|
|
310
310
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
311
311
|
|
|
@@ -340,7 +340,7 @@ They receive titles, summaries and questions (with their context), never code or
|
|
|
340
340
|
|
|
341
341
|
Then **Pick projects on it**: the machine's git repos are listed, and folders autocomplete. In the launcher, **Run on** (next to the harness) picks where the prompt goes: this computer, any machine, or several at once. Each place shows its own project chips; Enter launches on all of them in parallel, and the runs land as one task with a row per machine. Each run executes on its machine, with worktrees, diffs, rollback, `ask_user`, subagents, compare and Ship it all working on that machine. Its sessions join your feed with a badge, answer from your phone like any other, open in VS Code, Cursor or Windsurf through Remote - SSH, and **Take over** opens `ssh -t <host>` resuming the agent in your terminal.
|
|
342
342
|
|
|
343
|
-
Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
|
|
343
|
+
Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. When the machine runs an older wisp than this computer, **Update to X** next to its version restarts it there as this version. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
|
|
344
344
|
|
|
345
345
|
## Learning
|
|
346
346
|
|
package/package.json
CHANGED
package/server/adapters/index.js
CHANGED
|
@@ -35,7 +35,10 @@ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, ant
|
|
|
35
35
|
|
|
36
36
|
const detected = new Map();
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// fresh: detect again even where a hit is cached (Settings → Agents → Re-check), so a newly
|
|
39
|
+
// signed-in provider's models or an upgraded CLI show up without restarting wisp.
|
|
40
|
+
export async function listAgents({ fresh = false } = {}) {
|
|
41
|
+
if (fresh) detected.clear();
|
|
39
42
|
return Promise.all(
|
|
40
43
|
Object.values(adapters).map(async (a) => {
|
|
41
44
|
if (!detected.has(a.id)) detected.set(a.id, a.detect());
|
package/server/index.js
CHANGED
|
@@ -429,7 +429,12 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
429
429
|
}
|
|
430
430
|
if (root === 'openapi.json' && m === 'GET') return send(res, 200, openapi({ version: VERSION, port }));
|
|
431
431
|
|
|
432
|
-
if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents());
|
|
432
|
+
if (root === 'agents' && m === 'GET') return send(res, 200, await listAgents({ fresh: url.searchParams.has('fresh') }));
|
|
433
|
+
// Re-check: detect harnesses and models again, here and on every online machine.
|
|
434
|
+
if (root === 'agents' && id === 'recheck' && m === 'POST') {
|
|
435
|
+
const [agents] = await Promise.all([listAgents({ fresh: true }), machines.recheckAgents()]);
|
|
436
|
+
return send(res, 200, agents);
|
|
437
|
+
}
|
|
433
438
|
|
|
434
439
|
// Bridge for the ask_user MCP server running inside an agent process.
|
|
435
440
|
if (root === 'internal') {
|
package/server/machines.js
CHANGED
|
@@ -20,6 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import readline from 'node:readline';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { spawn, execFile, killTree } from './proc.js';
|
|
23
|
+
import { newer } from './update.js';
|
|
23
24
|
|
|
24
25
|
export const DEFAULT_PORT = 4477;
|
|
25
26
|
const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
|
|
@@ -45,6 +46,10 @@ export function splitId(id) {
|
|
|
45
46
|
return i > 0 ? { id: id.slice(0, i), machine: id.slice(i + 1) } : null;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// The version an older wisp on the machine would become on Restart wisp there: this one, unless a
|
|
50
|
+
// custom start command decides what runs there.
|
|
51
|
+
export const updateTo = (local, remote, command) => (!command && remote && newer(local, remote) ? local : null);
|
|
52
|
+
|
|
48
53
|
// What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
|
|
49
54
|
// sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
|
|
50
55
|
// <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
|
|
@@ -151,6 +156,9 @@ export function sshHosts(file = path.join(os.homedir(), '.ssh', 'config'), seen
|
|
|
151
156
|
return [...uniq.values()];
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
// What the UI needs of a machine's harness list.
|
|
160
|
+
const remoteAgent = ({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates });
|
|
161
|
+
|
|
154
162
|
// Code hosts you push to, not computers to run agents on.
|
|
155
163
|
const FORGE = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com|heroku\.com|codeberg\.org|sr\.ht)$/i;
|
|
156
164
|
|
|
@@ -332,6 +340,7 @@ export class Machines extends EventEmitter {
|
|
|
332
340
|
attempt: c?.attempt || 0,
|
|
333
341
|
since: c?.since || null,
|
|
334
342
|
remote: c?.remote || null,
|
|
343
|
+
outdated: updateTo(this.version, c?.remote?.version, m.command),
|
|
335
344
|
log: c?.log.slice(-30) || [],
|
|
336
345
|
projects: this.store.config.projects.filter((p) => p.machine === m.id).length,
|
|
337
346
|
working: cached.filter((s) => s.state === 'running').length,
|
|
@@ -431,7 +440,7 @@ export class Machines extends EventEmitter {
|
|
|
431
440
|
this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
|
|
432
441
|
const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
|
|
433
442
|
const ready = agents.filter((a) => a.available);
|
|
434
|
-
c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(
|
|
443
|
+
c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(remoteAgent) };
|
|
435
444
|
this.#step(c, 'agents', ready.length ? 'done' : 'warn', ready.length ? ready.map((a) => a.name).join(', ') : 'None found. Install a harness CLI there (claude, codex, opencode…) and sign in.');
|
|
436
445
|
clearTimeout(c.readyTimer);
|
|
437
446
|
Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
|
|
@@ -441,6 +450,20 @@ export class Machines extends EventEmitter {
|
|
|
441
450
|
this.emit('online', m);
|
|
442
451
|
}
|
|
443
452
|
|
|
453
|
+
// Detect harnesses and models again on every online machine (after installing or signing in
|
|
454
|
+
// to something there). A machine that doesn't answer keeps what it had.
|
|
455
|
+
async recheckAgents() {
|
|
456
|
+
const online = this.list.map((m) => [m, this.conns.get(m.id)]).filter(([, c]) => c?.state === 'online');
|
|
457
|
+
await Promise.all(
|
|
458
|
+
online.map(async ([m, c]) => {
|
|
459
|
+
const agents = await this.#raw(m, c, 'GET', '/agents?fresh=1').catch(() => null);
|
|
460
|
+
if (!Array.isArray(agents) || this.conns.get(m.id) !== c) return;
|
|
461
|
+
c.remote = { ...c.remote, agents: agents.map(remoteAgent) };
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
if (online.length) this.onChange();
|
|
465
|
+
}
|
|
466
|
+
|
|
444
467
|
#fail(c, msg, hint, { keep = false } = {}) {
|
|
445
468
|
if (this.conns.get(c.id) !== c) return;
|
|
446
469
|
if (c.error && !keep) return;
|
package/server/openapi.js
CHANGED
|
@@ -44,7 +44,8 @@ export function openapi({ version, port }) {
|
|
|
44
44
|
responses: { 200: json({ type: 'object' }), 400: err[400] },
|
|
45
45
|
},
|
|
46
46
|
},
|
|
47
|
-
'/agents': { get: { summary: 'Installed harnesses with their models and thinking levels', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
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
|
+
'/agents/recheck': { post: { summary: 'Detect harnesses and models again, here and on every online machine', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
48
49
|
'/config': {
|
|
49
50
|
get: { summary: 'Launcher defaults, projects, webhooks', responses: { 200: json({ type: 'object' }) } },
|
|
50
51
|
patch: {
|
|
Binary file
|
package/web/css/app.css
CHANGED
|
@@ -408,6 +408,7 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
408
408
|
.pk-text b { font-weight: 500; font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
409
409
|
.pk-text small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
410
410
|
.pk-text small:empty { display: none; }
|
|
411
|
+
.pk-text small.warn { color: var(--warn); }
|
|
411
412
|
.pk-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
|
412
413
|
.pk-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
413
414
|
.pk-search { flex: none; width: auto; margin: 8px 8px 2px; color: var(--faint); }
|
|
@@ -433,13 +434,13 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
433
434
|
}
|
|
434
435
|
|
|
435
436
|
/* ---------- feed ---------- */
|
|
436
|
-
.feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px
|
|
437
|
-
.fh-status { display: flex; gap: 14px; }
|
|
437
|
+
.feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 0 10px; padding: 0 4px; font-size: 13px; }
|
|
438
438
|
.feed-head[hidden] + .feed { margin-top: 26px; }
|
|
439
439
|
.hs { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-2); }
|
|
440
440
|
.hs i { width: 7px; height: 7px; border-radius: 50%; }
|
|
441
441
|
.hs.working i { background: var(--accent); animation: pulse 1.6s infinite; }
|
|
442
442
|
.hs.needs i { background: var(--warn); }
|
|
443
|
+
.hs.calm i { background: var(--good); }
|
|
443
444
|
|
|
444
445
|
.f-group { margin-bottom: 22px; }
|
|
445
446
|
.f-group h4 {
|
|
@@ -461,6 +462,11 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
461
462
|
.toggle-done:hover { color: var(--ink-2); }
|
|
462
463
|
.toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
|
|
463
464
|
.f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
|
|
465
|
+
/* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
|
|
466
|
+
.f-group.collapsed { margin-bottom: 2px; }
|
|
467
|
+
.f-group.collapsed h4 { margin: 0; }
|
|
468
|
+
.f-group h4.toggle-done { width: fit-content; min-height: 24px; margin-left: -4px; padding: 0 8px; border-radius: 6px; transition: background 0.15s, color 0.15s; }
|
|
469
|
+
.f-group h4.toggle-done:hover { background: var(--bg-2); }
|
|
464
470
|
.f-list { display: flex; flex-direction: column; gap: 6px; }
|
|
465
471
|
.feed-empty { padding: 48px 0; text-align: center; color: var(--faint); font-size: 13.5px; }
|
|
466
472
|
|
|
@@ -473,6 +479,10 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
473
479
|
.item.focused { border-color: var(--line-2); box-shadow: 0 0 0 1px var(--line-2); }
|
|
474
480
|
.item.open { border-color: var(--line-2); box-shadow: var(--shadow-2); }
|
|
475
481
|
.item.st-done:not(.open) { background: transparent; }
|
|
482
|
+
/* a launch the server hasn't confirmed yet: shown at once, can't be opened */
|
|
483
|
+
.item.pending { opacity: 0.75; }
|
|
484
|
+
.item.pending .it-head { cursor: default; }
|
|
485
|
+
.item.pending .chev { visibility: hidden; }
|
|
476
486
|
.item.arrived { animation: arrive 1.4s var(--ease); }
|
|
477
487
|
@keyframes arrive { from { box-shadow: 0 0 0 3px var(--accent-soft); border-color: var(--accent); } }
|
|
478
488
|
|
|
@@ -1195,6 +1205,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1195
1205
|
.sx-ctl .input { width: 300px; }
|
|
1196
1206
|
.sx-state { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
1197
1207
|
.sx-state.bad { color: var(--bad); }
|
|
1208
|
+
#recheck.busy { cursor: progress; }
|
|
1209
|
+
#recheck.busy .i { animation: spin 0.8s linear infinite reverse; }
|
|
1198
1210
|
.sx-usage .it-loading { grid-column: 1 / -1; }
|
|
1199
1211
|
/* a view (Memory, Automations, Usage, Phone) opened inside Settings: no dialog chrome of its own */
|
|
1200
1212
|
.modal.embedded { width: auto; max-height: none; overflow: visible; padding: 0; border: 0; border-radius: 0; box-shadow: none; background: none; transform: none; }
|
|
@@ -1423,6 +1435,7 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1423
1435
|
.mc-meta, .mc-steps, .mc-hint, .mc-projects, .mc-edit { margin-left: 42px; }
|
|
1424
1436
|
.mc-meta { display: flex; align-items: center; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--muted); }
|
|
1425
1437
|
.mc-meta .bad { color: var(--bad); }
|
|
1438
|
+
.mc-meta .mc-update { margin-left: -6px; color: var(--accent); }
|
|
1426
1439
|
.mc-agents { display: inline-flex; gap: 7px; }
|
|
1427
1440
|
.mc-steps { list-style: none; display: flex; flex-direction: column; gap: 5px; margin-top: 10px; margin-bottom: 0; padding: 0; }
|
|
1428
1441
|
.mc-steps li { display: grid; grid-template-columns: 16px auto 1fr; align-items: center; column-gap: 8px; font-size: 12.5px; color: var(--faint); }
|
|
@@ -1470,6 +1483,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1470
1483
|
.mc-adv summary { cursor: pointer; width: max-content; font-size: 12.5px; color: var(--muted); }
|
|
1471
1484
|
.mc-adv[open] summary { margin-bottom: 8px; }
|
|
1472
1485
|
.mc-check { margin-top: 8px; }
|
|
1486
|
+
/* Hosted inside Settings: undo its row styling for labels (centred, padded, bordered, input flexed to 0 tall). */
|
|
1487
|
+
.mc-form label { padding: 0; border-bottom: 0; }
|
|
1488
|
+
.mc-form .mc-field { align-items: stretch; justify-content: flex-start; gap: 5px; }
|
|
1489
|
+
.mc-form .mc-field > span { text-align: left; }
|
|
1490
|
+
.mc-form .mc-field > .input { flex: none; max-width: none; }
|
|
1491
|
+
.mc-form .mc-check { display: inline-flex; justify-content: flex-start; gap: 8px; width: max-content; }
|
|
1473
1492
|
.mc-foot { display: flex; align-items: center; gap: 10px; }
|
|
1474
1493
|
.mc-foot .hint { flex: 1; margin: 0 !important; font-size: 12px !important; }
|
|
1475
1494
|
.mc-foot .primary { flex: none; }
|
package/web/favicon.svg
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1c22"/><stop offset="1" stop-color="#0e0e10"/></linearGradient>
|
|
4
|
+
<radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#6a5cff" stop-opacity=".55"/><stop offset="1" stop-color="#6a5cff" stop-opacity="0"/></radialGradient>
|
|
5
|
+
<radialGradient id="orb" cx="36%" cy="32%" r="68%"><stop offset="0" stop-color="#fff"/><stop offset=".16" stop-color="#d9fff6"/><stop offset=".38" stop-color="#2fd0b4"/><stop offset=".78" stop-color="#6a5cff"/><stop offset="1" stop-color="#4b3fe0"/></radialGradient>
|
|
6
|
+
</defs>
|
|
7
|
+
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
|
|
8
|
+
<circle cx="512" cy="512" r="330" fill="url(#glow)"/>
|
|
9
|
+
<circle cx="512" cy="512" r="210" fill="url(#orb)"/>
|
|
10
|
+
<circle cx="742" cy="360" r="16" fill="#fff" opacity=".9"/>
|
|
11
|
+
<circle cx="300" cy="650" r="10" fill="#bff" opacity=".7"/>
|
|
12
|
+
<circle cx="690" cy="720" r="7" fill="#fff" opacity=".6"/>
|
|
5
13
|
</svg>
|
package/web/index.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>wisp</title>
|
|
7
7
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
8
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
|
8
9
|
<link rel="stylesheet" href="/css/app.css">
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
package/web/js/app.js
CHANGED
|
@@ -64,6 +64,28 @@ const MODES = [
|
|
|
64
64
|
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
|
|
65
65
|
const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
66
66
|
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
|
|
67
|
+
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
68
|
+
const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
69
|
+
// A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
|
|
70
|
+
function agentById(id) {
|
|
71
|
+
const local = S.agents.find((a) => a.id === id);
|
|
72
|
+
if (local?.available) return local;
|
|
73
|
+
return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
|
|
74
|
+
}
|
|
75
|
+
// The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
|
|
76
|
+
// `missing` names the picked places that don't have it. Machines not online yet don't count.
|
|
77
|
+
function harnessesAt(places) {
|
|
78
|
+
const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
|
|
79
|
+
if (!known.length) known.push(['', S.agents]);
|
|
80
|
+
const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
|
|
81
|
+
return ids
|
|
82
|
+
.map((id) => {
|
|
83
|
+
const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
|
|
84
|
+
const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
|
|
85
|
+
return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
|
|
86
|
+
})
|
|
87
|
+
.filter((a) => a.available || S.agents.some((x) => x.id === a.id));
|
|
88
|
+
}
|
|
67
89
|
|
|
68
90
|
// Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
|
|
69
91
|
// `mono` marks are drawn in the text colour so they work in both themes.
|
|
@@ -83,14 +105,14 @@ function harnessLogo(agent) {
|
|
|
83
105
|
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
84
106
|
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
85
107
|
function effortsFor(agent, model) {
|
|
86
|
-
const a =
|
|
108
|
+
const a = agentById(agent);
|
|
87
109
|
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
88
110
|
}
|
|
89
111
|
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
90
112
|
|
|
91
113
|
function modelBadge(agent, model, effort) {
|
|
92
114
|
if (agent === 'wisp') return '';
|
|
93
|
-
const label = (model &&
|
|
115
|
+
const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
94
116
|
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
95
117
|
const logo = harnessLogo(agent);
|
|
96
118
|
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
@@ -247,7 +269,7 @@ function renderFeedHead(needs, working) {
|
|
|
247
269
|
if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
|
|
248
270
|
if (needs.length) parts.push(`<span class="hs needs"><i></i>${needs.length} need${needs.length === 1 ? 's' : ''} you</span>`);
|
|
249
271
|
// Group headings already carry the counts; only say something when the feed is calm.
|
|
250
|
-
$('#feedHead').innerHTML = parts.length ? '' : '<span class="
|
|
272
|
+
$('#feedHead').innerHTML = parts.length ? '' : '<span class="hs calm"><i></i>Nothing needs you</span>';
|
|
251
273
|
$('#feedHead').hidden = !!parts.length;
|
|
252
274
|
placeCollapseAll();
|
|
253
275
|
}
|
|
@@ -294,14 +316,17 @@ function item(s) {
|
|
|
294
316
|
n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
|
|
295
317
|
S.nodes.set(s.id, n);
|
|
296
318
|
n.head.addEventListener('click', (e) => {
|
|
297
|
-
if (n.head.querySelector('[contenteditable="true"]')) return;
|
|
319
|
+
if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
|
|
298
320
|
if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
|
|
299
321
|
setFocus(s.id);
|
|
300
322
|
toggle(s.id);
|
|
301
323
|
});
|
|
302
|
-
card.addEventListener('mousedown', () =>
|
|
324
|
+
card.addEventListener('mousedown', () => {
|
|
325
|
+
if (!s.pending) setFocus(s.id, { scroll: false });
|
|
326
|
+
});
|
|
303
327
|
n.head.addEventListener('contextmenu', (e) => {
|
|
304
328
|
e.preventDefault();
|
|
329
|
+
if (s.pending) return;
|
|
305
330
|
setFocus(s.id, { scroll: false });
|
|
306
331
|
taskMenu(s.id, e);
|
|
307
332
|
});
|
|
@@ -322,7 +347,7 @@ function renderHead(n, s) {
|
|
|
322
347
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
323
348
|
: '';
|
|
324
349
|
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
325
|
-
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
350
|
+
n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
326
351
|
n.head.innerHTML = `
|
|
327
352
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
328
353
|
<span class="it-main">
|
|
@@ -528,6 +553,17 @@ function attachTray() {
|
|
|
528
553
|
items.length = 0;
|
|
529
554
|
render();
|
|
530
555
|
},
|
|
556
|
+
// Empty the tray for an optimistic send, and put the files back if the send fails.
|
|
557
|
+
take() {
|
|
558
|
+
const list = items.splice(0);
|
|
559
|
+
render();
|
|
560
|
+
return list;
|
|
561
|
+
},
|
|
562
|
+
restore(list) {
|
|
563
|
+
if (!list?.length) return;
|
|
564
|
+
items.unshift(...list);
|
|
565
|
+
render();
|
|
566
|
+
},
|
|
531
567
|
get busy() {
|
|
532
568
|
return items.some((i) => i.status === 'uploading');
|
|
533
569
|
},
|
|
@@ -606,7 +642,7 @@ function syncWhere() {
|
|
|
606
642
|
function savedModel(id) {
|
|
607
643
|
const d = S.config.defaults;
|
|
608
644
|
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
609
|
-
const models =
|
|
645
|
+
const models = agentById(id)?.models || [];
|
|
610
646
|
return models.some((m) => m.id === want) ? want : '';
|
|
611
647
|
}
|
|
612
648
|
// Thinking level too, and only if the chosen model still accepts it.
|
|
@@ -619,21 +655,21 @@ function savedEffort(id, model) {
|
|
|
619
655
|
function renderLauncher() {
|
|
620
656
|
const box = $('#launcher');
|
|
621
657
|
const mode = S.config.defaults.mode || 'edits';
|
|
622
|
-
const
|
|
623
|
-
const chosen =
|
|
624
|
-
let agent = chosen?.available ? chosen :
|
|
658
|
+
const here = harnessesAt(launchWhere);
|
|
659
|
+
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
|
|
660
|
+
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
625
661
|
box.innerHTML = `
|
|
626
662
|
<div class="l-projects"></div>
|
|
627
663
|
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
628
664
|
<div class="l-bar">
|
|
629
665
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
666
|
+
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
630
667
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
631
668
|
<div class="runners">
|
|
632
669
|
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
633
670
|
<button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
|
|
634
671
|
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
635
672
|
</div>
|
|
636
|
-
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
637
673
|
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
638
674
|
<span class="grow"></span>
|
|
639
675
|
<button class="tog-btn" id="wtBtn"></button>
|
|
@@ -719,7 +755,7 @@ function renderLauncher() {
|
|
|
719
755
|
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
720
756
|
paintTeam();
|
|
721
757
|
teamBtn.onclick = () =>
|
|
722
|
-
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
|
|
758
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launchWhere }, (members, preset) => {
|
|
723
759
|
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
724
760
|
team = members;
|
|
725
761
|
teamPreset = preset;
|
|
@@ -743,19 +779,19 @@ function renderLauncher() {
|
|
|
743
779
|
vsBtn.onclick = () => {
|
|
744
780
|
if (compare) compare = null;
|
|
745
781
|
else {
|
|
746
|
-
const other =
|
|
782
|
+
const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
|
|
747
783
|
compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
|
|
748
784
|
}
|
|
749
785
|
paintCompare();
|
|
750
786
|
};
|
|
751
787
|
runner2.onclick = () =>
|
|
752
|
-
runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
|
|
788
|
+
runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
753
789
|
compare = { agent: id, model, effort };
|
|
754
790
|
paintCompare();
|
|
755
791
|
});
|
|
756
792
|
runnerBtn.onclick = () =>
|
|
757
|
-
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
758
|
-
agent =
|
|
793
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
794
|
+
agent = harnessesAt(launchWhere).find((a) => a.id === id);
|
|
759
795
|
curModel = model;
|
|
760
796
|
curEffort = effort;
|
|
761
797
|
paintRunner();
|
|
@@ -779,21 +815,39 @@ function renderLauncher() {
|
|
|
779
815
|
if (off) return offlineToast(off.k);
|
|
780
816
|
const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
|
|
781
817
|
for (const t of targets) {
|
|
782
|
-
const
|
|
783
|
-
|
|
818
|
+
const list = agentsAt(t.k);
|
|
819
|
+
const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
|
|
820
|
+
if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
|
|
784
821
|
}
|
|
785
822
|
const btn = $('#launch', box);
|
|
786
|
-
|
|
787
|
-
|
|
823
|
+
// Optimistic: wait only for uploads, then empty the box and show the task in Working at
|
|
824
|
+
// once. Fetching and making worktrees can take a few seconds; the real card replaces the
|
|
825
|
+
// placeholder when the server has it. On failure the prompt and files come back.
|
|
826
|
+
const attachments = await tray.ids();
|
|
827
|
+
const files = tray.take();
|
|
828
|
+
const cmp = compare;
|
|
829
|
+
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
830
|
+
const pending = targets.map((t) => addPending(prompt, t, runner));
|
|
831
|
+
if (compare) (compare = null), paintCompare();
|
|
832
|
+
ta.value = '';
|
|
833
|
+
S.drafts.delete('home');
|
|
834
|
+
ta.dispatchEvent(new Event('input'));
|
|
835
|
+
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
836
|
+
S.filter = 'all';
|
|
837
|
+
save('wisp.filter', S.filter);
|
|
838
|
+
}
|
|
839
|
+
renderFeed();
|
|
840
|
+
const ghost = S.nodes.get(pending[0])?.card;
|
|
841
|
+
showLaunched(btn, ghost);
|
|
842
|
+
ta.focus();
|
|
788
843
|
try {
|
|
789
|
-
const attachments = await tray.ids();
|
|
790
844
|
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
791
845
|
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
792
846
|
const results = await Promise.allSettled(
|
|
793
847
|
targets.map((t) =>
|
|
794
848
|
api('/sessions', {
|
|
795
849
|
method: 'POST',
|
|
796
|
-
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent
|
|
850
|
+
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
797
851
|
}),
|
|
798
852
|
),
|
|
799
853
|
);
|
|
@@ -801,31 +855,36 @@ function renderLauncher() {
|
|
|
801
855
|
if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
|
|
802
856
|
});
|
|
803
857
|
const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
804
|
-
|
|
858
|
+
for (const id of pending) S.sessions.delete(id);
|
|
805
859
|
for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
|
|
813
|
-
S.filter = 'all';
|
|
814
|
-
save('wisp.filter', S.filter);
|
|
860
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
861
|
+
if (!started.length) {
|
|
862
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
863
|
+
tray.restore(files);
|
|
864
|
+
if (cmp && !compare) (compare = cmp), paintCompare();
|
|
865
|
+
return;
|
|
815
866
|
}
|
|
816
|
-
renderFeed();
|
|
817
|
-
const card = S.nodes.get(s.id)?.card;
|
|
818
|
-
showLaunched(btn, card?.closest('.task-item') || card);
|
|
819
867
|
askNotify();
|
|
820
868
|
} catch (err) {
|
|
869
|
+
for (const id of pending) S.sessions.delete(id);
|
|
870
|
+
renderFeed();
|
|
821
871
|
toast(esc(err.message), { tone: 'bad' });
|
|
822
|
-
} finally {
|
|
823
|
-
btn.disabled = false;
|
|
824
|
-
btn.classList.remove('busy');
|
|
825
|
-
ta.focus();
|
|
826
872
|
}
|
|
827
873
|
};
|
|
828
874
|
$('#launch', box).onclick = launch;
|
|
875
|
+
// Picking other places (or a machine coming online) changes which harnesses exist: keep the
|
|
876
|
+
// pick if one of them still has it, else move to one that's installed there.
|
|
877
|
+
box.placesChanged = () => {
|
|
878
|
+
const here = harnessesAt(launchWhere);
|
|
879
|
+
if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
|
|
880
|
+
const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
|
|
881
|
+
if (!next) return paintRunner();
|
|
882
|
+
agent = next;
|
|
883
|
+
curModel = savedModel(agent.id);
|
|
884
|
+
curEffort = savedEffort(agent.id, curModel);
|
|
885
|
+
paintRunner();
|
|
886
|
+
paintTeam();
|
|
887
|
+
};
|
|
829
888
|
ta.addEventListener('keydown', (e) => {
|
|
830
889
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
831
890
|
e.preventDefault();
|
|
@@ -840,7 +899,7 @@ function renderLauncher() {
|
|
|
840
899
|
|
|
841
900
|
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
842
901
|
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
843
|
-
const a =
|
|
902
|
+
const a = agentById(agentId);
|
|
844
903
|
const m = (a?.models || []).find((x) => x.id === model);
|
|
845
904
|
btn.innerHTML = agentId
|
|
846
905
|
? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
|
|
@@ -856,9 +915,9 @@ const MAX_COPIES = 5; // parallel copies of one row
|
|
|
856
915
|
const MAX_INSTANCES = 12; // copies across the team
|
|
857
916
|
const copiesOf = (m) => Math.max(1, m.count || 1);
|
|
858
917
|
const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
|
|
859
|
-
const canLead = (id) =>
|
|
918
|
+
const canLead = (id) => agentById(id)?.delegates !== false;
|
|
860
919
|
const memberLabel = (m) => {
|
|
861
|
-
const a =
|
|
920
|
+
const a = agentById(m.agent);
|
|
862
921
|
const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
|
|
863
922
|
return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
|
|
864
923
|
};
|
|
@@ -905,7 +964,7 @@ function paintTeamBtn(btn, team, presetId, lead) {
|
|
|
905
964
|
: `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
|
|
906
965
|
}
|
|
907
966
|
|
|
908
|
-
function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
967
|
+
function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
|
|
909
968
|
const open = $('.team-pop');
|
|
910
969
|
if (open) return open.close();
|
|
911
970
|
let members = team.map((m) => ({ ...m }));
|
|
@@ -969,7 +1028,8 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
969
1028
|
const add = () => {
|
|
970
1029
|
if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
|
|
971
1030
|
const used = new Set([lead, ...members.map((m) => m.agent)]);
|
|
972
|
-
const
|
|
1031
|
+
const pool = places ? harnessesAt(places) : S.agents;
|
|
1032
|
+
const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
|
|
973
1033
|
if (!a) return toast('No harness is installed.', { tone: 'bad' });
|
|
974
1034
|
const model = savedModel(a.id);
|
|
975
1035
|
members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
|
|
@@ -1072,7 +1132,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
1072
1132
|
const rb = e.target.closest('.tp-runner');
|
|
1073
1133
|
if (rb) {
|
|
1074
1134
|
const m = members[i];
|
|
1075
|
-
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
|
|
1135
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
|
|
1076
1136
|
Object.assign(m, { agent, model, effort });
|
|
1077
1137
|
paintList();
|
|
1078
1138
|
commit();
|
|
@@ -1349,6 +1409,7 @@ function whereMenu(anchor) {
|
|
|
1349
1409
|
// A machine picked while disconnected starts connecting; you asked to run there.
|
|
1350
1410
|
for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
1351
1411
|
renderLaunchChips();
|
|
1412
|
+
$('#launcher')?.placesChanged?.();
|
|
1352
1413
|
paint();
|
|
1353
1414
|
saveWhere();
|
|
1354
1415
|
};
|
|
@@ -1467,6 +1528,65 @@ function launchedFx(from, to) {
|
|
|
1467
1528
|
).onfinish = () => w.remove();
|
|
1468
1529
|
}
|
|
1469
1530
|
|
|
1531
|
+
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
1532
|
+
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
1533
|
+
let pendingSeq = 0;
|
|
1534
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
1535
|
+
const id = `pending-${++pendingSeq}`;
|
|
1536
|
+
const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
|
|
1537
|
+
const now = Date.now();
|
|
1538
|
+
S.sessions.set(id, {
|
|
1539
|
+
id,
|
|
1540
|
+
pending: true,
|
|
1541
|
+
title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
|
|
1542
|
+
agent,
|
|
1543
|
+
model,
|
|
1544
|
+
effort,
|
|
1545
|
+
cwd: p.path,
|
|
1546
|
+
project: p.name,
|
|
1547
|
+
projectId: p.id,
|
|
1548
|
+
color: p.color || 'slate',
|
|
1549
|
+
state: 'running',
|
|
1550
|
+
live: 'Starting up',
|
|
1551
|
+
createdAt: now,
|
|
1552
|
+
updatedAt: now,
|
|
1553
|
+
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
|
1554
|
+
});
|
|
1555
|
+
return id;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
1559
|
+
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
1560
|
+
async function launchBehind({ prompt, cwd, runner }, request) {
|
|
1561
|
+
const sum = S.config.projects.find((p) => p.path === cwd);
|
|
1562
|
+
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
1563
|
+
const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
|
|
1564
|
+
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
1565
|
+
S.filter = 'all';
|
|
1566
|
+
save('wisp.filter', S.filter);
|
|
1567
|
+
}
|
|
1568
|
+
renderFeed();
|
|
1569
|
+
const card = S.nodes.get(ghost)?.card;
|
|
1570
|
+
if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
|
|
1571
|
+
try {
|
|
1572
|
+
const s = await request();
|
|
1573
|
+
S.sessions.delete(ghost);
|
|
1574
|
+
for (const x of s.group || [s]) S.sessions.set(x.id, x);
|
|
1575
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
1576
|
+
return s;
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
S.sessions.delete(ghost);
|
|
1579
|
+
renderFeed();
|
|
1580
|
+
throw err;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// The real session showed up (live events can beat the launch response): drop its placeholder.
|
|
1585
|
+
function dropPending(s) {
|
|
1586
|
+
if (s.pending || s.parentId) return;
|
|
1587
|
+
for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1470
1590
|
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
1471
1591
|
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
1472
1592
|
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
@@ -1807,8 +1927,18 @@ function renderWorking(s, t) {
|
|
|
1807
1927
|
.filter((a) => a.kind !== 'text')
|
|
1808
1928
|
.slice(-5)
|
|
1809
1929
|
.forEach((a) => ticker.prepend(actItem(a)));
|
|
1810
|
-
$('.stop', node).onclick = async () => {
|
|
1811
|
-
|
|
1930
|
+
$('.stop', node).onclick = async (e) => {
|
|
1931
|
+
// Shows at once; the run winds down and the live update replaces this panel.
|
|
1932
|
+
const btn = e.currentTarget;
|
|
1933
|
+
btn.disabled = true;
|
|
1934
|
+
btn.lastChild.textContent = 'Stopping…';
|
|
1935
|
+
$('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
|
|
1936
|
+
await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
|
|
1937
|
+
btn.disabled = false;
|
|
1938
|
+
btn.lastChild.textContent = 'Stop';
|
|
1939
|
+
$('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
|
|
1940
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1941
|
+
});
|
|
1812
1942
|
};
|
|
1813
1943
|
$('.peek', node).onclick = (e) => {
|
|
1814
1944
|
const log = $('.log', node);
|
|
@@ -1836,7 +1966,15 @@ function renderAskCard(s) {
|
|
|
1836
1966
|
const answer = async (text) => {
|
|
1837
1967
|
if (!text.trim()) return;
|
|
1838
1968
|
node.classList.add('sent');
|
|
1839
|
-
|
|
1969
|
+
const undo = startOptimistic(s.id, text, null, { answering: true });
|
|
1970
|
+
try {
|
|
1971
|
+
await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
|
|
1972
|
+
if (undo) scheduleRefetch(s.id);
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
node.classList.remove('sent');
|
|
1975
|
+
undo?.();
|
|
1976
|
+
toast(esc(e.message), { tone: 'bad' });
|
|
1977
|
+
}
|
|
1840
1978
|
};
|
|
1841
1979
|
node.addEventListener('click', (e) => {
|
|
1842
1980
|
const b = e.target.closest('.opt');
|
|
@@ -2063,21 +2201,58 @@ async function sendReply(id, text) {
|
|
|
2063
2201
|
if (!s || !text) return;
|
|
2064
2202
|
if (n?.tray?.busy) toast('Waiting for uploads…');
|
|
2065
2203
|
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
2204
|
+
const answering = !!s.ask && !attachments.length;
|
|
2205
|
+
// Optimistic: the composer empties and the card goes to Working before the server answers
|
|
2206
|
+
// (it may first have to bring back an archived worktree, or reach another machine).
|
|
2207
|
+
const ta = n && $('.reply', n.card);
|
|
2208
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2209
|
+
S.drafts.delete(id);
|
|
2210
|
+
const files = n?.tray?.take();
|
|
2211
|
+
const undo = startOptimistic(id, text, files, { answering });
|
|
2066
2212
|
try {
|
|
2067
|
-
const r =
|
|
2213
|
+
const r = answering
|
|
2068
2214
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
2069
2215
|
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
2070
|
-
|
|
2071
|
-
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2072
|
-
S.drafts.delete(id);
|
|
2073
|
-
n?.tray?.clear();
|
|
2216
|
+
if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
|
|
2074
2217
|
if (r.queued)
|
|
2075
2218
|
toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
|
|
2076
2219
|
} catch (err) {
|
|
2220
|
+
undo?.();
|
|
2221
|
+
// Give the message back, unless the user has already started typing another one.
|
|
2222
|
+
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
2223
|
+
n?.tray?.restore(files);
|
|
2077
2224
|
toast(esc(err.message), { tone: 'bad' });
|
|
2078
2225
|
}
|
|
2079
2226
|
}
|
|
2080
2227
|
|
|
2228
|
+
// Show a follow-up as started right away: the card moves to Working with the prompt as a live
|
|
2229
|
+
// turn. Only when it will start now, not when it queues or interrupts a run already working.
|
|
2230
|
+
// Returns an undo for a failed send, or null when nothing changed.
|
|
2231
|
+
function startOptimistic(id, text, files, { answering }) {
|
|
2232
|
+
const full = S.full.get(id);
|
|
2233
|
+
const sum = S.sessions.get(id);
|
|
2234
|
+
if (!full || !sum) return null;
|
|
2235
|
+
const live = full.state === 'running' || full.state === 'waiting';
|
|
2236
|
+
if (live && !answering) return null;
|
|
2237
|
+
const now = Date.now();
|
|
2238
|
+
const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
|
|
2239
|
+
const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
|
|
2240
|
+
const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
|
|
2241
|
+
const apply = (f, sm) => {
|
|
2242
|
+
S.full.set(id, f);
|
|
2243
|
+
S.sessions.set(id, sm);
|
|
2244
|
+
const card = S.nodes.get(id)?.card;
|
|
2245
|
+
keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
|
|
2246
|
+
renderBody(id, { fresh: false });
|
|
2247
|
+
};
|
|
2248
|
+
apply(nextFull, nextSum);
|
|
2249
|
+
return () => {
|
|
2250
|
+
// A live update may already have replaced the guess; only roll back what is still ours.
|
|
2251
|
+
if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
|
|
2252
|
+
apply(full, sum);
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2081
2256
|
// --- item actions ------------------------------------------------------------------
|
|
2082
2257
|
|
|
2083
2258
|
async function toggleDone(id) {
|
|
@@ -2085,23 +2260,48 @@ async function toggleDone(id) {
|
|
|
2085
2260
|
if (!s) return;
|
|
2086
2261
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
2087
2262
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
if (
|
|
2091
|
-
|
|
2092
|
-
|
|
2263
|
+
// Optimistic: the card moves now; a failed save puts it back.
|
|
2264
|
+
move(id, { ...s, state: next }, next === 'done');
|
|
2265
|
+
if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
|
|
2266
|
+
try {
|
|
2267
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
revertSummary(id, s);
|
|
2270
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2093
2271
|
}
|
|
2094
2272
|
}
|
|
2095
2273
|
|
|
2274
|
+
// Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
|
|
2275
|
+
// hands focus to the next card; coming back just re-renders around the reader.
|
|
2276
|
+
function move(id, next, leaving) {
|
|
2277
|
+
if (leaving) return handOff(id)(() => S.sessions.set(id, next));
|
|
2278
|
+
S.sessions.set(id, next);
|
|
2279
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Put back a summary changed optimistically, unless a live update has replaced it since.
|
|
2283
|
+
function revertSummary(id, prev) {
|
|
2284
|
+
const cur = S.sessions.get(id);
|
|
2285
|
+
if (cur && cur.updatedAt > prev.updatedAt) return;
|
|
2286
|
+
S.sessions.set(id, prev);
|
|
2287
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2096
2290
|
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
2097
2291
|
async function taskDone(ids) {
|
|
2098
2292
|
if (!ids.length) return;
|
|
2099
2293
|
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
2100
2294
|
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
2101
|
-
const set = (state) =>
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2295
|
+
const set = (state) => {
|
|
2296
|
+
const prev = ids.map((id) => S.sessions.get(id));
|
|
2297
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
|
|
2298
|
+
renderFeed();
|
|
2299
|
+
Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
|
|
2300
|
+
prev.forEach((p, i) => p && revertSummary(ids[i], p));
|
|
2301
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2302
|
+
});
|
|
2303
|
+
};
|
|
2304
|
+
set('done');
|
|
2105
2305
|
if (next && S.nodes.has(next)) setFocus(next);
|
|
2106
2306
|
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
2107
2307
|
}
|
|
@@ -2182,10 +2382,17 @@ function rename(id) {
|
|
|
2182
2382
|
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
2183
2383
|
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
2184
2384
|
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
2185
|
-
|
|
2385
|
+
// places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
|
|
2386
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
|
|
2186
2387
|
const open = $('.picker');
|
|
2187
2388
|
if (open) return open.close();
|
|
2188
|
-
|
|
2389
|
+
const agents = places ? harnessesAt(places) : S.agents;
|
|
2390
|
+
const sub = (a) => {
|
|
2391
|
+
if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
|
|
2392
|
+
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
2393
|
+
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
2394
|
+
};
|
|
2395
|
+
let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
|
|
2189
2396
|
|
|
2190
2397
|
const r = anchor.getBoundingClientRect();
|
|
2191
2398
|
const width = Math.min(580, innerWidth - 24);
|
|
@@ -2198,11 +2405,11 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2198
2405
|
<div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
2199
2406
|
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
2200
2407
|
<div class="pk-head">Harness</div>
|
|
2201
|
-
${
|
|
2408
|
+
${agents
|
|
2202
2409
|
.map(
|
|
2203
2410
|
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
2204
2411
|
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
2205
|
-
<span class="pk-text"><b>${esc(a.name)}</b><small
|
|
2412
|
+
<span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
|
|
2206
2413
|
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
2207
2414
|
</button>`,
|
|
2208
2415
|
)
|
|
@@ -2246,7 +2453,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2246
2453
|
};
|
|
2247
2454
|
|
|
2248
2455
|
const paintModels = () => {
|
|
2249
|
-
const a =
|
|
2456
|
+
const a = agents.find((x) => x.id === focusAgent);
|
|
2250
2457
|
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
2251
2458
|
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
2252
2459
|
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
@@ -2476,9 +2683,12 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
2476
2683
|
|
|
2477
2684
|
async function deleteSession(id) {
|
|
2478
2685
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
2479
|
-
const
|
|
2480
|
-
|
|
2481
|
-
|
|
2686
|
+
const prev = S.sessions.get(id);
|
|
2687
|
+
handOff(id)(() => S.sessions.delete(id));
|
|
2688
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
|
|
2689
|
+
if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
|
|
2690
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2691
|
+
});
|
|
2482
2692
|
}
|
|
2483
2693
|
|
|
2484
2694
|
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
@@ -2537,22 +2747,13 @@ async function rerun(id) {
|
|
|
2537
2747
|
const go = async () => {
|
|
2538
2748
|
const prompt = ta.value.trim();
|
|
2539
2749
|
if (!prompt) return ta.focus();
|
|
2540
|
-
|
|
2541
|
-
btn.disabled = true;
|
|
2750
|
+
close();
|
|
2542
2751
|
try {
|
|
2543
|
-
const
|
|
2544
|
-
|
|
2545
|
-
close();
|
|
2546
|
-
if (S.filter !== 'all' && S.filter !== next.projectId) {
|
|
2547
|
-
S.filter = 'all';
|
|
2548
|
-
save('wisp.filter', S.filter);
|
|
2549
|
-
}
|
|
2550
|
-
renderFeed();
|
|
2752
|
+
const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
2753
|
+
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
2551
2754
|
setFocus(next.id);
|
|
2552
|
-
toast(`Rerunning on ${esc(agentName(agent))}`);
|
|
2553
2755
|
} catch (err) {
|
|
2554
|
-
|
|
2555
|
-
toast(esc(err.message), { tone: 'bad' });
|
|
2756
|
+
toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
|
|
2556
2757
|
}
|
|
2557
2758
|
};
|
|
2558
2759
|
$('[data-a="go"]', node).onclick = go;
|
|
@@ -2568,15 +2769,16 @@ async function rerun(id) {
|
|
|
2568
2769
|
}
|
|
2569
2770
|
|
|
2570
2771
|
async function setArchived(id, archived) {
|
|
2571
|
-
const
|
|
2772
|
+
const prev = S.sessions.get(id);
|
|
2773
|
+
if (!prev) return;
|
|
2774
|
+
// Optimistic: the card leaves (or comes back) now; a failed save puts it back.
|
|
2775
|
+
move(id, { ...prev, archived }, archived);
|
|
2776
|
+
if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2572
2777
|
try {
|
|
2573
2778
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
2574
2779
|
} catch (err) {
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
if (leave) {
|
|
2578
|
-
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
2579
|
-
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2780
|
+
revertSummary(id, prev);
|
|
2781
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2580
2782
|
}
|
|
2581
2783
|
}
|
|
2582
2784
|
|
|
@@ -3008,16 +3210,21 @@ async function compareView(id) {
|
|
|
3008
3210
|
if (a === 'open') return close(), reveal(keep);
|
|
3009
3211
|
const others = group.filter((g) => g.id !== keep);
|
|
3010
3212
|
if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
|
|
3213
|
+
// Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
|
|
3214
|
+
for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
|
|
3215
|
+
close();
|
|
3216
|
+
renderFeed();
|
|
3217
|
+
reveal(keep);
|
|
3218
|
+
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3219
|
+
let failed = false;
|
|
3011
3220
|
for (const o of others) {
|
|
3012
3221
|
if (o.state === 'running' || o.state === 'waiting') {
|
|
3013
3222
|
await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
|
|
3014
3223
|
await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
|
|
3015
3224
|
}
|
|
3016
|
-
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3225
|
+
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toast(esc(err.message), { tone: 'bad' })));
|
|
3017
3226
|
}
|
|
3018
|
-
|
|
3019
|
-
reveal(keep);
|
|
3020
|
-
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3227
|
+
if (failed) loadSessions(); // show what really got archived
|
|
3021
3228
|
};
|
|
3022
3229
|
}
|
|
3023
3230
|
|
|
@@ -3282,18 +3489,17 @@ function runRecipe(r) {
|
|
|
3282
3489
|
const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
|
|
3283
3490
|
const d = S.config.defaults;
|
|
3284
3491
|
const agent = r.agent || d.agent;
|
|
3492
|
+
const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
|
|
3493
|
+
const model = r.agent ? r.model : savedModel(agent);
|
|
3494
|
+
const effort = r.agent ? r.effort : savedEffort(agent, model);
|
|
3495
|
+
const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
|
|
3496
|
+
close();
|
|
3285
3497
|
try {
|
|
3286
|
-
const
|
|
3287
|
-
|
|
3288
|
-
body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
|
|
3289
|
-
});
|
|
3290
|
-
S.sessions.set(s.id, s);
|
|
3291
|
-
close();
|
|
3292
|
-
renderFeed();
|
|
3498
|
+
const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
|
|
3499
|
+
const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
|
|
3293
3500
|
setFocus(s.id);
|
|
3294
|
-
toast(`Running “${esc(r.name)}”`);
|
|
3295
3501
|
} catch (err) {
|
|
3296
|
-
toast(esc(err.message)
|
|
3502
|
+
toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
|
|
3297
3503
|
}
|
|
3298
3504
|
};
|
|
3299
3505
|
// Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
|
|
@@ -3500,6 +3706,7 @@ function setMachines(list) {
|
|
|
3500
3706
|
S.machines = list;
|
|
3501
3707
|
paintMachinesBtn();
|
|
3502
3708
|
renderLaunchChips();
|
|
3709
|
+
$('#launcher')?.placesChanged?.();
|
|
3503
3710
|
renderProjects();
|
|
3504
3711
|
$('.machines-modal')?.refresh();
|
|
3505
3712
|
$('.projects-modal')?.refresh?.();
|
|
@@ -3624,7 +3831,7 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3624
3831
|
</div>
|
|
3625
3832
|
${
|
|
3626
3833
|
st === 'online'
|
|
3627
|
-
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
3834
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
3628
3835
|
: ''
|
|
3629
3836
|
}
|
|
3630
3837
|
${busy || st === 'error' ? steps(m) : ''}
|
|
@@ -3697,8 +3904,9 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3697
3904
|
paint();
|
|
3698
3905
|
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
3699
3906
|
}
|
|
3700
|
-
if (a === 'restart') {
|
|
3701
|
-
|
|
3907
|
+
if (a === 'restart' || a === 'update') {
|
|
3908
|
+
const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
|
|
3909
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
|
|
3702
3910
|
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
3703
3911
|
}
|
|
3704
3912
|
if (a === 'stop') {
|
|
@@ -3812,6 +4020,22 @@ const SETTINGS_PANES = [
|
|
|
3812
4020
|
];
|
|
3813
4021
|
let settingsPane = 'general';
|
|
3814
4022
|
|
|
4023
|
+
// What a re-check turned up: harnesses found or gone, and model lists that grew or shrank.
|
|
4024
|
+
function recheckSummary(before, after) {
|
|
4025
|
+
const count = (a) => (a?.models || []).filter((m) => m.id).length;
|
|
4026
|
+
const notes = [];
|
|
4027
|
+
for (const a of after) {
|
|
4028
|
+
const was = before.get(a.id);
|
|
4029
|
+
if (a.available && !was?.available) notes.push(`<b>${esc(a.name)}</b> found`);
|
|
4030
|
+
else if (!a.available && was?.available) notes.push(`<b>${esc(a.name)}</b> not found`);
|
|
4031
|
+
else if (a.available && count(a) !== count(was)) {
|
|
4032
|
+
const d = count(a) - count(was);
|
|
4033
|
+
notes.push(`<b>${esc(a.name)}</b> ${d > 0 ? '+' : '−'}${Math.abs(d)} model${Math.abs(d) === 1 ? '' : 's'}`);
|
|
4034
|
+
} else if (a.available && a.version !== was?.version) notes.push(`<b>${esc(a.name)}</b> updated`);
|
|
4035
|
+
}
|
|
4036
|
+
return notes.length ? notes.join(' · ') : 'Up to date: nothing new';
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3815
4039
|
function openSettings(opts) {
|
|
3816
4040
|
if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
|
|
3817
4041
|
const d = S.config.defaults;
|
|
@@ -3848,9 +4072,10 @@ function openSettings(opts) {
|
|
|
3848
4072
|
</div>
|
|
3849
4073
|
</section>
|
|
3850
4074
|
<section class="sx-pane" data-pane="agents">
|
|
3851
|
-
<div class="sx-group"
|
|
3852
|
-
|
|
3853
|
-
|
|
4075
|
+
<div class="sx-group sx-agents"></div>
|
|
4076
|
+
<div class="sx-group">
|
|
4077
|
+
${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
|
|
4078
|
+
</div>
|
|
3854
4079
|
</section>
|
|
3855
4080
|
<section class="sx-pane" data-pane="machines">
|
|
3856
4081
|
<div class="sx-group">
|
|
@@ -3884,6 +4109,41 @@ function openSettings(opts) {
|
|
|
3884
4109
|
</div>`);
|
|
3885
4110
|
const close = overlay(node);
|
|
3886
4111
|
$('.close', node).onclick = close;
|
|
4112
|
+
const paintAgents = () => {
|
|
4113
|
+
$('.sx-agents', node).innerHTML = S.agents.length
|
|
4114
|
+
? S.agents.map((a) => {
|
|
4115
|
+
const n = (a.models || []).filter((m) => m.id).length;
|
|
4116
|
+
const desc = a.available ? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ') : `Install the ${a.name} CLI and sign in, then re-check`;
|
|
4117
|
+
return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `<span class="sx-state ${a.available ? 'ok' : 'bad'}" ${a.error ? `title="${esc(a.error)}"` : ''}><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`);
|
|
4118
|
+
}).join('')
|
|
4119
|
+
: '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
|
|
4120
|
+
const missing = S.agents.filter((a) => !a.available).length;
|
|
4121
|
+
const dot = $('.sx-nav [data-pane="agents"] .dot', node);
|
|
4122
|
+
if (dot && !missing) dot.remove();
|
|
4123
|
+
if (!dot && missing) $('.sx-nav [data-pane="agents"]', node).insertAdjacentHTML('beforeend', `<i class="dot bad" title="${missing} not found"></i>`);
|
|
4124
|
+
const online = S.machines.filter((m) => m.state === 'online').length;
|
|
4125
|
+
$('.recheck-hint', node).textContent = `After installing a CLI, signing in or adding a provider, look for harnesses and models again${online ? `, here and on ${online} machine${online === 1 ? '' : 's'}` : ''}`;
|
|
4126
|
+
};
|
|
4127
|
+
paintAgents();
|
|
4128
|
+
node.paintAgents = paintAgents;
|
|
4129
|
+
$('#recheck', node).onclick = async (e) => {
|
|
4130
|
+
const btn = e.currentTarget;
|
|
4131
|
+
btn.disabled = true;
|
|
4132
|
+
btn.classList.add('busy');
|
|
4133
|
+
const before = new Map(S.agents.map((a) => [a.id, a]));
|
|
4134
|
+
try {
|
|
4135
|
+
S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
|
|
4136
|
+
if (!node.isConnected) return;
|
|
4137
|
+
paintAgents();
|
|
4138
|
+
$('#launcher')?.placesChanged?.();
|
|
4139
|
+
toast(recheckSummary(before, S.agents));
|
|
4140
|
+
} catch (err) {
|
|
4141
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
4142
|
+
} finally {
|
|
4143
|
+
btn.disabled = false;
|
|
4144
|
+
btn.classList.remove('busy');
|
|
4145
|
+
}
|
|
4146
|
+
};
|
|
3887
4147
|
const pick = (id, focus = false) => {
|
|
3888
4148
|
settingsPane = id;
|
|
3889
4149
|
const p = SETTINGS_PANES.find((x) => x.id === id);
|
|
@@ -4250,7 +4510,7 @@ function paletteItems() {
|
|
|
4250
4510
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
4251
4511
|
}
|
|
4252
4512
|
|
|
4253
|
-
const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4513
|
+
const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4254
4514
|
for (const s of sessions) {
|
|
4255
4515
|
const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
|
|
4256
4516
|
items.push({
|
|
@@ -4336,7 +4596,7 @@ function notifyFinished(s) {
|
|
|
4336
4596
|
}
|
|
4337
4597
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
4338
4598
|
if (notifyOn() && !document.hasFocus()) {
|
|
4339
|
-
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/
|
|
4599
|
+
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
|
|
4340
4600
|
note.onclick = () => (window.focus(), reveal(s.id));
|
|
4341
4601
|
}
|
|
4342
4602
|
}
|
|
@@ -4349,6 +4609,7 @@ function connect() {
|
|
|
4349
4609
|
const s = JSON.parse(e.data);
|
|
4350
4610
|
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
4351
4611
|
const prev = S.sessions.get(s.id);
|
|
4612
|
+
if (!prev) dropPending(s);
|
|
4352
4613
|
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
4353
4614
|
S.sessions.set(s.id, s);
|
|
4354
4615
|
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
@@ -4448,7 +4709,7 @@ async function installUpdate() {
|
|
|
4448
4709
|
|
|
4449
4710
|
// --- keyboard ---------------------------------------------------------------------------
|
|
4450
4711
|
|
|
4451
|
-
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
4712
|
+
const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
|
|
4452
4713
|
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
4453
4714
|
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
4454
4715
|
// triage continues on the same spot. Call before the request, while the card is still laid
|
|
@@ -4611,6 +4872,7 @@ window.addEventListener('focus', () => {
|
|
|
4611
4872
|
(async () => {
|
|
4612
4873
|
[S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
|
|
4613
4874
|
renderLauncher();
|
|
4875
|
+
$('.settings-modal')?.paintAgents?.();
|
|
4614
4876
|
connect();
|
|
4615
4877
|
route();
|
|
4616
4878
|
$('#prompt')?.focus();
|