wisp-agents 0.1.0 → 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.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  Launch coding agents in the background, skip the transcript, and read the presentation they hand back when they finish.
10
10
 
11
+ [![npm](https://img.shields.io/npm/v/wisp-agents?color=cb3837&logo=npm)](https://www.npmjs.com/package/wisp-agents)
11
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-6a5cff.svg)](#license)
12
13
  [![Node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org)
13
14
  [![Electron](https://img.shields.io/badge/desktop-Electron-47848f?logo=electron&logoColor=white)](https://www.electronjs.org)
@@ -51,9 +52,13 @@ From there you can **follow up**, **answer its questions**, **take it over** in
51
52
  > **Requirements:** Node.js ≥ 20 and [Claude Code](https://claude.com/claude-code) (`claude`) installed and signed in.
52
53
 
53
54
  ```sh
54
- npx wisp-agents # → http://localhost:4477
55
+ npx wisp-agents # open the wisp desktop app
56
+ npx wisp-agents "fix the flaky auth test" # launch a task in the current folder
57
+ npx wisp-agents --browser # or use it in the browser → http://localhost:4477
55
58
  ```
56
59
 
60
+ The first run downloads Electron for the desktop app; if that isn't possible, wisp opens in the browser instead.
61
+
57
62
  Or install it globally to get the `wisp` command everywhere:
58
63
 
59
64
  ```sh
@@ -99,7 +104,8 @@ wisp --mode plan "audit deps" # permission mode: plan | edits | auto
99
104
  |---|---|
100
105
  | `--port`, `-p N` | Port to use (default `4477`, or `WISP_PORT`) |
101
106
  | `--mode`, `-m` | `plan` · `edits` · `auto` |
102
- | `--no-open` | Don't open a browser |
107
+ | `--browser`, `-b` | Use the browser instead of the desktop app |
108
+ | `--no-open` | Just run the server; open nothing |
103
109
  | `--version`, `-v` | Print the version |
104
110
 
105
111
  > `npm install -g wisp-agents` (or `npm link` from a clone) puts `wisp` on your `PATH`. Without installing, use `npx wisp-agents …` with the same arguments.
package/bin/wisp.js CHANGED
@@ -1,22 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // wisp — launcher + queue for coding agents.
3
3
  //
4
- // wisp start wisp (or focus the running one) and open the browser
4
+ // wisp open the wisp desktop app (or focus the running one)
5
5
  // wisp "fix the tests" launch a wisp in the current folder
6
6
  // wisp add [path] add a folder as a project (default: current folder)
7
7
  // --port N port (default 4477, or WISP_PORT)
8
8
  // --mode plan|edits|auto permission mode for a launch
9
- // --no-open don't open a browser
9
+ // --browser use the browser instead of the desktop app
10
+ // --no-open just run the server, open nothing
10
11
 
11
- import { exec } from 'node:child_process';
12
+ import { exec, spawn } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
12
15
  import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
13
17
 
14
18
  const argv = process.argv.slice(2);
15
- const flags = { open: true, port: Number(process.env.WISP_PORT) || 4477, mode: null };
19
+ const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null };
16
20
  const words = [];
17
21
  for (let i = 0; i < argv.length; i++) {
18
22
  const a = argv[i];
19
23
  if (a === '--no-open') flags.open = false;
24
+ else if (a === '--browser' || a === '-b') flags.browser = true;
20
25
  else if (a === '--port' || a === '-p') flags.port = Number(argv[++i]);
21
26
  else if (a === '--mode' || a === '-m') flags.mode = argv[++i];
22
27
  else if (a === '--version' || a === '-v') {
@@ -24,7 +29,7 @@ for (let i = 0; i < argv.length; i++) {
24
29
  console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
25
30
  process.exit(0);
26
31
  } else if (a === '--help' || a === '-h') {
27
- console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --no-open --version');
32
+ console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --browser --no-open --version');
28
33
  process.exit(0);
29
34
  } else words.push(a);
30
35
  }
@@ -47,7 +52,20 @@ try {
47
52
  running = true;
48
53
  } catch {}
49
54
 
50
- if (!running) {
55
+ // The desktop app (Electron is an optional dependency) runs the server in its own process, so
56
+ // it outlives this terminal. Without Electron, or with --browser/--no-open, serve from here.
57
+ // A task or `add` sent to an already-running wisp just goes over the API, no window.
58
+ const electron = flags.open && !flags.browser && (!running || !words.length) ? electronBinary() : null;
59
+ if (electron) {
60
+ const child = spawn(electron, [fileURLToPath(new URL('..', import.meta.url))], {
61
+ detached: true,
62
+ stdio: 'ignore',
63
+ env: { ...process.env, WISP_PORT: String(flags.port) },
64
+ });
65
+ child.unref();
66
+ if (!running) await waitForServer();
67
+ console.log(`\n ◌ wisp is open\n`);
68
+ } else if (!running) {
51
69
  const { startServer } = await import('../server/index.js');
52
70
  await startServer({ port: flags.port });
53
71
  console.log(`\n ◌ wisp is floating at ${base}\n`);
@@ -58,16 +76,38 @@ if (words[0] === 'add') {
58
76
  const dir = path.resolve(words[1] || process.cwd());
59
77
  const p = await api('/projects', { path: dir }).catch((e) => console.error(` ✗ ${e.message}`));
60
78
  if (p) console.log(` ✓ project added: ${p.name} (${p.path})`);
61
- if (running) process.exit(0);
79
+ if (running || electron) process.exit(0);
62
80
  } else if (words.length) {
63
81
  const cfg = await api('/config');
64
82
  const s = await api('/sessions', { prompt: words.join(' '), cwd: process.cwd(), agent: cfg.defaults.agent, mode: flags.mode || cfg.defaults.mode, model: cfg.defaults.model });
65
83
  console.log(` ◌ launched: ${s.title}`);
66
84
  openPath = `/#/s/${s.id}`;
67
- if (running) process.exit(0); // the running wisp will show it in the queue
85
+ if (running || electron) process.exit(0); // the running wisp will show it in the queue
68
86
  }
69
87
 
88
+ if (electron) process.exit(0);
70
89
  if (flags.open) {
71
90
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
72
91
  exec(`${cmd} "${base}${openPath}"`);
73
92
  }
93
+
94
+ function electronBinary() {
95
+ try {
96
+ const bin = createRequire(import.meta.url)('electron'); // the electron package exports its binary path
97
+ return typeof bin === 'string' && existsSync(bin) ? bin : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function waitForServer() {
104
+ for (let i = 0; i < 150; i++) {
105
+ try {
106
+ return await api('/config');
107
+ } catch {
108
+ await new Promise((r) => setTimeout(r, 200));
109
+ }
110
+ }
111
+ console.error(' ✗ the wisp app did not start in time — try `wisp --browser`');
112
+ process.exit(1);
113
+ }
Binary file
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1c22"/><stop offset="1" stop-color="#0e0e10"/></linearGradient>
4
+ <radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#6a5cff" stop-opacity=".55"/><stop offset="1" stop-color="#6a5cff" stop-opacity="0"/></radialGradient>
5
+ <radialGradient id="orb" cx="36%" cy="32%" r="68%"><stop offset="0" stop-color="#fff"/><stop offset=".16" stop-color="#d9fff6"/><stop offset=".38" stop-color="#2fd0b4"/><stop offset=".78" stop-color="#6a5cff"/><stop offset="1" stop-color="#4b3fe0"/></radialGradient>
6
+ </defs>
7
+ <rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
8
+ <circle cx="512" cy="512" r="330" fill="url(#glow)"/>
9
+ <circle cx="512" cy="512" r="210" fill="url(#orb)"/>
10
+ <circle cx="742" cy="360" r="16" fill="#fff" opacity=".9"/>
11
+ <circle cx="300" cy="650" r="10" fill="#bff" opacity=".7"/>
12
+ <circle cx="690" cy="720" r="7" fill="#fff" opacity=".6"/>
13
+ </svg>
@@ -0,0 +1,6 @@
1
+ // Minimal bridge for desktop-only niceties. Sandboxed preloads must be CommonJS.
2
+ const { contextBridge, ipcRenderer } = require('electron');
3
+
4
+ contextBridge.exposeInMainWorld('wispDesktop', {
5
+ pickFolder: () => ipcRenderer.invoke('wisp:pick-folder'),
6
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "bin/",
12
+ "electron/",
12
13
  "server/",
13
14
  "web/"
14
15
  ],
@@ -17,7 +18,7 @@
17
18
  "app": "electron .",
18
19
  "dev": "WISP_DEV=1 electron .",
19
20
  "dev:web": "node --watch-path=server bin/wisp.js --no-open",
20
- "prepublishOnly": "node --check bin/wisp.js && node -e \"import('./server/index.js')\""
21
+ "prepublishOnly": "node --check bin/wisp.js && node -e \"import('./server/index.js')\" && node --check electron/main.js"
21
22
  },
22
23
  "engines": {
23
24
  "node": ">=20"
@@ -42,7 +43,7 @@
42
43
  "url": "https://github.com/M0xoo/wisp/issues"
43
44
  },
44
45
  "main": "electron/main.js",
45
- "devDependencies": {
46
+ "optionalDependencies": {
46
47
  "electron": "^44.8.0"
47
48
  }
48
49
  }
@@ -76,26 +76,94 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
76
76
  \`\`\`
77
77
  `.trim();
78
78
 
79
- const FENCE = /```wisp[^\n]*\n([\s\S]*?)\n?```/g;
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;
80
82
 
81
83
  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]);
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));
86
88
  const blocks = Array.isArray(json) ? json : json?.blocks;
87
89
  if (Array.isArray(blocks) && blocks.length) {
88
- const extra = text.slice(0, last.index).trim();
90
+ const extra = text.slice(0, open.index).trim();
89
91
  return { blocks: blocks.filter((b) => b && typeof b.type === 'string'), notes: extra || undefined };
90
92
  }
91
93
  }
92
- // The agent ignored (or broke) the protocol — show its answer as plain markdown.
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
+ }
93
103
  return {
94
104
  fallback: true,
95
- blocks: [{ type: 'markdown', text: text.replace(FENCE, '').trim() || '_The agent finished without a message._' }],
105
+ blocks: [{ type: 'markdown', text: md || '_The agent finished without a message._' }],
96
106
  };
97
107
  }
98
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
+
99
167
  function tryJson(src) {
100
168
  try {
101
169
  return JSON.parse(src);
package/server/runner.js CHANGED
@@ -262,6 +262,7 @@ export class Runner extends EventEmitter {
262
262
  };
263
263
  } else {
264
264
  turn.status = 'done';
265
+ turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
265
266
  turn.presentation = parsePresentation(ev.text);
266
267
  const hero = heroOf(turn.presentation);
267
268
  if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
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 { parsePresentation } from './protocol.js';
6
7
 
7
8
  const DEFAULT_CONFIG = {
8
9
  projects: [], // [{ id, name, path, color, addedAt }]
@@ -24,6 +25,7 @@ export class Store {
24
25
  if (!f.endsWith('.json')) continue;
25
26
  try {
26
27
  const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
28
+ reparse(s);
27
29
  this.sessions.set(s.id, s);
28
30
  } catch (err) {
29
31
  console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
@@ -118,3 +120,13 @@ export class Store {
118
120
  }
119
121
  }
120
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
+ }