wisp-agents 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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);
@@ -33,8 +36,10 @@ export function summarize(s, store) {
33
36
  color: project?.color || 'slate',
34
37
  mode: s.mode,
35
38
  model: s.model,
39
+ effort: s.effort || '',
36
40
  state: s.state,
37
41
  unread: s.unread,
42
+ archived: !!s.archived,
38
43
  createdAt: s.createdAt,
39
44
  updatedAt: s.updatedAt,
40
45
  turnCount: s.turns.length,
@@ -55,6 +60,7 @@ export class Runner extends EventEmitter {
55
60
  this.bridge = bridge;
56
61
  this.live = new Map(); // sessionId -> adapter handle
57
62
  this.stopping = new Set();
63
+ this.interrupting = new Set();
58
64
  this.answers = new Map(); // askId -> answer, until the MCP server picks it up
59
65
 
60
66
  // Anything that was mid-flight when wisp last died is now orphaned.
@@ -69,7 +75,7 @@ export class Runner extends EventEmitter {
69
75
  }
70
76
  }
71
77
 
72
- create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', attachments = [] }) {
78
+ create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [] }) {
73
79
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
74
80
  const now = Date.now();
75
81
  const session = {
@@ -80,6 +86,7 @@ export class Runner extends EventEmitter {
80
86
  cwd,
81
87
  mode,
82
88
  model,
89
+ effort,
83
90
  state: 'running',
84
91
  unread: false,
85
92
  createdAt: now,
@@ -94,20 +101,46 @@ export class Runner extends EventEmitter {
94
101
  return session;
95
102
  }
96
103
 
97
- reply(session, text, attachments = []) {
104
+ reply(session, text, attachments = [], { queue = false } = {}) {
98
105
  if (session.ask) {
99
106
  this.answer(session, text);
100
107
  return { answered: true };
101
108
  }
102
- if (this.live.has(session.id)) {
103
- session.queue.push({ text, attachments });
104
- this.#touch(session);
105
- return { queued: true };
109
+ const handle = this.live.get(session.id);
110
+ if (handle) {
111
+ const turn = session.turns.at(-1);
112
+ // The run already produced its result and the CLI is only lingering on its way out:
113
+ // there's nothing to wait for, so let it go and start the follow-up now.
114
+ if (turn?.status !== 'running') {
115
+ session.queue.push({ id: newId(), text, attachments });
116
+ handle.release();
117
+ return { queued: session.queue.length > 0 };
118
+ }
119
+ // Interrupting needs an agent session to resume; before the agent reports one, queue.
120
+ if (queue || !session.agentSessionId) {
121
+ session.queue.push({ id: newId(), text, attachments });
122
+ this.#touch(session);
123
+ return { queued: true, ahead: session.queue.length - 1 };
124
+ }
125
+ // Ahead of anything queued, but after earlier interrupting follow-ups, so they keep their order.
126
+ const at = session.queue.findIndex((q) => !q.now);
127
+ session.queue.splice(at < 0 ? session.queue.length : at, 0, { id: newId(), text, attachments, now: true });
128
+ this.#interrupt(session);
129
+ return { interrupted: true };
106
130
  }
107
131
  this.#startTurn(session, text, attachments);
108
132
  return { queued: false };
109
133
  }
110
134
 
135
+ // Drop a follow-up that hasn't gone out yet.
136
+ unqueue(session, itemId) {
137
+ const at = session.queue.findIndex((q) => q.id === itemId);
138
+ if (at < 0) return false;
139
+ session.queue.splice(at, 1);
140
+ this.#touch(session);
141
+ return true;
142
+ }
143
+
111
144
  // ask_user: the agent is blocked inside a tool call until answer() is called.
112
145
  ask(session, { question, context, options }) {
113
146
  const turn = session.turns.at(-1);
@@ -120,6 +153,7 @@ export class Runner extends EventEmitter {
120
153
  };
121
154
  session.state = 'waiting';
122
155
  session.unread = true;
156
+ delete session.archived; // a question needs the user, so it comes back to the feed
123
157
  if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
124
158
  this.#touch(session, { turn: turn?.id });
125
159
  this.emit('finished', { session: summarize(session, this.store) });
@@ -157,10 +191,21 @@ export class Runner extends EventEmitter {
157
191
  this.live.get(session.id).stop();
158
192
  }
159
193
 
194
+ // Stop the current run but keep the queue: done.finally() starts the next follow-up.
195
+ #interrupt(session) {
196
+ if (this.interrupting.has(session.id)) return; // already on its way down
197
+ this.interrupting.add(session.id);
198
+ this.stopping.add(session.id);
199
+ this.live.get(session.id).stop();
200
+ }
201
+
160
202
  #startTurn(session, prompt, attachments = []) {
161
203
  const turn = {
162
204
  id: newId(),
163
205
  prompt,
206
+ agent: session.agent,
207
+ model: session.model,
208
+ effort: session.effort || '',
164
209
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
165
210
  startedAt: Date.now(),
166
211
  endedAt: null,
@@ -173,8 +218,11 @@ export class Runner extends EventEmitter {
173
218
  session.turns.push(turn);
174
219
  session.state = 'running';
175
220
  session.unread = false;
221
+ delete session.archived;
176
222
  this.#touch(session, { turn: turn.id });
177
223
 
224
+ let released = false;
225
+ let linger;
178
226
  const adapter = adapters[session.agent];
179
227
  const handle = adapter.run({
180
228
  prompt,
@@ -183,8 +231,9 @@ export class Runner extends EventEmitter {
183
231
  resumeId: session.agentSessionId,
184
232
  mode: session.mode,
185
233
  model: session.model,
186
- systemPrompt: SYSTEM_PROMPT,
187
- mcpServers: this.bridge
234
+ effort: session.effort || '',
235
+ systemPrompt: systemPrompt({ askTool: adapter.askTool }),
236
+ mcpServers: this.bridge && adapter.askTool !== null
188
237
  ? {
189
238
  wisp: {
190
239
  command: process.execPath,
@@ -195,17 +244,32 @@ export class Runner extends EventEmitter {
195
244
  },
196
245
  }
197
246
  : {},
198
- onEvent: (ev) => this.#onEvent(session, turn, ev),
247
+ onEvent: (ev) => {
248
+ if (released) return; // a lingering process we already let go of
249
+ this.#onEvent(session, turn, ev);
250
+ // A CLI can outlive its result (flushing, or a background process it spawned holding
251
+ // stdout open, so 'close' never fires). Don't hold the session hostage to that.
252
+ if (ev.type === 'result') linger = setTimeout(release, LINGER_MS);
253
+ },
199
254
  });
200
- this.live.set(session.id, handle);
201
-
202
- handle.done.finally(() => {
203
- this.live.delete(session.id);
255
+ // Ends the run exactly once: on exit, LINGER_MS after the result, or when a follow-up arrives.
256
+ const release = () => {
257
+ if (released) return;
204
258
  if (turn.status === 'running') this.#finishTurn(session, turn, { isError: true, text: 'The agent exited without a result.' });
259
+ released = true;
260
+ clearTimeout(linger);
261
+ handle.stop();
262
+ if (this.live.get(session.id) !== handle) return;
263
+ this.live.delete(session.id);
264
+ this.interrupting.delete(session.id);
265
+ this.stopping.delete(session.id); // a stop that landed after the result has nothing left to stop
205
266
  const next = session.queue.shift();
206
267
  if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
207
268
  else this.#touch(session);
208
- });
269
+ };
270
+ handle.release = release;
271
+ this.live.set(session.id, handle);
272
+ handle.done.finally(release);
209
273
  }
210
274
 
211
275
  #onEvent(session, turn, ev) {
@@ -245,11 +309,14 @@ export class Runner extends EventEmitter {
245
309
  turn.stats = ev.stats || null;
246
310
  delete session.ask;
247
311
  if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
312
+ const interrupted = ev.stopped && this.interrupting.has(session.id);
248
313
  if (ev.stopped) {
249
314
  turn.status = 'stopped';
250
315
  turn.presentation = {
251
316
  blocks: [
252
- { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
317
+ interrupted
318
+ ? { type: 'hero', title: 'Interrupted', summary: 'You sent a follow-up while this was running, so it picked that up instead.', status: 'partial' }
319
+ : { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
253
320
  ...(turn.changes.length ? [{ type: 'files', items: turn.changes.map((c) => ({ path: c.file, change: c.op === 'create' ? 'added' : 'modified' })) }] : []),
254
321
  ],
255
322
  };
@@ -266,14 +333,16 @@ export class Runner extends EventEmitter {
266
333
  turn.status = 'done';
267
334
  turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
268
335
  turn.presentation = parsePresentation(ev.text);
336
+ this.store.keepImages(session, turn);
269
337
  const hero = heroOf(turn.presentation);
270
338
  if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
271
339
  }
272
340
  session.state = 'review';
273
- session.unread = true;
341
+ session.unread = !interrupted;
274
342
  this.store.save(session, { now: true });
275
343
  this.#touch(session, { turn: turn.id });
276
- this.emit('finished', { session: summarize(session, this.store) });
344
+ // The follow-up starts right away; don't notify for the run it replaced.
345
+ if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
277
346
  }
278
347
 
279
348
  #touch(session, { turn, quiet } = {}) {
package/server/store.js CHANGED
@@ -3,19 +3,24 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { parsePresentation } from './protocol.js';
7
8
 
8
9
  const DEFAULT_CONFIG = {
9
10
  projects: [], // [{ id, name, path, color, addedAt }]
10
- defaults: { agent: 'claude', mode: 'edits', model: '' },
11
+ defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
11
12
  };
12
13
 
14
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
15
+ const MAX_KEPT_IMAGE = 25 * 1024 * 1024;
16
+
13
17
  export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
14
18
 
15
19
  export class Store {
16
20
  constructor(home = process.env.WISP_HOME || path.join(os.homedir(), '.wisp')) {
17
21
  this.home = home;
18
22
  this.dir = path.join(home, 'sessions');
23
+ this.shots = path.join(home, 'shots');
19
24
  fs.mkdirSync(this.dir, { recursive: true });
20
25
  this.sessions = new Map();
21
26
  this.timers = new Map();
@@ -27,6 +32,10 @@ export class Store {
27
32
  const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
28
33
  reparse(s);
29
34
  this.sessions.set(s.id, s);
35
+ // Backfill: follow-ups queued before items had ids can't be cancelled without one.
36
+ const ids = (s.queue || []).filter((q) => !q.id).map((q) => (q.id = randomUUID().replace(/-/g, '').slice(0, 12)));
37
+ // Backfill: keep screenshots from older turns while their files still exist.
38
+ if ((s.turns || []).map((t) => this.keepImages(s, t)).some(Boolean) || ids.length) this.save(s, { now: true });
30
39
  } catch (err) {
31
40
  console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
32
41
  }
@@ -111,6 +120,34 @@ export class Store {
111
120
  clearTimeout(this.timers.get(id));
112
121
  this.sessions.delete(id);
113
122
  fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
123
+ fs.rmSync(path.join(this.shots, id), { recursive: true, force: true });
124
+ }
125
+
126
+ // Agents save screenshots to /tmp, which macOS clears. Copy every local image a turn shows
127
+ // into ~/.wisp/shots/<session>/ and point the block at the copy; `source` keeps the original.
128
+ keepImages(session, turn) {
129
+ let changed = false;
130
+ (turn.presentation?.blocks || []).forEach((b, i) => {
131
+ const rel = b?.type === 'image' && (b.path || b.src);
132
+ if (typeof rel !== 'string' || !rel || /^https?:/.test(rel)) return;
133
+ const src = path.resolve(session.cwd || this.home, rel);
134
+ const ext = path.extname(src).toLowerCase();
135
+ if (!IMAGE_EXT.has(ext) || src.startsWith(this.shots + path.sep)) return;
136
+ try {
137
+ const st = fs.statSync(src);
138
+ if (!st.isFile() || st.size > MAX_KEPT_IMAGE) return;
139
+ const dir = path.join(this.shots, session.id);
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ const dest = path.join(dir, `${turn.id}-${i}${ext}`);
142
+ fs.copyFileSync(src, dest);
143
+ Object.assign(b, { path: dest, source: rel });
144
+ delete b.src;
145
+ changed = true;
146
+ } catch {
147
+ // gone or unreadable: leave the block as it was
148
+ }
149
+ });
150
+ return changed;
114
151
  }
115
152
 
116
153
  flush() {