wisp-agents 0.7.0 → 0.9.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 +57 -1
- package/README.md +99 -8
- package/bin/wisp.js +53 -6
- package/package.json +2 -2
- package/server/adapters/claude.js +7 -1
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +4 -2
- package/server/adapters/opencode.js +7 -1
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +559 -20
- package/server/machines.js +672 -0
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +9 -3
- package/server/openapi.js +164 -5
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +283 -0
- package/server/render.js +18 -0
- package/server/runner.js +154 -13
- package/server/store.js +20 -7
- package/server/subagents.js +86 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +5 -4
- package/web/css/app.css +702 -34
- package/web/index.html +15 -3
- package/web/js/app.js +2298 -134
- package/web/js/blocks.js +396 -22
- package/web/logos/grok.svg +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Grok Build adapter — drives `grok --prompt-file … --output-format streaming-messages-json`
|
|
2
|
+
// (Claude Code's stream-json shape, with grok's own tool names).
|
|
3
|
+
//
|
|
4
|
+
// grok has no flag for an extra MCP server (and its GROK_CONFIG overlay can't add one), but an
|
|
5
|
+
// agent definition passed with `--agent <file>` can declare `mcpServers`. So each turn writes a
|
|
6
|
+
// throwaway agent file that inherits the full stock prompt and only adds wisp's server; the model
|
|
7
|
+
// reaches it through grok's `use_tool` as `wisp__ask_user`. The protocol goes in with `--rules`.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
|
|
13
|
+
|
|
14
|
+
const BIN = process.env.WISP_GROK_BIN || 'grok';
|
|
15
|
+
|
|
16
|
+
const PERMISSION_MODES = {
|
|
17
|
+
plan: 'plan',
|
|
18
|
+
edits: 'acceptEdits',
|
|
19
|
+
auto: 'bypassPermissions',
|
|
20
|
+
};
|
|
21
|
+
const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
id: 'grok',
|
|
25
|
+
name: 'Grok',
|
|
26
|
+
askTool: 'wisp__ask_user',
|
|
27
|
+
models: [{ id: '', label: 'Default', hint: 'Whatever Grok is set to' }],
|
|
28
|
+
efforts: ['low', 'medium', 'high', 'xhigh'],
|
|
29
|
+
|
|
30
|
+
resumeCommand({ resumeId, model, effort }) {
|
|
31
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--reasoning-effort', effort] : [])];
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async detect() {
|
|
35
|
+
const found = await detectVersion(BIN);
|
|
36
|
+
if (!found.available) return found;
|
|
37
|
+
// `grok models` prints " * grok-4.7 (default)" / " - grok-4.6" lines.
|
|
38
|
+
const ids = [...(await quietOutput(BIN, ['models'])).matchAll(/^\s+[*-]\s+([\w.\-/:]+)/gm)].map((m) => m[1]);
|
|
39
|
+
if (!ids.length) return found;
|
|
40
|
+
const meta = cachedModels();
|
|
41
|
+
const models = ids.map((id) => {
|
|
42
|
+
const info = meta[id]?.info || {};
|
|
43
|
+
const efforts = info.supports_reasoning_effort === false ? [] : effortsOf(info.reasoning_efforts);
|
|
44
|
+
return { id, label: info.name || id, hint: clip(info.description || id, 60), ...(efforts && { efforts }) };
|
|
45
|
+
});
|
|
46
|
+
return { ...found, models: [this.models[0], ...models] };
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
50
|
+
// grok -p doesn't read stdin, and a prompt in argv is capped on Windows: hand it over as a file.
|
|
51
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wisp-grok-'));
|
|
52
|
+
const promptFile = path.join(tmp, 'prompt.md');
|
|
53
|
+
fs.writeFileSync(promptFile, prompt + attachmentNote(attachments));
|
|
54
|
+
|
|
55
|
+
const args = [
|
|
56
|
+
'--prompt-file', promptFile,
|
|
57
|
+
'--output-format', 'streaming-messages-json',
|
|
58
|
+
'--permission-mode', PERMISSION_MODES[mode] || 'acceptEdits',
|
|
59
|
+
'--rules', systemPrompt,
|
|
60
|
+
// A headless run can't answer grok's own question tool; wisp's ask_user (or a question block) replaces it.
|
|
61
|
+
'--disallowed-tools', 'ask_user_question',
|
|
62
|
+
'--no-auto-update',
|
|
63
|
+
];
|
|
64
|
+
if (model) args.push('--model', model);
|
|
65
|
+
if (effort) args.push('--reasoning-effort', effort);
|
|
66
|
+
if (resumeId) args.push('--resume', resumeId);
|
|
67
|
+
if (Object.keys(mcpServers).length) {
|
|
68
|
+
const agentFile = path.join(tmp, 'wisp.md');
|
|
69
|
+
fs.writeFileSync(agentFile, agentDefinition(mcpServers));
|
|
70
|
+
args.push('--agent', agentFile);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files = fileTracker(cwd);
|
|
74
|
+
const pending = new Map(); // tool_use_id -> { name, input }
|
|
75
|
+
let session = null;
|
|
76
|
+
let sawResult = false;
|
|
77
|
+
let error = null;
|
|
78
|
+
|
|
79
|
+
const handle = spawnJsonl({
|
|
80
|
+
bin: BIN,
|
|
81
|
+
args,
|
|
82
|
+
cwd,
|
|
83
|
+
env: { ...process.env, GROK_DISABLE_AUTOUPDATER: '1' },
|
|
84
|
+
onEvent,
|
|
85
|
+
onJson(msg) {
|
|
86
|
+
if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
|
|
87
|
+
// Subagent traffic carries its parent's tool id; only the main conversation is shown.
|
|
88
|
+
if (msg.parent_tool_use_id) return;
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case 'assistant':
|
|
91
|
+
for (const part of msg.message?.content || []) {
|
|
92
|
+
if (part.type === 'text' && part.text?.trim()) onEvent({ type: 'text', text: part.text });
|
|
93
|
+
else if (part.type === 'thinking') onEvent({ type: 'thinking' });
|
|
94
|
+
else if (part.type === 'server_tool_use') onEvent({ type: 'tool', kind: 'web', label: `Searching the web: ${clip(part.input?.query, 50)}` });
|
|
95
|
+
else if (part.type === 'tool_use') {
|
|
96
|
+
const input = part.input || {};
|
|
97
|
+
pending.set(part.id, { name: part.name, input });
|
|
98
|
+
if (part.name === 'write' && input.file_path) files.remember(input.file_path);
|
|
99
|
+
// Streamed once answered, so "waiting" would land after "You answered"; the trail has both.
|
|
100
|
+
if (part.name === 'use_tool' && input.tool_name === 'wisp__ask_user') continue;
|
|
101
|
+
onEvent({ type: 'tool', ...describeTool(part.name, input, cwd) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case 'user':
|
|
106
|
+
for (const part of msg.message?.content || []) {
|
|
107
|
+
if (part.type !== 'tool_result') continue;
|
|
108
|
+
const call = pending.get(part.tool_use_id);
|
|
109
|
+
pending.delete(part.tool_use_id);
|
|
110
|
+
if (!call) continue;
|
|
111
|
+
if (part.is_error) onEvent({ type: 'tool_error', name: call.name, text: clip(flatten(part.content), 400) });
|
|
112
|
+
else {
|
|
113
|
+
const change = captureChange(call.name, call.input, cwd, files);
|
|
114
|
+
if (change) onEvent({ type: 'change', ...change });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
case 'result':
|
|
119
|
+
sawResult = true;
|
|
120
|
+
onEvent({
|
|
121
|
+
type: 'result',
|
|
122
|
+
text: msg.is_error ? (msg.errors || []).join('\n') || msg.result || `grok stopped: ${msg.subtype}` : msg.result ?? '',
|
|
123
|
+
isError: !!msg.is_error || (msg.subtype && msg.subtype !== 'success'),
|
|
124
|
+
stats: {
|
|
125
|
+
costUsd: msg.total_cost_usd || undefined, // 0 means "not reported", never free
|
|
126
|
+
durationMs: msg.duration_ms,
|
|
127
|
+
turns: msg.num_turns,
|
|
128
|
+
inputTokens: msg.usage?.input_tokens,
|
|
129
|
+
outputTokens: msg.usage?.output_tokens,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
break;
|
|
133
|
+
case 'error':
|
|
134
|
+
error = msg.message || 'grok reported an error.';
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
onClose({ code, signal, stderr, stopped }) {
|
|
139
|
+
if (sawResult) return;
|
|
140
|
+
onEvent({ type: 'result', isError: true, stopped, text: error || stderr || `grok exited with ${signal || `code ${code}`}` });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
handle.done.finally(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
144
|
+
return handle;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// `prompt_mode: full` keeps grok's stock system prompt; the body adds nothing.
|
|
149
|
+
function agentDefinition(mcpServers) {
|
|
150
|
+
const servers = Object.entries(mcpServers).map(([name, srv]) => ({
|
|
151
|
+
name,
|
|
152
|
+
command: srv.command,
|
|
153
|
+
args: srv.args || [],
|
|
154
|
+
env: srv.env || {},
|
|
155
|
+
tool_timeout_sec: ASK_TIMEOUT_MS / 1000,
|
|
156
|
+
startup_timeout_sec: 60,
|
|
157
|
+
}));
|
|
158
|
+
// YAML is a superset of JSON, so JSON values need no escaping of their own.
|
|
159
|
+
return `---\nname: wisp\ndescription: A task launched by wisp\nprompt_mode: full\nagents_md: true\nmcpServers: ${JSON.stringify(servers)}\n---\n`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// grok caches the model catalog, with each model's reasoning levels, in $GROK_HOME.
|
|
163
|
+
function cachedModels() {
|
|
164
|
+
try {
|
|
165
|
+
const home = process.env.GROK_HOME || path.join(os.homedir(), '.grok');
|
|
166
|
+
return JSON.parse(fs.readFileSync(path.join(home, 'models_cache.json'), 'utf8')).models || {};
|
|
167
|
+
} catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function effortsOf(list) {
|
|
173
|
+
if (!Array.isArray(list)) return undefined;
|
|
174
|
+
const rank = (id) => (EFFORT_ORDER.includes(id) ? EFFORT_ORDER.indexOf(id) : EFFORT_ORDER.length);
|
|
175
|
+
return list.map((e) => e.id).filter(Boolean).sort((a, b) => rank(a) - rank(b));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function describeTool(name, input, cwd) {
|
|
179
|
+
const f = rel(input.target_file || input.file_path || input.path || input.target_directory, cwd);
|
|
180
|
+
switch (name) {
|
|
181
|
+
case 'read_file':
|
|
182
|
+
return { kind: 'read', label: `Reading ${f}`, file: f };
|
|
183
|
+
case 'search_replace':
|
|
184
|
+
return { kind: 'edit', label: `Editing ${f}`, file: f };
|
|
185
|
+
case 'write':
|
|
186
|
+
return { kind: 'edit', label: `Writing ${f}`, file: f };
|
|
187
|
+
case 'run_terminal_command':
|
|
188
|
+
case 'run_terminal_cmd':
|
|
189
|
+
return { kind: 'run', label: input.description ? clip(input.description) : `Running ${clip(input.command, 60)}`, detail: input.command };
|
|
190
|
+
case 'grep':
|
|
191
|
+
return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
|
|
192
|
+
case 'list_dir':
|
|
193
|
+
return { kind: 'search', label: `Listing ${isDir(input.target_directory, cwd) ? '.' : f || '.'}` };
|
|
194
|
+
case 'web_search':
|
|
195
|
+
return { kind: 'web', label: `Searching the web: ${clip(input.query, 50)}` };
|
|
196
|
+
case 'web_fetch':
|
|
197
|
+
return { kind: 'web', label: `Reading ${clip(input.url, 60)}` };
|
|
198
|
+
case 'spawn_subagent':
|
|
199
|
+
case 'task':
|
|
200
|
+
return { kind: 'agent', label: `Delegating: ${clip(input.description || input.prompt, 60)}` };
|
|
201
|
+
case 'todo_write': {
|
|
202
|
+
const active = input.todos?.find((t) => t.status === 'in_progress' && t.content);
|
|
203
|
+
return { kind: 'plan', label: active ? clip(active.content) : 'Planning' };
|
|
204
|
+
}
|
|
205
|
+
case 'enter_plan_mode':
|
|
206
|
+
case 'exit_plan_mode':
|
|
207
|
+
return { kind: 'plan', label: 'Planning' };
|
|
208
|
+
case 'search_tool':
|
|
209
|
+
return { kind: 'search', label: `Looking up tools: ${clip(input.query, 50)}` };
|
|
210
|
+
case 'use_tool':
|
|
211
|
+
return describeMcp(String(input.tool_name || ''), input.tool_input || {});
|
|
212
|
+
default:
|
|
213
|
+
return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// grok passes absolute paths, also for the project root itself (which rel() leaves as is).
|
|
218
|
+
function isDir(dir, cwd) {
|
|
219
|
+
try {
|
|
220
|
+
return !!dir && fs.realpathSync(path.resolve(cwd, dir)) === fs.realpathSync(cwd);
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// MCP tools are called through `use_tool` with a `server__tool` name.
|
|
227
|
+
function describeMcp(tool, input) {
|
|
228
|
+
switch (tool) {
|
|
229
|
+
case 'wisp__ask_user':
|
|
230
|
+
return { kind: 'wait', label: 'Waiting for your answer' };
|
|
231
|
+
case 'wisp__delegate':
|
|
232
|
+
return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
233
|
+
case 'wisp__subagent_results':
|
|
234
|
+
return { kind: 'agent', label: 'Waiting on subagents' };
|
|
235
|
+
default:
|
|
236
|
+
return { kind: 'tool', label: `Using ${tool.split('__').join(' · ') || 'an integration'}` };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Called once a tool succeeded. grok may stream a batch of tool calls after running them, so the
|
|
241
|
+
// "before" side of a write can't come from disk at tool_use time: the tracker supplies it.
|
|
242
|
+
function captureChange(name, input, cwd, files) {
|
|
243
|
+
const file = input.file_path;
|
|
244
|
+
if (!file) return null;
|
|
245
|
+
if (name === 'search_replace' && typeof input.old_string === 'string' && input.old_string) {
|
|
246
|
+
files.change(file); // keep the tracker's snapshot current for later writes
|
|
247
|
+
return { file: rel(file, cwd), op: 'edit', edits: [{ before: input.old_string, after: input.new_string ?? '' }] };
|
|
248
|
+
}
|
|
249
|
+
if (name === 'search_replace' || name === 'write') return files.change(file);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
package/server/adapters/index.js
CHANGED
|
@@ -29,8 +29,9 @@ import codex from './codex.js';
|
|
|
29
29
|
import opencode from './opencode.js';
|
|
30
30
|
import cursor from './cursor.js';
|
|
31
31
|
import antigravity from './antigravity.js';
|
|
32
|
+
import grok from './grok.js';
|
|
32
33
|
|
|
33
|
-
export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity].map((a) => [a.id, a]));
|
|
34
|
+
export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, a]));
|
|
34
35
|
|
|
35
36
|
const detected = new Map();
|
|
36
37
|
|
|
@@ -41,7 +42,8 @@ export async function listAgents() {
|
|
|
41
42
|
const found = await detected.get(a.id);
|
|
42
43
|
// Only a hit is cached: a CLI installed while wisp is running shows up on the next call.
|
|
43
44
|
if (!found.available) detected.delete(a.id);
|
|
44
|
-
|
|
45
|
+
// delegates: can lead a team of subagents (needs wisp's MCP server injected).
|
|
46
|
+
return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], delegates: a.askTool !== null, ...found };
|
|
45
47
|
}),
|
|
46
48
|
);
|
|
47
49
|
}
|
|
@@ -38,7 +38,7 @@ export default {
|
|
|
38
38
|
return { ...found, models: [this.models[0], ...models] };
|
|
39
39
|
},
|
|
40
40
|
|
|
41
|
-
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
41
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
|
|
42
42
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
43
43
|
const args = ['run', '--format', 'json'];
|
|
44
44
|
if (mode === 'plan') args.push('--agent', 'plan');
|
|
@@ -60,6 +60,8 @@ export default {
|
|
|
60
60
|
...inline,
|
|
61
61
|
mcp: { ...inline.mcp, ...mcp },
|
|
62
62
|
experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS },
|
|
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' } }),
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -169,6 +171,10 @@ function describeTool(name, input, cwd) {
|
|
|
169
171
|
}
|
|
170
172
|
case 'wisp_ask_user':
|
|
171
173
|
return { kind: 'wait', label: 'Waiting for your answer' };
|
|
174
|
+
case 'wisp_delegate':
|
|
175
|
+
return { kind: 'agent', label: `Delegated to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
176
|
+
case 'wisp_subagent_results':
|
|
177
|
+
return { kind: 'agent', label: 'Collected subagent reports' };
|
|
172
178
|
default:
|
|
173
179
|
return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
|
|
174
180
|
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Per-turn git checkpoints, single-file revert, turn rollback, and action command execution.
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { killTree } from './proc.js';
|
|
7
|
+
import { MAX_SNAPSHOT, rel } from './adapters/common.js';
|
|
8
|
+
|
|
9
|
+
const MAX_EXEC_OUTPUT = 32 * 1024;
|
|
10
|
+
const EXEC_TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
function git(cwd, args, opts = {}) {
|
|
13
|
+
return execFileSync('git', args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
...opts,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tryGit(cwd, args, opts) {
|
|
22
|
+
try {
|
|
23
|
+
return git(cwd, args, opts).toString('utf8').trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Snapshot the working tree before a turn starts. `git stash create` writes a commit object
|
|
30
|
+
// holding tracked working-tree and index changes without touching HEAD, the index, or the stash
|
|
31
|
+
// reflog; if the tree is clean it prints nothing and HEAD is the snapshot. Pinning the SHA under
|
|
32
|
+
// refs/wisp/checkpoints/<session>/<turn> keeps `git gc` from collecting it.
|
|
33
|
+
export function captureCheckpoint(cwd, sessionId, turnId) {
|
|
34
|
+
if (!cwd || !fs.existsSync(cwd)) return null;
|
|
35
|
+
const head = tryGit(cwd, ['rev-parse', '--verify', 'HEAD']);
|
|
36
|
+
if (!head) return null;
|
|
37
|
+
const stash = tryGit(cwd, ['stash', 'create']);
|
|
38
|
+
const commit = stash || head;
|
|
39
|
+
if (sessionId && turnId) {
|
|
40
|
+
tryGit(cwd, ['update-ref', `refs/wisp/checkpoints/${sessionId}/${turnId}`, commit]);
|
|
41
|
+
}
|
|
42
|
+
const branch = tryGit(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']) || '';
|
|
43
|
+
const untrackedRaw = tryGit(cwd, ['ls-files', '--others', '--exclude-standard'], { maxBuffer: 2 * 1024 * 1024 });
|
|
44
|
+
const untracked = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean).slice(0, 500) : [];
|
|
45
|
+
return {
|
|
46
|
+
commit,
|
|
47
|
+
head,
|
|
48
|
+
dirty: !!stash,
|
|
49
|
+
branch,
|
|
50
|
+
untracked,
|
|
51
|
+
at: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The first turn in a worktree made for it: clean, at its base commit, so the checkpoint is known
|
|
56
|
+
// without asking git (captureCheckpoint's synchronous calls held up every launch, once per run).
|
|
57
|
+
// The base commit stays reachable from the base branch, so it needs no pinning ref.
|
|
58
|
+
export function freshCheckpoint(wt) {
|
|
59
|
+
return { commit: wt.head, head: wt.head, dirty: false, branch: wt.branch, untracked: [], at: Date.now() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function cleanupCheckpoints(cwd, sessionId) {
|
|
63
|
+
if (!cwd || !sessionId || !fs.existsSync(cwd)) return;
|
|
64
|
+
const refs = tryGit(cwd, ['for-each-ref', '--format=%(refname)', `refs/wisp/checkpoints/${sessionId}/`]);
|
|
65
|
+
if (!refs) return;
|
|
66
|
+
for (const ref of refs.split('\n').filter(Boolean)) {
|
|
67
|
+
tryGit(cwd, ['update-ref', '-d', ref]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readGitFile(cwd, commit, file) {
|
|
72
|
+
if (!cwd || !commit || !file || path.isAbsolute(file)) return null;
|
|
73
|
+
const gitPath = `./${file.split(path.sep).join('/')}`;
|
|
74
|
+
try {
|
|
75
|
+
const out = git(cwd, ['show', `${commit}:${gitPath}`], { maxBuffer: 8 * MAX_SNAPSHOT });
|
|
76
|
+
return out.length <= MAX_SNAPSHOT ? out.toString('utf8') : '';
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readDiskSnapshot(abs) {
|
|
83
|
+
try {
|
|
84
|
+
const st = fs.statSync(abs);
|
|
85
|
+
if (!st.isFile()) return null;
|
|
86
|
+
return st.size <= MAX_SNAPSHOT ? fs.readFileSync(abs, 'utf8') : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeDiskSnapshot(abs, content) {
|
|
93
|
+
if (content === null || content === undefined) {
|
|
94
|
+
fs.rmSync(abs, { force: true });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
98
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveInsideCwd(cwd, file) {
|
|
102
|
+
if (!file || typeof file !== 'string') {
|
|
103
|
+
throw Object.assign(new Error('file path required'), { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
const abs = path.resolve(cwd, file);
|
|
106
|
+
const relPath = path.relative(cwd, abs);
|
|
107
|
+
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
108
|
+
throw Object.assign(new Error(`File lies outside the project folder: ${file}`), { status: 400 });
|
|
109
|
+
}
|
|
110
|
+
return { abs, relPath: rel(file, cwd) || relPath };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findChangeEntry(turn, file, cwd) {
|
|
114
|
+
if (!turn?.changes?.length) return null;
|
|
115
|
+
const r = rel(file, cwd);
|
|
116
|
+
return (
|
|
117
|
+
turn.changes.find((c) => c.file === file || c.file === r) ||
|
|
118
|
+
turn.changes.find((c) => c.file.endsWith('/' + file) || file.endsWith('/' + c.file))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sessionDir = (session) => session.worktree?.cwd || session.cwd;
|
|
123
|
+
|
|
124
|
+
// Revert (or re-apply when restore=true) a single file's changes from `turn`.
|
|
125
|
+
export function revertFileChange(session, turn, file, { restore = false } = {}) {
|
|
126
|
+
const cwd = sessionDir(session);
|
|
127
|
+
const { abs, relPath } = resolveInsideCwd(cwd, file);
|
|
128
|
+
const entry = findChangeEntry(turn, file, cwd);
|
|
129
|
+
const commit = turn.checkpoint?.commit;
|
|
130
|
+
if (!entry && !commit) {
|
|
131
|
+
throw Object.assign(new Error(`No captured changes or checkpoint for ${relPath}`), { status: 404 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (restore) {
|
|
135
|
+
if (entry?.afterSnapshot !== undefined) {
|
|
136
|
+
writeDiskSnapshot(abs, entry.afterSnapshot);
|
|
137
|
+
} else if (entry?.edits?.length) {
|
|
138
|
+
if (entry.op === 'create' || entry.op === 'write') {
|
|
139
|
+
writeDiskSnapshot(abs, entry.edits.at(-1).after ?? '');
|
|
140
|
+
} else {
|
|
141
|
+
let cur = readDiskSnapshot(abs) ?? '';
|
|
142
|
+
for (const e of entry.edits) {
|
|
143
|
+
if (e.before && cur.includes(e.before)) cur = cur.replace(e.before, e.after ?? '');
|
|
144
|
+
}
|
|
145
|
+
writeDiskSnapshot(abs, cur);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
throw Object.assign(new Error(`Nothing to re-apply for ${relPath}`), { status: 400 });
|
|
149
|
+
}
|
|
150
|
+
if (entry) entry.reverted = false;
|
|
151
|
+
if (turn.changes?.every((c) => !c.reverted)) delete turn.rolledBackAt;
|
|
152
|
+
return { file: entry?.file || relPath, reverted: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Save what is currently on disk so the user can Undo / Re-apply in one click.
|
|
156
|
+
const currentOnDisk = readDiskSnapshot(abs);
|
|
157
|
+
if (entry) entry.afterSnapshot = currentOnDisk;
|
|
158
|
+
|
|
159
|
+
// 1. Prefer the exact pre-turn file content recorded when the turn first touched the file.
|
|
160
|
+
if (entry && entry.beforeFile !== undefined) {
|
|
161
|
+
writeDiskSnapshot(abs, entry.op === 'create' ? null : entry.beforeFile);
|
|
162
|
+
} else if (commit) {
|
|
163
|
+
// 2. Otherwise check the turn's git checkpoint commit.
|
|
164
|
+
const fromGit = readGitFile(cwd, commit, entry?.file || relPath);
|
|
165
|
+
if (fromGit !== null) {
|
|
166
|
+
writeDiskSnapshot(abs, fromGit);
|
|
167
|
+
} else if (entry?.op === 'create' || !(turn.checkpoint?.untracked || []).includes(entry?.file || relPath)) {
|
|
168
|
+
writeDiskSnapshot(abs, null);
|
|
169
|
+
} else if (entry?.edits?.length) {
|
|
170
|
+
reverseEditsOnDisk(abs, entry);
|
|
171
|
+
}
|
|
172
|
+
} else if (entry) {
|
|
173
|
+
// 3. Fallback for non-git folders or legacy turns.
|
|
174
|
+
if (entry.op === 'create') {
|
|
175
|
+
writeDiskSnapshot(abs, null);
|
|
176
|
+
} else if (entry.op === 'write' && entry.edits?.[0]?.before !== undefined) {
|
|
177
|
+
writeDiskSnapshot(abs, entry.edits[0].before);
|
|
178
|
+
} else {
|
|
179
|
+
reverseEditsOnDisk(abs, entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (entry) entry.reverted = true;
|
|
184
|
+
return { file: entry?.file || relPath, reverted: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function reverseEditsOnDisk(abs, entry) {
|
|
188
|
+
const cur = readDiskSnapshot(abs);
|
|
189
|
+
if (cur === null) return;
|
|
190
|
+
let next = cur;
|
|
191
|
+
for (const e of [...(entry.edits || [])].reverse()) {
|
|
192
|
+
if (e.after && next.includes(e.after)) {
|
|
193
|
+
next = next.replace(e.after, e.before ?? '');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
writeDiskSnapshot(abs, next);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Restore the project working tree to the state before `turn` ran (or undo that rollback when restore=true).
|
|
200
|
+
export function rollbackToTurn(session, turn, { restore = false } = {}) {
|
|
201
|
+
const cwd = sessionDir(session);
|
|
202
|
+
const idx = session.turns.indexOf(turn);
|
|
203
|
+
if (idx < 0) throw Object.assign(new Error('No such turn'), { status: 404 });
|
|
204
|
+
const affected = session.turns.slice(idx);
|
|
205
|
+
const touchedFiles = [...new Set(affected.flatMap((t) => (t.changes || []).map((c) => c.file)))];
|
|
206
|
+
|
|
207
|
+
if (restore) {
|
|
208
|
+
const pre = turn.preRollback;
|
|
209
|
+
if (!pre) {
|
|
210
|
+
for (const t of affected) {
|
|
211
|
+
for (const c of t.changes || []) {
|
|
212
|
+
revertFileChange(session, t, c.file, { restore: true });
|
|
213
|
+
}
|
|
214
|
+
delete t.rolledBackAt;
|
|
215
|
+
}
|
|
216
|
+
return { files: touchedFiles, rolledBack: false };
|
|
217
|
+
}
|
|
218
|
+
if (pre.commit) {
|
|
219
|
+
tryGit(cwd, ['checkout', pre.commit, '--', '.']);
|
|
220
|
+
tryGit(cwd, ['read-tree', 'HEAD']);
|
|
221
|
+
}
|
|
222
|
+
for (const [relFile, content] of Object.entries(pre.files || {})) {
|
|
223
|
+
const { abs } = resolveInsideCwd(cwd, relFile);
|
|
224
|
+
writeDiskSnapshot(abs, content);
|
|
225
|
+
}
|
|
226
|
+
for (const t of affected) {
|
|
227
|
+
delete t.rolledBackAt;
|
|
228
|
+
for (const c of t.changes || []) c.reverted = false;
|
|
229
|
+
}
|
|
230
|
+
return { files: Object.keys(pre.files || {}).length ? Object.keys(pre.files) : touchedFiles, rolledBack: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cp = turn.checkpoint;
|
|
234
|
+
const preUntracked = new Set(cp?.untracked || []);
|
|
235
|
+
const curUntrackedRaw = cp?.commit ? tryGit(cwd, ['ls-files', '--others', '--exclude-standard'], { maxBuffer: 2 * 1024 * 1024 }) : null;
|
|
236
|
+
const curUntracked = curUntrackedRaw ? curUntrackedRaw.split('\n').filter(Boolean).slice(0, 500) : [];
|
|
237
|
+
const newUntracked = curUntracked.filter((f) => !preUntracked.has(f));
|
|
238
|
+
|
|
239
|
+
// Snapshot current state of all affected files + new untracked files + git tree before rolling back so Undo works.
|
|
240
|
+
const preFiles = {};
|
|
241
|
+
for (const f of [...new Set([...touchedFiles, ...newUntracked])]) {
|
|
242
|
+
try {
|
|
243
|
+
const { abs } = resolveInsideCwd(cwd, f);
|
|
244
|
+
preFiles[f] = readDiskSnapshot(abs);
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
const preCp = captureCheckpoint(cwd, session.id, `${turn.id}-undo`);
|
|
248
|
+
turn.preRollback = { commit: preCp?.commit || null, files: preFiles };
|
|
249
|
+
|
|
250
|
+
if (cp?.commit) {
|
|
251
|
+
// Find any tracked files added since the checkpoint commit so we can remove them too.
|
|
252
|
+
const addedRaw = tryGit(cwd, ['diff', '--name-only', '--diff-filter=D', 'HEAD', cp.commit]) || '';
|
|
253
|
+
const addedTracked = addedRaw.split('\n').filter(Boolean);
|
|
254
|
+
|
|
255
|
+
// Restore tracked files from the checkpoint commit.
|
|
256
|
+
tryGit(cwd, ['checkout', cp.commit, '--', '.']);
|
|
257
|
+
// Keep the git index aligned with HEAD rather than the stash commit's tree.
|
|
258
|
+
tryGit(cwd, ['read-tree', 'HEAD']);
|
|
259
|
+
|
|
260
|
+
for (const f of [...addedTracked, ...newUntracked]) {
|
|
261
|
+
try {
|
|
262
|
+
const { abs } = resolveInsideCwd(cwd, f);
|
|
263
|
+
fs.rmSync(abs, { force: true });
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Restore or remove untracked files touched in the affected turns.
|
|
268
|
+
for (const t of affected.slice().reverse()) {
|
|
269
|
+
for (const c of t.changes || []) {
|
|
270
|
+
try {
|
|
271
|
+
const { abs, relPath } = resolveInsideCwd(cwd, c.file);
|
|
272
|
+
const inGit = readGitFile(cwd, cp.commit, relPath) !== null;
|
|
273
|
+
if (!inGit) {
|
|
274
|
+
if (c.op === 'create' || (!preUntracked.has(relPath) && c.beforeFile === null)) {
|
|
275
|
+
fs.rmSync(abs, { force: true });
|
|
276
|
+
} else if (typeof c.beforeFile === 'string') {
|
|
277
|
+
writeDiskSnapshot(abs, c.beforeFile);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Non-git folder or turn without a git checkpoint: revert each turn's captured file changes in reverse.
|
|
285
|
+
for (const t of affected.slice().reverse()) {
|
|
286
|
+
for (const c of [...(t.changes || [])].reverse()) {
|
|
287
|
+
revertFileChange(session, t, c.file, { restore: false });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
for (const t of affected) {
|
|
294
|
+
t.rolledBackAt = now;
|
|
295
|
+
for (const c of t.changes || []) c.reverted = true;
|
|
296
|
+
}
|
|
297
|
+
return { files: touchedFiles, rolledBack: true, commit: cp?.head || cp?.commit || null };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Run a shell command from an actionable presentation block inside `cwd`.
|
|
301
|
+
export function execCommand(cwd, command, { timeoutMs = EXEC_TIMEOUT_MS } = {}) {
|
|
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
|
+
}
|