wisp-agents 0.2.1 → 0.3.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 +44 -1
- package/README.md +41 -13
- package/bin/wisp.js +8 -3
- package/package.json +3 -1
- package/server/adapters/claude.js +9 -29
- package/server/adapters/codex.js +188 -0
- package/server/adapters/common.js +175 -0
- package/server/adapters/cursor.js +156 -0
- package/server/adapters/index.js +11 -6
- package/server/adapters/opencode.js +180 -0
- package/server/index.js +25 -10
- package/server/proc.js +127 -0
- package/server/protocol.js +13 -4
- package/server/runner.js +86 -19
- package/server/store.js +37 -0
- package/server/terminal.js +95 -41
- package/web/css/app.css +164 -17
- package/web/index.html +6 -1
- package/web/js/app.js +619 -98
- 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);
|
|
@@ -35,6 +38,7 @@ export function summarize(s, store) {
|
|
|
35
38
|
model: s.model,
|
|
36
39
|
state: s.state,
|
|
37
40
|
unread: s.unread,
|
|
41
|
+
archived: !!s.archived,
|
|
38
42
|
createdAt: s.createdAt,
|
|
39
43
|
updatedAt: s.updatedAt,
|
|
40
44
|
turnCount: s.turns.length,
|
|
@@ -55,6 +59,7 @@ export class Runner extends EventEmitter {
|
|
|
55
59
|
this.bridge = bridge;
|
|
56
60
|
this.live = new Map(); // sessionId -> adapter handle
|
|
57
61
|
this.stopping = new Set();
|
|
62
|
+
this.interrupting = new Set();
|
|
58
63
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
59
64
|
|
|
60
65
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
@@ -94,20 +99,46 @@ export class Runner extends EventEmitter {
|
|
|
94
99
|
return session;
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
reply(session, text, attachments = []) {
|
|
102
|
+
reply(session, text, attachments = [], { queue = false } = {}) {
|
|
98
103
|
if (session.ask) {
|
|
99
104
|
this.answer(session, text);
|
|
100
105
|
return { answered: true };
|
|
101
106
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
const handle = this.live.get(session.id);
|
|
108
|
+
if (handle) {
|
|
109
|
+
const turn = session.turns.at(-1);
|
|
110
|
+
// The run already produced its result and the CLI is only lingering on its way out:
|
|
111
|
+
// there's nothing to wait for, so let it go and start the follow-up now.
|
|
112
|
+
if (turn?.status !== 'running') {
|
|
113
|
+
session.queue.push({ id: newId(), text, attachments });
|
|
114
|
+
handle.release();
|
|
115
|
+
return { queued: session.queue.length > 0 };
|
|
116
|
+
}
|
|
117
|
+
// Interrupting needs an agent session to resume; before the agent reports one, queue.
|
|
118
|
+
if (queue || !session.agentSessionId) {
|
|
119
|
+
session.queue.push({ id: newId(), text, attachments });
|
|
120
|
+
this.#touch(session);
|
|
121
|
+
return { queued: true, ahead: session.queue.length - 1 };
|
|
122
|
+
}
|
|
123
|
+
// Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
|
|
124
|
+
const at = session.queue.findIndex((q) => !q.now);
|
|
125
|
+
session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
|
|
126
|
+
this.#interrupt(session);
|
|
127
|
+
return { interrupted: true };
|
|
106
128
|
}
|
|
107
129
|
this.#startTurn(session, text, attachments);
|
|
108
130
|
return { queued: false };
|
|
109
131
|
}
|
|
110
132
|
|
|
133
|
+
// Drop a follow-up that hasn't gone out yet.
|
|
134
|
+
unqueue(session, itemId) {
|
|
135
|
+
const at = session.queue.findIndex((q) => q.id === itemId);
|
|
136
|
+
if (at < 0) return false;
|
|
137
|
+
session.queue.splice(at, 1);
|
|
138
|
+
this.#touch(session);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
// ask_user: the agent is blocked inside a tool call until answer() is called.
|
|
112
143
|
ask(session, { question, context, options }) {
|
|
113
144
|
const turn = session.turns.at(-1);
|
|
@@ -120,6 +151,7 @@ export class Runner extends EventEmitter {
|
|
|
120
151
|
};
|
|
121
152
|
session.state = 'waiting';
|
|
122
153
|
session.unread = true;
|
|
154
|
+
delete session.archived; // a question needs the user, so it comes back to the feed
|
|
123
155
|
if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
|
|
124
156
|
this.#touch(session, { turn: turn?.id });
|
|
125
157
|
this.emit('finished', { session: summarize(session, this.store) });
|
|
@@ -157,10 +189,20 @@ export class Runner extends EventEmitter {
|
|
|
157
189
|
this.live.get(session.id).stop();
|
|
158
190
|
}
|
|
159
191
|
|
|
192
|
+
// Stop the current run but keep the queue: done.finally() starts the next follow-up.
|
|
193
|
+
#interrupt(session) {
|
|
194
|
+
if (this.interrupting.has(session.id)) return; // already on its way down
|
|
195
|
+
this.interrupting.add(session.id);
|
|
196
|
+
this.stopping.add(session.id);
|
|
197
|
+
this.live.get(session.id).stop();
|
|
198
|
+
}
|
|
199
|
+
|
|
160
200
|
#startTurn(session, prompt, attachments = []) {
|
|
161
201
|
const turn = {
|
|
162
202
|
id: newId(),
|
|
163
203
|
prompt,
|
|
204
|
+
agent: session.agent,
|
|
205
|
+
model: session.model,
|
|
164
206
|
attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
|
|
165
207
|
startedAt: Date.now(),
|
|
166
208
|
endedAt: null,
|
|
@@ -173,8 +215,11 @@ export class Runner extends EventEmitter {
|
|
|
173
215
|
session.turns.push(turn);
|
|
174
216
|
session.state = 'running';
|
|
175
217
|
session.unread = false;
|
|
218
|
+
delete session.archived;
|
|
176
219
|
this.#touch(session, { turn: turn.id });
|
|
177
220
|
|
|
221
|
+
let released = false;
|
|
222
|
+
let linger;
|
|
178
223
|
const adapter = adapters[session.agent];
|
|
179
224
|
const handle = adapter.run({
|
|
180
225
|
prompt,
|
|
@@ -183,27 +228,44 @@ export class Runner extends EventEmitter {
|
|
|
183
228
|
resumeId: session.agentSessionId,
|
|
184
229
|
mode: session.mode,
|
|
185
230
|
model: session.model,
|
|
186
|
-
systemPrompt:
|
|
187
|
-
mcpServers: this.bridge
|
|
231
|
+
systemPrompt: systemPrompt({ askTool: adapter.askTool }),
|
|
232
|
+
mcpServers: this.bridge && adapter.askTool !== null
|
|
188
233
|
? {
|
|
189
234
|
wisp: {
|
|
190
235
|
command: process.execPath,
|
|
191
236
|
args: [ASK_SERVER],
|
|
192
|
-
|
|
237
|
+
// Inside the desktop app execPath is the Electron binary; without
|
|
238
|
+
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
239
|
+
env: { ELECTRON_RUN_AS_NODE: '1', WISP_URL: this.bridge.url, WISP_TOKEN: this.bridge.token, WISP_SESSION: session.id },
|
|
193
240
|
},
|
|
194
241
|
}
|
|
195
242
|
: {},
|
|
196
|
-
onEvent: (ev) =>
|
|
243
|
+
onEvent: (ev) => {
|
|
244
|
+
if (released) return; // a lingering process we already let go of
|
|
245
|
+
this.#onEvent(session, turn, ev);
|
|
246
|
+
// A CLI can outlive its result (flushing, or a background process it spawned holding
|
|
247
|
+
// stdout open, so 'close' never fires). Don't hold the session hostage to that.
|
|
248
|
+
if (ev.type === 'result') linger = setTimeout(release, LINGER_MS);
|
|
249
|
+
},
|
|
197
250
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
this.live.delete(session.id);
|
|
251
|
+
// Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
|
|
252
|
+
const release = () => {
|
|
253
|
+
if (released) return;
|
|
202
254
|
if (turn.status === 'running') this.#finishTurn(session, turn, { isError: true, text: 'The agent exited without a result.' });
|
|
255
|
+
released = true;
|
|
256
|
+
clearTimeout(linger);
|
|
257
|
+
handle.stop();
|
|
258
|
+
if (this.live.get(session.id) !== handle) return;
|
|
259
|
+
this.live.delete(session.id);
|
|
260
|
+
this.interrupting.delete(session.id);
|
|
261
|
+
this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
|
|
203
262
|
const next = session.queue.shift();
|
|
204
263
|
if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
|
|
205
264
|
else this.#touch(session);
|
|
206
|
-
}
|
|
265
|
+
};
|
|
266
|
+
handle.release = release;
|
|
267
|
+
this.live.set(session.id, handle);
|
|
268
|
+
handle.done.finally(release);
|
|
207
269
|
}
|
|
208
270
|
|
|
209
271
|
#onEvent(session, turn, ev) {
|
|
@@ -243,11 +305,14 @@ export class Runner extends EventEmitter {
|
|
|
243
305
|
turn.stats = ev.stats || null;
|
|
244
306
|
delete session.ask;
|
|
245
307
|
if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
|
|
308
|
+
const interrupted = ev.stopped && this.interrupting.has(session.id);
|
|
246
309
|
if (ev.stopped) {
|
|
247
310
|
turn.status = 'stopped';
|
|
248
311
|
turn.presentation = {
|
|
249
312
|
blocks: [
|
|
250
|
-
|
|
313
|
+
interrupted
|
|
314
|
+
? { type: 'hero', title: 'Interrupted', summary: 'You sent a follow-up while this was running, so it picked that up instead.', status: 'partial' }
|
|
315
|
+
: { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
|
|
251
316
|
...(turn.changes.length ? [{ type: 'files', items: turn.changes.map((c) => ({ path: c.file, change: c.op === 'create' ? 'added' : 'modified' })) }] : []),
|
|
252
317
|
],
|
|
253
318
|
};
|
|
@@ -264,14 +329,16 @@ export class Runner extends EventEmitter {
|
|
|
264
329
|
turn.status = 'done';
|
|
265
330
|
turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
|
|
266
331
|
turn.presentation = parsePresentation(ev.text);
|
|
332
|
+
this.store.keepImages(session, turn);
|
|
267
333
|
const hero = heroOf(turn.presentation);
|
|
268
334
|
if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
|
|
269
335
|
}
|
|
270
336
|
session.state = 'review';
|
|
271
|
-
session.unread =
|
|
337
|
+
session.unread = !interrupted;
|
|
272
338
|
this.store.save(session, { now: true });
|
|
273
339
|
this.#touch(session, { turn: turn.id });
|
|
274
|
-
|
|
340
|
+
// The follow-up starts right away; don't notify for the run it replaced.
|
|
341
|
+
if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
|
|
275
342
|
}
|
|
276
343
|
|
|
277
344
|
#touch(session, { turn, quiet } = {}) {
|
package/server/store.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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 = {
|
|
@@ -10,12 +11,16 @@ const DEFAULT_CONFIG = {
|
|
|
10
11
|
defaults: { agent: 'claude', mode: 'edits', model: '' },
|
|
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() {
|
package/server/terminal.js
CHANGED
|
@@ -1,73 +1,127 @@
|
|
|
1
1
|
// Open a command in the user's terminal so they can take over an agent session interactively.
|
|
2
|
+
//
|
|
3
|
+
// terminalPlan() is pure (platform and PATH lookups are injected) so every platform's command
|
|
4
|
+
// line can be tested anywhere; openInTerminal() runs the plan.
|
|
2
5
|
|
|
3
|
-
import { spawn
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
4
7
|
import fs from 'node:fs';
|
|
8
|
+
import { escapeCmdArgument, escapeCmdCommand, onPath } from './proc.js';
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
// os: which platforms list the entry (all if omitted).
|
|
11
|
+
const ALL_TERMINALS = [
|
|
7
12
|
{ id: 'auto', label: 'Auto-detect' },
|
|
8
|
-
{ id: 'terminal', label: 'Terminal.app',
|
|
9
|
-
{ id: 'iterm', label: 'iTerm',
|
|
10
|
-
{ id: '
|
|
11
|
-
{ id: '
|
|
12
|
-
{ id: '
|
|
13
|
-
{ id: '
|
|
13
|
+
{ id: 'terminal', label: 'Terminal.app', os: ['darwin'] },
|
|
14
|
+
{ id: 'iterm', label: 'iTerm', os: ['darwin'] },
|
|
15
|
+
{ id: 'warp', label: 'Warp', os: ['darwin'] },
|
|
16
|
+
{ id: 'wt', label: 'Windows Terminal', os: ['win32'] },
|
|
17
|
+
{ id: 'powershell', label: 'PowerShell', os: ['win32'] },
|
|
18
|
+
{ id: 'cmd', label: 'Command Prompt', os: ['win32'] },
|
|
19
|
+
{ id: 'ghostty', label: 'Ghostty', os: ['darwin', 'linux'] },
|
|
20
|
+
{ id: 'kitty', label: 'kitty', os: ['darwin', 'linux'] },
|
|
21
|
+
{ id: 'wezterm', label: 'WezTerm' },
|
|
22
|
+
{ id: 'gnome-terminal', label: 'GNOME Terminal', os: ['linux'] },
|
|
23
|
+
{ id: 'konsole', label: 'Konsole', os: ['linux'] },
|
|
24
|
+
{ id: 'xfce4-terminal', label: 'Xfce Terminal', os: ['linux'] },
|
|
25
|
+
{ id: 'alacritty', label: 'Alacritty', os: ['linux'] },
|
|
26
|
+
{ id: 'xterm', label: 'xterm', os: ['linux'] },
|
|
14
27
|
{ id: 'custom', label: 'Custom command' },
|
|
15
28
|
];
|
|
16
29
|
|
|
30
|
+
export const terminalsFor = (platform = process.platform) => ALL_TERMINALS.filter((t) => !t.os || t.os.includes(platform === 'darwin' || platform === 'win32' ? platform : 'linux')).map(({ id, label }) => ({ id, label }));
|
|
31
|
+
export const TERMINALS = terminalsFor();
|
|
32
|
+
|
|
17
33
|
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
34
|
+
const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
|
|
35
|
+
const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
|
|
18
36
|
|
|
19
|
-
const
|
|
20
|
-
try {
|
|
21
|
-
execFileSync('which', [bin], { stdio: 'ignore' });
|
|
22
|
-
return true;
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
};
|
|
37
|
+
const LINUX_ORDER = ['ghostty', 'kitty', 'wezterm', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'alacritty', 'x-terminal-emulator', 'xterm'];
|
|
27
38
|
|
|
28
|
-
function
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
39
|
+
export function detectTerminal({ platform = process.platform, env = process.env, has = (b) => onPath(b, env), exists = fs.existsSync } = {}) {
|
|
40
|
+
if (platform === 'darwin') {
|
|
41
|
+
if (env.TERM_PROGRAM === 'iTerm.app' && exists('/Applications/iTerm.app')) return 'iterm';
|
|
42
|
+
if (env.TERM_PROGRAM === 'ghostty' && exists('/Applications/Ghostty.app')) return 'ghostty';
|
|
32
43
|
return 'terminal';
|
|
33
44
|
}
|
|
34
|
-
|
|
35
|
-
return '
|
|
45
|
+
if (platform === 'win32') return env.WT_SESSION || has('wt') ? 'wt' : 'powershell';
|
|
46
|
+
return LINUX_ORDER.find((t) => has(t)) || 'xterm';
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
// Returns a list of steps: { command, args, options? } to spawn, { osa } to run as AppleScript,
|
|
50
|
+
// or { delay } in ms. argv is the agent's resume command, e.g. ['claude', '--resume', 'abc'].
|
|
51
|
+
export function terminalPlan({ cwd, argv, terminal = 'auto', custom = '', platform = process.platform, env = process.env, has, exists } = {}) {
|
|
52
|
+
const t = terminal === 'auto' ? detectTerminal({ platform, env, has, exists }) : terminal;
|
|
53
|
+
const mac = platform === 'darwin';
|
|
54
|
+
const win = platform === 'win32';
|
|
43
55
|
const cmd = argv.map(shq).join(' ');
|
|
44
56
|
const line = `cd ${shq(cwd)} && ${cmd}`;
|
|
45
|
-
|
|
46
|
-
const
|
|
57
|
+
// Linux terminals: run the command, then leave an interactive shell open in the project.
|
|
58
|
+
const keepOpen = ['sh', '-c', `${cmd}; exec "\${SHELL:-sh}"`];
|
|
59
|
+
// Windows: `& 'codex' 'resume' 'id'` works for .exe, .cmd and .ps1 alike.
|
|
60
|
+
const ps = `& ${argv.map(psq).join(' ')}`;
|
|
61
|
+
const cmdLine = [escapeCmdCommand(argv[0]), ...argv.slice(1).map((a) => escapeCmdArgument(a, true))].join(' ');
|
|
62
|
+
const detached = { cwd, detached: true, stdio: 'ignore' };
|
|
47
63
|
|
|
48
64
|
switch (t) {
|
|
49
65
|
case 'terminal':
|
|
50
|
-
return osa
|
|
66
|
+
return [{ osa: `tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell` }];
|
|
51
67
|
case 'iterm':
|
|
52
|
-
return osa
|
|
68
|
+
return [{ osa: `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${asStr(line)}\nend tell` }];
|
|
53
69
|
case 'warp':
|
|
54
70
|
// Warp has no scripting API; open a tab in the folder and type via System Events.
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
return [{ command: 'open', args: ['-a', 'Warp', cwd] }, { delay: 1200 }, { osa: `tell application "System Events" to keystroke ${asStr(cmd)} & return` }];
|
|
72
|
+
case 'wt':
|
|
73
|
+
// `;` separates wt subcommands, so it can't appear unescaped in the command line.
|
|
74
|
+
return [{ command: 'wt.exe', args: ['-d', cwd, 'powershell.exe', '-NoExit', '-Command', ps.replace(/;/g, '\\;')], options: detached }];
|
|
75
|
+
case 'powershell':
|
|
76
|
+
// detached gives the child its own console window on Windows.
|
|
77
|
+
return [{ command: 'powershell.exe', args: ['-NoExit', '-Command', ps], options: detached }];
|
|
78
|
+
case 'cmd':
|
|
79
|
+
return [{ command: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/k', `"${cmdLine}"`], options: { ...detached, windowsVerbatimArguments: true } }];
|
|
57
80
|
case 'ghostty':
|
|
58
|
-
if (mac) return
|
|
59
|
-
return
|
|
81
|
+
if (mac) return [{ command: 'open', args: ['-na', 'Ghostty', '--args', `--working-directory=${cwd}`, '-e', ...argv] }];
|
|
82
|
+
return [{ command: 'ghostty', args: [`--working-directory=${cwd}`, '-e', ...keepOpen] }];
|
|
60
83
|
case 'kitty':
|
|
61
|
-
if (mac) return
|
|
62
|
-
return
|
|
84
|
+
if (mac) return [{ command: 'open', args: ['-na', 'kitty', '--args', '--directory', cwd, ...argv] }];
|
|
85
|
+
return [{ command: 'kitty', args: ['--directory', cwd, ...keepOpen] }];
|
|
63
86
|
case 'wezterm':
|
|
64
|
-
return
|
|
87
|
+
return [{ command: mac ? '/Applications/WezTerm.app/Contents/MacOS/wezterm' : 'wezterm', args: ['start', '--cwd', cwd, '--', ...(win ? ['powershell.exe', '-NoExit', '-Command', ps] : mac ? argv : keepOpen)], ...(win ? { options: detached } : {}) }];
|
|
88
|
+
case 'gnome-terminal':
|
|
89
|
+
return [{ command: 'gnome-terminal', args: [`--working-directory=${cwd}`, '--', ...keepOpen] }];
|
|
90
|
+
case 'konsole':
|
|
91
|
+
return [{ command: 'konsole', args: ['--workdir', cwd, '-e', ...keepOpen] }];
|
|
92
|
+
case 'xfce4-terminal':
|
|
93
|
+
return [{ command: 'xfce4-terminal', args: [`--working-directory=${cwd}`, '-x', ...keepOpen] }];
|
|
94
|
+
case 'alacritty':
|
|
95
|
+
return [{ command: 'alacritty', args: ['--working-directory', cwd, '-e', ...keepOpen] }];
|
|
65
96
|
case 'custom': {
|
|
66
97
|
if (!custom) throw new Error('Set a custom terminal command in settings, e.g. alacritty --working-directory {cwd} -e {cmd}');
|
|
98
|
+
if (win) {
|
|
99
|
+
const full = custom.replaceAll('{cwd}', `"${cwd}"`).replaceAll('{cmd}', cmdLine).replaceAll('{line}', `cd /d "${cwd}" ^&^& ${cmdLine}`);
|
|
100
|
+
return [{ command: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c', `"${full}"`], options: { ...detached, windowsVerbatimArguments: true } }];
|
|
101
|
+
}
|
|
67
102
|
const full = custom.replaceAll('{cwd}', shq(cwd)).replaceAll('{cmd}', cmd).replaceAll('{line}', shq(line));
|
|
68
|
-
return
|
|
103
|
+
return [{ command: 'sh', args: ['-c', full], options: detached }];
|
|
104
|
+
}
|
|
105
|
+
default: // x-terminal-emulator (Debian/Ubuntu alternatives), xterm
|
|
106
|
+
return [{ command: t, args: ['-e', ...keepOpen], options: detached }];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function openInTerminal(opts) {
|
|
111
|
+
const steps = terminalPlan(opts);
|
|
112
|
+
let wait = 0;
|
|
113
|
+
for (const step of steps) {
|
|
114
|
+
if (step.delay) {
|
|
115
|
+
wait += step.delay;
|
|
116
|
+
continue;
|
|
69
117
|
}
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
const run = () => {
|
|
119
|
+
const [command, args] = step.osa ? ['osascript', ['-e', step.osa]] : [step.command, step.args];
|
|
120
|
+
const child = spawn(command, args, { cwd: opts.cwd, detached: true, stdio: 'ignore', ...step.options });
|
|
121
|
+
child.on('error', () => {}); // a missing terminal shouldn't crash the server
|
|
122
|
+
child.unref();
|
|
123
|
+
};
|
|
124
|
+
if (wait) setTimeout(run, wait);
|
|
125
|
+
else run();
|
|
72
126
|
}
|
|
73
127
|
}
|