wisp-agents 0.2.2 → 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 +37 -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 +83 -18
- 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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Helpers shared by the adapters: process plumbing, labels, and diff capture.
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn, execFile, killTree } from '../proc.js';
|
|
8
|
+
|
|
9
|
+
export const MAX_SNAPSHOT = 256 * 1024;
|
|
10
|
+
export const ASK_TIMEOUT_MS = 7 * 24 * 3600 * 1000; // ask_user may wait for the human a long time
|
|
11
|
+
|
|
12
|
+
export function detectVersion(bin, args = ['--version']) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
execFile(bin, args, { timeout: 10_000 }, (err, stdout) => {
|
|
15
|
+
resolve(err ? { available: false, error: err.message } : { available: true, version: stdout.trim().split('\n')[0] });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Run `bin args` and resolve with stdout, or '' on any failure. Used for model discovery.
|
|
21
|
+
export function quietOutput(bin, args, timeout = 15_000) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile(bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => resolve(err ? '' : stdout));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Spawn a CLI that prints one JSON object per line. onJson gets each parsed line;
|
|
28
|
+
// onClose({ code, signal, stderr }) must emit the final `result` if the stream didn't.
|
|
29
|
+
export function spawnJsonl({ bin, args, cwd, env, input, onJson, onClose, onEvent }) {
|
|
30
|
+
// Some CLIs (opencode) take their working directory from $PWD rather than the real cwd.
|
|
31
|
+
const child = spawn(bin, args, { cwd, env: { ...env, PWD: cwd }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
32
|
+
child.stdin.on('error', () => {}); // the CLI may exit before reading stdin
|
|
33
|
+
child.stdin.end(input ?? '');
|
|
34
|
+
|
|
35
|
+
let stderr = '';
|
|
36
|
+
child.stderr.on('data', (d) => {
|
|
37
|
+
stderr = (stderr + d).slice(-8000);
|
|
38
|
+
});
|
|
39
|
+
readline.createInterface({ input: child.stdout }).on('line', (line) => {
|
|
40
|
+
if (!line.trim()) return;
|
|
41
|
+
let msg;
|
|
42
|
+
try {
|
|
43
|
+
msg = JSON.parse(line);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
onJson(msg);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let failed = false;
|
|
51
|
+
let stopping = false; // Windows reports a taskkill'd child as an exit code, not a signal
|
|
52
|
+
const done = new Promise((resolve) => {
|
|
53
|
+
child.on('error', (err) => {
|
|
54
|
+
failed = true;
|
|
55
|
+
onEvent({ type: 'result', isError: true, text: `Could not start \`${bin}\`: ${err.message}` });
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
child.on('close', (code, signal) => {
|
|
59
|
+
if (!failed) onClose({ code, signal, stderr: stderr.trim(), stopped: stopping || signal === 'SIGTERM' || signal === 'SIGINT' });
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
done,
|
|
66
|
+
stop() {
|
|
67
|
+
stopping = true;
|
|
68
|
+
killTree(child);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function attachmentNote(files, how = 'Read images and PDFs with your file-reading tool') {
|
|
74
|
+
if (!files.length) return '';
|
|
75
|
+
const lines = files.map((f) => `- ${f.path} (${f.mime}, ${Math.max(1, Math.round(f.size / 1024))} KB)`);
|
|
76
|
+
return `\n\n<attachments>\nThe user attached these files. ${how}; for video/audio/other formats use the shell (e.g. ffmpeg, ffprobe) if needed.\n${lines.join('\n')}\n</attachments>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Harnesses without a system-prompt flag get the wisp protocol in front of the first prompt.
|
|
80
|
+
// Follow-ups resume the same session, so the protocol is still in context.
|
|
81
|
+
export function withProtocol(prompt, systemPrompt, resumeId) {
|
|
82
|
+
return resumeId ? prompt : `${systemPrompt.trim()}\n\n---\n\n# Task\n\n${prompt}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const realDirs = new Map();
|
|
86
|
+
function realDir(dir) {
|
|
87
|
+
if (!realDirs.has(dir)) {
|
|
88
|
+
try {
|
|
89
|
+
realDirs.set(dir, fs.realpathSync(dir));
|
|
90
|
+
} catch {
|
|
91
|
+
realDirs.set(dir, dir);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return realDirs.get(dir);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Path relative to cwd, or the path as given if it lies outside. Harnesses may report paths
|
|
98
|
+
// through the resolved cwd (/private/tmp vs /tmp on macOS), so both spellings count.
|
|
99
|
+
export function rel(file, cwd) {
|
|
100
|
+
if (!file) return '';
|
|
101
|
+
const abs = path.resolve(cwd, file);
|
|
102
|
+
for (const base of [cwd, realDir(cwd)]) {
|
|
103
|
+
const r = path.relative(base, abs);
|
|
104
|
+
if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r;
|
|
105
|
+
}
|
|
106
|
+
return file;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function clip(s, n = 80) {
|
|
110
|
+
s = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
111
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function flatten(content) {
|
|
115
|
+
if (typeof content === 'string') return content;
|
|
116
|
+
if (Array.isArray(content)) return content.map((c) => c.text || '').join('\n');
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readSnapshot(abs) {
|
|
121
|
+
try {
|
|
122
|
+
const st = fs.statSync(abs);
|
|
123
|
+
if (!st.isFile()) return null;
|
|
124
|
+
return st.size <= MAX_SNAPSHOT ? fs.readFileSync(abs, 'utf8') : '';
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// For harnesses that only report "file X changed" after the fact. The "before" side comes from,
|
|
131
|
+
// in order: the content after the previous change we saw, else a snapshot taken when the
|
|
132
|
+
// harness announced the edit (only if it predates the write — the harness doesn't wait for us),
|
|
133
|
+
// else the file as it was when the turn started — `git stash create` captures tracked
|
|
134
|
+
// working-tree changes without touching anything — else nothing (treated as a new file).
|
|
135
|
+
export function fileTracker(cwd) {
|
|
136
|
+
const known = new Map(); // rel path -> content after our last change() (null = missing)
|
|
137
|
+
const announced = new Map(); // rel path -> content when the harness announced an edit
|
|
138
|
+
let base;
|
|
139
|
+
const git = (args, opts = {}) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, ...opts });
|
|
140
|
+
try {
|
|
141
|
+
// Pinned before the agent starts writing.
|
|
142
|
+
base = git(['stash', 'create']).toString().trim() || git(['rev-parse', '--verify', 'HEAD']).toString().trim();
|
|
143
|
+
} catch {
|
|
144
|
+
base = null;
|
|
145
|
+
}
|
|
146
|
+
const fromGit = (r) => {
|
|
147
|
+
if (!base || path.isAbsolute(r)) return null;
|
|
148
|
+
try {
|
|
149
|
+
const out = git(['show', `${base}:./${r.split(path.sep).join('/')}`], { maxBuffer: 8 * MAX_SNAPSHOT }); // git wants / even on Windows
|
|
150
|
+
return out.length <= MAX_SNAPSHOT ? out.toString('utf8') : '';
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
remember(file) {
|
|
158
|
+
const r = rel(file, cwd);
|
|
159
|
+
if (!known.has(r) && !announced.has(r)) announced.set(r, readSnapshot(path.resolve(cwd, file)));
|
|
160
|
+
},
|
|
161
|
+
// Call after the harness wrote `file`. Returns a `change` event payload, or null if unchanged.
|
|
162
|
+
change(file) {
|
|
163
|
+
const r = rel(file, cwd);
|
|
164
|
+
const after = readSnapshot(path.resolve(cwd, file));
|
|
165
|
+
let before;
|
|
166
|
+
if (known.has(r)) before = known.get(r);
|
|
167
|
+
else if (announced.has(r) && announced.get(r) !== after) before = announced.get(r);
|
|
168
|
+
else before = fromGit(r);
|
|
169
|
+
known.set(r, after);
|
|
170
|
+
announced.delete(r);
|
|
171
|
+
if (before === after) return null;
|
|
172
|
+
return { file: r, op: before === null ? 'create' : 'write', edits: [{ before: before ?? '', after: after ?? '' }] };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Cursor CLI adapter — drives `cursor-agent -p --output-format stream-json`.
|
|
2
|
+
//
|
|
3
|
+
// cursor-agent has no flag to inject an MCP server (it only reads .cursor/mcp.json), so wisp's
|
|
4
|
+
// ask_user tool isn't available here; the protocol tells the agent to end with a question block.
|
|
5
|
+
|
|
6
|
+
import { attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
7
|
+
|
|
8
|
+
const BIN = process.env.WISP_CURSOR_BIN || 'cursor-agent';
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: 'cursor',
|
|
12
|
+
name: 'Cursor',
|
|
13
|
+
askTool: null,
|
|
14
|
+
models: [{ id: '', label: 'Default', hint: 'Whatever Cursor is set to' }],
|
|
15
|
+
|
|
16
|
+
resumeCommand({ resumeId, model }) {
|
|
17
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async detect() {
|
|
21
|
+
const found = await detectVersion(BIN);
|
|
22
|
+
if (!found.available) return found;
|
|
23
|
+
// `--list-models` prints "id - Label" lines, with "(current, default)" on the active one.
|
|
24
|
+
const models = [];
|
|
25
|
+
for (const line of (await quietOutput(BIN, ['--list-models'])).split('\n')) {
|
|
26
|
+
const m = line.trim().match(/^([\w.\-[\]=,]+) - (.+?)(?:\s+\((?:current|default)[^)]*\))?$/);
|
|
27
|
+
if (m && m[1] !== 'auto') models.push({ id: m[1], label: m[2], hint: m[1] });
|
|
28
|
+
}
|
|
29
|
+
return { ...found, models: [this.models[0], ...models] };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, onEvent }) {
|
|
33
|
+
const args = ['-p', '--output-format', 'stream-json', '--trust'];
|
|
34
|
+
if (mode === 'plan') args.push('--mode', 'plan');
|
|
35
|
+
if (mode === 'auto') args.push('--force');
|
|
36
|
+
if (model) args.push('--model', model);
|
|
37
|
+
if (resumeId) args.push('--resume', resumeId);
|
|
38
|
+
// No prompt argument: in --print mode cursor-agent reads it from stdin.
|
|
39
|
+
|
|
40
|
+
const input = withProtocol(prompt, systemPrompt, resumeId) + attachmentNote(attachments);
|
|
41
|
+
const files = fileTracker(cwd);
|
|
42
|
+
let sawResult = false;
|
|
43
|
+
let session = null;
|
|
44
|
+
|
|
45
|
+
return spawnJsonl({
|
|
46
|
+
bin: BIN,
|
|
47
|
+
args,
|
|
48
|
+
cwd,
|
|
49
|
+
env: process.env,
|
|
50
|
+
input,
|
|
51
|
+
onEvent,
|
|
52
|
+
onJson(msg) {
|
|
53
|
+
if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
|
|
54
|
+
switch (msg.type) {
|
|
55
|
+
case 'assistant':
|
|
56
|
+
for (const part of msg.message?.content || []) {
|
|
57
|
+
if (part.type === 'text' && part.text?.trim()) onEvent({ type: 'text', text: part.text });
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case 'thinking':
|
|
61
|
+
if (msg.subtype === 'completed') onEvent({ type: 'thinking' });
|
|
62
|
+
break;
|
|
63
|
+
case 'tool_call': {
|
|
64
|
+
const [name, body = {}] = toolOf(msg.tool_call);
|
|
65
|
+
if (!name) break;
|
|
66
|
+
const args = body.args || {};
|
|
67
|
+
if (msg.subtype === 'started') {
|
|
68
|
+
if ((name === 'editToolCall' || name === 'deleteToolCall') && args.path) files.remember(args.path);
|
|
69
|
+
onEvent({ type: 'tool', ...describeTool(name, args, cwd) });
|
|
70
|
+
} else if (msg.subtype === 'completed') {
|
|
71
|
+
const [outcome, value = {}] = resultOf(body.result);
|
|
72
|
+
if (outcome && outcome !== 'success') {
|
|
73
|
+
onEvent({ type: 'tool_error', name: name.replace(/ToolCall$/, ''), text: clip(value.error || value.message || value.reason || value.stderr || outcome, 400) });
|
|
74
|
+
} else if (name === 'editToolCall' || name === 'deleteToolCall') {
|
|
75
|
+
const change = editChange(args.path || value.path, value, cwd, files);
|
|
76
|
+
if (change) onEvent({ type: 'change', ...change });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'result':
|
|
82
|
+
sawResult = true;
|
|
83
|
+
onEvent({
|
|
84
|
+
type: 'result',
|
|
85
|
+
text: msg.result ?? '',
|
|
86
|
+
isError: !!msg.is_error || (msg.subtype && msg.subtype !== 'success'),
|
|
87
|
+
stats: { durationMs: msg.duration_ms, inputTokens: msg.usage?.inputTokens ?? msg.usage?.input_tokens, outputTokens: msg.usage?.outputTokens ?? msg.usage?.output_tokens },
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onClose({ code, signal, stderr, stopped }) {
|
|
93
|
+
if (sawResult) return;
|
|
94
|
+
onEvent({ type: 'result', isError: true, stopped, text: stderr || `cursor-agent exited with ${signal || `code ${code}`}` });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Tool calls are protobuf oneofs; depending on the serializer they arrive as
|
|
101
|
+
// { editToolCall: {...} } or { tool: { case: 'editToolCall', value: {...} } }.
|
|
102
|
+
function oneof(o) {
|
|
103
|
+
if (!o || typeof o !== 'object') return [];
|
|
104
|
+
if ('case' in o) return [o.case, o.value];
|
|
105
|
+
const k = Object.keys(o)[0];
|
|
106
|
+
return k ? [k, o[k]] : [];
|
|
107
|
+
}
|
|
108
|
+
const toolOf = (tc) => oneof(tc?.tool && typeof tc.tool === 'object' ? tc.tool : tc);
|
|
109
|
+
const resultOf = (r) => oneof(r?.result && typeof r.result === 'object' && 'case' in r.result ? r.result : r);
|
|
110
|
+
|
|
111
|
+
function editChange(file, value, cwd, files) {
|
|
112
|
+
if (!file) return null;
|
|
113
|
+
if (typeof value.afterFullFileContent === 'string') {
|
|
114
|
+
files.change(file); // keep the snapshot current
|
|
115
|
+
const before = value.beforeFullFileContent;
|
|
116
|
+
return { file: rel(file, cwd), op: before == null ? 'create' : 'write', edits: [{ before: before ?? '', after: value.afterFullFileContent }] };
|
|
117
|
+
}
|
|
118
|
+
return files.change(file);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describeTool(name, a, cwd) {
|
|
122
|
+
const f = rel(a.path, cwd);
|
|
123
|
+
switch (name) {
|
|
124
|
+
case 'readToolCall':
|
|
125
|
+
return { kind: 'read', label: `Reading ${f}`, file: f };
|
|
126
|
+
case 'editToolCall':
|
|
127
|
+
return { kind: 'edit', label: `Editing ${f}`, file: f };
|
|
128
|
+
case 'deleteToolCall':
|
|
129
|
+
return { kind: 'edit', label: `Deleting ${f}`, file: f };
|
|
130
|
+
case 'shellToolCall':
|
|
131
|
+
return { kind: 'run', label: a.description ? clip(a.description) : `Running ${clip(a.command, 60)}`, detail: a.command };
|
|
132
|
+
case 'grepToolCall':
|
|
133
|
+
return { kind: 'search', label: `Searching for “${clip(a.pattern, 40)}”` };
|
|
134
|
+
case 'globToolCall':
|
|
135
|
+
return { kind: 'search', label: `Finding ${clip(a.globPattern || a.pattern, 50)}` };
|
|
136
|
+
case 'lsToolCall':
|
|
137
|
+
return { kind: 'search', label: `Listing ${f || '.'}` };
|
|
138
|
+
case 'semSearchToolCall':
|
|
139
|
+
return { kind: 'search', label: `Searching the codebase: ${clip(a.query, 50)}` };
|
|
140
|
+
case 'webSearchToolCall':
|
|
141
|
+
return { kind: 'web', label: `Searching the web: ${clip(a.searchTerm || a.query, 50)}` };
|
|
142
|
+
case 'webFetchToolCall':
|
|
143
|
+
case 'fetchToolCall':
|
|
144
|
+
return { kind: 'web', label: `Reading ${clip(a.url, 60)}` };
|
|
145
|
+
case 'taskToolCall':
|
|
146
|
+
return { kind: 'agent', label: `Delegating: ${clip(a.description || a.prompt, 60)}` };
|
|
147
|
+
case 'updateTodosToolCall': {
|
|
148
|
+
const active = a.todos?.find((t) => /in_?progress/i.test(String(t.status)));
|
|
149
|
+
return { kind: 'plan', label: active ? clip(active.content) : 'Planning' };
|
|
150
|
+
}
|
|
151
|
+
case 'mcpToolCall':
|
|
152
|
+
return { kind: 'tool', label: `Using ${a.providerIdentifier || 'mcp'} · ${a.toolName || 'tool'}` };
|
|
153
|
+
default:
|
|
154
|
+
return { kind: 'tool', label: `Using ${name.replace(/ToolCall$/, '').replace(/([A-Z])/g, ' $1').toLowerCase()}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
package/server/adapters/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Agent registry. To add a harness
|
|
2
|
-
//
|
|
1
|
+
// Agent registry. To add a harness write an adapter with the same shape as ./claude.js
|
|
2
|
+
// (./common.js has the shared plumbing) and register it here:
|
|
3
3
|
//
|
|
4
4
|
// {
|
|
5
|
-
// id, name, models: [{id, label}],
|
|
6
|
-
//
|
|
5
|
+
// id, name, models: [{id, label, hint?}],
|
|
6
|
+
// askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
|
|
7
|
+
// null = can't inject MCP, ask via a question block
|
|
8
|
+
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
7
9
|
// resumeCommand({ resumeId, model }): string[] argv to continue the session interactively
|
|
8
10
|
// run({ prompt, cwd, resumeId, mode, model, systemPrompt, onEvent }): { stop(), done: Promise }
|
|
9
11
|
// }
|
|
@@ -18,11 +20,14 @@
|
|
|
18
20
|
// { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
|
|
19
21
|
//
|
|
20
22
|
// mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
|
|
21
|
-
// Agents without a system-prompt flag
|
|
23
|
+
// Agents without a system-prompt flag prepend `systemPrompt` to the first prompt (withProtocol).
|
|
22
24
|
|
|
23
25
|
import claude from './claude.js';
|
|
26
|
+
import codex from './codex.js';
|
|
27
|
+
import opencode from './opencode.js';
|
|
28
|
+
import cursor from './cursor.js';
|
|
24
29
|
|
|
25
|
-
export const adapters =
|
|
30
|
+
export const adapters = Object.fromEntries([claude, codex, opencode, cursor].map((a) => [a.id, a]));
|
|
26
31
|
|
|
27
32
|
const detected = new Map();
|
|
28
33
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// opencode adapter — drives `opencode run --format json` (one JSON event per line).
|
|
2
|
+
|
|
3
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
4
|
+
|
|
5
|
+
const BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
id: 'opencode',
|
|
9
|
+
name: 'opencode',
|
|
10
|
+
askTool: 'wisp_ask_user',
|
|
11
|
+
models: [{ id: '', label: 'Default', hint: 'Whatever opencode is set to' }],
|
|
12
|
+
|
|
13
|
+
resumeCommand({ resumeId, model }) {
|
|
14
|
+
return [BIN, ...(resumeId ? ['--session', resumeId] : []), ...(model ? ['--model', model] : [])];
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async detect() {
|
|
18
|
+
const found = await detectVersion(BIN);
|
|
19
|
+
if (!found.available) return found;
|
|
20
|
+
// `opencode models` prints provider/model ids, one per line, for every configured provider.
|
|
21
|
+
const ids = (await quietOutput(BIN, ['models'])).split('\n').map((l) => l.trim()).filter((l) => /^[\w.-]+\/\S+$/.test(l));
|
|
22
|
+
return { ...found, models: [this.models[0], ...ids.map((id) => ({ id, label: id.split('/').slice(1).join('/'), hint: id.split('/')[0] }))] };
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
|
|
26
|
+
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
27
|
+
const args = ['run', '--format', 'json'];
|
|
28
|
+
if (mode === 'plan') args.push('--agent', 'plan');
|
|
29
|
+
if (mode === 'auto') args.push('--auto');
|
|
30
|
+
if (model) args.push('--model', model);
|
|
31
|
+
if (resumeId) args.push('--session', resumeId);
|
|
32
|
+
for (const a of attachments) args.push('--file', a.path);
|
|
33
|
+
|
|
34
|
+
// Inject the ask_user server through inline config, merged over the user's own.
|
|
35
|
+
const mcp = {};
|
|
36
|
+
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
37
|
+
mcp[name] = { type: 'local', command: [srv.command, ...(srv.args || [])], environment: srv.env || {}, enabled: true, timeout: ASK_TIMEOUT_MS };
|
|
38
|
+
}
|
|
39
|
+
const env = { ...process.env };
|
|
40
|
+
if (Object.keys(mcp).length) {
|
|
41
|
+
const inline = env.OPENCODE_CONFIG_CONTENT ? safeJson(env.OPENCODE_CONFIG_CONTENT) : {};
|
|
42
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
43
|
+
...inline,
|
|
44
|
+
mcp: { ...inline.mcp, ...mcp },
|
|
45
|
+
experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const input = withProtocol(prompt, systemPrompt, resumeId) + attachmentNote(attachments, 'They are also attached to this message');
|
|
50
|
+
const files = fileTracker(cwd);
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
let session = null;
|
|
53
|
+
let lastMsg = null; // messageID of the latest assistant text
|
|
54
|
+
let text = [];
|
|
55
|
+
let error = null;
|
|
56
|
+
const tokens = { input: 0, output: 0 };
|
|
57
|
+
let cost = 0;
|
|
58
|
+
|
|
59
|
+
return spawnJsonl({
|
|
60
|
+
bin: BIN,
|
|
61
|
+
args,
|
|
62
|
+
cwd,
|
|
63
|
+
env,
|
|
64
|
+
input,
|
|
65
|
+
onEvent,
|
|
66
|
+
onJson(msg) {
|
|
67
|
+
if (msg.sessionID && msg.sessionID !== session) onEvent({ type: 'session', id: (session = msg.sessionID) });
|
|
68
|
+
const part = msg.part || {};
|
|
69
|
+
switch (msg.type) {
|
|
70
|
+
case 'text':
|
|
71
|
+
if (!part.text?.trim()) break;
|
|
72
|
+
// The final answer is the text of the last assistant message.
|
|
73
|
+
if (part.messageID !== lastMsg) ((lastMsg = part.messageID), (text = []));
|
|
74
|
+
text.push(part.text);
|
|
75
|
+
onEvent({ type: 'text', text: part.text });
|
|
76
|
+
break;
|
|
77
|
+
case 'reasoning':
|
|
78
|
+
onEvent({ type: 'thinking' });
|
|
79
|
+
break;
|
|
80
|
+
case 'tool_use': {
|
|
81
|
+
// Emitted once the tool has finished, with its input and output.
|
|
82
|
+
const state = part.state || {};
|
|
83
|
+
const input = state.input || {};
|
|
84
|
+
onEvent({ type: 'tool', ...describeTool(part.tool, input, cwd) });
|
|
85
|
+
if (state.status === 'error') onEvent({ type: 'tool_error', name: part.tool, text: clip(state.error || state.output, 400) });
|
|
86
|
+
else {
|
|
87
|
+
for (const change of captureChanges(part.tool, input, state.metadata || {}, cwd, files)) onEvent({ type: 'change', ...change });
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'step_finish':
|
|
92
|
+
tokens.input += part.tokens?.input || 0;
|
|
93
|
+
tokens.output += part.tokens?.output || 0;
|
|
94
|
+
cost += part.cost || 0;
|
|
95
|
+
break;
|
|
96
|
+
case 'error':
|
|
97
|
+
error = msg.error?.data?.message || msg.error?.message || msg.error?.name || 'opencode reported an error.';
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
onClose({ code, signal, stderr, stopped }) {
|
|
102
|
+
const ok = code === 0 && !error;
|
|
103
|
+
onEvent({
|
|
104
|
+
type: 'result',
|
|
105
|
+
isError: !ok,
|
|
106
|
+
stopped,
|
|
107
|
+
text: ok ? text.join('\n') : error || stderr || `opencode exited with ${signal || `code ${code}`}`,
|
|
108
|
+
stats: { durationMs: Date.now() - started, costUsd: cost || undefined, inputTokens: tokens.input, outputTokens: tokens.output },
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function safeJson(s) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(s) || {};
|
|
118
|
+
} catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function describeTool(name, input, cwd) {
|
|
124
|
+
const f = rel(input.filePath || input.path, cwd);
|
|
125
|
+
switch (name) {
|
|
126
|
+
case 'read':
|
|
127
|
+
return { kind: 'read', label: `Reading ${f}`, file: f };
|
|
128
|
+
case 'edit':
|
|
129
|
+
case 'patch':
|
|
130
|
+
case 'apply_patch':
|
|
131
|
+
case 'multiedit':
|
|
132
|
+
return { kind: 'edit', label: f ? `Editing ${f}` : 'Editing files', file: f || undefined };
|
|
133
|
+
case 'write':
|
|
134
|
+
return { kind: 'edit', label: `Writing ${f}`, file: f };
|
|
135
|
+
case 'bash':
|
|
136
|
+
case 'shell':
|
|
137
|
+
return { kind: 'run', label: input.description ? clip(input.description) : `Running ${clip(input.command, 60)}`, detail: input.command };
|
|
138
|
+
case 'grep':
|
|
139
|
+
return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
|
|
140
|
+
case 'glob':
|
|
141
|
+
case 'list':
|
|
142
|
+
return { kind: 'search', label: input.pattern ? `Finding ${clip(input.pattern, 50)}` : `Listing ${f || '.'}` };
|
|
143
|
+
case 'webfetch':
|
|
144
|
+
return { kind: 'web', label: `Reading ${clip(input.url, 60)}` };
|
|
145
|
+
case 'websearch':
|
|
146
|
+
return { kind: 'web', label: `Searching the web: ${clip(input.query, 50)}` };
|
|
147
|
+
case 'task':
|
|
148
|
+
return { kind: 'agent', label: `Delegating: ${clip(input.description || input.prompt, 60)}` };
|
|
149
|
+
case 'todowrite': {
|
|
150
|
+
const active = input.todos?.find((t) => t.status === 'in_progress');
|
|
151
|
+
return { kind: 'plan', label: active ? clip(active.content) : 'Planning' };
|
|
152
|
+
}
|
|
153
|
+
case 'wisp_ask_user':
|
|
154
|
+
return { kind: 'wait', label: 'Waiting for your answer' };
|
|
155
|
+
default:
|
|
156
|
+
return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// opencode reports tools after they ran. Edits carry old/new strings (key names vary across
|
|
161
|
+
// versions); anything else is diffed against the tracker's snapshot of the file.
|
|
162
|
+
function captureChanges(tool, input, meta, cwd, files) {
|
|
163
|
+
const file = input.filePath || input.path;
|
|
164
|
+
const before = input.oldString ?? input.old_string;
|
|
165
|
+
if (tool === 'edit' && file && typeof before === 'string') {
|
|
166
|
+
files.change(file); // keep the tracker's snapshot current for later writes
|
|
167
|
+
return [{ file: rel(file, cwd), op: 'edit', edits: [{ before, after: input.newString ?? input.new_string ?? '' }] }];
|
|
168
|
+
}
|
|
169
|
+
let paths = [];
|
|
170
|
+
if (['write', 'edit', 'multiedit'].includes(tool) && file) paths = [file];
|
|
171
|
+
else if (tool === 'patch' || tool === 'apply_patch') {
|
|
172
|
+
const listed = (meta.files || []).map((x) => (typeof x === 'string' ? x : x.filePath || x.file));
|
|
173
|
+
paths = [...new Set([...listed, ...patchPaths(input.patchText || input.patch || '')])].filter((p) => typeof p === 'string');
|
|
174
|
+
}
|
|
175
|
+
return paths.map((p) => files.change(p)).filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function patchPaths(text) {
|
|
179
|
+
return [...String(text).matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1].trim());
|
|
180
|
+
}
|
package/server/index.js
CHANGED
|
@@ -162,7 +162,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
162
162
|
if (!id && m === 'GET') return send(res, 200, store.config.projects);
|
|
163
163
|
if (!id && m === 'POST') {
|
|
164
164
|
const body = await readJson(req);
|
|
165
|
-
const dir = path.resolve(String(body.path || '').replace(/^~(
|
|
165
|
+
const dir = path.resolve(String(body.path || '').replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
166
166
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return send(res, 400, { error: `Not a folder: ${dir}` });
|
|
167
167
|
const p = store.addProject(dir, { name: body.name });
|
|
168
168
|
broadcast('config', {});
|
|
@@ -211,9 +211,10 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
211
211
|
|
|
212
212
|
// Directory suggestions for the launcher's project picker.
|
|
213
213
|
if (root === 'fs' && m === 'GET') {
|
|
214
|
-
const raw = (url.searchParams.get('q') || '').replace(/^~(
|
|
215
|
-
const
|
|
216
|
-
const
|
|
214
|
+
const raw = (url.searchParams.get('q') || '').replace(/^~(?=$|[\\/])/, os.homedir());
|
|
215
|
+
const trailing = /[\\/]$/.test(raw); // a trailing backslash counts too (Windows)
|
|
216
|
+
const dir = trailing ? raw : path.dirname(raw);
|
|
217
|
+
const prefix = trailing ? '' : path.basename(raw).toLowerCase();
|
|
217
218
|
let entries = [];
|
|
218
219
|
try {
|
|
219
220
|
entries = fs
|
|
@@ -231,7 +232,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
231
232
|
if (m === 'POST') {
|
|
232
233
|
const body = await readJson(req);
|
|
233
234
|
if (!body.prompt?.trim()) return send(res, 400, { error: 'prompt required' });
|
|
234
|
-
const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(
|
|
235
|
+
const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
235
236
|
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
|
|
236
237
|
if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
|
|
237
238
|
const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, attachments: resolveAttachments(body.attachments) });
|
|
@@ -250,6 +251,8 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
250
251
|
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
251
252
|
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
252
253
|
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
254
|
+
if (body.archived === true && s.state !== 'running' && s.state !== 'waiting') Object.assign(s, { archived: true, unread: false });
|
|
255
|
+
if (body.archived === false) delete s.archived;
|
|
253
256
|
store.save(s);
|
|
254
257
|
broadcast('summary', summarize(s, store));
|
|
255
258
|
return send(res, 200, summarize(s, store));
|
|
@@ -263,10 +266,15 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
if (action === 'reply' && m === 'POST') {
|
|
266
|
-
const { text, attachments } = await readJson(req);
|
|
269
|
+
const { text, attachments, queue } = await readJson(req);
|
|
267
270
|
if (!text?.trim()) return send(res, 400, { error: 'text required' });
|
|
268
271
|
if (s.agent === 'wisp') return send(res, 400, { error: 'This is the tour — launch a real wisp to follow up.' });
|
|
269
|
-
return send(res, 200, runner.reply(s, text.trim(), resolveAttachments(attachments)));
|
|
272
|
+
return send(res, 200, runner.reply(s, text.trim(), resolveAttachments(attachments), { queue: queue ?? store.config.defaults.followUp === 'queue' }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (action === 'queue' && m === 'DELETE') {
|
|
276
|
+
const ok = runner.unqueue(s, url.searchParams.get('item') || '');
|
|
277
|
+
return ok ? send(res, 200, { ok }) : send(res, 404, { error: 'Already sent.' });
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
if (action === 'answer' && m === 'POST') {
|
|
@@ -299,11 +307,18 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
|
|
|
299
307
|
return send(res, 200, { ok: true });
|
|
300
308
|
}
|
|
301
309
|
|
|
302
|
-
// Images the agent pointed at (screenshots etc.)
|
|
310
|
+
// Images the agent pointed at (screenshots etc.). Served if inside the session's project,
|
|
311
|
+
// or if the agent named that exact path in an image block (screenshots often land in /tmp).
|
|
303
312
|
if (action === 'file' && m === 'GET') {
|
|
304
|
-
|
|
313
|
+
let p = path.resolve(s.cwd, url.searchParams.get('path') || '');
|
|
305
314
|
const ext = path.extname(p).toLowerCase();
|
|
306
|
-
|
|
315
|
+
const images = s.turns.flatMap((t) => (t.presentation?.blocks || []).filter((b) => b?.type === 'image'));
|
|
316
|
+
const at = (v) => typeof v === 'string' && path.resolve(s.cwd, v) === p;
|
|
317
|
+
const named = images.find((b) => at(b.path || b.src));
|
|
318
|
+
// A page rendered before keepImages() rewrote the block still asks for the original path.
|
|
319
|
+
const kept = !named && images.find((b) => at(b.source) && typeof b.path === 'string');
|
|
320
|
+
if (kept) p = path.resolve(s.cwd, kept.path);
|
|
321
|
+
if (!(p.startsWith(s.cwd + path.sep) || named || kept) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
|
|
307
322
|
res.writeHead(200, { 'content-type': MIME[ext] });
|
|
308
323
|
return fs.createReadStream(p).pipe(res);
|
|
309
324
|
}
|