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.
@@ -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
+ }
@@ -1,9 +1,11 @@
1
- // Agent registry. To add a harness (codex, opencode, …) write an adapter with the
2
- // same shape as ./claude.js and register it here:
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
- // detect(): Promise<{ available, version?, error? }>,
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 can prepend `systemPrompt` to the first prompt instead.
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 = { [claude.id]: claude };
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(/^~(?=$|\/)/, os.homedir()));
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(/^~(?=$|\/)/, os.homedir());
215
- const dir = raw.endsWith('/') ? raw : path.dirname(raw);
216
- const prefix = raw.endsWith('/') ? '' : path.basename(raw).toLowerCase();
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(/^~(?=$|\/)/, os.homedir()));
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.), confined to the session's project.
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
- const p = path.resolve(s.cwd, url.searchParams.get('path') || '');
313
+ let p = path.resolve(s.cwd, url.searchParams.get('path') || '');
305
314
  const ext = path.extname(p).toLowerCase();
306
- if (!p.startsWith(s.cwd + path.sep) || !IMAGE_EXT.has(ext) || !fs.existsSync(p)) return send(res, 404, { error: 'not found' });
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
  }