wisp-agents 0.14.0 → 0.16.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 +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -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-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- 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 +56 -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 +339 -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 +87 -36
- 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 +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- 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 +62 -0
- package/web/css/feed.css +208 -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 +16 -1
- package/web/js/app.js +28 -5232
- 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 +339 -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 +104 -0
- package/web/js/features/composer.js +311 -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 +559 -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/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 +161 -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 +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -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 -1901
|
@@ -1,8 +1,22 @@
|
|
|
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;
|
|
6
20
|
|
|
7
21
|
export default {
|
|
8
22
|
id: 'opencode',
|
|
@@ -16,7 +30,16 @@ export default {
|
|
|
16
30
|
},
|
|
17
31
|
|
|
18
32
|
async detect() {
|
|
19
|
-
|
|
33
|
+
let found = await detectVersion(BIN);
|
|
34
|
+
if (!found.available && !process.env.WISP_OPENCODE_BIN) {
|
|
35
|
+
for (const bin of fallbackBins().filter((b) => fs.existsSync(b))) {
|
|
36
|
+
const there = await detectVersion(bin);
|
|
37
|
+
if (!there.available) continue;
|
|
38
|
+
BIN = bin;
|
|
39
|
+
found = there;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
20
43
|
if (!found.available) return found;
|
|
21
44
|
// `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
|
|
22
45
|
// the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
|
|
@@ -40,7 +63,8 @@ export default {
|
|
|
40
63
|
|
|
41
64
|
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
|
|
42
65
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
43
|
-
|
|
66
|
+
// Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
|
|
67
|
+
const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
|
|
44
68
|
if (mode === 'plan') args.push('--agent', 'plan');
|
|
45
69
|
if (mode === 'auto') args.push('--auto');
|
|
46
70
|
if (model) args.push('--model', model);
|
|
@@ -75,14 +99,22 @@ export default {
|
|
|
75
99
|
const tokens = { input: 0, output: 0 };
|
|
76
100
|
let cost = 0;
|
|
77
101
|
|
|
78
|
-
|
|
102
|
+
let limitTimer = null;
|
|
103
|
+
let limited = false;
|
|
104
|
+
const proc = spawnJsonl({
|
|
79
105
|
bin: BIN,
|
|
80
106
|
args,
|
|
81
107
|
cwd,
|
|
82
108
|
env,
|
|
83
109
|
input,
|
|
84
110
|
onEvent,
|
|
111
|
+
onStderr(chunk) {
|
|
112
|
+
if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
|
|
113
|
+
limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
|
|
114
|
+
},
|
|
85
115
|
onJson(msg) {
|
|
116
|
+
// Anything coming through means a retry got an answer.
|
|
117
|
+
if (limitTimer && msg.type !== 'error') (clearTimeout(limitTimer), (limitTimer = null));
|
|
86
118
|
if (msg.sessionID && msg.sessionID !== session) onEvent({ type: 'session', id: (session = msg.sessionID) });
|
|
87
119
|
const part = msg.part || {};
|
|
88
120
|
switch (msg.type) {
|
|
@@ -118,19 +150,28 @@ export default {
|
|
|
118
150
|
}
|
|
119
151
|
},
|
|
120
152
|
onClose({ code, signal, stderr, stopped }) {
|
|
153
|
+
clearTimeout(limitTimer);
|
|
154
|
+
if (limited) {
|
|
155
|
+
const which = model ? `\`${model}\`` : 'opencode’s default model';
|
|
156
|
+
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 } });
|
|
157
|
+
}
|
|
121
158
|
const ok = code === 0 && !error;
|
|
122
159
|
onEvent({
|
|
123
160
|
type: 'result',
|
|
124
161
|
isError: !ok,
|
|
125
162
|
stopped,
|
|
126
|
-
text: ok ? text.join('\n') : error || stderr ||
|
|
163
|
+
text: ok ? text.join('\n') : error || lastLoggedError(stderr) || exitText('opencode', code, signal),
|
|
127
164
|
stats: { durationMs: Date.now() - started, costUsd: cost || undefined, inputTokens: tokens.input, outputTokens: tokens.output },
|
|
128
165
|
});
|
|
129
166
|
},
|
|
130
167
|
});
|
|
168
|
+
return proc;
|
|
131
169
|
},
|
|
132
170
|
};
|
|
133
171
|
|
|
172
|
+
// stderr with --print-logs: the last logged error's message, else stderr as it is.
|
|
173
|
+
const lastLoggedError = (stderr) => [...stderr.matchAll(/error\.error="((?:[^"\\]|\\.)*)"/g)].at(-1)?.[1] || stderr;
|
|
174
|
+
|
|
134
175
|
function safeJson(s) {
|
|
135
176
|
try {
|
|
136
177
|
return JSON.parse(s) || {};
|
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
|
|
|
@@ -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,70 @@
|
|
|
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 { blocksToMarkdown } from './render.js';
|
|
7
|
+
import { clip, truncate } from './util.js';
|
|
8
|
+
|
|
9
|
+
const BUDGET = 24_000; // characters of earlier turns, newest kept first
|
|
10
|
+
const PER_TURN = 6_000;
|
|
11
|
+
// The parts of a report worth passing on. Diffs, code and terminal output are on disk or in git.
|
|
12
|
+
const KEEP = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tests', 'commits', 'checklist', 'callout', 'question', 'next']);
|
|
13
|
+
|
|
14
|
+
// The turns before the current one that `agent` hasn't seen: everything after the last turn it
|
|
15
|
+
// ran, or all of them when it has no conversation of its own to resume (resumeId). Only a gap
|
|
16
|
+
// that another harness filled counts: on its own, a harness has nothing to catch up on.
|
|
17
|
+
export function missedTurns(session, agent, resumeId) {
|
|
18
|
+
const prior = session.turns.slice(0, -1);
|
|
19
|
+
const missed = resumeId ? prior.slice(prior.findLastIndex((t) => (t.agent || agent) === agent) + 1) : prior;
|
|
20
|
+
return missed.some((t) => (t.agent || agent) !== agent) ? missed : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function turnText(t, n, name) {
|
|
24
|
+
const blocks = (t.presentation?.blocks || []).filter((b) => KEEP.has(b?.type));
|
|
25
|
+
const report = t.status === 'running' ? '' : blocksToMarkdown(blocks, t) || clip(t.raw, 1500);
|
|
26
|
+
const files = (t.changes || []).filter((c) => !c.reverted).map((c) => `\`${c.file}\``);
|
|
27
|
+
return truncate(
|
|
28
|
+
[
|
|
29
|
+
`### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
|
|
30
|
+
`**User:** ${t.prompt}`,
|
|
31
|
+
report && `**Report:**\n\n${report}`,
|
|
32
|
+
files.length && `**Files it edited:** ${files.join(', ')}`,
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join('\n\n'),
|
|
36
|
+
PER_TURN,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The note for `agent` taking the next turn, or '' when it has seen everything.
|
|
41
|
+
// name(turn) → the harness's display name for that turn.
|
|
42
|
+
export function handoffNote(session, agent, resumeId, name) {
|
|
43
|
+
const missed = missedTurns(session, agent, resumeId);
|
|
44
|
+
if (!missed.length) return '';
|
|
45
|
+
const all = session.turns.slice(0, -1);
|
|
46
|
+
const parts = [];
|
|
47
|
+
let used = 0;
|
|
48
|
+
for (let i = missed.length - 1; i >= 0; i--) {
|
|
49
|
+
const text = turnText(missed[i], all.indexOf(missed[i]) + 1, name);
|
|
50
|
+
if (used + text.length > BUDGET && parts.length) break;
|
|
51
|
+
parts.unshift(text);
|
|
52
|
+
used += text.length;
|
|
53
|
+
}
|
|
54
|
+
const dropped = missed.length - parts.length;
|
|
55
|
+
const fresh = !resumeId;
|
|
56
|
+
const others = [...new Set(missed.filter((t) => t.agent !== agent).map(name))].join(', ');
|
|
57
|
+
const intro = fresh
|
|
58
|
+
? `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.`
|
|
59
|
+
: `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.`;
|
|
60
|
+
return [
|
|
61
|
+
'## Earlier in this task',
|
|
62
|
+
intro,
|
|
63
|
+
dropped ? `_${dropped} earlier turn${dropped > 1 ? 's' : ''} left out for length._` : '',
|
|
64
|
+
...parts,
|
|
65
|
+
'---',
|
|
66
|
+
'## Follow-up',
|
|
67
|
+
]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join('\n\n');
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A small route table: `route('PATCH', '/sessions/:id', handler)`. Patterns match whole path
|
|
2
|
+
// segments under /api; `:name` captures one (URI-decoded). Routes are tried in the order they
|
|
3
|
+
// were added, so a fixed segment (`/projects/discover`) goes before a capture (`/projects/:id`).
|
|
4
|
+
//
|
|
5
|
+
// A handler gets one context object and answers through it:
|
|
6
|
+
// { req, res, url, query, params, body(), send(status, data) }
|
|
7
|
+
// body() reads and parses the JSON request body (once). Options: `doc: false` marks a route that
|
|
8
|
+
// is not part of the public API (left out of openapi.json on purpose); `when(params)` narrows a
|
|
9
|
+
// pattern further (a route whose `when` says no is skipped, as if it didn't match).
|
|
10
|
+
|
|
11
|
+
import { readJson, send } from './respond.js';
|
|
12
|
+
|
|
13
|
+
export class Routes {
|
|
14
|
+
#list = [];
|
|
15
|
+
|
|
16
|
+
route(method, pattern, handler, { doc = true, when = null } = {}) {
|
|
17
|
+
const segs = pattern.split('/').filter(Boolean);
|
|
18
|
+
for (const m of [method].flat()) this.#list.push({ method: m, pattern, segs, handler, doc, when });
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every route, for the docs check in the tests.
|
|
23
|
+
list() {
|
|
24
|
+
return this.#list.map(({ method, pattern, doc }) => ({ method, pattern, doc }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Handles the request and returns true, or returns false when nothing matched.
|
|
28
|
+
async handle(req, res, url) {
|
|
29
|
+
const parts = url.pathname.split('/').filter(Boolean).slice(1); // drop "api"
|
|
30
|
+
for (const r of this.#list) {
|
|
31
|
+
if (r.method !== req.method) continue;
|
|
32
|
+
const params = match(r.segs, parts);
|
|
33
|
+
if (!params || (r.when && !r.when(params))) continue;
|
|
34
|
+
let parsed;
|
|
35
|
+
const ctx = {
|
|
36
|
+
req,
|
|
37
|
+
res,
|
|
38
|
+
url,
|
|
39
|
+
params,
|
|
40
|
+
query: url.searchParams,
|
|
41
|
+
body: () => (parsed ??= readJson(req)),
|
|
42
|
+
send: (status, data) => send(res, status, data),
|
|
43
|
+
};
|
|
44
|
+
await r.handler(ctx);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function match(segs, parts) {
|
|
52
|
+
if (segs.length !== parts.length) return null;
|
|
53
|
+
const params = {};
|
|
54
|
+
for (let i = 0; i < segs.length; i++) {
|
|
55
|
+
if (segs[i].startsWith(':')) {
|
|
56
|
+
try {
|
|
57
|
+
params[segs[i].slice(1)] = decodeURIComponent(parts[i]);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
} else if (segs[i] !== parts[i]) return null;
|
|
62
|
+
}
|
|
63
|
+
return params;
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The web UI: files under web/, with index.html for every other path (the app routes itself).
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'web');
|
|
8
|
+
|
|
9
|
+
export const MIME = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.svg': 'image/svg+xml',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.jpg': 'image/jpeg',
|
|
16
|
+
'.jpeg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.webp': 'image/webp',
|
|
19
|
+
'.ico': 'image/x-icon',
|
|
20
|
+
'.mp4': 'video/mp4',
|
|
21
|
+
'.webm': 'video/webm',
|
|
22
|
+
'.mov': 'video/quicktime',
|
|
23
|
+
'.pdf': 'application/pdf',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function serveStatic(res, pathname) {
|
|
27
|
+
const p = path.normalize(path.join(WEB, pathname === '/' ? 'index.html' : pathname));
|
|
28
|
+
// Inside web/ only: a sibling folder whose name starts with "web" doesn't count.
|
|
29
|
+
if (!p.startsWith(WEB + path.sep) || !fs.existsSync(p) || fs.statSync(p).isDirectory()) {
|
|
30
|
+
// SPA fallback
|
|
31
|
+
res.writeHead(200, { 'content-type': MIME['.html'] });
|
|
32
|
+
return fs.createReadStream(path.join(WEB, 'index.html')).pipe(res);
|
|
33
|
+
}
|
|
34
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(p)] || 'application/octet-stream', 'cache-control': 'no-cache' });
|
|
35
|
+
fs.createReadStream(p).pipe(res);
|
|
36
|
+
}
|