wisp-agents 0.0.0-stage → 0.1.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,114 @@
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
+ const FENCE = /```wisp[^\n]*\n([\s\S]*?)\n?```/g;
80
+
81
+ export function parsePresentation(text = '') {
82
+ const matches = [...text.matchAll(FENCE)];
83
+ const last = matches.at(-1);
84
+ if (last) {
85
+ const json = tryJson(last[1]);
86
+ const blocks = Array.isArray(json) ? json : json?.blocks;
87
+ if (Array.isArray(blocks) && blocks.length) {
88
+ const extra = text.slice(0, last.index).trim();
89
+ return { blocks: blocks.filter((b) => b && typeof b.type === 'string'), notes: extra || undefined };
90
+ }
91
+ }
92
+ // The agent ignored (or broke) the protocol — show its answer as plain markdown.
93
+ return {
94
+ fallback: true,
95
+ blocks: [{ type: 'markdown', text: text.replace(FENCE, '').trim() || '_The agent finished without a message._' }],
96
+ };
97
+ }
98
+
99
+ function tryJson(src) {
100
+ try {
101
+ return JSON.parse(src);
102
+ } catch {}
103
+ // Common LLM slips: trailing commas, smart quotes.
104
+ const relaxed = src.replace(/,\s*([}\]])/g, '$1').replace(/[“”]/g, '"');
105
+ try {
106
+ return JSON.parse(relaxed);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ export function heroOf(presentation) {
113
+ return presentation?.blocks?.find((b) => b.type === 'hero');
114
+ }
@@ -0,0 +1,282 @@
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.presentation = parsePresentation(ev.text);
266
+ const hero = heroOf(turn.presentation);
267
+ if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
268
+ }
269
+ session.state = 'review';
270
+ session.unread = true;
271
+ this.store.save(session, { now: true });
272
+ this.#touch(session, { turn: turn.id });
273
+ this.emit('finished', { session: summarize(session, this.store) });
274
+ }
275
+
276
+ #touch(session, { turn, quiet } = {}) {
277
+ session.updatedAt = Date.now();
278
+ this.store.save(session);
279
+ if (!quiet) this.emit('summary', summarize(session, this.store));
280
+ if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
281
+ }
282
+ }
@@ -0,0 +1,120 @@
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
+
7
+ const DEFAULT_CONFIG = {
8
+ projects: [], // [{ id, name, path, color, addedAt }]
9
+ defaults: { agent: 'claude', mode: 'edits', model: '' },
10
+ };
11
+
12
+ export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
13
+
14
+ export class Store {
15
+ constructor(home = process.env.WISP_HOME || path.join(os.homedir(), '.wisp')) {
16
+ this.home = home;
17
+ this.dir = path.join(home, 'sessions');
18
+ fs.mkdirSync(this.dir, { recursive: true });
19
+ this.sessions = new Map();
20
+ this.timers = new Map();
21
+ this.config = this.#loadConfig();
22
+
23
+ for (const f of fs.readdirSync(this.dir)) {
24
+ if (!f.endsWith('.json')) continue;
25
+ try {
26
+ const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
27
+ this.sessions.set(s.id, s);
28
+ } catch (err) {
29
+ console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
30
+ }
31
+ }
32
+ }
33
+
34
+ #loadConfig() {
35
+ try {
36
+ const raw = JSON.parse(fs.readFileSync(path.join(this.home, 'config.json'), 'utf8'));
37
+ const cfg = { ...structuredClone(DEFAULT_CONFIG), ...raw, defaults: { ...DEFAULT_CONFIG.defaults, ...raw.defaults } };
38
+ cfg.projects ||= [];
39
+ return cfg;
40
+ } catch {
41
+ return structuredClone(DEFAULT_CONFIG);
42
+ }
43
+ }
44
+
45
+ saveConfig() {
46
+ fs.writeFileSync(path.join(this.home, 'config.json'), JSON.stringify(this.config, null, 2));
47
+ }
48
+
49
+ // --- projects: plain folders, matched to sessions by path prefix ---
50
+
51
+ projectFor(cwd = '') {
52
+ let best = null;
53
+ for (const p of this.config.projects)
54
+ if ((cwd === p.path || cwd.startsWith(p.path + path.sep)) && (!best || p.path.length > best.path.length)) best = p;
55
+ return best;
56
+ }
57
+
58
+ addProject(dir, { name, color } = {}) {
59
+ const existing = this.config.projects.find((p) => p.path === dir);
60
+ if (existing) return existing;
61
+ const used = new Set(this.config.projects.map((p) => p.color));
62
+ const project = {
63
+ id: Math.random().toString(36).slice(2, 10),
64
+ name: name || path.basename(dir) || dir,
65
+ path: dir,
66
+ color: color || PROJECT_COLORS.find((c) => !used.has(c)) || PROJECT_COLORS[this.config.projects.length % PROJECT_COLORS.length],
67
+ addedAt: Date.now(),
68
+ };
69
+ this.config.projects.push(project);
70
+ this.saveConfig();
71
+ return project;
72
+ }
73
+
74
+ touchProject(dir) {
75
+ const p = this.projectFor(dir) || this.addProject(dir);
76
+ p.lastUsedAt = Date.now();
77
+ this.saveConfig();
78
+ return p;
79
+ }
80
+
81
+ all() {
82
+ return [...this.sessions.values()];
83
+ }
84
+
85
+ get(id) {
86
+ return this.sessions.get(id);
87
+ }
88
+
89
+ put(session) {
90
+ this.sessions.set(session.id, session);
91
+ this.save(session);
92
+ }
93
+
94
+ // Debounced write so streaming activity doesn't hammer the disk.
95
+ save(session, { now = false } = {}) {
96
+ clearTimeout(this.timers.get(session.id));
97
+ const write = () => {
98
+ this.timers.delete(session.id);
99
+ if (!this.sessions.has(session.id)) return;
100
+ const file = path.join(this.dir, `${session.id}.json`);
101
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(session));
102
+ fs.renameSync(`${file}.tmp`, file);
103
+ };
104
+ if (now) write();
105
+ else this.timers.set(session.id, setTimeout(write, 400));
106
+ }
107
+
108
+ delete(id) {
109
+ clearTimeout(this.timers.get(id));
110
+ this.sessions.delete(id);
111
+ fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
112
+ }
113
+
114
+ flush() {
115
+ for (const id of [...this.timers.keys()]) {
116
+ const s = this.sessions.get(id);
117
+ if (s) this.save(s, { now: true });
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,73 @@
1
+ // Open a command in the user's terminal so they can take over an agent session interactively.
2
+
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+
6
+ export const TERMINALS = [
7
+ { 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' },
14
+ { id: 'custom', label: 'Custom command' },
15
+ ];
16
+
17
+ export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
18
+
19
+ const has = (bin) => {
20
+ try {
21
+ execFileSync('which', [bin], { stdio: 'ignore' });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ };
27
+
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';
32
+ return 'terminal';
33
+ }
34
+ for (const t of ['ghostty', 'kitty', 'wezterm']) if (has(t)) return t;
35
+ return 'x-terminal-emulator';
36
+ }
37
+
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 = '' }) {
43
+ const cmd = argv.map(shq).join(' ');
44
+ const line = `cd ${shq(cwd)} && ${cmd}`;
45
+ const t = terminal === 'auto' ? detect() : terminal;
46
+ const mac = process.platform === 'darwin';
47
+
48
+ switch (t) {
49
+ case 'terminal':
50
+ return osa(`tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell`);
51
+ 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`);
53
+ case 'warp':
54
+ // 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);
57
+ 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();
60
+ 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();
63
+ case 'wezterm':
64
+ return spawn(mac ? '/Applications/WezTerm.app/Contents/MacOS/wezterm' : 'wezterm', ['start', '--cwd', cwd, '--', ...argv], { detached: true, stdio: 'ignore' }).unref();
65
+ case 'custom': {
66
+ if (!custom) throw new Error('Set a custom terminal command in settings, e.g. alacritty --working-directory {cwd} -e {cmd}');
67
+ 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();
69
+ }
70
+ default:
71
+ return spawn('x-terminal-emulator', ['-e', 'sh', '-c', `${line}; exec $SHELL`], { detached: true, stdio: 'ignore' }).unref();
72
+ }
73
+ }