wisp-agents 0.2.2 → 0.4.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 +45 -1
- package/README.md +41 -13
- package/bin/wisp.js +8 -3
- package/package.json +3 -1
- package/server/adapters/claude.js +14 -32
- package/server/adapters/codex.js +190 -0
- package/server/adapters/common.js +175 -0
- package/server/adapters/cursor.js +156 -0
- package/server/adapters/index.js +16 -9
- package/server/adapters/opencode.js +197 -0
- package/server/index.js +27 -12
- package/server/proc.js +127 -0
- package/server/protocol.js +13 -4
- package/server/runner.js +88 -19
- package/server/store.js +38 -1
- package/server/terminal.js +110 -41
- package/web/css/app.css +173 -17
- package/web/index.html +6 -1
- package/web/js/app.js +691 -99
- package/web/js/fuzzy.js +100 -0
- package/web/js/palette.js +110 -0
- package/web/logos/LICENSE +3 -0
- package/web/logos/claudecode-color.svg +1 -0
- package/web/logos/codex-color.svg +1 -0
- package/web/logos/cursor.svg +1 -0
- package/web/logos/opencode.svg +1 -0
package/server/proc.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Cross-platform process launching. On macOS/Linux this is a thin wrapper over spawn(). On
|
|
2
|
+
// Windows, CLIs installed through npm (claude, codex, opencode…) are `.cmd` shims, which
|
|
3
|
+
// spawn() can't start without a shell. Wrapping them in cmd.exe would mangle arguments wisp
|
|
4
|
+
// passes (JSON, newlines in the system prompt), so npm shims are unwrapped into
|
|
5
|
+
// `node <script>` and only unknown batch files go through cmd.exe, with careful escaping.
|
|
6
|
+
|
|
7
|
+
import { spawn as nodeSpawn, execFile as nodeExecFile, spawnSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Find `bin` on PATH the way cmd.exe would (PATHEXT). Returns an absolute path or null.
|
|
12
|
+
// A copied env object loses Windows' case-insensitive keys, so PATH may be spelled "Path".
|
|
13
|
+
export function whichWindows(bin, env = process.env) {
|
|
14
|
+
const p = path.win32;
|
|
15
|
+
const exts = (env.PATHEXT || env.Pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase());
|
|
16
|
+
// .ps1 isn't in PATHEXT, but some installers (Cursor) only drop a PowerShell script.
|
|
17
|
+
const names = p.extname(bin) ? [bin] : [...exts.map((e) => bin + e), bin + '.ps1'];
|
|
18
|
+
const dirs = /[\\/]/.test(bin) ? [''] : (env.PATH ?? env.Path ?? '').split(';').filter(Boolean);
|
|
19
|
+
for (const dir of dirs) {
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
const full = dir ? p.join(dir, name) : name;
|
|
22
|
+
try {
|
|
23
|
+
if (fs.statSync(full).isFile()) return full;
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// npm/pnpm/yarn `.cmd` shims end by running node on a script, or by running the target directly:
|
|
31
|
+
// ... & "%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*
|
|
32
|
+
// "%~dp0\node.exe" "%~dp0\..\opencode-ai\bin\opencode" %*
|
|
33
|
+
// "%dp0%\node_modules\some-cli\bin\tool.exe" %*
|
|
34
|
+
// Returns { script } or { exe }, relative to the shim's folder, or null if it isn't recognisable.
|
|
35
|
+
export function parseCmdShim(text) {
|
|
36
|
+
const line = String(text).split(/\r?\n/).map((l) => l.trim()).findLast((l) => /%\*$/.test(l));
|
|
37
|
+
if (!line) return null;
|
|
38
|
+
const quoted = [...line.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
39
|
+
const fromDp0 = (s) => s?.match(/^%~?dp0%?\\?(.+)$/i)?.[1] ?? null;
|
|
40
|
+
const target = fromDp0(quoted.at(-1));
|
|
41
|
+
if (!target) return null;
|
|
42
|
+
const prog = quoted.length > 1 ? quoted.at(-2) : '';
|
|
43
|
+
if (/^%_prog%$|(^|\\)node(\.exe)?$/i.test(prog) || /\.[cm]?js$/i.test(target)) return { script: target };
|
|
44
|
+
if (/\.(exe|com)$/i.test(target)) return { exe: target };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// cmd.exe escaping, as in cross-spawn: quote for CommandLineToArgvW, then caret-escape cmd's
|
|
49
|
+
// metacharacters (twice when the target is itself a batch file, which re-parses its line).
|
|
50
|
+
const META = /([()\][%!^"`<>&|;, *?])/g;
|
|
51
|
+
export const escapeCmdCommand = (s) => String(s).replace(META, '^$1');
|
|
52
|
+
export function escapeCmdArgument(arg, doubleEscape = false) {
|
|
53
|
+
let s = String(arg);
|
|
54
|
+
s = s.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
|
|
55
|
+
s = s.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
|
56
|
+
s = `"${s}"`.replace(META, '^$1');
|
|
57
|
+
return doubleEscape ? s.replace(META, '^$1') : s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve `bin args` into something spawn() can run directly on this platform.
|
|
61
|
+
// Returns { command, args, verbatim }; verbatim => pass windowsVerbatimArguments.
|
|
62
|
+
export function resolveCommand(bin, args = [], { platform = process.platform, env = process.env } = {}) {
|
|
63
|
+
if (platform !== 'win32') return { command: bin, args, verbatim: false };
|
|
64
|
+
const file = whichWindows(bin, env);
|
|
65
|
+
if (!file) return { command: bin, args, verbatim: false }; // let spawn report ENOENT
|
|
66
|
+
const ext = path.win32.extname(file).toLowerCase();
|
|
67
|
+
if (ext === '.exe' || ext === '.com') return { command: file, args, verbatim: false };
|
|
68
|
+
if (ext === '.ps1') {
|
|
69
|
+
return { command: 'powershell.exe', args: ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file, ...args], verbatim: false };
|
|
70
|
+
}
|
|
71
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
72
|
+
let shim = null;
|
|
73
|
+
try {
|
|
74
|
+
shim = parseCmdShim(fs.readFileSync(file, 'utf8'));
|
|
75
|
+
} catch {}
|
|
76
|
+
const dir = path.win32.dirname(file);
|
|
77
|
+
if (shim?.exe) {
|
|
78
|
+
const exe = path.win32.resolve(dir, shim.exe);
|
|
79
|
+
if (fs.existsSync(exe)) return { command: exe, args, verbatim: false };
|
|
80
|
+
}
|
|
81
|
+
if (shim?.script) {
|
|
82
|
+
const script = path.win32.resolve(dir, shim.script);
|
|
83
|
+
if (fs.existsSync(script)) {
|
|
84
|
+
const localNode = path.win32.join(dir, 'node.exe');
|
|
85
|
+
return { command: fs.existsSync(localNode) ? localNode : whichWindows('node', env) || 'node', args: [script, ...args], verbatim: false };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Unknown batch file: go through cmd.exe. Newlines can't survive cmd's parser.
|
|
89
|
+
const line = [escapeCmdCommand(file), ...args.map((a) => escapeCmdArgument(String(a).replace(/\r?\n/g, ' '), true))].join(' ');
|
|
90
|
+
return { command: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], verbatim: true };
|
|
91
|
+
}
|
|
92
|
+
return { command: file, args, verbatim: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function spawn(bin, args, opts = {}) {
|
|
96
|
+
const r = resolveCommand(bin, args, { env: opts.env || process.env });
|
|
97
|
+
return nodeSpawn(r.command, r.args, { ...opts, windowsHide: true, windowsVerbatimArguments: r.verbatim });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function execFile(bin, args, opts, cb) {
|
|
101
|
+
const r = resolveCommand(bin, args, { env: opts?.env || process.env });
|
|
102
|
+
return nodeExecFile(r.command, r.args, { ...opts, windowsHide: true, windowsVerbatimArguments: r.verbatim }, cb);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Stop a child and everything it started. On Windows, kill() only ends the direct child
|
|
106
|
+
// (e.g. node, leaving the agent's own subprocesses running), so use taskkill /T.
|
|
107
|
+
export function killTree(child) {
|
|
108
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
109
|
+
if (process.platform === 'win32' && child.pid) {
|
|
110
|
+
const r = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
111
|
+
if (r.status === 0) return;
|
|
112
|
+
}
|
|
113
|
+
child.kill('SIGTERM');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Is `bin` runnable from PATH?
|
|
117
|
+
export function onPath(bin, env = process.env) {
|
|
118
|
+
if (process.platform === 'win32') return !!whichWindows(bin, env);
|
|
119
|
+
return (env.PATH || '').split(path.delimiter).some((d) => {
|
|
120
|
+
try {
|
|
121
|
+
fs.accessSync(path.join(d, bin), fs.constants.X_OK);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
package/server/protocol.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Agent-agnostic — any harness that can take an extra system prompt (or a prompt
|
|
3
3
|
// prefix) and return a final text message can speak it.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const PROTOCOL = `
|
|
6
6
|
# You are running inside wisp
|
|
7
7
|
|
|
8
8
|
wisp launched you to do a task on the user's behalf. The user is a software engineer who will NOT
|
|
@@ -10,9 +10,7 @@ read your transcript, tool calls or intermediate messages. The only thing they w
|
|
|
10
10
|
presentation you put in your final message. Work autonomously:
|
|
11
11
|
|
|
12
12
|
- Make sensible assumptions for small decisions, note them, keep going.
|
|
13
|
-
|
|
14
|
-
two very different approaches), call the \`ask_user\` tool (mcp__wisp__ask_user). It pauses you until
|
|
15
|
-
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
|
|
13
|
+
{{ASK}}
|
|
16
14
|
- If the task is done but there is a choice for later, end with a "question" block instead.
|
|
17
15
|
- Verify your work where it is cheap (tests, type-check, lint, build) and report results honestly,
|
|
18
16
|
including failures. Never claim something passes that you did not run.
|
|
@@ -76,6 +74,17 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
|
|
|
76
74
|
\`\`\`
|
|
77
75
|
`.trim();
|
|
78
76
|
|
|
77
|
+
const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
78
|
+
two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
|
|
79
|
+
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.`;
|
|
80
|
+
const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
81
|
+
two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
|
|
82
|
+
The user's answer comes back as a follow-up in this same session.`;
|
|
83
|
+
|
|
84
|
+
// askTool: how the harness names wisp's injected ask_user tool, or null if it can't take one.
|
|
85
|
+
export const systemPrompt = ({ askTool = 'mcp__wisp__ask_user' } = {}) => PROTOCOL.replace('{{ASK}}', askTool ? ASK_TOOL(askTool) : ASK_BLOCK);
|
|
86
|
+
export const SYSTEM_PROMPT = systemPrompt();
|
|
87
|
+
|
|
79
88
|
// Only the opener is matched by regex. The closing fence can't be: the JSON itself often
|
|
80
89
|
// contains ``` (a markdown block with a code sample), and a lazy match would cut it there.
|
|
81
90
|
const OPEN = /```wisp[^\n]*\n/g;
|
package/server/runner.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// Session orchestration: launches turns on adapters, folds their events into the
|
|
2
|
-
// session record, and
|
|
2
|
+
// session record, and handles follow-ups that arrive while the agent is busy: by default
|
|
3
|
+
// they interrupt the run and resume the session with the new message; with the `queue`
|
|
4
|
+
// setting they wait until the run finishes.
|
|
3
5
|
|
|
4
6
|
import { EventEmitter } from 'node:events';
|
|
5
7
|
import { randomUUID } from 'node:crypto';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
import { adapters } from './adapters/index.js';
|
|
9
|
-
import {
|
|
11
|
+
import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
|
|
10
12
|
|
|
11
13
|
const MAX_ACTIVITY = 400;
|
|
14
|
+
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
12
15
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
13
16
|
|
|
14
17
|
export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
|
|
@@ -33,8 +36,10 @@ export function summarize(s, store) {
|
|
|
33
36
|
color: project?.color || 'slate',
|
|
34
37
|
mode: s.mode,
|
|
35
38
|
model: s.model,
|
|
39
|
+
effort: s.effort || '',
|
|
36
40
|
state: s.state,
|
|
37
41
|
unread: s.unread,
|
|
42
|
+
archived: !!s.archived,
|
|
38
43
|
createdAt: s.createdAt,
|
|
39
44
|
updatedAt: s.updatedAt,
|
|
40
45
|
turnCount: s.turns.length,
|
|
@@ -55,6 +60,7 @@ export class Runner extends EventEmitter {
|
|
|
55
60
|
this.bridge = bridge;
|
|
56
61
|
this.live = new Map(); // sessionId -> adapter handle
|
|
57
62
|
this.stopping = new Set();
|
|
63
|
+
this.interrupting = new Set();
|
|
58
64
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
59
65
|
|
|
60
66
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
@@ -69,7 +75,7 @@ export class Runner extends EventEmitter {
|
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', attachments = [] }) {
|
|
78
|
+
create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [] }) {
|
|
73
79
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
74
80
|
const now = Date.now();
|
|
75
81
|
const session = {
|
|
@@ -80,6 +86,7 @@ export class Runner extends EventEmitter {
|
|
|
80
86
|
cwd,
|
|
81
87
|
mode,
|
|
82
88
|
model,
|
|
89
|
+
effort,
|
|
83
90
|
state: 'running',
|
|
84
91
|
unread: false,
|
|
85
92
|
createdAt: now,
|
|
@@ -94,20 +101,46 @@ export class Runner extends EventEmitter {
|
|
|
94
101
|
return session;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
reply(session, text, attachments = []) {
|
|
104
|
+
reply(session, text, attachments = [], { queue = false } = {}) {
|
|
98
105
|
if (session.ask) {
|
|
99
106
|
this.answer(session, text);
|
|
100
107
|
return { answered: true };
|
|
101
108
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
const handle = this.live.get(session.id);
|
|
110
|
+
if (handle) {
|
|
111
|
+
const turn = session.turns.at(-1);
|
|
112
|
+
// The run already produced its result and the CLI is only lingering on its way out:
|
|
113
|
+
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
114
|
+
if (turn?.status !== 'running') {
|
|
115
|
+
session.queue.push({ id: newId(), text, attachments });
|
|
116
|
+
handle.release();
|
|
117
|
+
return { queued: session.queue.length > 0 };
|
|
118
|
+
}
|
|
119
|
+
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
120
|
+
if (queue || !session.agentSessionId) {
|
|
121
|
+
session.queue.push({ id: newId(), text, attachments });
|
|
122
|
+
this.#touch(session);
|
|
123
|
+
return { queued: true, ahead: session.queue.length - 1 };
|
|
124
|
+
}
|
|
125
|
+
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
126
|
+
const at = session.queue.findIndex((q) => !q.now);
|
|
127
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
|
|
128
|
+
this.#interrupt(session);
|
|
129
|
+
return { interrupted: true };
|
|
106
130
|
}
|
|
107
131
|
this.#startTurn(session, text, attachments);
|
|
108
132
|
return { queued: false };
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
// Drop a follow-up that hasn't gone out yet.
|
|
136
|
+
unqueue(session, itemId) {
|
|
137
|
+
const at = session.queue.findIndex((q) => q.id === itemId);
|
|
138
|
+
if (at < 0) return false;
|
|
139
|
+
session.queue.splice(at, 1);
|
|
140
|
+
this.#touch(session);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
111
144
|
// ask_user: the agent is blocked inside a tool call until answer() is called.
|
|
112
145
|
ask(session, { question, context, options }) {
|
|
113
146
|
const turn = session.turns.at(-1);
|
|
@@ -120,6 +153,7 @@ export class Runner extends EventEmitter {
|
|
|
120
153
|
};
|
|
121
154
|
session.state = 'waiting';
|
|
122
155
|
session.unread = true;
|
|
156
|
+
delete session.archived; // a question needs the user, so it comes back to the feed
|
|
123
157
|
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
|
|
124
158
|
this.#touch(session, { turn: turn?.id });
|
|
125
159
|
this.emit('finished', { session: summarize(session, this.store) });
|
|
@@ -157,10 +191,21 @@ export class Runner extends EventEmitter {
|
|
|
157
191
|
this.live.get(session.id).stop();
|
|
158
192
|
}
|
|
159
193
|
|
|
194
|
+
// Stop the current run but keep the queue: done.finally() starts the next follow-up.
|
|
195
|
+
#interrupt(session) {
|
|
196
|
+
if (this.interrupting.has(session.id)) return; // already on its way down
|
|
197
|
+
this.interrupting.add(session.id);
|
|
198
|
+
this.stopping.add(session.id);
|
|
199
|
+
this.live.get(session.id).stop();
|
|
200
|
+
}
|
|
201
|
+
|
|
160
202
|
#startTurn(session, prompt, attachments = []) {
|
|
161
203
|
const turn = {
|
|
162
204
|
id: newId(),
|
|
163
205
|
prompt,
|
|
206
|
+
agent: session.agent,
|
|
207
|
+
model: session.model,
|
|
208
|
+
effort: session.effort || '',
|
|
164
209
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
165
210
|
startedAt: Date.now(),
|
|
166
211
|
endedAt: null,
|
|
@@ -173,8 +218,11 @@ export class Runner extends EventEmitter {
|
|
|
173
218
|
session.turns.push(turn);
|
|
174
219
|
session.state = 'running';
|
|
175
220
|
session.unread = false;
|
|
221
|
+
delete session.archived;
|
|
176
222
|
this.#touch(session, { turn: turn.id });
|
|
177
223
|
|
|
224
|
+
let released = false;
|
|
225
|
+
let linger;
|
|
178
226
|
const adapter = adapters[session.agent];
|
|
179
227
|
const handle = adapter.run({
|
|
180
228
|
prompt,
|
|
@@ -183,8 +231,9 @@ export class Runner extends EventEmitter {
|
|
|
183
231
|
resumeId: session.agentSessionId,
|
|
184
232
|
mode: session.mode,
|
|
185
233
|
model: session.model,
|
|
186
|
-
|
|
187
|
-
|
|
234
|
+
effort: session.effort || '',
|
|
235
|
+
systemPrompt: systemPrompt({ askTool: adapter.askTool }),
|
|
236
|
+
mcpServers: this.bridge && adapter.askTool !== null
|
|
188
237
|
? {
|
|
189
238
|
wisp: {
|
|
190
239
|
command: process.execPath,
|
|
@@ -195,17 +244,32 @@ export class Runner extends EventEmitter {
|
|
|
195
244
|
},
|
|
196
245
|
}
|
|
197
246
|
: {},
|
|
198
|
-
onEvent: (ev) =>
|
|
247
|
+
onEvent: (ev) => {
|
|
248
|
+
if (released) return; // a lingering process we already let go of
|
|
249
|
+
this.#onEvent(session, turn, ev);
|
|
250
|
+
// A CLI can outlive its result (flushing, or a background process it spawned holding
|
|
251
|
+
// stdout open, so 'close' never fires). Don't hold the session hostage to that.
|
|
252
|
+
if (ev.type === 'result') linger = setTimeout(release, LINGER_MS);
|
|
253
|
+
},
|
|
199
254
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.live.delete(session.id);
|
|
255
|
+
// Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
|
|
256
|
+
const release = () => {
|
|
257
|
+
if (released) return;
|
|
204
258
|
if (turn.status === 'running') this.#finishTurn(session, turn, { isError: true, text: 'The agent exited without a result.' });
|
|
259
|
+
released = true;
|
|
260
|
+
clearTimeout(linger);
|
|
261
|
+
handle.stop();
|
|
262
|
+
if (this.live.get(session.id) !== handle) return;
|
|
263
|
+
this.live.delete(session.id);
|
|
264
|
+
this.interrupting.delete(session.id);
|
|
265
|
+
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
205
266
|
const next = session.queue.shift();
|
|
206
267
|
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
207
268
|
else this.#touch(session);
|
|
208
|
-
}
|
|
269
|
+
};
|
|
270
|
+
handle.release = release;
|
|
271
|
+
this.live.set(session.id, handle);
|
|
272
|
+
handle.done.finally(release);
|
|
209
273
|
}
|
|
210
274
|
|
|
211
275
|
#onEvent(session, turn, ev) {
|
|
@@ -245,11 +309,14 @@ export class Runner extends EventEmitter {
|
|
|
245
309
|
turn.stats = ev.stats || null;
|
|
246
310
|
delete session.ask;
|
|
247
311
|
if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
|
|
312
|
+
const interrupted = ev.stopped && this.interrupting.has(session.id);
|
|
248
313
|
if (ev.stopped) {
|
|
249
314
|
turn.status = 'stopped';
|
|
250
315
|
turn.presentation = {
|
|
251
316
|
blocks: [
|
|
252
|
-
|
|
317
|
+
interrupted
|
|
318
|
+
? { type: 'hero', title: 'Interrupted', summary: 'You sent a follow-up while this was running, so it picked that up instead.', status: 'partial' }
|
|
319
|
+
: { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
|
|
253
320
|
...(turn.changes.length ? [{ type: 'files', items: turn.changes.map((c) => ({ path: c.file, change: c.op === 'create' ? 'added' : 'modified' })) }] : []),
|
|
254
321
|
],
|
|
255
322
|
};
|
|
@@ -266,14 +333,16 @@ export class Runner extends EventEmitter {
|
|
|
266
333
|
turn.status = 'done';
|
|
267
334
|
turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
|
|
268
335
|
turn.presentation = parsePresentation(ev.text);
|
|
336
|
+
this.store.keepImages(session, turn);
|
|
269
337
|
const hero = heroOf(turn.presentation);
|
|
270
338
|
if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
|
|
271
339
|
}
|
|
272
340
|
session.state = 'review';
|
|
273
|
-
session.unread =
|
|
341
|
+
session.unread = !interrupted;
|
|
274
342
|
this.store.save(session, { now: true });
|
|
275
343
|
this.#touch(session, { turn: turn.id });
|
|
276
|
-
|
|
344
|
+
// The follow-up starts right away; don't notify for the run it replaced.
|
|
345
|
+
if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
|
|
277
346
|
}
|
|
278
347
|
|
|
279
348
|
#touch(session, { turn, quiet } = {}) {
|
package/server/store.js
CHANGED
|
@@ -3,19 +3,24 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import { parsePresentation } from './protocol.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_CONFIG = {
|
|
9
10
|
projects: [], // [{ id, name, path, color, addedAt }]
|
|
10
|
-
defaults: { agent: 'claude', mode: 'edits', model: '' },
|
|
11
|
+
defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
15
|
+
const MAX_KEPT_IMAGE = 25 * 1024 * 1024;
|
|
16
|
+
|
|
13
17
|
export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
|
|
14
18
|
|
|
15
19
|
export class Store {
|
|
16
20
|
constructor(home = process.env.WISP_HOME || path.join(os.homedir(), '.wisp')) {
|
|
17
21
|
this.home = home;
|
|
18
22
|
this.dir = path.join(home, 'sessions');
|
|
23
|
+
this.shots = path.join(home, 'shots');
|
|
19
24
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
20
25
|
this.sessions = new Map();
|
|
21
26
|
this.timers = new Map();
|
|
@@ -27,6 +32,10 @@ export class Store {
|
|
|
27
32
|
const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
|
|
28
33
|
reparse(s);
|
|
29
34
|
this.sessions.set(s.id, s);
|
|
35
|
+
// Backfill: follow-ups queued before items had ids can't be cancelled without one.
|
|
36
|
+
const ids = (s.queue || []).filter((q) => !q.id).map((q) => (q.id = randomUUID().replace(/-/g, '').slice(0, 12)));
|
|
37
|
+
// Backfill: keep screenshots from older turns while their files still exist.
|
|
38
|
+
if ((s.turns || []).map((t) => this.keepImages(s, t)).some(Boolean) || ids.length) this.save(s, { now: true });
|
|
30
39
|
} catch (err) {
|
|
31
40
|
console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
|
|
32
41
|
}
|
|
@@ -111,6 +120,34 @@ export class Store {
|
|
|
111
120
|
clearTimeout(this.timers.get(id));
|
|
112
121
|
this.sessions.delete(id);
|
|
113
122
|
fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
|
|
123
|
+
fs.rmSync(path.join(this.shots, id), { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Agents save screenshots to /tmp, which macOS clears. Copy every local image a turn shows
|
|
127
|
+
// into ~/.wisp/shots/<session>/ and point the block at the copy; `source` keeps the original.
|
|
128
|
+
keepImages(session, turn) {
|
|
129
|
+
let changed = false;
|
|
130
|
+
(turn.presentation?.blocks || []).forEach((b, i) => {
|
|
131
|
+
const rel = b?.type === 'image' && (b.path || b.src);
|
|
132
|
+
if (typeof rel !== 'string' || !rel || /^https?:/.test(rel)) return;
|
|
133
|
+
const src = path.resolve(session.cwd || this.home, rel);
|
|
134
|
+
const ext = path.extname(src).toLowerCase();
|
|
135
|
+
if (!IMAGE_EXT.has(ext) || src.startsWith(this.shots + path.sep)) return;
|
|
136
|
+
try {
|
|
137
|
+
const st = fs.statSync(src);
|
|
138
|
+
if (!st.isFile() || st.size > MAX_KEPT_IMAGE) return;
|
|
139
|
+
const dir = path.join(this.shots, session.id);
|
|
140
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
141
|
+
const dest = path.join(dir, `${turn.id}-${i}${ext}`);
|
|
142
|
+
fs.copyFileSync(src, dest);
|
|
143
|
+
Object.assign(b, { path: dest, source: rel });
|
|
144
|
+
delete b.src;
|
|
145
|
+
changed = true;
|
|
146
|
+
} catch {
|
|
147
|
+
// gone or unreadable: leave the block as it was
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return changed;
|
|
114
151
|
}
|
|
115
152
|
|
|
116
153
|
flush() {
|