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/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
+ }
@@ -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
- export const SYSTEM_PROMPT = `
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
- - If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
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 queues follow-ups that arrive while the agent is busy.
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 { SYSTEM_PROMPT, parsePresentation, heroOf } from './protocol.js';
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
- if (this.live.has(session.id)) {
103
- session.queue.push({ text, attachments });
104
- this.#touch(session);
105
- return { queued: true };
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: SYSTEM_PROMPT,
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
- env: { WISP_URL: this.bridge.url, WISP_TOKEN: this.bridge.token, WISP_SESSION: session.id },
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) => this.#onEvent(session, turn, 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
- this.live.set(session.id, handle);
199
-
200
- handle.done.finally(() => {
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
- { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
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 = true;
337
+ session.unread = !interrupted;
272
338
  this.store.save(session, { now: true });
273
339
  this.#touch(session, { turn: turn.id });
274
- this.emit('finished', { session: summarize(session, this.store) });
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() {
@@ -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, execFileSync } from 'node:child_process';
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
- export const TERMINALS = [
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', mac: true },
9
- { id: 'iterm', label: 'iTerm', mac: true, app: '/Applications/iTerm.app' },
10
- { id: 'ghostty', label: 'Ghostty', app: '/Applications/Ghostty.app', bin: 'ghostty' },
11
- { id: 'warp', label: 'Warp', mac: true, app: '/Applications/Warp.app' },
12
- { id: 'kitty', label: 'kitty', app: '/Applications/kitty.app', bin: 'kitty' },
13
- { id: 'wezterm', label: 'WezTerm', app: '/Applications/WezTerm.app', bin: 'wezterm' },
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 has = (bin) => {
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 detect() {
29
- if (process.platform === 'darwin') {
30
- if (process.env.TERM_PROGRAM === 'iTerm.app' && fs.existsSync('/Applications/iTerm.app')) return 'iterm';
31
- if (process.env.TERM_PROGRAM === 'ghostty' && fs.existsSync('/Applications/Ghostty.app')) return 'ghostty';
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
- for (const t of ['ghostty', 'kitty', 'wezterm']) if (has(t)) return t;
35
- return 'x-terminal-emulator';
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
- const osa = (script) => spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
39
- const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
40
-
41
- // argv: the agent's resume command, e.g. ['claude', '--resume', 'abc'].
42
- export function openInTerminal({ cwd, argv, terminal = 'auto', custom = '' }) {
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
- const t = terminal === 'auto' ? detect() : terminal;
46
- const mac = process.platform === 'darwin';
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(`tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell`);
66
+ return [{ osa: `tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell` }];
51
67
  case 'iterm':
52
- 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`);
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
- spawn('open', ['-a', 'Warp', cwd], { detached: true, stdio: 'ignore' }).unref();
56
- return setTimeout(() => osa(`tell application "System Events" to keystroke ${asStr(cmd)} & return`), 1200);
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 spawn('open', ['-na', 'Ghostty', '--args', `--working-directory=${cwd}`, '-e', ...argv], { detached: true, stdio: 'ignore' }).unref();
59
- return spawn('ghostty', [`--working-directory=${cwd}`, '-e', ...argv], { detached: true, stdio: 'ignore' }).unref();
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 spawn('open', ['-na', 'kitty', '--args', '--directory', cwd, ...argv], { detached: true, stdio: 'ignore' }).unref();
62
- return spawn('kitty', ['--directory', cwd, ...argv], { detached: true, stdio: 'ignore' }).unref();
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 spawn(mac ? '/Applications/WezTerm.app/Contents/MacOS/wezterm' : 'wezterm', ['start', '--cwd', cwd, '--', ...argv], { detached: true, stdio: 'ignore' }).unref();
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 spawn('sh', ['-c', full], { detached: true, stdio: 'ignore' }).unref();
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
- default:
71
- return spawn('x-terminal-emulator', ['-e', 'sh', '-c', `${line}; exec $SHELL`], { detached: true, stdio: 'ignore' }).unref();
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
  }