wisp-agents 0.0.0-stage → 0.2.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,82 @@
1
+ #!/usr/bin/env node
2
+ // Minimal stdio MCP server exposing `ask_user`, injected into every agent wisp launches.
3
+ // The call is relayed to the wisp server, which surfaces it in the queue and blocks
4
+ // (by polling) until the user answers — so the agent keeps its full context.
5
+
6
+ import readline from 'node:readline';
7
+
8
+ const { WISP_URL, WISP_TOKEN, WISP_SESSION } = process.env;
9
+
10
+ const TOOL = {
11
+ name: 'ask_user',
12
+ description:
13
+ 'Ask the user a question through wisp and wait for their answer. Use when a decision would change the rest of your work ' +
14
+ '(ambiguous requirements, risky/destructive steps, choosing between approaches). The user may take minutes or hours to answer. ' +
15
+ 'Do not use it for trivial choices you can make yourself.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ question: { type: 'string', description: 'The question, one or two sentences.' },
20
+ context: { type: 'string', description: 'Optional markdown: what you found so far and why you are asking.' },
21
+ options: { type: 'array', items: { type: 'string' }, description: 'Optional suggested answers (2–4). The user can always type their own.' },
22
+ },
23
+ required: ['question'],
24
+ },
25
+ };
26
+
27
+ const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ async function call(path, body) {
31
+ const res = await fetch(`${WISP_URL}${path}`, {
32
+ method: body ? 'POST' : 'GET',
33
+ headers: { 'content-type': 'application/json', 'x-wisp-token': WISP_TOKEN },
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ });
36
+ if (!res.ok) throw new Error(`wisp ${res.status}: ${await res.text()}`);
37
+ return res.json();
38
+ }
39
+
40
+ async function askUser(args) {
41
+ const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
42
+ for (;;) {
43
+ await sleep(1500);
44
+ let r;
45
+ try {
46
+ r = await call(`/api/internal/ask/${id}`);
47
+ } catch {
48
+ continue; // wisp restarting — keep waiting
49
+ }
50
+ if (r.answer !== undefined) return r.answer;
51
+ if (r.cancelled) return null;
52
+ }
53
+ }
54
+
55
+ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
56
+ let msg;
57
+ try {
58
+ msg = JSON.parse(line);
59
+ } catch {
60
+ return;
61
+ }
62
+ const { id, method, params } = msg;
63
+ if (id === undefined) return; // notification
64
+
65
+ try {
66
+ if (method === 'initialize') {
67
+ send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
68
+ } else if (method === 'tools/list') {
69
+ send({ id, result: { tools: [TOOL] } });
70
+ } else if (method === 'tools/call' && params?.name === 'ask_user') {
71
+ const answer = await askUser(params.arguments || {});
72
+ const text = answer === null ? 'The user did not answer (the question was cancelled). Use your best judgment and note the assumption.' : `The user answered: ${answer}`;
73
+ send({ id, result: { content: [{ type: 'text', text }] } });
74
+ } else if (method === 'ping') {
75
+ send({ id, result: {} });
76
+ } else {
77
+ send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
78
+ }
79
+ } catch (err) {
80
+ send({ id, result: { isError: true, content: [{ type: 'text', text: `wisp could not reach the user: ${err.message}` }] } });
81
+ }
82
+ });
@@ -0,0 +1,182 @@
1
+ // The wisp protocol: what we tell the agent, and how we read its answer back.
2
+ // Agent-agnostic — any harness that can take an extra system prompt (or a prompt
3
+ // prefix) and return a final text message can speak it.
4
+
5
+ export const SYSTEM_PROMPT = `
6
+ # You are running inside wisp
7
+
8
+ wisp launched you to do a task on the user's behalf. The user is a software engineer who will NOT
9
+ read your transcript, tool calls or intermediate messages. The only thing they will see is the
10
+ presentation you put in your final message. Work autonomously:
11
+
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.
16
+ - If the task is done but there is a choice for later, end with a "question" block instead.
17
+ - Verify your work where it is cheap (tests, type-check, lint, build) and report results honestly,
18
+ including failures. Never claim something passes that you did not run.
19
+ - The user can follow up; follow-ups resume this same session.
20
+ - Attached files (images, video, docs) are listed in an <attachments> section of the prompt.
21
+
22
+ ## Your final message
23
+
24
+ End your final message with exactly ONE fenced code block tagged \`wisp\` containing a JSON object
25
+ \`{"blocks": [...]}\`. Anything outside the block is ignored. Write like a strong engineer's PR
26
+ description for a busy reviewer: technical, precise, skimmable. Name files, functions, commands and
27
+ numbers. No filler, no praise, no restating the task. Usually 4–8 blocks. Lead with evidence — the
28
+ diff, the test run, the command output — over prose. Use file paths relative to the project root.
29
+
30
+ ### Blocks
31
+
32
+ - {"type":"hero","title":"<what happened, ≤8 words>","summary":"<1–2 sentences>","status":"done|partial|blocked|info"}
33
+ Always first.
34
+ - {"type":"markdown","text":"<github-flavoured markdown>"}
35
+ - {"type":"steps","title":"…","items":[{"title":"…","detail":"…","status":"done|skipped|failed"}]}
36
+ What you did, in order.
37
+ - {"type":"stats","items":[{"label":"tests passing","value":42,"unit":"","delta":"+6","tone":"good|bad|neutral"}]}
38
+ Numbers animate; use real numbers only.
39
+ - {"type":"files","items":[{"path":"src/a.ts","change":"added|modified|deleted|renamed","note":"why"}]}
40
+ - {"type":"diff","file":"src/a.ts","caption":"…"}
41
+ Shows the REAL diff of your edits to that file (wisp captured them). Use for the 1–3 most important changes.
42
+ Optionally add "before"/"after" strings to show a hand-picked snippet instead.
43
+ - {"type":"tests","command":"npm test","passed":48,"failed":1,"skipped":2,"durationMs":5300,"failures":[{"name":"auth > rejects expired token","message":"expected 401, got 500","file":"test/auth.test.ts:42"}]}
44
+ A test/lint/type-check run you actually executed. Prefer this over "terminal" for test runs.
45
+ - {"type":"commits","items":[{"hash":"a1b2c3d","message":"feat(auth): add rate limiter","files":3,"additions":120,"deletions":8}],"branch":"feat/limit"}
46
+ Commits you created, if any.
47
+ - {"type":"tree","title":"…","items":[{"path":"src/auth/limiter.ts","change":"added","note":"…"}]}
48
+ A file tree; use for new modules/scaffolding where structure matters.
49
+ - {"type":"code","language":"ts","code":"…","caption":"…","file":"src/a.ts","line":10,"highlight":[3,4]}
50
+ Key snippet (new API, signature, config) — not whole files.
51
+ - {"type":"compare","before":{"label":"Before","items":["…"]},"after":{"label":"After","items":["…"]}}
52
+ - {"type":"flow","title":"…","nodes":[{"id":"a","label":"Request","note":"…"}],"edges":[["a","b","label"]]}
53
+ Architecture / data flow / sequence. Edges optional (nodes are chained in order if omitted).
54
+ - {"type":"chart","title":"…","unit":"ms","items":[{"label":"before","value":420},{"label":"after","value":95}]}
55
+ - {"type":"terminal","command":"npm test","output":"<trimmed output>","exitCode":0}
56
+ - {"type":"checklist","title":"…","items":[{"text":"…","done":true}]}
57
+ - {"type":"callout","tone":"info|success|warn|danger","title":"…","text":"…"}
58
+ Risks, caveats, assumptions you made.
59
+ - {"type":"image","path":"relative/or/absolute.png","caption":"…"}
60
+ A screenshot or generated image on disk.
61
+ - {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
62
+ When you need a decision. The user's click resumes you with the reply.
63
+ - {"type":"next","items":["Suggested follow-up the user might send", "…"]}
64
+ Usually last. 2–4 concrete follow-ups.
65
+
66
+ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
67
+
68
+ \`\`\`wisp
69
+ {"blocks":[
70
+ {"type":"hero","title":"Login now rate-limited","summary":"Added a sliding-window limiter to /login. 5 attempts per minute per IP.","status":"done"},
71
+ {"type":"diff","file":"src/auth/login.ts"},
72
+ {"type":"tests","command":"npm test","passed":48,"failed":0,"skipped":0,"durationMs":4100},
73
+ {"type":"callout","tone":"warn","title":"Assumption","text":"Behind a proxy, so I read the IP from X-Forwarded-For."},
74
+ {"type":"next","items":["Make the limit configurable","Add the same limiter to /reset-password"]}
75
+ ]}
76
+ \`\`\`
77
+ `.trim();
78
+
79
+ // Only the opener is matched by regex. The closing fence can't be: the JSON itself often
80
+ // contains ``` (a markdown block with a code sample), and a lazy match would cut it there.
81
+ const OPEN = /```wisp[^\n]*\n/g;
82
+
83
+ export function parsePresentation(text = '') {
84
+ const opens = [...text.matchAll(OPEN)];
85
+ for (let i = opens.length - 1; i >= 0; i--) {
86
+ const open = opens[i];
87
+ const json = readJson(text.slice(open.index + open[0].length));
88
+ const blocks = Array.isArray(json) ? json : json?.blocks;
89
+ if (Array.isArray(blocks) && blocks.length) {
90
+ const extra = text.slice(0, open.index).trim();
91
+ return { blocks: blocks.filter((b) => b && typeof b.type === 'string'), notes: extra || undefined };
92
+ }
93
+ }
94
+ // The agent ignored (or broke) the protocol — show its answer as plain markdown, with any
95
+ // broken wisp payload kept verbatim in a fence rather than half-rendered as prose.
96
+ const last = opens.at(-1);
97
+ let md = text.trim();
98
+ if (last) {
99
+ const prose = text.slice(0, last.index).trim();
100
+ const raw = text.slice(last.index + last[0].length).replace(/\n?```\s*$/, '');
101
+ md = `${prose}\n\n\`\`\`\`\`json\n${raw}\n\`\`\`\`\``.trim();
102
+ }
103
+ return {
104
+ fallback: true,
105
+ blocks: [{ type: 'markdown', text: md || '_The agent finished without a message._' }],
106
+ };
107
+ }
108
+
109
+ // Parse the JSON value at the start of `src` (the body after a ```wisp opener). First find its
110
+ // end by bracket matching, respecting strings; if that fails (e.g. a stray unescaped quote), try
111
+ // every ``` after it as the closing fence.
112
+ function readJson(src) {
113
+ const end = jsonEnd(src);
114
+ if (end > 0) {
115
+ const json = tryJson(src.slice(0, end));
116
+ if (json) return json;
117
+ }
118
+ for (let i = src.indexOf('```'); i !== -1; i = src.indexOf('```', i + 3)) {
119
+ const json = tryJson(src.slice(0, i));
120
+ if (json) return json;
121
+ }
122
+ return salvageBlocks(src);
123
+ }
124
+
125
+ // Last resort: parse the blocks array one element at a time, so a single malformed block (an
126
+ // unescaped quote in a terminal output, say) costs that block rather than the whole answer.
127
+ function salvageBlocks(src) {
128
+ const m = /^\s*(\{\s*"blocks"\s*:\s*)?\[/.exec(src);
129
+ if (!m) return null;
130
+ const blocks = [];
131
+ let bad = 0;
132
+ let i = m[0].length;
133
+ while (i < src.length) {
134
+ while (/[\s,]/.test(src[i] || '')) i++;
135
+ if (src[i] !== '{') break;
136
+ const len = jsonEnd(src.slice(i));
137
+ if (len < 0) break;
138
+ const block = tryJson(src.slice(i, i + len));
139
+ if (block && typeof block === 'object') blocks.push(block);
140
+ else bad++;
141
+ i += len;
142
+ }
143
+ if (!blocks.length) return null;
144
+ if (bad) blocks.push({ type: 'callout', tone: 'warn', title: `${bad} block${bad > 1 ? 's' : ''} skipped`, text: 'Part of the agent\'s answer was malformed JSON and could not be shown.' });
145
+ return blocks;
146
+ }
147
+
148
+ function jsonEnd(src) {
149
+ const start = src.search(/\S/);
150
+ if (src[start] !== '{' && src[start] !== '[') return -1;
151
+ let depth = 0;
152
+ let inStr = false;
153
+ for (let i = start; i < src.length; i++) {
154
+ const c = src[i];
155
+ if (inStr) {
156
+ if (c === '\\') i++;
157
+ else if (c === '"') inStr = false;
158
+ } else if (c === '"') inStr = true;
159
+ else if (c === '{' || c === '[') depth++;
160
+ else if (c === '}' || c === ']') {
161
+ if (--depth === 0) return i + 1;
162
+ }
163
+ }
164
+ return -1;
165
+ }
166
+
167
+ function tryJson(src) {
168
+ try {
169
+ return JSON.parse(src);
170
+ } catch {}
171
+ // Common LLM slips: trailing commas, smart quotes.
172
+ const relaxed = src.replace(/,\s*([}\]])/g, '$1').replace(/[“”]/g, '"');
173
+ try {
174
+ return JSON.parse(relaxed);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ export function heroOf(presentation) {
181
+ return presentation?.blocks?.find((b) => b.type === 'hero');
182
+ }
@@ -0,0 +1,283 @@
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.
3
+
4
+ import { EventEmitter } from 'node:events';
5
+ import { randomUUID } from 'node:crypto';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { adapters } from './adapters/index.js';
9
+ import { SYSTEM_PROMPT, parsePresentation, heroOf } from './protocol.js';
10
+
11
+ const MAX_ACTIVITY = 400;
12
+ const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
13
+
14
+ export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
15
+
16
+ function autoTitle(prompt) {
17
+ const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
18
+ return line.length > 64 ? `${line.slice(0, 63)}…` : line;
19
+ }
20
+
21
+ export function summarize(s, store) {
22
+ const project = store?.projectFor(s.cwd);
23
+ const last = s.turns.at(-1);
24
+ const hero = heroOf(last?.presentation);
25
+ const lastAct = last?.activity?.findLast?.((a) => a.kind !== 'text');
26
+ return {
27
+ id: s.id,
28
+ title: s.title,
29
+ agent: s.agent,
30
+ cwd: s.cwd,
31
+ project: s.agent === 'wisp' ? 'tour' : project?.name || path.basename(s.cwd || ''),
32
+ projectId: project?.id || null,
33
+ color: project?.color || 'slate',
34
+ mode: s.mode,
35
+ model: s.model,
36
+ state: s.state,
37
+ unread: s.unread,
38
+ createdAt: s.createdAt,
39
+ updatedAt: s.updatedAt,
40
+ turnCount: s.turns.length,
41
+ queued: s.queue?.length || 0,
42
+ status: last?.status === 'running' ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
43
+ preview: hero?.summary || '',
44
+ live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? lastAct?.label || 'Starting up' : '',
45
+ asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
46
+ question: s.ask?.question || '',
47
+ };
48
+ }
49
+
50
+ export class Runner extends EventEmitter {
51
+ // bridge: { url, token } — how the injected ask_user MCP server reaches us.
52
+ constructor(store, bridge) {
53
+ super();
54
+ this.store = store;
55
+ this.bridge = bridge;
56
+ this.live = new Map(); // sessionId -> adapter handle
57
+ this.stopping = new Set();
58
+ this.answers = new Map(); // askId -> answer, until the MCP server picks it up
59
+
60
+ // Anything that was mid-flight when wisp last died is now orphaned.
61
+ for (const s of store.all()) {
62
+ const t = s.turns.at(-1);
63
+ delete s.ask;
64
+ if (s.state === 'running' || s.state === 'waiting' || t?.status === 'running') {
65
+ if (t?.status === 'running') this.#finishTurn(s, t, { isError: true, stopped: true, text: '' }, 'wisp was restarted while this was running.');
66
+ s.state = 'review';
67
+ store.save(s);
68
+ }
69
+ }
70
+ }
71
+
72
+ create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', attachments = [] }) {
73
+ if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
74
+ const now = Date.now();
75
+ const session = {
76
+ id: newId(),
77
+ title: autoTitle(prompt),
78
+ titleAuto: true,
79
+ agent,
80
+ cwd,
81
+ mode,
82
+ model,
83
+ state: 'running',
84
+ unread: false,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ agentSessionId: null,
88
+ queue: [],
89
+ turns: [],
90
+ };
91
+ this.store.put(session);
92
+ this.store.touchProject(cwd);
93
+ this.#startTurn(session, prompt, attachments);
94
+ return session;
95
+ }
96
+
97
+ reply(session, text, attachments = []) {
98
+ if (session.ask) {
99
+ this.answer(session, text);
100
+ return { answered: true };
101
+ }
102
+ if (this.live.has(session.id)) {
103
+ session.queue.push({ text, attachments });
104
+ this.#touch(session);
105
+ return { queued: true };
106
+ }
107
+ this.#startTurn(session, text, attachments);
108
+ return { queued: false };
109
+ }
110
+
111
+ // ask_user: the agent is blocked inside a tool call until answer() is called.
112
+ ask(session, { question, context, options }) {
113
+ const turn = session.turns.at(-1);
114
+ session.ask = {
115
+ id: newId(),
116
+ question: String(question || '').trim() || 'The agent has a question.',
117
+ context: context ? String(context) : '',
118
+ options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
119
+ askedAt: Date.now(),
120
+ };
121
+ session.state = 'waiting';
122
+ session.unread = true;
123
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
124
+ this.#touch(session, { turn: turn?.id });
125
+ this.emit('finished', { session: summarize(session, this.store) });
126
+ return session.ask.id;
127
+ }
128
+
129
+ answer(session, text) {
130
+ const ask = session.ask;
131
+ if (!ask) return false;
132
+ ask.answer = text;
133
+ this.answers.set(ask.id, text);
134
+ delete session.ask;
135
+ session.state = this.live.has(session.id) ? 'running' : 'review';
136
+ const turn = session.turns.at(-1);
137
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `You answered: ${text}` });
138
+ this.#touch(session, { turn: turn?.id });
139
+ return true;
140
+ }
141
+
142
+ pollAnswer(askId) {
143
+ if (this.answers.has(askId)) {
144
+ const answer = this.answers.get(askId);
145
+ this.answers.delete(askId);
146
+ return { answer };
147
+ }
148
+ const open = this.store.all().some((s) => s.ask?.id === askId);
149
+ return open ? { pending: true } : { cancelled: true };
150
+ }
151
+
152
+ stop(session) {
153
+ session.queue = [];
154
+ delete session.ask;
155
+ if (!this.live.has(session.id)) return;
156
+ this.stopping.add(session.id);
157
+ this.live.get(session.id).stop();
158
+ }
159
+
160
+ #startTurn(session, prompt, attachments = []) {
161
+ const turn = {
162
+ id: newId(),
163
+ prompt,
164
+ attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
165
+ startedAt: Date.now(),
166
+ endedAt: null,
167
+ status: 'running',
168
+ activity: [],
169
+ changes: [],
170
+ presentation: null,
171
+ stats: null,
172
+ };
173
+ session.turns.push(turn);
174
+ session.state = 'running';
175
+ session.unread = false;
176
+ this.#touch(session, { turn: turn.id });
177
+
178
+ const adapter = adapters[session.agent];
179
+ const handle = adapter.run({
180
+ prompt,
181
+ attachments,
182
+ cwd: session.cwd,
183
+ resumeId: session.agentSessionId,
184
+ mode: session.mode,
185
+ model: session.model,
186
+ systemPrompt: SYSTEM_PROMPT,
187
+ mcpServers: this.bridge
188
+ ? {
189
+ wisp: {
190
+ command: process.execPath,
191
+ args: [ASK_SERVER],
192
+ env: { WISP_URL: this.bridge.url, WISP_TOKEN: this.bridge.token, WISP_SESSION: session.id },
193
+ },
194
+ }
195
+ : {},
196
+ onEvent: (ev) => this.#onEvent(session, turn, ev),
197
+ });
198
+ this.live.set(session.id, handle);
199
+
200
+ handle.done.finally(() => {
201
+ this.live.delete(session.id);
202
+ if (turn.status === 'running') this.#finishTurn(session, turn, { isError: true, text: 'The agent exited without a result.' });
203
+ const next = session.queue.shift();
204
+ if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
205
+ else this.#touch(session);
206
+ });
207
+ }
208
+
209
+ #onEvent(session, turn, ev) {
210
+ switch (ev.type) {
211
+ case 'session':
212
+ session.agentSessionId = ev.id;
213
+ return;
214
+ case 'tool':
215
+ case 'tool_error':
216
+ case 'text': {
217
+ const item = { t: Date.now(), kind: ev.type === 'tool' ? ev.kind : ev.type === 'text' ? 'text' : 'error' };
218
+ if (ev.type === 'tool') Object.assign(item, { label: ev.label, file: ev.file, detail: ev.detail });
219
+ else if (ev.type === 'text') item.label = ev.text.slice(0, 600);
220
+ else item.label = `${ev.name || 'Tool'} failed: ${ev.text}`;
221
+ turn.activity.push(item);
222
+ if (turn.activity.length > MAX_ACTIVITY) turn.activity.splice(0, turn.activity.length - MAX_ACTIVITY);
223
+ this.emit('activity', { sessionId: session.id, turnId: turn.id, item });
224
+ this.#touch(session, { quiet: ev.type === 'text' });
225
+ return;
226
+ }
227
+ case 'change': {
228
+ let entry = turn.changes.find((c) => c.file === ev.file);
229
+ if (!entry) turn.changes.push((entry = { file: ev.file, op: ev.op, edits: [] }));
230
+ if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
231
+ entry.edits.push(...ev.edits);
232
+ this.store.save(session);
233
+ return;
234
+ }
235
+ case 'result':
236
+ this.#finishTurn(session, turn, ev);
237
+ return;
238
+ }
239
+ }
240
+
241
+ #finishTurn(session, turn, ev, reason) {
242
+ turn.endedAt = Date.now();
243
+ turn.stats = ev.stats || null;
244
+ delete session.ask;
245
+ if (this.stopping.delete(session.id)) ev = { ...ev, stopped: true };
246
+ if (ev.stopped) {
247
+ turn.status = 'stopped';
248
+ turn.presentation = {
249
+ blocks: [
250
+ { type: 'hero', title: 'Stopped', summary: reason || 'You stopped this run. Follow up to pick it back up.', status: 'partial' },
251
+ ...(turn.changes.length ? [{ type: 'files', items: turn.changes.map((c) => ({ path: c.file, change: c.op === 'create' ? 'added' : 'modified' })) }] : []),
252
+ ],
253
+ };
254
+ } else if (ev.isError) {
255
+ turn.status = 'error';
256
+ turn.presentation = {
257
+ blocks: [
258
+ { type: 'hero', title: 'Something went wrong', summary: 'The agent run ended with an error.', status: 'blocked' },
259
+ { type: 'terminal', command: `${session.agent} run`, output: ev.text || 'No output.', exitCode: 1 },
260
+ { type: 'next', items: ['Try again', 'Explain what went wrong'] },
261
+ ],
262
+ };
263
+ } else {
264
+ turn.status = 'done';
265
+ turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
266
+ turn.presentation = parsePresentation(ev.text);
267
+ const hero = heroOf(turn.presentation);
268
+ if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
269
+ }
270
+ session.state = 'review';
271
+ session.unread = true;
272
+ this.store.save(session, { now: true });
273
+ this.#touch(session, { turn: turn.id });
274
+ this.emit('finished', { session: summarize(session, this.store) });
275
+ }
276
+
277
+ #touch(session, { turn, quiet } = {}) {
278
+ session.updatedAt = Date.now();
279
+ this.store.save(session);
280
+ if (!quiet) this.emit('summary', summarize(session, this.store));
281
+ if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
282
+ }
283
+ }
@@ -0,0 +1,132 @@
1
+ // Persistence: one JSON file per session under ~/.wisp/sessions, plus config.json.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { parsePresentation } from './protocol.js';
7
+
8
+ const DEFAULT_CONFIG = {
9
+ projects: [], // [{ id, name, path, color, addedAt }]
10
+ defaults: { agent: 'claude', mode: 'edits', model: '' },
11
+ };
12
+
13
+ export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
14
+
15
+ export class Store {
16
+ constructor(home = process.env.WISP_HOME || path.join(os.homedir(), '.wisp')) {
17
+ this.home = home;
18
+ this.dir = path.join(home, 'sessions');
19
+ fs.mkdirSync(this.dir, { recursive: true });
20
+ this.sessions = new Map();
21
+ this.timers = new Map();
22
+ this.config = this.#loadConfig();
23
+
24
+ for (const f of fs.readdirSync(this.dir)) {
25
+ if (!f.endsWith('.json')) continue;
26
+ try {
27
+ const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
28
+ reparse(s);
29
+ this.sessions.set(s.id, s);
30
+ } catch (err) {
31
+ console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ #loadConfig() {
37
+ try {
38
+ const raw = JSON.parse(fs.readFileSync(path.join(this.home, 'config.json'), 'utf8'));
39
+ const cfg = { ...structuredClone(DEFAULT_CONFIG), ...raw, defaults: { ...DEFAULT_CONFIG.defaults, ...raw.defaults } };
40
+ cfg.projects ||= [];
41
+ return cfg;
42
+ } catch {
43
+ return structuredClone(DEFAULT_CONFIG);
44
+ }
45
+ }
46
+
47
+ saveConfig() {
48
+ fs.writeFileSync(path.join(this.home, 'config.json'), JSON.stringify(this.config, null, 2));
49
+ }
50
+
51
+ // --- projects: plain folders, matched to sessions by path prefix ---
52
+
53
+ projectFor(cwd = '') {
54
+ let best = null;
55
+ for (const p of this.config.projects)
56
+ if ((cwd === p.path || cwd.startsWith(p.path + path.sep)) && (!best || p.path.length > best.path.length)) best = p;
57
+ return best;
58
+ }
59
+
60
+ addProject(dir, { name, color } = {}) {
61
+ const existing = this.config.projects.find((p) => p.path === dir);
62
+ if (existing) return existing;
63
+ const used = new Set(this.config.projects.map((p) => p.color));
64
+ const project = {
65
+ id: Math.random().toString(36).slice(2, 10),
66
+ name: name || path.basename(dir) || dir,
67
+ path: dir,
68
+ color: color || PROJECT_COLORS.find((c) => !used.has(c)) || PROJECT_COLORS[this.config.projects.length % PROJECT_COLORS.length],
69
+ addedAt: Date.now(),
70
+ };
71
+ this.config.projects.push(project);
72
+ this.saveConfig();
73
+ return project;
74
+ }
75
+
76
+ touchProject(dir) {
77
+ const p = this.projectFor(dir) || this.addProject(dir);
78
+ p.lastUsedAt = Date.now();
79
+ this.saveConfig();
80
+ return p;
81
+ }
82
+
83
+ all() {
84
+ return [...this.sessions.values()];
85
+ }
86
+
87
+ get(id) {
88
+ return this.sessions.get(id);
89
+ }
90
+
91
+ put(session) {
92
+ this.sessions.set(session.id, session);
93
+ this.save(session);
94
+ }
95
+
96
+ // Debounced write so streaming activity doesn't hammer the disk.
97
+ save(session, { now = false } = {}) {
98
+ clearTimeout(this.timers.get(session.id));
99
+ const write = () => {
100
+ this.timers.delete(session.id);
101
+ if (!this.sessions.has(session.id)) return;
102
+ const file = path.join(this.dir, `${session.id}.json`);
103
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(session));
104
+ fs.renameSync(`${file}.tmp`, file);
105
+ };
106
+ if (now) write();
107
+ else this.timers.set(session.id, setTimeout(write, 400));
108
+ }
109
+
110
+ delete(id) {
111
+ clearTimeout(this.timers.get(id));
112
+ this.sessions.delete(id);
113
+ fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
114
+ }
115
+
116
+ flush() {
117
+ for (const id of [...this.timers.keys()]) {
118
+ const s = this.sessions.get(id);
119
+ if (s) this.save(s, { now: true });
120
+ }
121
+ }
122
+ }
123
+
124
+ // Turns that fell back to plain text are re-parsed from their raw answer on load, so parser
125
+ // fixes repair old turns too.
126
+ function reparse(session) {
127
+ for (const t of session.turns || []) {
128
+ if (!t.presentation?.fallback || typeof t.raw !== 'string') continue;
129
+ const p = parsePresentation(t.raw);
130
+ if (!p.fallback) t.presentation = p;
131
+ }
132
+ }