wisp-agents 0.3.0 → 0.5.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 +16 -1
- package/README.md +17 -2
- package/package.json +1 -1
- package/server/adapters/claude.js +5 -3
- package/server/adapters/codex.js +5 -3
- package/server/adapters/index.js +6 -4
- package/server/adapters/opencode.js +21 -4
- package/server/index.js +39 -4
- package/server/runner.js +7 -1
- package/server/store.js +1 -1
- package/server/telemetry.js +132 -0
- package/server/terminal.js +15 -0
- package/web/css/app.css +9 -0
- package/web/js/app.js +110 -15
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Anonymous usage stats and error reports via [PostHog](https://posthog.com): which agents, models and modes are used, run outcomes and timings, and errors (agent failures, server errors, crashes) with paths scrubbed. Never sends prompts, output, paths or project names. Turn it off in Settings → *Anonymous usage stats*, or with `WISP_TELEMETRY=0` / `DO_NOT_TRACK=1`; always off when `CI` is set. See the Telemetry section of the README.
|
|
13
|
+
|
|
14
|
+
## [0.4.0] - 2026-10-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Thinking level in the harness · model picker: Default / Low / Medium / High / X-High / Max under the model list, remembered per harness and shown on the launcher button. Claude Code gets `--effort`, Codex `-c model_reasoning_effort=…`, opencode `--variant` (levels read per model from `opencode models --verbose`). Cursor bakes effort into its model ids, so it has no separate selector.
|
|
19
|
+
- Take over in [cmux](https://cmux.dev): opens a new cmux workspace in the project running the resume command. Auto-detected when wisp is started from a cmux terminal.
|
|
20
|
+
|
|
8
21
|
## [0.3.0] - 2026-10-17
|
|
9
22
|
|
|
10
23
|
### Added
|
|
@@ -76,7 +89,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
76
89
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
77
90
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
78
91
|
|
|
79
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
92
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.5.0...HEAD
|
|
93
|
+
[0.5.0]: https://github.com/M0xoo/wisp/compare/v0.4.0...v0.5.0
|
|
94
|
+
[0.4.0]: https://github.com/M0xoo/wisp/compare/v0.3.0...v0.4.0
|
|
80
95
|
[0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
|
|
81
96
|
[0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
|
|
82
97
|
[0.2.1]: https://github.com/M0xoo/wisp/compare/v0.2.0...v0.2.1
|
package/README.md
CHANGED
|
@@ -44,8 +44,8 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
44
44
|
| 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. Archive what you don't need to see. |
|
|
45
45
|
| 💬 **Follow-ups that interrupt** | A follow-up sent mid-run interrupts the agent and resumes it with your message. Turn on **Queue** to wait for the run to finish instead; queued messages can be cancelled. |
|
|
46
46
|
| 🔎 **Command palette** | `⌘K` / `Ctrl+K` fuzzy-searches sessions, projects, and actions. |
|
|
47
|
-
| 🤖 **Any harness** | Claude Code, Codex, opencode, or Cursor CLI. Pick the harness and
|
|
48
|
-
| 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
|
|
47
|
+
| 🤖 **Any harness** | Claude Code, Codex, opencode, or Cursor CLI. Pick the harness, model and thinking level together in the launcher; both are remembered per harness. Each card shows the harness it ran on. |
|
|
48
|
+
| 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, cmux, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
|
|
49
49
|
| 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
|
|
50
50
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
51
51
|
| 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
|
|
@@ -202,6 +202,21 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
|
|
|
202
202
|
- [x] opencode adapter
|
|
203
203
|
- [x] Cursor CLI adapter
|
|
204
204
|
|
|
205
|
+
## Telemetry
|
|
206
|
+
|
|
207
|
+
wisp sends anonymous usage stats and error reports to [PostHog](https://posthog.com) so we can see which agents people use and what breaks. The code is in [`server/telemetry.js`](server/telemetry.js).
|
|
208
|
+
|
|
209
|
+
**What is sent:** a random install id (not tied to you or your machine), the wisp, OS, Node and agent CLI versions, and per run: agent, model, mode, effort, status, duration, token counts and cost, how many files changed, and which presentation block types came back. Errors include a stack trace and message with your home directory and file paths removed, cut to 500 characters.
|
|
210
|
+
|
|
211
|
+
**Never sent:** prompts, replies, agent output, file contents, project names, paths or repo URLs.
|
|
212
|
+
|
|
213
|
+
**Opt out** with any of these:
|
|
214
|
+
- Settings → *Anonymous usage stats*
|
|
215
|
+
- `WISP_TELEMETRY=0`
|
|
216
|
+
- `DO_NOT_TRACK=1`
|
|
217
|
+
|
|
218
|
+
It is also off whenever `CI` is set.
|
|
219
|
+
|
|
205
220
|
## Contributing
|
|
206
221
|
|
|
207
222
|
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/package.json
CHANGED
|
@@ -23,10 +23,11 @@ export default {
|
|
|
23
23
|
{ id: 'sonnet', label: 'Sonnet', hint: 'Fast and smart, everyday work' },
|
|
24
24
|
{ id: 'haiku', label: 'Haiku', hint: 'Fastest, for quick tasks' },
|
|
25
25
|
],
|
|
26
|
+
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
26
27
|
|
|
27
28
|
// Interactive command to take over a session in a terminal.
|
|
28
|
-
resumeCommand({ resumeId, model }) {
|
|
29
|
-
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
29
|
+
resumeCommand({ resumeId, model, effort }) {
|
|
30
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
|
|
30
31
|
},
|
|
31
32
|
|
|
32
33
|
detect() {
|
|
@@ -34,7 +35,7 @@ export default {
|
|
|
34
35
|
},
|
|
35
36
|
|
|
36
37
|
// Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
|
|
37
|
-
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
|
|
38
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
38
39
|
const args = [
|
|
39
40
|
'-p',
|
|
40
41
|
'--output-format', 'stream-json',
|
|
@@ -42,6 +43,7 @@ export default {
|
|
|
42
43
|
'--permission-mode', PERMISSION_MODES[mode] || 'acceptEdits',
|
|
43
44
|
];
|
|
44
45
|
if (model) args.push('--model', model);
|
|
46
|
+
if (effort) args.push('--effort', effort);
|
|
45
47
|
if (resumeId) args.push('--resume', resumeId);
|
|
46
48
|
if (Object.keys(mcpServers).length) {
|
|
47
49
|
args.push('--mcp-config', JSON.stringify({ mcpServers }));
|
package/server/adapters/codex.js
CHANGED
|
@@ -21,21 +21,23 @@ export default {
|
|
|
21
21
|
name: 'Codex',
|
|
22
22
|
askTool: 'mcp__wisp__ask_user',
|
|
23
23
|
models: [{ id: '', label: 'Default', hint: 'Whatever Codex is set to' }],
|
|
24
|
+
efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
24
25
|
|
|
25
|
-
resumeCommand({ resumeId, model }) {
|
|
26
|
-
return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
26
|
+
resumeCommand({ resumeId, model, effort }) {
|
|
27
|
+
return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort=${toml(effort)}`] : [])];
|
|
27
28
|
},
|
|
28
29
|
|
|
29
30
|
detect() {
|
|
30
31
|
return detectVersion(BIN);
|
|
31
32
|
},
|
|
32
33
|
|
|
33
|
-
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
|
|
34
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
34
35
|
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
35
36
|
// `resume` doesn't take --sandbox, so set it through config for both paths.
|
|
36
37
|
if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
37
38
|
else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
|
|
38
39
|
if (model) args.push('-m', model);
|
|
40
|
+
if (effort) args.push('-c', `model_reasoning_effort=${toml(effort)}`);
|
|
39
41
|
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
40
42
|
const key = `mcp_servers.${name}`;
|
|
41
43
|
args.push('-c', `${key}.command=${toml(srv.command)}`, '-c', `${key}.args=${toml(srv.args || [])}`);
|
package/server/adapters/index.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// (./common.js has the shared plumbing) and register it here:
|
|
3
3
|
//
|
|
4
4
|
// {
|
|
5
|
-
// id, name, models: [{id, label, hint?}],
|
|
5
|
+
// id, name, models: [{id, label, hint?, efforts?}],
|
|
6
|
+
// efforts?: string[] reasoning levels for every model (a model's own
|
|
7
|
+
// efforts list wins); omit if the CLI has none
|
|
6
8
|
// askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
|
|
7
9
|
// null = can't inject MCP, ask via a question block
|
|
8
10
|
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
9
|
-
// resumeCommand({ resumeId, model }): string[] argv to continue the session interactively
|
|
10
|
-
// run({ prompt, cwd, resumeId, mode, model, systemPrompt, onEvent }): { stop(), done: Promise }
|
|
11
|
+
// resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
|
|
12
|
+
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
|
|
11
13
|
// }
|
|
12
14
|
//
|
|
13
15
|
// onEvent receives normalized events:
|
|
@@ -35,7 +37,7 @@ export async function listAgents() {
|
|
|
35
37
|
return Promise.all(
|
|
36
38
|
Object.values(adapters).map(async (a) => {
|
|
37
39
|
if (!detected.has(a.id)) detected.set(a.id, a.detect());
|
|
38
|
-
return { id: a.id, name: a.name, models: a.models, ...(await detected.get(a.id)) };
|
|
40
|
+
return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], ...(await detected.get(a.id)) };
|
|
39
41
|
}),
|
|
40
42
|
);
|
|
41
43
|
}
|
|
@@ -11,23 +11,40 @@ export default {
|
|
|
11
11
|
models: [{ id: '', label: 'Default', hint: 'Whatever opencode is set to' }],
|
|
12
12
|
|
|
13
13
|
resumeCommand({ resumeId, model }) {
|
|
14
|
+
// The TUI has no --variant flag; the effort only applies to `run`.
|
|
14
15
|
return [BIN, ...(resumeId ? ['--session', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
15
16
|
},
|
|
16
17
|
|
|
17
18
|
async detect() {
|
|
18
19
|
const found = await detectVersion(BIN);
|
|
19
20
|
if (!found.available) return found;
|
|
20
|
-
// `opencode models` prints provider/model
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
|
|
22
|
+
// the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
|
|
23
|
+
const out = await quietOutput(BIN, ['models', '--verbose']);
|
|
24
|
+
const models = [];
|
|
25
|
+
for (const [, id, json] of out.matchAll(/^([\w.-]+\/\S+)\n(\{[\s\S]*?\n\})$/gm)) {
|
|
26
|
+
let efforts = [];
|
|
27
|
+
try {
|
|
28
|
+
efforts = Object.keys(JSON.parse(json).variants || {});
|
|
29
|
+
} catch {}
|
|
30
|
+
models.push({ id, label: id.split('/').slice(1).join('/'), hint: id.split('/')[0], efforts });
|
|
31
|
+
}
|
|
32
|
+
if (!models.length) {
|
|
33
|
+
// Older builds without --verbose: plain ids, one per line.
|
|
34
|
+
for (const l of (await quietOutput(BIN, ['models'])).split('\n').map((x) => x.trim())) {
|
|
35
|
+
if (/^[\w.-]+\/\S+$/.test(l)) models.push({ id: l, label: l.split('/').slice(1).join('/'), hint: l.split('/')[0] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ...found, models: [this.models[0], ...models] };
|
|
23
39
|
},
|
|
24
40
|
|
|
25
|
-
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
|
|
41
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
26
42
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
27
43
|
const args = ['run', '--format', 'json'];
|
|
28
44
|
if (mode === 'plan') args.push('--agent', 'plan');
|
|
29
45
|
if (mode === 'auto') args.push('--auto');
|
|
30
46
|
if (model) args.push('--model', model);
|
|
47
|
+
if (model && effort) args.push('--variant', effort);
|
|
31
48
|
if (resumeId) args.push('--session', resumeId);
|
|
32
49
|
for (const a of attachments) args.push('--file', a.path);
|
|
33
50
|
|
package/server/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { Runner, summarize, newId } 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
15
|
|
|
15
16
|
const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
16
17
|
|
|
@@ -62,6 +63,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
62
63
|
runner.on('finished', (f) => broadcast('finished', f));
|
|
63
64
|
setInterval(() => broadcast('ping', {}), 25_000).unref();
|
|
64
65
|
|
|
66
|
+
const telemetry = new Telemetry(store);
|
|
67
|
+
const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
|
|
68
|
+
runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
|
|
69
|
+
runner.on('ended', ({ session, turn, interrupted, errorText }) => {
|
|
70
|
+
const blocks = turn.presentation?.blocks || [];
|
|
71
|
+
const st = turn.stats || {};
|
|
72
|
+
telemetry.track('turn_ended', {
|
|
73
|
+
...run(session, turn),
|
|
74
|
+
status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
|
|
75
|
+
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
76
|
+
durationMs: turn.endedAt - turn.startedAt,
|
|
77
|
+
blockCount: blocks.length,
|
|
78
|
+
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
79
|
+
presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
|
|
80
|
+
changedFiles: turn.changes.length,
|
|
81
|
+
inputTokens: st.inputTokens,
|
|
82
|
+
outputTokens: st.outputTokens,
|
|
83
|
+
costUsd: st.costUsd,
|
|
84
|
+
});
|
|
85
|
+
if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...run(session, turn) });
|
|
86
|
+
});
|
|
87
|
+
if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
|
|
88
|
+
|
|
65
89
|
const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
66
90
|
|
|
67
91
|
const server = http.createServer(async (req, res) => {
|
|
@@ -81,6 +105,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
81
105
|
return serveStatic(res, url.pathname);
|
|
82
106
|
} catch (err) {
|
|
83
107
|
console.error(err);
|
|
108
|
+
if (!err.status || err.status >= 500) telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
|
|
84
109
|
send(res, err.status || 500, { error: err.message });
|
|
85
110
|
}
|
|
86
111
|
});
|
|
@@ -107,6 +132,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
107
132
|
const body = await readJson(req);
|
|
108
133
|
const s = store.get(body.session);
|
|
109
134
|
if (!s) return send(res, 404, { error: 'no such session' });
|
|
135
|
+
telemetry.track('ask_user', { agent: s.agent });
|
|
110
136
|
return send(res, 200, { id: runner.ask(s, body) });
|
|
111
137
|
}
|
|
112
138
|
if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
|
|
@@ -148,7 +174,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
148
174
|
}
|
|
149
175
|
|
|
150
176
|
if (root === 'config') {
|
|
151
|
-
if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS });
|
|
177
|
+
if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
|
|
152
178
|
if (m === 'PATCH') {
|
|
153
179
|
const body = await readJson(req);
|
|
154
180
|
if (body.defaults) Object.assign(store.config.defaults, body.defaults);
|
|
@@ -235,7 +261,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
235
261
|
const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
236
262
|
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
|
|
237
263
|
if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
|
|
238
|
-
const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, attachments: resolveAttachments(body.attachments) });
|
|
264
|
+
const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, effort: /^[\w-]{1,20}$/.test(body.effort || '') ? body.effort : '', attachments: resolveAttachments(body.attachments) });
|
|
239
265
|
return send(res, 201, summarize(s, store));
|
|
240
266
|
}
|
|
241
267
|
}
|
|
@@ -287,7 +313,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
287
313
|
if (action === 'takeover') {
|
|
288
314
|
const adapter = adapters[s.agent];
|
|
289
315
|
if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
|
|
290
|
-
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model });
|
|
316
|
+
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
291
317
|
const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
|
|
292
318
|
if (m === 'GET') return send(res, 200, { command });
|
|
293
319
|
if (m === 'POST') {
|
|
@@ -297,6 +323,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
297
323
|
}
|
|
298
324
|
openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
|
|
299
325
|
s.takenOverAt = Date.now();
|
|
326
|
+
telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
|
|
300
327
|
store.save(s);
|
|
301
328
|
return send(res, 200, { command });
|
|
302
329
|
}
|
|
@@ -341,6 +368,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
341
368
|
const shutdown = () => {
|
|
342
369
|
for (const s of store.all()) runner.stop(s);
|
|
343
370
|
store.flush();
|
|
371
|
+
telemetry.persist();
|
|
344
372
|
};
|
|
345
373
|
if (handleSignals) {
|
|
346
374
|
const exit = () => (shutdown(), process.exit(0));
|
|
@@ -353,7 +381,14 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
353
381
|
server.listen(port, host, resolve);
|
|
354
382
|
});
|
|
355
383
|
|
|
356
|
-
|
|
384
|
+
if (telemetry.enabled) {
|
|
385
|
+
if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
|
|
386
|
+
listAgents()
|
|
387
|
+
.then((agents) => telemetry.track('app_started', { agentsInstalled: agents.filter((a) => a.available).map((a) => a.id), ...Object.fromEntries(agents.filter((a) => a.available).map((a) => [`${a.id}Version`, a.version])) }))
|
|
388
|
+
.catch(() => {});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
|
|
357
392
|
}
|
|
358
393
|
|
|
359
394
|
function send(res, status, body) {
|
package/server/runner.js
CHANGED
|
@@ -36,6 +36,7 @@ export function summarize(s, store) {
|
|
|
36
36
|
color: project?.color || 'slate',
|
|
37
37
|
mode: s.mode,
|
|
38
38
|
model: s.model,
|
|
39
|
+
effort: s.effort || '',
|
|
39
40
|
state: s.state,
|
|
40
41
|
unread: s.unread,
|
|
41
42
|
archived: !!s.archived,
|
|
@@ -74,7 +75,7 @@ export class Runner extends EventEmitter {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', attachments = [] }) {
|
|
78
|
+
create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [] }) {
|
|
78
79
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
79
80
|
const now = Date.now();
|
|
80
81
|
const session = {
|
|
@@ -85,6 +86,7 @@ export class Runner extends EventEmitter {
|
|
|
85
86
|
cwd,
|
|
86
87
|
mode,
|
|
87
88
|
model,
|
|
89
|
+
effort,
|
|
88
90
|
state: 'running',
|
|
89
91
|
unread: false,
|
|
90
92
|
createdAt: now,
|
|
@@ -203,6 +205,7 @@ export class Runner extends EventEmitter {
|
|
|
203
205
|
prompt,
|
|
204
206
|
agent: session.agent,
|
|
205
207
|
model: session.model,
|
|
208
|
+
effort: session.effort || '',
|
|
206
209
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
207
210
|
startedAt: Date.now(),
|
|
208
211
|
endedAt: null,
|
|
@@ -217,6 +220,7 @@ export class Runner extends EventEmitter {
|
|
|
217
220
|
session.unread = false;
|
|
218
221
|
delete session.archived;
|
|
219
222
|
this.#touch(session, { turn: turn.id });
|
|
223
|
+
this.emit('started', { session, turn });
|
|
220
224
|
|
|
221
225
|
let released = false;
|
|
222
226
|
let linger;
|
|
@@ -228,6 +232,7 @@ export class Runner extends EventEmitter {
|
|
|
228
232
|
resumeId: session.agentSessionId,
|
|
229
233
|
mode: session.mode,
|
|
230
234
|
model: session.model,
|
|
235
|
+
effort: session.effort || '',
|
|
231
236
|
systemPrompt: systemPrompt({ askTool: adapter.askTool }),
|
|
232
237
|
mcpServers: this.bridge && adapter.askTool !== null
|
|
233
238
|
? {
|
|
@@ -337,6 +342,7 @@ export class Runner extends EventEmitter {
|
|
|
337
342
|
session.unread = !interrupted;
|
|
338
343
|
this.store.save(session, { now: true });
|
|
339
344
|
this.#touch(session, { turn: turn.id });
|
|
345
|
+
this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
|
|
340
346
|
// The follow-up starts right away; don't notify for the run it replaced.
|
|
341
347
|
if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
|
|
342
348
|
}
|
package/server/store.js
CHANGED
|
@@ -8,7 +8,7 @@ import { parsePresentation } from './protocol.js';
|
|
|
8
8
|
|
|
9
9
|
const DEFAULT_CONFIG = {
|
|
10
10
|
projects: [], // [{ id, name, path, color, addedAt }]
|
|
11
|
-
defaults: { agent: 'claude', mode: 'edits', model: '' },
|
|
11
|
+
defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Anonymous usage analytics + error reports, sent to PostHog over its plain HTTP API (no SDK, to
|
|
2
|
+
// stay dependency-free). What goes out is fixed values only — agent, model, mode, status, timings,
|
|
3
|
+
// counts — never prompts, paths, project names or agent output. Error messages and stacks are
|
|
4
|
+
// scrubbed of the home directory and truncated.
|
|
5
|
+
//
|
|
6
|
+
// Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
|
|
7
|
+
// settings. See the Telemetry section of the README.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
// PostHog project API key: write-only, safe to ship in the client.
|
|
16
|
+
const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
|
|
17
|
+
const HOST = (process.env.WISP_POSTHOG_HOST || 'https://us.i.posthog.com').replace(/\/$/, '');
|
|
18
|
+
const FLUSH_MS = 15_000;
|
|
19
|
+
const MAX_QUEUE = 50;
|
|
20
|
+
const TIMEOUT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
23
|
+
const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
|
|
24
|
+
|
|
25
|
+
export function envDisabled(env = process.env) {
|
|
26
|
+
return !KEY || truthy(env.DO_NOT_TRACK) || /^(0|false|no|off)$/i.test(env.WISP_TELEMETRY || '') || truthy(env.CI);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
|
|
31
|
+
// Paths in messages and stacks would leak usernames and project names: wisp's own files keep
|
|
32
|
+
// their package-relative path, anything else is cut down to its file name.
|
|
33
|
+
export function scrub(text = '', { home = os.homedir(), root = ROOT } = {}) {
|
|
34
|
+
let s = String(text).split(root).join('<wisp>');
|
|
35
|
+
if (home) s = s.split(home).join('~');
|
|
36
|
+
// Any other absolute path keeps only its file name.
|
|
37
|
+
return s
|
|
38
|
+
.replace(/(?<![\w<>~.:/\\-])(?:~|[A-Za-z]:)?[/\\](?:[^\s'"`():/\\]+[/\\])+([^\s'"`():/\\]*)/g, (m, base) => `…/${base}`)
|
|
39
|
+
.slice(0, 500);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function frames(stack = '') {
|
|
43
|
+
return String(stack)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.slice(1, 30)
|
|
46
|
+
.map((l) => l.trim().match(/^at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/))
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map(([, fn, file, line, col]) => ({ platform: 'custom', lang: 'javascript', function: fn || '?', filename: scrub(file.replace(/^file:\/\//, '')), lineno: +line, colno: +col, in_app: file.includes(ROOT) && !file.includes('node_modules') }))
|
|
49
|
+
.reverse(); // PostHog wants the innermost frame last
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Telemetry {
|
|
53
|
+
constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
|
|
54
|
+
this.store = store;
|
|
55
|
+
this.queue = [];
|
|
56
|
+
this.pendingFile = path.join(store.home, 'telemetry-pending.json');
|
|
57
|
+
this.common = {
|
|
58
|
+
$lib: 'wisp',
|
|
59
|
+
$process_person_profile: false, // anonymous events: no person profiles
|
|
60
|
+
version: VERSION,
|
|
61
|
+
os: process.platform,
|
|
62
|
+
osRelease: os.release(),
|
|
63
|
+
arch: process.arch,
|
|
64
|
+
node: process.versions.node,
|
|
65
|
+
electron: process.versions.electron || undefined,
|
|
66
|
+
shell,
|
|
67
|
+
};
|
|
68
|
+
this.timer = setInterval(() => this.flush(), FLUSH_MS);
|
|
69
|
+
this.timer.unref();
|
|
70
|
+
this.#sendPending();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get enabled() {
|
|
74
|
+
return !envDisabled() && this.store.config.defaults.telemetry !== 'off';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
track(event, props = {}) {
|
|
78
|
+
if (!this.enabled) return;
|
|
79
|
+
if (!this.store.config.telemetryId) {
|
|
80
|
+
this.store.config.telemetryId = randomUUID(); // random per install, not tied to the machine or user
|
|
81
|
+
this.store.saveConfig();
|
|
82
|
+
}
|
|
83
|
+
this.queue.push({ event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, distinct_id: this.store.config.telemetryId } });
|
|
84
|
+
if (this.queue.length >= MAX_QUEUE) this.flush();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// PostHog error tracking format.
|
|
88
|
+
captureError(err, props = {}) {
|
|
89
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
90
|
+
this.track('$exception', {
|
|
91
|
+
...props,
|
|
92
|
+
$exception_list: [{ type: props.type || e.name || 'Error', value: scrub(e.message), mechanism: { handled: props.handled ?? true, synthetic: false }, stacktrace: { type: 'raw', frames: frames(e.stack) } }],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async flush() {
|
|
97
|
+
if (!this.queue.length) return;
|
|
98
|
+
const batch = this.queue.splice(0);
|
|
99
|
+
try {
|
|
100
|
+
await fetch(`${HOST}/batch/`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ api_key: KEY, batch }),
|
|
104
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
} catch {} // offline or blocked: drop it, telemetry must never get in the way
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
|
|
110
|
+
// send it on the next start.
|
|
111
|
+
persist() {
|
|
112
|
+
clearInterval(this.timer);
|
|
113
|
+
if (!this.queue.length) return;
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(this.pendingFile, JSON.stringify(this.queue.splice(0)));
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
crashed(err) {
|
|
120
|
+
if (!this.enabled) return;
|
|
121
|
+
this.captureError(err, { handled: false, type: err?.name || 'UncaughtException' });
|
|
122
|
+
this.persist();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#sendPending() {
|
|
126
|
+
try {
|
|
127
|
+
const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
|
|
128
|
+
fs.rmSync(this.pendingFile, { force: true });
|
|
129
|
+
if (this.enabled && Array.isArray(pending)) this.queue.push(...pending.slice(-200));
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
package/server/terminal.js
CHANGED
|
@@ -13,6 +13,7 @@ const ALL_TERMINALS = [
|
|
|
13
13
|
{ id: 'terminal', label: 'Terminal.app', os: ['darwin'] },
|
|
14
14
|
{ id: 'iterm', label: 'iTerm', os: ['darwin'] },
|
|
15
15
|
{ id: 'warp', label: 'Warp', os: ['darwin'] },
|
|
16
|
+
{ id: 'cmux', label: 'cmux', os: ['darwin'] },
|
|
16
17
|
{ id: 'wt', label: 'Windows Terminal', os: ['win32'] },
|
|
17
18
|
{ id: 'powershell', label: 'PowerShell', os: ['win32'] },
|
|
18
19
|
{ id: 'cmd', label: 'Command Prompt', os: ['win32'] },
|
|
@@ -34,11 +35,16 @@ export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
|
34
35
|
const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
|
|
35
36
|
const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
|
|
36
37
|
|
|
38
|
+
const CMUX_APP = '/Applications/cmux.app';
|
|
39
|
+
const CMUX_CLI = `${CMUX_APP}/Contents/Resources/bin/cmux`;
|
|
40
|
+
|
|
37
41
|
const LINUX_ORDER = ['ghostty', 'kitty', 'wezterm', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'alacritty', 'x-terminal-emulator', 'xterm'];
|
|
38
42
|
|
|
39
43
|
export function detectTerminal({ platform = process.platform, env = process.env, has = (b) => onPath(b, env), exists = fs.existsSync } = {}) {
|
|
40
44
|
if (platform === 'darwin') {
|
|
41
45
|
if (env.TERM_PROGRAM === 'iTerm.app' && exists('/Applications/iTerm.app')) return 'iterm';
|
|
46
|
+
// cmux is built on Ghostty and reports TERM_PROGRAM=ghostty, so check it first.
|
|
47
|
+
if ((env.CMUX_WORKSPACE_ID || env.__CFBundleIdentifier === 'com.cmuxterm.app') && exists(CMUX_APP)) return 'cmux';
|
|
42
48
|
if (env.TERM_PROGRAM === 'ghostty' && exists('/Applications/Ghostty.app')) return 'ghostty';
|
|
43
49
|
return 'terminal';
|
|
44
50
|
}
|
|
@@ -69,6 +75,15 @@ export function terminalPlan({ cwd, argv, terminal = 'auto', custom = '', platfo
|
|
|
69
75
|
case 'warp':
|
|
70
76
|
// Warp has no scripting API; open a tab in the folder and type via System Events.
|
|
71
77
|
return [{ command: 'open', args: ['-a', 'Warp', cwd] }, { delay: 1200 }, { osa: `tell application "System Events" to keystroke ${asStr(cmd)} & return` }];
|
|
78
|
+
case 'cmux': {
|
|
79
|
+
// `cmux new-workspace` talks to cmux's socket, which by default only accepts processes started
|
|
80
|
+
// inside cmux. When it's refused (or cmux isn't running), open the folder as a new workspace
|
|
81
|
+
// and type the command, like Warp.
|
|
82
|
+
const cli = (has || ((b) => onPath(b, env)))('cmux') ? 'cmux' : CMUX_CLI;
|
|
83
|
+
const typeIt = `tell application "System Events" to keystroke ${asStr(cmd)} & return`;
|
|
84
|
+
const script = '"$1" new-workspace --cwd "$2" --command "$3" >/dev/null 2>&1 && open -a cmux || { open -a cmux "$2" && sleep 1.5 && osascript -e "$4"; }';
|
|
85
|
+
return [{ command: '/bin/sh', args: ['-c', script, 'sh', cli, cwd, cmd, typeIt], options: detached }];
|
|
86
|
+
}
|
|
72
87
|
case 'wt':
|
|
73
88
|
// `;` separates wt subcommands, so it can't appear unescaped in the command line.
|
|
74
89
|
return [{ command: 'wt.exe', args: ['-d', cwd, 'powershell.exe', '-NoExit', '-Command', ps.replace(/;/g, '\\;')], options: detached }];
|
package/web/css/app.css
CHANGED
|
@@ -290,6 +290,7 @@ select {
|
|
|
290
290
|
.rb-h { font-weight: 500; color: var(--ink); }
|
|
291
291
|
.rb-m { position: relative; margin-left: 13px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
|
292
292
|
.rb-m::before { content: ''; position: absolute; left: -7px; top: 50%; width: 1px; height: 12px; margin-top: -6px; background: var(--line-2); }
|
|
293
|
+
.rb-e { flex: none; margin-left: 6px; padding: 1px 6px; border-radius: 6px; font-size: 11.5px; background: var(--accent-soft); color: var(--accent-ink); }
|
|
293
294
|
.runner-btn .chev { width: 12px; height: 12px; margin-left: 6px; color: var(--faint); transform: rotate(90deg); transition: transform 0.2s var(--ease); }
|
|
294
295
|
.runner-btn.open .chev { transform: rotate(-90deg); }
|
|
295
296
|
|
|
@@ -323,6 +324,14 @@ select {
|
|
|
323
324
|
.pk-check { width: 15px; height: 15px; flex: none; color: var(--accent); visibility: hidden; }
|
|
324
325
|
.pk-m.on .pk-check { visibility: visible; }
|
|
325
326
|
.pk-m.on b { color: var(--accent-ink); }
|
|
327
|
+
.pk-effort { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 8px 10px; border-top: 1px solid var(--line); background: var(--surface-2); }
|
|
328
|
+
.pk-effort[hidden] { display: none; }
|
|
329
|
+
.pk-ehead { padding: 0 2px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
|
330
|
+
.pk-seg { display: flex; min-width: 0; gap: 2px; padding: 2px; overflow-x: auto; border-radius: 9px; background: var(--bg-2); border: 1px solid var(--line); }
|
|
331
|
+
.pk-seg button { flex: 1 0 auto; padding: 4px 7px; border-radius: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; outline: 0; transition: background 0.12s, color 0.12s; }
|
|
332
|
+
.pk-seg button:hover { color: var(--ink); }
|
|
333
|
+
.pk-seg button:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
|
|
334
|
+
.pk-seg button.on { background: var(--surface); color: var(--accent-ink); font-weight: 500; box-shadow: var(--shadow-1); }
|
|
326
335
|
.pk-empty { padding: 30px 10px; text-align: center; font-size: 13px; color: var(--muted); }
|
|
327
336
|
@media (max-width: 560px) {
|
|
328
337
|
.picker { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
package/web/js/app.js
CHANGED
|
@@ -73,10 +73,18 @@ function harnessLogo(agent) {
|
|
|
73
73
|
const src = l && `/logos/${l.file}.svg`;
|
|
74
74
|
return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
77
|
+
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
78
|
+
function effortsFor(agent, model) {
|
|
79
|
+
const a = S.agents.find((x) => x.id === agent);
|
|
80
|
+
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
81
|
+
}
|
|
82
|
+
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
83
|
+
|
|
84
|
+
function modelBadge(agent, model, effort) {
|
|
77
85
|
if (agent === 'wisp') return '';
|
|
78
86
|
const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
79
|
-
const tip = `${agentName(agent)} · ${label}`;
|
|
87
|
+
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
80
88
|
const logo = harnessLogo(agent);
|
|
81
89
|
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
82
90
|
}
|
|
@@ -241,7 +249,7 @@ function renderHead(n, s) {
|
|
|
241
249
|
<span class="it-side">
|
|
242
250
|
${pill}
|
|
243
251
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
244
|
-
${modelBadge(s.agent, s.model)}
|
|
252
|
+
${modelBadge(s.agent, s.model, s.effort)}
|
|
245
253
|
<span class="q-proj">${esc(s.project)}</span>
|
|
246
254
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
247
255
|
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
@@ -484,21 +492,33 @@ function renderLauncher() {
|
|
|
484
492
|
const models = S.agents.find((a) => a.id === id)?.models || [];
|
|
485
493
|
return models.some((m) => m.id === want) ? want : '';
|
|
486
494
|
};
|
|
495
|
+
// Thinking level too, and only if the chosen model still accepts it.
|
|
496
|
+
const savedEffort = (id, model) => {
|
|
497
|
+
const d = S.config.defaults;
|
|
498
|
+
const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
|
|
499
|
+
return effortsFor(id, model).includes(want) ? want : '';
|
|
500
|
+
};
|
|
487
501
|
let curModel = savedModel(agent?.id);
|
|
502
|
+
let curEffort = savedEffort(agent?.id, curModel);
|
|
488
503
|
const runnerBtn = $('#runner', box);
|
|
489
504
|
const paintRunner = () => {
|
|
490
505
|
const m = (agent?.models || []).find((x) => x.id === curModel);
|
|
491
|
-
runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${icon('chevron', 'chev')}`;
|
|
506
|
+
runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
|
|
507
|
+
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
492
508
|
};
|
|
493
509
|
paintRunner();
|
|
494
510
|
runnerBtn.onclick = () =>
|
|
495
|
-
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, savedModel }, (id, model) => {
|
|
511
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
496
512
|
agent = S.agents.find((a) => a.id === id);
|
|
497
513
|
curModel = model;
|
|
514
|
+
curEffort = effort;
|
|
498
515
|
paintRunner();
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
516
|
+
const d = S.config.defaults;
|
|
517
|
+
const models = { ...d.models, [id]: model };
|
|
518
|
+
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
519
|
+
const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
|
|
520
|
+
Object.assign(d, { agent: id, model, models, effort, efforts });
|
|
521
|
+
api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
|
|
502
522
|
});
|
|
503
523
|
|
|
504
524
|
const launch = async () => {
|
|
@@ -510,7 +530,7 @@ function renderLauncher() {
|
|
|
510
530
|
btn.disabled = true;
|
|
511
531
|
try {
|
|
512
532
|
const attachments = await tray.ids();
|
|
513
|
-
const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, attachments } });
|
|
533
|
+
const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
|
|
514
534
|
S.sessions.set(s.id, s);
|
|
515
535
|
ta.value = '';
|
|
516
536
|
S.drafts.delete('home');
|
|
@@ -765,6 +785,7 @@ function renderBody(id, { fresh }) {
|
|
|
765
785
|
<span class="m">${esc(agentName(s.agent))}</span>
|
|
766
786
|
<span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
|
|
767
787
|
${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
|
|
788
|
+
${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
|
|
768
789
|
<span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
|
|
769
790
|
// Done lives both in the top bar and next to the composer, so it's reachable after reading.
|
|
770
791
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
@@ -838,7 +859,7 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
838
859
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
839
860
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
840
861
|
${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
|
|
841
|
-
<div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
|
|
862
|
+
<div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
|
|
842
863
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
843
864
|
</div>`);
|
|
844
865
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -1199,9 +1220,10 @@ function rename(id) {
|
|
|
1199
1220
|
title.onblur = () => done(true);
|
|
1200
1221
|
}
|
|
1201
1222
|
|
|
1202
|
-
// One popover for
|
|
1203
|
-
// Clicking (or arrowing to) a harness previews its
|
|
1204
|
-
|
|
1223
|
+
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
1224
|
+
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
1225
|
+
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
1226
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort }, pick) {
|
|
1205
1227
|
const open = $('.picker');
|
|
1206
1228
|
if (open) return open.close();
|
|
1207
1229
|
let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
|
|
@@ -1214,7 +1236,7 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1214
1236
|
const left = Math.max(12, Math.min(r.left, right - width));
|
|
1215
1237
|
const below = r.bottom + 440 < innerHeight;
|
|
1216
1238
|
const node = el(`
|
|
1217
|
-
<div class="picker" role="dialog" aria-label="Harness and
|
|
1239
|
+
<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`}">
|
|
1218
1240
|
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
1219
1241
|
<div class="pk-head">Harness</div>
|
|
1220
1242
|
${S.agents
|
|
@@ -1230,11 +1252,38 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1230
1252
|
<div class="pk-main">
|
|
1231
1253
|
<label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
1232
1254
|
<div class="pk-list" role="listbox" aria-label="Model"></div>
|
|
1255
|
+
<div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
|
|
1233
1256
|
</div>
|
|
1234
1257
|
</div>`);
|
|
1235
1258
|
const input = $('.pk-search input', node);
|
|
1236
1259
|
const list = $('.pk-list', node);
|
|
1237
1260
|
const sideRows = $$('.pk-h', node);
|
|
1261
|
+
const seg = $('.pk-effort', node);
|
|
1262
|
+
const modelOf = (id) => (id === agent ? model : savedModel(id));
|
|
1263
|
+
const effortOf = (id) => (id === agent ? effort : savedEffort(id, modelOf(id)));
|
|
1264
|
+
|
|
1265
|
+
const paintEffort = () => {
|
|
1266
|
+
const levels = effortsFor(focusAgent, modelOf(focusAgent));
|
|
1267
|
+
const current = effortOf(focusAgent);
|
|
1268
|
+
seg.hidden = !levels.length;
|
|
1269
|
+
seg.innerHTML = `<span class="pk-ehead">Thinking</span><span class="pk-seg">${['', ...levels]
|
|
1270
|
+
.map((e) => `<button role="radio" aria-checked="${e === current}" class="${e === current ? 'on' : ''}" data-e="${esc(e)}" title="${e ? `${esc(effortLabel(e))} reasoning effort` : 'Whatever the harness is set to'}">${esc(e ? effortLabel(e) : 'Default')}</button>`)
|
|
1271
|
+
.join('')}</span>`;
|
|
1272
|
+
};
|
|
1273
|
+
const chooseEffort = (e) => {
|
|
1274
|
+
const m = modelOf(focusAgent);
|
|
1275
|
+
if (focusAgent === agent && e === effort) return;
|
|
1276
|
+
if (focusAgent !== agent) {
|
|
1277
|
+
$$('.pk-dot', node).forEach((d) => d.remove());
|
|
1278
|
+
sideRows.find((b) => b.dataset.id === focusAgent)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
|
|
1279
|
+
}
|
|
1280
|
+
agent = focusAgent;
|
|
1281
|
+
model = m;
|
|
1282
|
+
effort = e;
|
|
1283
|
+
pick(agent, model, effort);
|
|
1284
|
+
paintEffort();
|
|
1285
|
+
$(`button[data-e="${CSS.escape(e)}"]`, seg)?.focus();
|
|
1286
|
+
};
|
|
1238
1287
|
|
|
1239
1288
|
const paintModels = () => {
|
|
1240
1289
|
const a = S.agents.find((x) => x.id === focusAgent);
|
|
@@ -1263,6 +1312,7 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1263
1312
|
)
|
|
1264
1313
|
.join('') + '<div class="pk-empty" hidden>No models match</div>';
|
|
1265
1314
|
filter();
|
|
1315
|
+
paintEffort();
|
|
1266
1316
|
};
|
|
1267
1317
|
const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
|
|
1268
1318
|
const filter = () => {
|
|
@@ -1296,7 +1346,10 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1296
1346
|
const choose = (m) => {
|
|
1297
1347
|
close();
|
|
1298
1348
|
anchor.focus();
|
|
1299
|
-
|
|
1349
|
+
// Carry the thinking level over when the new model accepts it, else fall back to the saved one.
|
|
1350
|
+
const cur = effortOf(focusAgent);
|
|
1351
|
+
const e = effortsFor(focusAgent, m).includes(cur) ? cur : savedEffort(focusAgent, m);
|
|
1352
|
+
if (focusAgent !== agent || m !== model || e !== effort) pick(focusAgent, m, e);
|
|
1300
1353
|
};
|
|
1301
1354
|
const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && close();
|
|
1302
1355
|
const step = (items, d) => {
|
|
@@ -1310,8 +1363,22 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1310
1363
|
const keys = (e) => {
|
|
1311
1364
|
const inSide = sideRows.includes(document.activeElement);
|
|
1312
1365
|
const live = sideRows.filter((b) => !b.disabled);
|
|
1366
|
+
const segBtns = $$('button', seg);
|
|
1367
|
+
const inSeg = segBtns.includes(document.activeElement);
|
|
1313
1368
|
if (e.key === 'Escape') return e.preventDefault(), e.stopPropagation(), close(), anchor.focus();
|
|
1369
|
+
// Tab hops from the model list to the thinking levels and back; anywhere else it leaves.
|
|
1370
|
+
if (e.key === 'Tab' && !seg.hidden && (inSeg ? e.shiftKey : !e.shiftKey && !inSide)) {
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
return inSeg ? focusModels() : (segBtns.find((b) => b.classList.contains('on')) || segBtns[0]).focus();
|
|
1373
|
+
}
|
|
1314
1374
|
if (e.key === 'Tab') return close();
|
|
1375
|
+
if (inSeg && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
1376
|
+
e.preventDefault();
|
|
1377
|
+
e.stopPropagation();
|
|
1378
|
+
return step(segBtns, e.key === 'ArrowRight' ? 1 : -1);
|
|
1379
|
+
}
|
|
1380
|
+
if (inSeg && e.key === 'ArrowUp') return e.preventDefault(), focusModels();
|
|
1381
|
+
if (inSeg && (e.key === 'Enter' || e.key === ' ')) return e.preventDefault(), chooseEffort(document.activeElement.dataset.e);
|
|
1315
1382
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1316
1383
|
e.preventDefault();
|
|
1317
1384
|
e.stopPropagation();
|
|
@@ -1337,6 +1404,10 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
|
1337
1404
|
if (b) choose(b.dataset.id);
|
|
1338
1405
|
};
|
|
1339
1406
|
input.oninput = filter;
|
|
1407
|
+
seg.onclick = (e) => {
|
|
1408
|
+
const b = e.target.closest('button[data-e]');
|
|
1409
|
+
if (b) chooseEffort(b.dataset.e);
|
|
1410
|
+
};
|
|
1340
1411
|
|
|
1341
1412
|
paintModels();
|
|
1342
1413
|
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
@@ -1630,6 +1701,7 @@ function openSettings() {
|
|
|
1630
1701
|
<label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
|
|
1631
1702
|
<input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
|
|
1632
1703
|
<div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
|
|
1704
|
+
<div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
|
|
1633
1705
|
<div class="agents">${S.agents
|
|
1634
1706
|
.map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
|
|
1635
1707
|
.join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
|
|
@@ -1662,6 +1734,28 @@ function openSettings() {
|
|
|
1662
1734
|
paint();
|
|
1663
1735
|
};
|
|
1664
1736
|
paint();
|
|
1737
|
+
// Agent, model, status, timings and scrubbed errors only; never prompts, paths or code.
|
|
1738
|
+
const tele = $('#tele', node);
|
|
1739
|
+
const paintTele = () => {
|
|
1740
|
+
const t = S.config.telemetry || {};
|
|
1741
|
+
tele.setAttribute('aria-checked', !!t.enabled);
|
|
1742
|
+
tele.disabled = !t.available;
|
|
1743
|
+
$('.tele-hint', node).textContent = !t.available ? 'Off (DO_NOT_TRACK, WISP_TELEMETRY=0 or CI)' : 'No prompts, paths or code';
|
|
1744
|
+
};
|
|
1745
|
+
tele.onclick = async () => {
|
|
1746
|
+
await api('/config', { method: 'PATCH', body: { defaults: { telemetry: S.config.telemetry?.enabled ? 'off' : 'on' } } });
|
|
1747
|
+
await loadConfig();
|
|
1748
|
+
paintTele();
|
|
1749
|
+
};
|
|
1750
|
+
paintTele();
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Say once that anonymous stats are on, with a way out.
|
|
1754
|
+
function telemetryNotice() {
|
|
1755
|
+
if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
|
|
1756
|
+
S.config.defaults.telemetryNotice = 'seen';
|
|
1757
|
+
api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
|
|
1758
|
+
toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
|
|
1665
1759
|
}
|
|
1666
1760
|
|
|
1667
1761
|
function shortcuts() {
|
|
@@ -1970,4 +2064,5 @@ window.addEventListener('focus', () => {
|
|
|
1970
2064
|
connect();
|
|
1971
2065
|
route();
|
|
1972
2066
|
$('#prompt')?.focus();
|
|
2067
|
+
telemetryNotice();
|
|
1973
2068
|
})();
|