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 +8 -2
- package/bin/wisp.js +48 -8
- package/electron/icon.png +0 -0
- package/electron/icon.svg +13 -0
- package/electron/preload.cjs +6 -0
- package/package.json +4 -3
- package/server/protocol.js +76 -8
- package/server/runner.js +1 -0
- package/server/store.js +12 -0
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
|
+
[](https://www.npmjs.com/package/wisp-agents)
|
|
11
12
|
[](#license)
|
|
12
13
|
[](https://nodejs.org)
|
|
13
14
|
[](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
|
|
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
|
-
| `--
|
|
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
|
|
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
|
-
// --
|
|
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
|
-
|
|
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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "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
|
-
"
|
|
46
|
+
"optionalDependencies": {
|
|
46
47
|
"electron": "^44.8.0"
|
|
47
48
|
}
|
|
48
49
|
}
|
package/server/protocol.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const json =
|
|
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,
|
|
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:
|
|
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
|
+
}
|