wisp-agents 0.15.0 → 0.17.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/CHANGELOG.md +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
package/server/adapters/grok.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
|
|
12
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
|
|
13
13
|
|
|
14
14
|
const BIN = process.env.WISP_GROK_BIN || 'grok';
|
|
15
15
|
|
|
@@ -19,6 +19,9 @@ const PERMISSION_MODES = {
|
|
|
19
19
|
auto: 'bypassPermissions',
|
|
20
20
|
};
|
|
21
21
|
const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
22
|
+
// A headless run refuses what would need approval and names it on stderr, once per call:
|
|
23
|
+
// "permission required: Edit calc.py was not run, as a headless run cannot ask for approval".
|
|
24
|
+
const REFUSED = /permission required: (\S+) ?(.*?) was not run\b/g;
|
|
22
25
|
|
|
23
26
|
export default {
|
|
24
27
|
id: 'grok',
|
|
@@ -75,6 +78,7 @@ export default {
|
|
|
75
78
|
let session = null;
|
|
76
79
|
let sawResult = false;
|
|
77
80
|
let error = null;
|
|
81
|
+
let carry = ''; // a stderr line still being written
|
|
78
82
|
|
|
79
83
|
const handle = spawnJsonl({
|
|
80
84
|
bin: BIN,
|
|
@@ -82,6 +86,11 @@ export default {
|
|
|
82
86
|
cwd,
|
|
83
87
|
env: { ...process.env, GROK_DISABLE_AUTOUPDATER: '1' },
|
|
84
88
|
onEvent,
|
|
89
|
+
onStderr(chunk) {
|
|
90
|
+
const lines = (carry + chunk).split('\n');
|
|
91
|
+
carry = lines.pop();
|
|
92
|
+
for (const [, tool, detail] of lines.join('\n').matchAll(REFUSED)) onEvent({ type: 'blocked', tool, detail: detail.replace(/ \(workflow .*\)$/, '') });
|
|
93
|
+
},
|
|
85
94
|
onJson(msg) {
|
|
86
95
|
if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
|
|
87
96
|
// Subagent traffic carries its parent's tool id; only the main conversation is shown.
|
|
@@ -137,7 +146,7 @@ export default {
|
|
|
137
146
|
},
|
|
138
147
|
onClose({ code, signal, stderr, stopped }) {
|
|
139
148
|
if (sawResult) return;
|
|
140
|
-
onEvent({ type: 'result', isError: true, stopped, text: error || stderr ||
|
|
149
|
+
onEvent({ type: 'result', isError: true, stopped, text: error || stderr || exitText('grok', code, signal) });
|
|
141
150
|
},
|
|
142
151
|
});
|
|
143
152
|
handle.done.finally(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
@@ -232,6 +241,12 @@ function describeMcp(tool, input) {
|
|
|
232
241
|
return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
233
242
|
case 'wisp__subagent_results':
|
|
234
243
|
return { kind: 'agent', label: 'Waiting on subagents' };
|
|
244
|
+
case 'wisp__wisp_stats':
|
|
245
|
+
return { kind: 'tool', label: 'Reading wisp stats' };
|
|
246
|
+
case 'wisp__wisp_sessions':
|
|
247
|
+
return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
248
|
+
case 'wisp__wisp_session':
|
|
249
|
+
return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
|
|
235
250
|
default:
|
|
236
251
|
return { kind: 'tool', label: `Using ${tool.split('__').join(' · ') || 'an integration'}` };
|
|
237
252
|
}
|
package/server/adapters/index.js
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
// efforts list wins); omit if the CLI has none
|
|
8
8
|
// askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
|
|
9
9
|
// null = can't inject MCP, ask via a question block
|
|
10
|
+
// approveTool?: string its permission prompts go to wisp's `approve` MCP
|
|
11
|
+
// tool (live approval); omit if it can't route them
|
|
12
|
+
// permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
|
|
10
13
|
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
11
14
|
// resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
|
|
12
|
-
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
|
|
15
|
+
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, grants, onEvent }): { stop(), done: Promise }
|
|
16
|
+
// grants: `rule`s from its own blocked events the
|
|
17
|
+
// user allowed, for this turn only
|
|
13
18
|
// }
|
|
14
19
|
//
|
|
15
20
|
// onEvent receives normalized events:
|
|
@@ -19,6 +24,8 @@
|
|
|
19
24
|
// { type: 'text', text } intermediate assistant prose
|
|
20
25
|
// { type: 'thinking' }
|
|
21
26
|
// { type: 'change', file, op, edits: [{ before, after }] }
|
|
27
|
+
// { type: 'blocked', tool, detail, rule? } a call its permissions refused; rule: what allows
|
|
28
|
+
// exactly that on a retry (none: retry in Full auto)
|
|
22
29
|
// { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
|
|
23
30
|
//
|
|
24
31
|
// mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
|
|
@@ -31,7 +38,23 @@ import cursor from './cursor.js';
|
|
|
31
38
|
import antigravity from './antigravity.js';
|
|
32
39
|
import grok from './grok.js';
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
// The shape above, checked when wisp starts: a missing piece fails loudly here, not mid-run.
|
|
42
|
+
function checked(a) {
|
|
43
|
+
const missing = [
|
|
44
|
+
typeof a.id !== 'string' && 'id',
|
|
45
|
+
typeof a.name !== 'string' && 'name',
|
|
46
|
+
!Array.isArray(a.models) && 'models',
|
|
47
|
+
a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
|
|
48
|
+
a.approveTool !== undefined && (typeof a.approveTool !== 'string' || typeof a.permission !== 'function') && 'approveTool + permission()',
|
|
49
|
+
typeof a.detect !== 'function' && 'detect()',
|
|
50
|
+
typeof a.run !== 'function' && 'run()',
|
|
51
|
+
a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
|
|
52
|
+
].filter(Boolean);
|
|
53
|
+
if (missing.length) throw new Error(`adapter ${a.id || '?'}: missing or wrong ${missing.join(', ')}`);
|
|
54
|
+
return a;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, checked(a)]));
|
|
35
58
|
|
|
36
59
|
const detected = new Map();
|
|
37
60
|
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
// opencode adapter — drives `opencode run --format json` (one JSON event per line).
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { toolsDir } from '../util.js';
|
|
7
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
4
8
|
|
|
5
|
-
const
|
|
9
|
+
const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
10
|
+
// Where opencode lives when it isn't on PATH: the copy wisp installs (POST /agents/install),
|
|
11
|
+
// and the official install script's folder (it edits your shell profile, which an app started
|
|
12
|
+
// from the Dock or a service never reads).
|
|
13
|
+
export const fallbackBins = () => [path.join(toolsDir(), 'node_modules', '.bin', exe), path.join(os.homedir(), '.opencode', 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode')];
|
|
14
|
+
let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
|
|
15
|
+
// Rate-limited (the free models, mostly), `opencode run` keeps retrying and prints nothing, so
|
|
16
|
+
// the task would sit at "Starting up" for good. Its error log says so: give it this long to get
|
|
17
|
+
// through, then stop and tell the user what to do.
|
|
18
|
+
const rateLimitGrace = () => Number(process.env.WISP_RATE_LIMIT_GRACE_MS) || 60_000;
|
|
19
|
+
const RATE_LIMITED = /Rate limit exceeded|Too Many Requests|status(?:Code)?[=: ]+429\b/i;
|
|
20
|
+
// `run` answers every permission prompt itself and says so on stderr (cli/cmd/run.ts):
|
|
21
|
+
// "! permission requested: bash (npm publish); auto-rejecting".
|
|
22
|
+
const REJECTED = /permission requested: ([\w.-]+) \((.*?)\); auto-rejecting/g;
|
|
23
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
6
24
|
|
|
7
25
|
export default {
|
|
8
26
|
id: 'opencode',
|
|
@@ -16,7 +34,16 @@ export default {
|
|
|
16
34
|
},
|
|
17
35
|
|
|
18
36
|
async detect() {
|
|
19
|
-
|
|
37
|
+
let found = await detectVersion(BIN);
|
|
38
|
+
if (!found.available && !process.env.WISP_OPENCODE_BIN) {
|
|
39
|
+
for (const bin of fallbackBins().filter((b) => fs.existsSync(b))) {
|
|
40
|
+
const there = await detectVersion(bin);
|
|
41
|
+
if (!there.available) continue;
|
|
42
|
+
BIN = bin;
|
|
43
|
+
found = there;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
20
47
|
if (!found.available) return found;
|
|
21
48
|
// `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
|
|
22
49
|
// the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
|
|
@@ -38,9 +65,11 @@ export default {
|
|
|
38
65
|
return { ...found, models: [this.models[0], ...models] };
|
|
39
66
|
},
|
|
40
67
|
|
|
41
|
-
|
|
68
|
+
// grants: { permission, patterns } the user allowed after `run` rejected them, for this turn.
|
|
69
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
42
70
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
43
|
-
|
|
71
|
+
// Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
|
|
72
|
+
const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
|
|
44
73
|
if (mode === 'plan') args.push('--agent', 'plan');
|
|
45
74
|
if (mode === 'auto') args.push('--auto');
|
|
46
75
|
if (model) args.push('--model', model);
|
|
@@ -54,14 +83,20 @@ export default {
|
|
|
54
83
|
mcp[name] = { type: 'local', command: [srv.command, ...(srv.args || [])], environment: srv.env || {}, enabled: true, timeout: ASK_TIMEOUT_MS };
|
|
55
84
|
}
|
|
56
85
|
const env = { ...process.env };
|
|
57
|
-
|
|
86
|
+
const allow = grants.filter((g) => g && typeof g.permission === 'string' && Array.isArray(g.patterns));
|
|
87
|
+
if (Object.keys(mcp).length || allow.length) {
|
|
58
88
|
const inline = env.OPENCODE_CONFIG_CONTENT ? safeJson(env.OPENCODE_CONFIG_CONTENT) : {};
|
|
89
|
+
const permission = typeof inline.permission === 'object' ? { ...inline.permission } : inline.permission ? { '*': inline.permission } : {};
|
|
90
|
+
// Its built-in `task` subagents would bypass the user's team.
|
|
91
|
+
if (delegating) permission.task = 'deny';
|
|
92
|
+
for (const g of allow) {
|
|
93
|
+
const was = permission[g.permission];
|
|
94
|
+
permission[g.permission] = { ...(typeof was === 'object' ? was : was && { '*': was }), ...Object.fromEntries(g.patterns.map((p) => [String(p), 'allow'])) };
|
|
95
|
+
}
|
|
59
96
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
60
97
|
...inline,
|
|
61
|
-
mcp: { ...inline.mcp, ...mcp },
|
|
62
|
-
|
|
63
|
-
// Its built-in `task` subagents would bypass the user's team.
|
|
64
|
-
...(delegating && { permission: { ...(typeof inline.permission === 'object' ? inline.permission : inline.permission && { '*': inline.permission }), task: 'deny' } }),
|
|
98
|
+
...(Object.keys(mcp).length && { mcp: { ...inline.mcp, ...mcp }, experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS } }),
|
|
99
|
+
...(Object.keys(permission).length && { permission }),
|
|
65
100
|
});
|
|
66
101
|
}
|
|
67
102
|
|
|
@@ -75,14 +110,30 @@ export default {
|
|
|
75
110
|
const tokens = { input: 0, output: 0 };
|
|
76
111
|
let cost = 0;
|
|
77
112
|
|
|
78
|
-
|
|
113
|
+
let limitTimer = null;
|
|
114
|
+
let limited = false;
|
|
115
|
+
let carry = ''; // a stderr line still being written
|
|
116
|
+
const proc = spawnJsonl({
|
|
79
117
|
bin: BIN,
|
|
80
118
|
args,
|
|
81
119
|
cwd,
|
|
82
120
|
env,
|
|
83
121
|
input,
|
|
84
122
|
onEvent,
|
|
123
|
+
onStderr(chunk) {
|
|
124
|
+
// Whole lines only: a chunk can end mid-line.
|
|
125
|
+
const lines = (carry + chunk).split('\n');
|
|
126
|
+
carry = lines.pop();
|
|
127
|
+
for (const [, permission, list] of lines.join('\n').replace(ANSI, '').matchAll(REJECTED)) {
|
|
128
|
+
const patterns = list.split(', ').filter(Boolean);
|
|
129
|
+
onEvent({ type: 'blocked', tool: permission, detail: patterns.join(', '), rule: { permission, patterns } });
|
|
130
|
+
}
|
|
131
|
+
if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
|
|
132
|
+
limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
|
|
133
|
+
},
|
|
85
134
|
onJson(msg) {
|
|
135
|
+
// Anything coming through means a retry got an answer.
|
|
136
|
+
if (limitTimer && msg.type !== 'error') (clearTimeout(limitTimer), (limitTimer = null));
|
|
86
137
|
if (msg.sessionID && msg.sessionID !== session) onEvent({ type: 'session', id: (session = msg.sessionID) });
|
|
87
138
|
const part = msg.part || {};
|
|
88
139
|
switch (msg.type) {
|
|
@@ -118,19 +169,28 @@ export default {
|
|
|
118
169
|
}
|
|
119
170
|
},
|
|
120
171
|
onClose({ code, signal, stderr, stopped }) {
|
|
172
|
+
clearTimeout(limitTimer);
|
|
173
|
+
if (limited) {
|
|
174
|
+
const which = model ? `\`${model}\`` : 'opencode’s default model';
|
|
175
|
+
return onEvent({ type: 'result', isError: true, text: `${which} is rate-limited right now: opencode kept retrying for ${Math.round(rateLimitGrace() / 1000)}s without an answer. Try again in a few minutes, pick another model, or connect your own provider with \`opencode auth login\`.`, stats: { durationMs: Date.now() - started } });
|
|
176
|
+
}
|
|
121
177
|
const ok = code === 0 && !error;
|
|
122
178
|
onEvent({
|
|
123
179
|
type: 'result',
|
|
124
180
|
isError: !ok,
|
|
125
181
|
stopped,
|
|
126
|
-
text: ok ? text.join('\n') : error || stderr ||
|
|
182
|
+
text: ok ? text.join('\n') : error || lastLoggedError(stderr) || exitText('opencode', code, signal),
|
|
127
183
|
stats: { durationMs: Date.now() - started, costUsd: cost || undefined, inputTokens: tokens.input, outputTokens: tokens.output },
|
|
128
184
|
});
|
|
129
185
|
},
|
|
130
186
|
});
|
|
187
|
+
return proc;
|
|
131
188
|
},
|
|
132
189
|
};
|
|
133
190
|
|
|
191
|
+
// stderr with --print-logs: the last logged error's message, else stderr as it is.
|
|
192
|
+
const lastLoggedError = (stderr) => [...stderr.matchAll(/error\.error="((?:[^"\\]|\\.)*)"/g)].at(-1)?.[1] || stderr;
|
|
193
|
+
|
|
134
194
|
function safeJson(s) {
|
|
135
195
|
try {
|
|
136
196
|
return JSON.parse(s) || {};
|
|
@@ -175,6 +235,12 @@ function describeTool(name, input, cwd) {
|
|
|
175
235
|
return { kind: 'agent', label: `Delegated to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
176
236
|
case 'wisp_subagent_results':
|
|
177
237
|
return { kind: 'agent', label: 'Collected subagent reports' };
|
|
238
|
+
case 'wisp_wisp_stats':
|
|
239
|
+
return { kind: 'tool', label: 'Reading wisp stats' };
|
|
240
|
+
case 'wisp_wisp_sessions':
|
|
241
|
+
return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
242
|
+
case 'wisp_wisp_session':
|
|
243
|
+
return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
|
|
178
244
|
default:
|
|
179
245
|
return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
|
|
180
246
|
}
|
package/server/checkpoint.js
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
|
-
// Per-turn git checkpoints, single-file revert
|
|
1
|
+
// Per-turn git checkpoints, single-file revert and turn rollback.
|
|
2
2
|
|
|
3
|
-
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
|
4
3
|
import fs from 'node:fs';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
7
|
-
import { MAX_SNAPSHOT, rel } from './
|
|
5
|
+
import { gitSync } from './git.js';
|
|
6
|
+
import { MAX_SNAPSHOT, rel } from './paths.js';
|
|
7
|
+
import { bad } from './util.js';
|
|
8
8
|
|
|
9
|
-
const MAX_EXEC_OUTPUT = 32 * 1024;
|
|
10
|
-
const EXEC_TIMEOUT_MS = 60_000;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
return execFileSync('git', args, {
|
|
14
|
-
cwd,
|
|
15
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
-
timeout: 5000,
|
|
17
|
-
...opts,
|
|
18
|
-
});
|
|
19
|
-
}
|
|
10
|
+
const git = gitSync;
|
|
20
11
|
|
|
21
12
|
function tryGit(cwd, args, opts) {
|
|
22
13
|
try {
|
|
@@ -100,12 +91,12 @@ function writeDiskSnapshot(abs, content) {
|
|
|
100
91
|
|
|
101
92
|
function resolveInsideCwd(cwd, file) {
|
|
102
93
|
if (!file || typeof file !== 'string') {
|
|
103
|
-
throw
|
|
94
|
+
throw bad('file path required');
|
|
104
95
|
}
|
|
105
96
|
const abs = path.resolve(cwd, file);
|
|
106
97
|
const relPath = path.relative(cwd, abs);
|
|
107
98
|
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
108
|
-
throw
|
|
99
|
+
throw bad(`File lies outside the project folder: ${file}`);
|
|
109
100
|
}
|
|
110
101
|
return { abs, relPath: rel(file, cwd) || relPath };
|
|
111
102
|
}
|
|
@@ -128,7 +119,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
|
|
|
128
119
|
const entry = findChangeEntry(turn, file, cwd);
|
|
129
120
|
const commit = turn.checkpoint?.commit;
|
|
130
121
|
if (!entry && !commit) {
|
|
131
|
-
throw
|
|
122
|
+
throw bad(`No captured changes or checkpoint for ${relPath}`, 404);
|
|
132
123
|
}
|
|
133
124
|
|
|
134
125
|
if (restore) {
|
|
@@ -145,7 +136,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
|
|
|
145
136
|
writeDiskSnapshot(abs, cur);
|
|
146
137
|
}
|
|
147
138
|
} else {
|
|
148
|
-
throw
|
|
139
|
+
throw bad(`Nothing to re-apply for ${relPath}`);
|
|
149
140
|
}
|
|
150
141
|
if (entry) entry.reverted = false;
|
|
151
142
|
if (turn.changes?.every((c) => !c.reverted)) delete turn.rolledBackAt;
|
|
@@ -200,7 +191,7 @@ function reverseEditsOnDisk(abs, entry) {
|
|
|
200
191
|
export function rollbackToTurn(session, turn, { restore = false } = {}) {
|
|
201
192
|
const cwd = sessionDir(session);
|
|
202
193
|
const idx = session.turns.indexOf(turn);
|
|
203
|
-
if (idx < 0) throw
|
|
194
|
+
if (idx < 0) throw bad('No such turn', 404);
|
|
204
195
|
const affected = session.turns.slice(idx);
|
|
205
196
|
const touchedFiles = [...new Set(affected.flatMap((t) => (t.changes || []).map((c) => c.file)))];
|
|
206
197
|
|
|
@@ -297,52 +288,5 @@ export function rollbackToTurn(session, turn, { restore = false } = {}) {
|
|
|
297
288
|
return { files: touchedFiles, rolledBack: true, commit: cp?.head || cp?.commit || null };
|
|
298
289
|
}
|
|
299
290
|
|
|
300
|
-
//
|
|
301
|
-
export
|
|
302
|
-
const cmd = String(command || '').trim();
|
|
303
|
-
if (!cmd) return Promise.reject(Object.assign(new Error('command required'), { status: 400 }));
|
|
304
|
-
const t0 = Date.now();
|
|
305
|
-
|
|
306
|
-
return new Promise((resolve) => {
|
|
307
|
-
const child = nodeSpawn(cmd, {
|
|
308
|
-
cwd,
|
|
309
|
-
env: { ...process.env, PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', CI: '1' },
|
|
310
|
-
shell: true,
|
|
311
|
-
windowsHide: true,
|
|
312
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
313
|
-
});
|
|
314
|
-
let out = '';
|
|
315
|
-
const append = (chunk) => {
|
|
316
|
-
if (out.length < MAX_EXEC_OUTPUT) out += chunk.toString('utf8').slice(0, MAX_EXEC_OUTPUT - out.length);
|
|
317
|
-
};
|
|
318
|
-
child.stdout.on('data', append);
|
|
319
|
-
child.stderr.on('data', append);
|
|
320
|
-
|
|
321
|
-
let timedOut = false;
|
|
322
|
-
const timer = setTimeout(() => {
|
|
323
|
-
timedOut = true;
|
|
324
|
-
killTree(child);
|
|
325
|
-
}, timeoutMs);
|
|
326
|
-
|
|
327
|
-
child.on('error', (err) => {
|
|
328
|
-
clearTimeout(timer);
|
|
329
|
-
resolve({
|
|
330
|
-
command: cmd,
|
|
331
|
-
output: (out ? `${out}\n` : '') + err.message,
|
|
332
|
-
exitCode: 1,
|
|
333
|
-
durationMs: Date.now() - t0,
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
child.on('close', (code) => {
|
|
338
|
-
clearTimeout(timer);
|
|
339
|
-
const suffix = timedOut ? `\n[timed out after ${Math.round(timeoutMs / 1000)}s]` : '';
|
|
340
|
-
resolve({
|
|
341
|
-
command: cmd,
|
|
342
|
-
output: (out.replace(/\s+$/, '') + suffix).trim(),
|
|
343
|
-
exitCode: timedOut ? 124 : (code ?? 1),
|
|
344
|
-
durationMs: Date.now() - t0,
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
}
|
|
291
|
+
// Moved to proc.js; kept here for anything that still imports it from checkpoint.
|
|
292
|
+
export { execCommand } from './proc.js';
|
package/server/client.js
CHANGED
|
@@ -13,8 +13,9 @@ import fs from 'node:fs';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { DEFAULT_PORT } from './util.js';
|
|
16
17
|
|
|
17
|
-
export
|
|
18
|
+
export { DEFAULT_PORT };
|
|
18
19
|
const BIN = fileURLToPath(new URL('../bin/wisp.js', import.meta.url));
|
|
19
20
|
const MAX_WAIT_CALL = 280; // seconds per long-poll; fetch gives up on headers after 300s
|
|
20
21
|
|
|
@@ -135,7 +136,7 @@ export class WispClient {
|
|
|
135
136
|
if (!agent || !mode || model === undefined) {
|
|
136
137
|
const { defaults = {} } = await this.config();
|
|
137
138
|
agent ||= defaults.agent || 'auto';
|
|
138
|
-
mode ||= defaults.mode || '
|
|
139
|
+
mode ||= defaults.mode || 'auto';
|
|
139
140
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
140
141
|
}
|
|
141
142
|
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
@@ -154,8 +155,9 @@ export class WispClient {
|
|
|
154
155
|
usage = ({ days } = {}) => this.request('GET', `/usage${days ? `?days=${days}` : ''}`);
|
|
155
156
|
|
|
156
157
|
// A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
|
|
157
|
-
// current run instead of interrupting it (default: the user's setting).
|
|
158
|
-
|
|
158
|
+
// current run instead of interrupting it (default: the user's setting). agent / model / effort:
|
|
159
|
+
// run it, and the follow-ups after it, on another harness, model or thinking level.
|
|
160
|
+
reply = (id, text, { attachments, queue, mode, agent, model, effort } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode, agent, model, effort });
|
|
159
161
|
|
|
160
162
|
// Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
|
|
161
163
|
async upload(file, { name, type } = {}) {
|
package/server/git.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
6
7
|
import { execFile } from './proc.js';
|
|
8
|
+
import { bad } from './util.js';
|
|
7
9
|
|
|
8
10
|
const GIT = () => process.env.WISP_GIT_BIN || 'git';
|
|
9
11
|
const GH = () => process.env.WISP_GH_BIN || 'gh';
|
|
@@ -22,6 +24,9 @@ export function run(bin, args, { cwd, timeout = 60_000 } = {}) {
|
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
26
|
export const git = (cwd, args, opts) => run(GIT(), args, { cwd, ...opts });
|
|
27
|
+
// Synchronous, for the few places that must not yield (checkpoints taken right before a turn
|
|
28
|
+
// starts, file snapshots while a harness streams). Returns a Buffer; throws on failure.
|
|
29
|
+
export const gitSync = (cwd, args, opts = {}) => execFileSync(GIT(), args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, env: { ...process.env, ...QUIET_ENV }, ...opts });
|
|
25
30
|
export const gh = (cwd, args, opts) => run(GH(), args, { cwd, timeout: 120_000, ...opts });
|
|
26
31
|
const quiet = (p) => p.catch(() => '');
|
|
27
32
|
|
|
@@ -161,13 +166,13 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
|
|
|
161
166
|
await ensureWorktree(wt);
|
|
162
167
|
const dir = wt.path;
|
|
163
168
|
if (await quiet(git(dir, ['status', '--porcelain']))) {
|
|
164
|
-
if (!String(title || '').trim()) throw
|
|
169
|
+
if (!String(title || '').trim()) throw bad('A commit message is required.');
|
|
165
170
|
await git(dir, ['add', '-A']);
|
|
166
171
|
await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
|
|
167
172
|
}
|
|
168
173
|
const { ahead } = await worktreeStatus(wt);
|
|
169
|
-
if (!ahead) throw
|
|
170
|
-
if (!wt.remote) throw
|
|
174
|
+
if (!ahead) throw bad('Nothing to ship: this branch has no changes.');
|
|
175
|
+
if (!wt.remote) throw bad('This repo has no origin remote to push to.');
|
|
171
176
|
await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
|
|
172
177
|
if (pr?.state === 'OPEN') return prView(dir, pr.number);
|
|
173
178
|
const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
|
|
@@ -178,7 +183,7 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
|
|
|
178
183
|
export async function merge(wt, number) {
|
|
179
184
|
const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
|
|
180
185
|
let pr = await prView(dir, number);
|
|
181
|
-
if (pr.state !== 'OPEN') throw
|
|
186
|
+
if (pr.state !== 'OPEN') throw bad(`PR #${number} is ${pr.state.toLowerCase()}.`);
|
|
182
187
|
if (pr.draft) await gh(dir, ['pr', 'ready', String(number)]);
|
|
183
188
|
await gh(dir, ['pr', 'merge', String(number), '--squash', '--match-head-commit', pr.head]);
|
|
184
189
|
pr = await prView(dir, number);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Switching harness in a follow-up. Each harness keeps its own conversation (native session id),
|
|
2
|
+
// so a harness picked up mid-task hasn't seen the turns that ran elsewhere. handoffNote() writes
|
|
3
|
+
// those turns out (requests, reports, files touched) and goes ahead of the follow-up's prompt.
|
|
4
|
+
// A harness that ran earlier in the task resumes its own session and only hears what it missed.
|
|
5
|
+
|
|
6
|
+
import { textMentions } from './mentions.js';
|
|
7
|
+
import { blocksToMarkdown } from './render.js';
|
|
8
|
+
import { clip, truncate } from './util.js';
|
|
9
|
+
|
|
10
|
+
const BUDGET = 24_000; // characters of earlier turns, newest kept first
|
|
11
|
+
const PER_TURN = 6_000;
|
|
12
|
+
// The parts of a report worth passing on. Diffs, code and terminal output are on disk or in git.
|
|
13
|
+
const KEEP = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tests', 'commits', 'checklist', 'callout', 'question', 'next']);
|
|
14
|
+
|
|
15
|
+
// The turns before the current one that `agent` hasn't seen: everything after the last turn it
|
|
16
|
+
// ran, or all of them when it has no conversation of its own to resume (resumeId). Only a gap
|
|
17
|
+
// that another harness filled counts: on its own, a harness has nothing to catch up on.
|
|
18
|
+
export function missedTurns(session, agent, resumeId) {
|
|
19
|
+
const prior = session.turns.slice(0, -1);
|
|
20
|
+
const missed = resumeId ? prior.slice(prior.findLastIndex((t) => (t.agent || agent) === agent) + 1) : prior;
|
|
21
|
+
return missed.some((t) => (t.agent || agent) !== agent) ? missed : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function turnText(t, n, name) {
|
|
25
|
+
const blocks = (t.presentation?.blocks || []).filter((b) => KEEP.has(b?.type));
|
|
26
|
+
const report = t.status === 'running' ? '' : blocksToMarkdown(blocks, t) || clip(t.raw, 1500);
|
|
27
|
+
const files = (t.changes || []).filter((c) => !c.reverted).map((c) => `\`${c.file}\``);
|
|
28
|
+
return truncate(
|
|
29
|
+
[
|
|
30
|
+
`### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
|
|
31
|
+
`**User:** ${textMentions(t.prompt)}`,
|
|
32
|
+
report && `**Report:**\n\n${report}`,
|
|
33
|
+
files.length && `**Files it edited:** ${files.join(', ')}`,
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join('\n\n'),
|
|
37
|
+
PER_TURN,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The note for `agent` taking the next turn, or '' when it has seen everything.
|
|
42
|
+
// name(turn) → the harness's display name for that turn.
|
|
43
|
+
export function handoffNote(session, agent, resumeId, name) {
|
|
44
|
+
const missed = missedTurns(session, agent, resumeId);
|
|
45
|
+
if (!missed.length) return '';
|
|
46
|
+
const all = session.turns.slice(0, -1);
|
|
47
|
+
const parts = [];
|
|
48
|
+
let used = 0;
|
|
49
|
+
for (let i = missed.length - 1; i >= 0; i--) {
|
|
50
|
+
const text = turnText(missed[i], all.indexOf(missed[i]) + 1, name);
|
|
51
|
+
if (used + text.length > BUDGET && parts.length) break;
|
|
52
|
+
parts.unshift(text);
|
|
53
|
+
used += text.length;
|
|
54
|
+
}
|
|
55
|
+
const dropped = missed.length - parts.length;
|
|
56
|
+
const fresh = !resumeId;
|
|
57
|
+
const others = [...new Set(missed.filter((t) => t.agent !== agent).map(name))].join(', ');
|
|
58
|
+
const intro = fresh
|
|
59
|
+
? `You are taking over a task that another coding agent (${others}) has been working on. Its work is already in this folder. Below is what happened so far, oldest first; read the files and \`git diff\` for the details, then carry on with the follow-up.`
|
|
60
|
+
: `While you were away, this task continued on another coding agent (${others}). Its work is already in this folder. Below is what happened since your last turn, oldest first; check the files and \`git diff\` before you rely on what you remember.`;
|
|
61
|
+
return [
|
|
62
|
+
'## Earlier in this task',
|
|
63
|
+
intro,
|
|
64
|
+
dropped ? `_${dropped} earlier turn${dropped > 1 ? 's' : ''} left out for length._` : '',
|
|
65
|
+
...parts,
|
|
66
|
+
'---',
|
|
67
|
+
'## Follow-up',
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join('\n\n');
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Writing responses and reading request bodies. The API speaks JSON; a few routes stream.
|
|
2
|
+
|
|
3
|
+
const MAX_BODY = 2e6;
|
|
4
|
+
|
|
5
|
+
export function send(res, status, body) {
|
|
6
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
7
|
+
res.end(JSON.stringify(body));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readJson(req) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let data = '';
|
|
13
|
+
let tooLarge = false;
|
|
14
|
+
req.on('data', (c) => {
|
|
15
|
+
if (tooLarge) return;
|
|
16
|
+
data += c;
|
|
17
|
+
if (data.length > MAX_BODY) {
|
|
18
|
+
// Stop buffering: the rest of the body is dropped, not held in memory.
|
|
19
|
+
tooLarge = true;
|
|
20
|
+
data = '';
|
|
21
|
+
reject(Object.assign(new Error('body too large'), { status: 413 }));
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
req.on('end', () => {
|
|
25
|
+
if (tooLarge) return;
|
|
26
|
+
try {
|
|
27
|
+
resolve(data ? JSON.parse(data) : {});
|
|
28
|
+
} catch {
|
|
29
|
+
reject(Object.assign(new Error('invalid json'), { status: 400 }));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Pass a machine's raw response (images, markdown, uploads) straight through.
|
|
36
|
+
export function pipeFrom(res, r) {
|
|
37
|
+
const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
|
|
38
|
+
if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
|
|
39
|
+
res.writeHead(r.statusCode, headers);
|
|
40
|
+
r.pipe(res);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Server-sent events: every open /api/events stream gets every broadcast.
|
|
44
|
+
export function eventStream() {
|
|
45
|
+
const clients = new Set();
|
|
46
|
+
return {
|
|
47
|
+
clients,
|
|
48
|
+
attach(req, res) {
|
|
49
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
50
|
+
res.write(`event: hello\ndata: {}\n\n`);
|
|
51
|
+
clients.add(res);
|
|
52
|
+
req.on('close', () => clients.delete(res));
|
|
53
|
+
},
|
|
54
|
+
broadcast(event, data) {
|
|
55
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
56
|
+
for (const res of clients) res.write(frame);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|