wisp-agents 0.20.0 → 0.22.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 +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +22 -2
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
package/server/client.js
CHANGED
|
@@ -132,14 +132,15 @@ export class WispClient {
|
|
|
132
132
|
// worktree: true/false overrides the default (on in git repos). compare: [{ agent, model, effort }]
|
|
133
133
|
// runs the same task on more harnesses at once, each in its own worktree. subagents:
|
|
134
134
|
// [{ name, agent, model, effort, use }] (or preset: a saved team's id) the lead can delegate to.
|
|
135
|
-
|
|
135
|
+
// also: more folders the same task works in (each git repo gets its own worktree and PR).
|
|
136
|
+
async launch({ prompt, cwd = process.cwd(), also, agent, mode, model, effort, attachments, worktree, compare, subagents, preset } = {}) {
|
|
136
137
|
if (!agent || !mode || model === undefined) {
|
|
137
138
|
const { defaults = {} } = await this.config();
|
|
138
139
|
agent ||= defaults.agent || 'auto';
|
|
139
140
|
mode ||= defaults.mode || 'auto';
|
|
140
141
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
141
142
|
}
|
|
142
|
-
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
143
|
+
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), also: also?.length ? also.map((d) => path.resolve(d)) : undefined, agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// Ship it: commit the session's worktree, push its branch, open (or update) the PR.
|
|
@@ -159,6 +160,25 @@ export class WispClient {
|
|
|
159
160
|
// run it, and the follow-ups after it, on another harness, model or thinking level.
|
|
160
161
|
reply = (id, text, { attachments, queue, mode, agent, model, effort } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode, agent, model, effort });
|
|
161
162
|
|
|
163
|
+
// A side question (/btw): a read-only run of its own answers it while the task's run carries on.
|
|
164
|
+
// Resolves with { id, question, status: done | error, answer | error } once it's answered
|
|
165
|
+
// (status stays running if timeoutMs runs out first), or right away with wait: false.
|
|
166
|
+
async btw(id, text, { attachments, wait = true, timeoutMs = 6 * 60_000 } = {}) {
|
|
167
|
+
const q = await this.request('POST', `/sessions/${id}/btw`, { text, attachments });
|
|
168
|
+
return wait ? this.btwAnswer(id, q.id, { timeoutMs }) : q;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async btwAnswer(id, btwId, { timeoutMs = 6 * 60_000 } = {}) {
|
|
172
|
+
const end = Date.now() + timeoutMs;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const s = await this.request('GET', `/sessions/${id}`);
|
|
175
|
+
const b = s.turns.flatMap((t) => t.btw || []).find((x) => x.id === btwId);
|
|
176
|
+
if (!b) return { id: btwId, status: 'error', error: 'It was dismissed.' };
|
|
177
|
+
if (b.status !== 'running' || Date.now() > end) return b;
|
|
178
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
// Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
|
|
163
183
|
async upload(file, { name, type } = {}) {
|
|
164
184
|
const data = typeof file === 'string' ? fs.readFileSync(file) : file;
|
package/server/git.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
7
8
|
import { execFile } from './proc.js';
|
|
8
9
|
import { bad } from './util.js';
|
|
9
10
|
|
|
@@ -12,9 +13,9 @@ const GH = () => process.env.WISP_GH_BIN || 'gh';
|
|
|
12
13
|
// Never wait on a credential or confirmation prompt nobody can see.
|
|
13
14
|
const QUIET_ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GIT_EDITOR: 'true' };
|
|
14
15
|
|
|
15
|
-
export function run(bin, args, { cwd, timeout = 60_000 } = {}) {
|
|
16
|
+
export function run(bin, args, { cwd, timeout = 60_000, env } = {}) {
|
|
16
17
|
return new Promise((resolve, reject) => {
|
|
17
|
-
execFile(bin, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ...QUIET_ENV } }, (err, stdout, stderr) => {
|
|
18
|
+
execFile(bin, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ...QUIET_ENV, ...env } }, (err, stdout, stderr) => {
|
|
18
19
|
if (!err) return resolve(String(stdout).trim());
|
|
19
20
|
// git and gh lead with "fatal:" / "error:"; otherwise the tail is the useful part.
|
|
20
21
|
const lines = String(stderr || '').trim().split('\n').filter(Boolean);
|
|
@@ -279,10 +280,13 @@ export function parseRepo(input, { shorthand = false } = {}) {
|
|
|
279
280
|
return null;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
// parseRepo, plus a plain path to a repo on disk (an origin can be one).
|
|
284
|
+
export const repoOf = (u) => parseRepo(u) || (path.isAbsolute(String(u || '')) ? parseRepo(pathToFileURL(String(u)).href) : null);
|
|
285
|
+
|
|
282
286
|
// The same repo, however it was spelled (https vs ssh, .git or not, case of the host).
|
|
283
|
-
const sameRepo = (a, b) => {
|
|
287
|
+
export const sameRepo = (a, b) => {
|
|
284
288
|
const key = (u) => {
|
|
285
|
-
const r =
|
|
289
|
+
const r = repoOf(u);
|
|
286
290
|
return r && (r.gh ? `github.com/${r.gh.toLowerCase()}` : `${r.host}/${r.path}`);
|
|
287
291
|
};
|
|
288
292
|
return !!key(a) && key(a) === key(b);
|
|
@@ -317,3 +321,75 @@ export async function cloneRepo(repo, parent, { timeout = 10 * 60_000 } = {}) {
|
|
|
317
321
|
throw bad(`Couldn’t clone ${what}: it doesn’t exist, or this login can’t see it.${repo.gh ? ' Private repo? Check `gh auth status`.' : ''}`);
|
|
318
322
|
throw bad(`Couldn’t clone ${what}: ${last.message}`);
|
|
319
323
|
}
|
|
324
|
+
|
|
325
|
+
// --- moving a task to another place: its work travels as a git bundle ---
|
|
326
|
+
|
|
327
|
+
// Who the snapshot commit is by, so packing works where git has no user.name.
|
|
328
|
+
const MOVER = { GIT_AUTHOR_NAME: 'wisp', GIT_AUTHOR_EMAIL: 'wisp@localhost', GIT_COMMITTER_NAME: 'wisp', GIT_COMMITTER_EMAIL: 'wisp@localhost' };
|
|
329
|
+
|
|
330
|
+
// Pack the work in `dir` into `file`: the branch's commits plus one snapshot commit on top that
|
|
331
|
+
// holds what isn't committed (untracked files too, ignored ones not). The user's index and
|
|
332
|
+
// working tree are only read. Commits the remote already has (from wt.head down) stay out.
|
|
333
|
+
// Resolves the pack's description for unpackWork(), or null outside a repo with commits.
|
|
334
|
+
export async function packWork(dir, wt, file) {
|
|
335
|
+
const root = await repoRoot(dir);
|
|
336
|
+
const head = root && (await quiet(git(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'])));
|
|
337
|
+
if (!head) return null;
|
|
338
|
+
const index = `${file}.index`;
|
|
339
|
+
const env = { ...MOVER, GIT_INDEX_FILE: index };
|
|
340
|
+
const ref = `refs/wisp/move/${path.basename(file).replace(/\W+/g, '-')}`;
|
|
341
|
+
try {
|
|
342
|
+
await git(root, ['read-tree', 'HEAD'], { env });
|
|
343
|
+
await git(root, ['add', '-A'], { env, timeout: 120_000 });
|
|
344
|
+
const tree = await git(root, ['write-tree'], { env });
|
|
345
|
+
const snap = await git(root, ['commit-tree', tree, '-p', head, '-m', 'wisp: work in progress, moved'], { env });
|
|
346
|
+
await git(root, ['update-ref', ref, snap]);
|
|
347
|
+
const published = wt?.head && (await quiet(git(root, ['branch', '-r', '--contains', wt.head])));
|
|
348
|
+
await git(root, ['bundle', 'create', file, ref, ...(published ? [`^${wt.head}`] : [])], { timeout: 300_000 });
|
|
349
|
+
const branch = wt?.branch || '';
|
|
350
|
+
return { ref, branch, base: wt?.base || '', baseRef: wt?.baseRef || '', head: wt?.head || null, remote: wt?.remote ?? null, prereq: !!published };
|
|
351
|
+
} finally {
|
|
352
|
+
fs.rmSync(index, { force: true });
|
|
353
|
+
await quiet(git(root, ['update-ref', '-d', ref]));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Unpack a packWork() bundle into a new worktree of the repo at `cwd`, on the same branch name
|
|
358
|
+
// when it's free: its commits as commits, the snapshot as uncommitted changes again.
|
|
359
|
+
export async function unpackWork({ cwd, home, id, title, pack, file }) {
|
|
360
|
+
const root = await repoRoot(cwd);
|
|
361
|
+
if (!root) throw bad(`${cwd} isn’t a git repo here, so the work has nowhere to go.`);
|
|
362
|
+
if (pack.prereq) await quiet(git(root, ['fetch', '--quiet', 'origin'], { timeout: 60_000 })); // the commits the bundle leaves out
|
|
363
|
+
const local = `refs/wisp/moved/${id}`;
|
|
364
|
+
try {
|
|
365
|
+
await git(root, ['fetch', '--quiet', '--no-tags', file, `+${pack.ref}:${local}`], { timeout: 300_000 });
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw bad(`Couldn’t bring the work over: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
const taken = async (b) => !!(await quiet(git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${b}`])));
|
|
370
|
+
let branch = /^[\w./-]{1,120}$/.test(pack.branch || '') && !(await taken(pack.branch)) ? pack.branch : `wisp/${slug(title) || 'task'}-${id.slice(0, 6)}`;
|
|
371
|
+
if (await taken(branch)) branch = `wisp/${slug(title) || 'task'}-${id}`;
|
|
372
|
+
const dir = path.join(home, 'worktrees', `${path.basename(root)}-${id}`);
|
|
373
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
374
|
+
try {
|
|
375
|
+
await git(root, ['worktree', 'add', '--quiet', '--no-track', '-b', branch, dir, `${local}^`]);
|
|
376
|
+
await git(dir, ['read-tree', '-u', '--reset', local]);
|
|
377
|
+
await git(dir, ['reset', '--quiet']);
|
|
378
|
+
} finally {
|
|
379
|
+
await quiet(git(root, ['update-ref', '-d', local]));
|
|
380
|
+
}
|
|
381
|
+
// A task that ran in the folder itself (no worktree) branches from what's here.
|
|
382
|
+
const base = pack.baseRef ? null : await baseRef(root);
|
|
383
|
+
const sub = path.relative(realpath(root), realpath(cwd));
|
|
384
|
+
return {
|
|
385
|
+
path: dir,
|
|
386
|
+
cwd: sub && !sub.startsWith('..') ? path.join(dir, sub) : dir,
|
|
387
|
+
branch,
|
|
388
|
+
base: pack.base || base?.branch || '',
|
|
389
|
+
baseRef: pack.baseRef || base?.ref || '',
|
|
390
|
+
head: pack.head || (await quiet(git(dir, ['rev-parse', 'HEAD']))) || null,
|
|
391
|
+
remote: pack.baseRef ? pack.remote : base?.remote || null,
|
|
392
|
+
root,
|
|
393
|
+
createdAt: Date.now(),
|
|
394
|
+
};
|
|
395
|
+
}
|
package/server/handoff.js
CHANGED
|
@@ -15,19 +15,22 @@ const KEEP = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tests', 'c
|
|
|
15
15
|
// The turns before the current one that `agent` hasn't seen: everything after the last turn it
|
|
16
16
|
// ran, or all of them when it has no conversation of its own to resume (resumeId). Only a gap
|
|
17
17
|
// that another harness filled counts: on its own, a harness has nothing to catch up on.
|
|
18
|
+
// Turns that ran on another place before the task moved here (t.moved) count as unseen by every
|
|
19
|
+
// harness: their conversations stayed there.
|
|
18
20
|
export function missedTurns(session, agent, resumeId) {
|
|
19
21
|
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
|
+
const missed = resumeId ? prior.slice(prior.findLastIndex((t) => !t.moved && (t.agent || agent) === agent) + 1) : prior;
|
|
23
|
+
return missed.some((t) => t.moved || (t.agent || agent) !== agent) ? missed : [];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
// One earlier turn as markdown: the request, its report and the files it edited. /btw uses it too.
|
|
27
|
+
export function turnText(t, n, name) {
|
|
25
28
|
const blocks = (t.presentation?.blocks || []).filter((b) => KEEP.has(b?.type));
|
|
26
29
|
const report = t.status === 'running' ? '' : blocksToMarkdown(blocks, t) || clip(t.raw, 1500);
|
|
27
30
|
const files = (t.changes || []).filter((c) => !c.reverted).map((c) => `\`${c.file}\``);
|
|
28
31
|
return truncate(
|
|
29
32
|
[
|
|
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' : ''}`,
|
|
33
|
+
`### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.moved ? ` · on ${t.moved}` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
|
|
31
34
|
`**User:** ${textMentions(t.prompt)}`,
|
|
32
35
|
report && `**Report:**\n\n${report}`,
|
|
33
36
|
files.length && `**Files it edited:** ${files.join(', ')}`,
|
|
@@ -55,9 +58,12 @@ export function handoffNote(session, agent, resumeId, name) {
|
|
|
55
58
|
const dropped = missed.length - parts.length;
|
|
56
59
|
const fresh = !resumeId;
|
|
57
60
|
const others = [...new Set(missed.filter((t) => t.agent !== agent).map(name))].join(', ');
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const places = [...new Set(missed.map((t) => t.moved).filter(Boolean))].join(', ');
|
|
62
|
+
const intro = places
|
|
63
|
+
? `You are taking over a task that ran on another computer (${places})${others ? `, on ${others}` : ''}, and was moved here. Its work so far is already in this folder: committed work as commits, the rest as uncommitted changes. Below is what happened so far, oldest first; read the files and \`git diff\` for the details, then carry on with the follow-up.`
|
|
64
|
+
: fresh
|
|
65
|
+
? `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.`
|
|
66
|
+
: `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
67
|
return [
|
|
62
68
|
'## Earlier in this task',
|
|
63
69
|
intro,
|
package/server/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import { scoutNote } from './scout.js';
|
|
|
29
29
|
import { createFeedback } from './services/feedback.js';
|
|
30
30
|
import { createInstaller } from './services/install.js';
|
|
31
31
|
import { createLaunch } from './services/launch.js';
|
|
32
|
+
import { createMove } from './services/move.js';
|
|
33
|
+
import { createPlaces } from './services/places.js';
|
|
32
34
|
import { phoneActions } from './services/phone.js';
|
|
33
35
|
import { createShip } from './services/ship.js';
|
|
34
36
|
import { trackTurns } from './services/telemetry.js';
|
|
@@ -148,6 +150,8 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
|
|
|
148
150
|
});
|
|
149
151
|
trackTurns(app);
|
|
150
152
|
|
|
153
|
+
app.places = createPlaces(app);
|
|
154
|
+
app.mover = createMove(app);
|
|
151
155
|
app.launcher = createLaunch(app);
|
|
152
156
|
app.sendFeedback = createFeedback(app);
|
|
153
157
|
app.triggers = new Triggers({ store, launch: async (body, origin) => (await app.launcher.launchAny(body, origin))[0], onChange: () => app.broadcast('config', {}) });
|
package/server/machines.js
CHANGED
|
@@ -21,6 +21,7 @@ import { spawn, killTree } from './proc.js';
|
|
|
21
21
|
import { newer } from './update.js';
|
|
22
22
|
import { bootstrapScript } from './machines/bootstrap.js';
|
|
23
23
|
import { sshHint, sshHosts, knownHosts, tailscalePeers, discoverHosts, TARGET } from './machines/hosts.js';
|
|
24
|
+
import { HERE } from './store.js';
|
|
24
25
|
import { bad, DEFAULT_PORT, shq } from './util.js';
|
|
25
26
|
|
|
26
27
|
export { DEFAULT_PORT };
|
|
@@ -153,7 +154,17 @@ export class Machines extends EventEmitter {
|
|
|
153
154
|
this.conns.delete(id);
|
|
154
155
|
this.cache.delete(id);
|
|
155
156
|
this.store.config.machines = this.list.filter((x) => x.id !== id);
|
|
156
|
-
|
|
157
|
+
// Its checkouts go. A project added there lives on in a copy elsewhere, if it has one.
|
|
158
|
+
this.store.config.projects = this.store.config.projects.filter((p) => {
|
|
159
|
+
delete p.copies?.[id];
|
|
160
|
+
if (p.machine !== id) return true;
|
|
161
|
+
const [k, dir] = Object.entries(p.copies || {})[0] || [];
|
|
162
|
+
if (!k) return false;
|
|
163
|
+
delete p.copies[k];
|
|
164
|
+
delete p.machine;
|
|
165
|
+
Object.assign(p, { path: dir }, k !== HERE && { machine: k });
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
157
168
|
this.store.saveConfig();
|
|
158
169
|
this.onChange();
|
|
159
170
|
}
|
|
@@ -175,7 +186,7 @@ export class Machines extends EventEmitter {
|
|
|
175
186
|
remote: c?.remote || null,
|
|
176
187
|
outdated: updateTo(this.version, c?.remote?.version, m.command),
|
|
177
188
|
log: c?.log.slice(-30) || [],
|
|
178
|
-
projects: this.store.config.projects.filter((p) => p
|
|
189
|
+
projects: this.store.config.projects.filter((p) => this.store.pathAt(p, m.id)).length,
|
|
179
190
|
working: cached.filter((s) => s.state === 'running').length,
|
|
180
191
|
};
|
|
181
192
|
});
|
|
@@ -577,10 +588,10 @@ export class Machines extends EventEmitter {
|
|
|
577
588
|
return j;
|
|
578
589
|
}
|
|
579
590
|
|
|
591
|
+
// A folder there that's a checkout of a repo already added elsewhere joins that project.
|
|
580
592
|
async addProject(mid, dir, name) {
|
|
581
593
|
const p = await this.json(mid, 'POST', '/projects', { path: dir }, { timeout: 10 * 60_000 }); // dir may be a repo URL to clone
|
|
582
|
-
|
|
583
|
-
return local;
|
|
594
|
+
return this.store.addProject(p.path, { name: name || p.name, machine: mid, repo: p.origin || p.cloned?.url || undefined });
|
|
584
595
|
}
|
|
585
596
|
|
|
586
597
|
// A terminal on the machine running `command` (Take over), in the user's own interactive login
|
package/server/mcp-wisp.js
CHANGED
|
@@ -23,6 +23,7 @@ const TOOLS = [
|
|
|
23
23
|
properties: {
|
|
24
24
|
prompt: { type: 'string', description: 'The task, written as you would brief a colleague: goal, constraints, how to verify.' },
|
|
25
25
|
cwd: { type: 'string', description: 'Absolute path of the project folder. Default: this MCP server’s working directory.' },
|
|
26
|
+
also: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of more folders the same agent works in (e.g. the API next to its web app). Git repos each get a worktree, and Ship it opens a PR in each.' },
|
|
26
27
|
agent: { type: 'string', enum: AGENTS, description: 'Harness to run; auto lets wisp pick harness and model from what worked before. Default: the user’s last pick in wisp.' },
|
|
27
28
|
mode: { type: 'string', enum: ['plan', 'edits', 'auto'], description: 'plan = read-only, edits = auto-accept file edits, auto = no permission prompts. Default: the user’s pick.' },
|
|
28
29
|
model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
|
|
@@ -116,7 +117,7 @@ async function callTool(name, a = {}) {
|
|
|
116
117
|
switch (name) {
|
|
117
118
|
case 'wisp_launch': {
|
|
118
119
|
if (!a.prompt?.trim()) throw new Error('prompt is required');
|
|
119
|
-
const s = await c.launch({ prompt: a.prompt, cwd: a.cwd || process.cwd(), agent: a.agent, mode: a.mode, model: a.model, effort: a.effort, subagents: a.subagents, preset: a.preset });
|
|
120
|
+
const s = await c.launch({ prompt: a.prompt, cwd: a.cwd || process.cwd(), also: Array.isArray(a.also) ? a.also : undefined, agent: a.agent, mode: a.mode, model: a.model, effort: a.effort, subagents: a.subagents, preset: a.preset });
|
|
120
121
|
if (a.wait) return waitFor(s.id);
|
|
121
122
|
return `Launched session \`${s.id}\` (“${s.title}”) on ${s.agent} in ${s.cwd}, mode ${s.mode}. Call wisp_wait or wisp_get with this id for the result.`;
|
|
122
123
|
}
|
package/server/openapi.js
CHANGED
|
@@ -234,11 +234,20 @@ export function openapi({ version, port }) {
|
|
|
234
234
|
'/sessions/{id}/reply': {
|
|
235
235
|
parameters: [id],
|
|
236
236
|
post: {
|
|
237
|
-
summary: 'Follow up (answers a pending ask_user question if there is one)',
|
|
237
|
+
summary: 'Follow up (answers a pending ask_user question if there is one). Text starting with /btw is a side question instead (see /btw)',
|
|
238
238
|
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' }, attachments: { type: 'array', items: { type: 'string' } }, queue: { type: 'boolean', description: 'Wait for the current run instead of interrupting it' }, mode: { enum: ['plan', 'edits', 'auto'], description: 'Switch the session to this mode from this follow-up on' }, agent: { type: 'string', description: 'Switch to this harness from this follow-up on; it is handed the turns it has not seen' }, model: { type: 'string', description: 'Switch model (empty: the harness default)' }, effort: { type: 'string', description: 'Switch thinking level (empty: the default)' } } }),
|
|
239
|
-
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
|
|
239
|
+
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' }, btw: ref('Btw') } }), 400: err[400] },
|
|
240
240
|
},
|
|
241
241
|
},
|
|
242
|
+
'/sessions/{id}/btw': {
|
|
243
|
+
parameters: [id],
|
|
244
|
+
post: {
|
|
245
|
+
summary: 'Ask a side question without interrupting: a read-only run of its own answers it (on a fork of the agent’s conversation where the harness can fork) and the answer lands on the latest turn’s btw list',
|
|
246
|
+
requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string', description: 'The question; a leading /btw is dropped' }, attachments: { type: 'array', items: { type: 'string' } } } }),
|
|
247
|
+
responses: { 200: json(ref('Btw')), 400: err[400], 404: err[404], 429: err[400] },
|
|
248
|
+
},
|
|
249
|
+
delete: { summary: 'Dismiss a side question (stops its run if it is still answering)', parameters: [{ name: 'item', in: 'query', required: true, schema: { type: 'string' } }], responses: { 200: ok, 404: err[404] } },
|
|
250
|
+
},
|
|
242
251
|
'/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
|
|
243
252
|
'/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
|
|
244
253
|
'/sessions/{id}/revert': {
|
|
@@ -304,6 +313,7 @@ export function openapi({ version, port }) {
|
|
|
304
313
|
properties: {
|
|
305
314
|
prompt: { type: 'string' },
|
|
306
315
|
cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
|
|
316
|
+
also: { type: 'array', maxItems: 8, items: { type: 'string' }, description: 'More folders the same task works in (absolute; ~ allowed). The agent gets each; git repos get their own worktree, and Ship it opens a PR in each' },
|
|
307
317
|
scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
|
|
308
318
|
agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
|
|
309
319
|
important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
|
|
@@ -430,6 +440,22 @@ export function openapi({ version, port }) {
|
|
|
430
440
|
stats: { type: ['object', 'null'] },
|
|
431
441
|
},
|
|
432
442
|
},
|
|
443
|
+
Btw: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
description: 'A side question (/btw). It sits on turns[].btw of the turn it was asked during; GET the session or wait for its turn event to see the answer',
|
|
446
|
+
properties: {
|
|
447
|
+
id: { type: 'string' },
|
|
448
|
+
question: { type: 'string' },
|
|
449
|
+
status: { enum: ['running', 'done', 'error'] },
|
|
450
|
+
answer: { type: 'string', description: 'Markdown, once done' },
|
|
451
|
+
error: { type: 'string' },
|
|
452
|
+
forked: { type: 'boolean', description: 'Answered on a copy of the agent’s conversation (else from wisp’s record of the task)' },
|
|
453
|
+
agent: { type: 'string' },
|
|
454
|
+
model: { type: 'string' },
|
|
455
|
+
askedAt: { type: 'integer' },
|
|
456
|
+
answeredAt: { type: 'integer' },
|
|
457
|
+
},
|
|
458
|
+
},
|
|
433
459
|
},
|
|
434
460
|
},
|
|
435
461
|
};
|
package/server/remote.js
CHANGED
|
@@ -25,7 +25,8 @@ export function findProject(projects, q) {
|
|
|
25
25
|
export const mask = (t) => (t ? `••••${String(t).slice(-4)}` : '');
|
|
26
26
|
|
|
27
27
|
export class Remote {
|
|
28
|
-
// act({ sessionId, kind: answer|reply|done|text, ref, index, text }) → a short result line
|
|
28
|
+
// act({ sessionId, kind: answer|reply|done|text, ref, index, text, later? }) → a short result line;
|
|
29
|
+
// later(text) sends one more message afterwards (a /btw answer).
|
|
29
30
|
// launch({ project, prompt }) → the new session's summary (a task started with /new).
|
|
30
31
|
constructor({ store, act, launch = async () => null, onChange = () => {} }) {
|
|
31
32
|
Object.assign(this, { store, act, launch, onChange });
|
|
@@ -253,7 +254,8 @@ export class Remote {
|
|
|
253
254
|
const replyTo = m.reply_to_message?.message_id;
|
|
254
255
|
const sessionId = replyTo && (this.store.all().find((x) => x.remote?.tg?.includes(replyTo))?.id || this.tgElsewhere.get(replyTo));
|
|
255
256
|
if (!sessionId) return say(t.allowStart ? 'Reply to one of wisp’s messages to answer it or follow up, or send /new <task> to start one.' : 'Reply to one of wisp’s messages to answer it or send a follow-up.');
|
|
256
|
-
|
|
257
|
+
const later = (text) => say(text, { reply_to_message_id: m.message_id }).catch(() => {});
|
|
258
|
+
await say(await this.act({ sessionId, kind: 'text', text: m.text, later }), { reply_to_message_id: m.message_id });
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
async #tgCommand(name, arg, m, say) {
|
|
@@ -36,7 +36,7 @@ export function register(r, app) {
|
|
|
36
36
|
'GET',
|
|
37
37
|
'/machines/:id/discover',
|
|
38
38
|
known(async (c, id) => {
|
|
39
|
-
const have = new Set(store.config.projects.
|
|
39
|
+
const have = new Set(store.config.projects.map((p) => store.pathAt(p, id)).filter(Boolean));
|
|
40
40
|
c.send(200, (await machines.json(id, 'GET', '/projects/discover')).filter((f) => !have.has(f.path)));
|
|
41
41
|
}),
|
|
42
42
|
);
|
package/server/routes/proxy.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Registered before the session routes, so a qualified id never reaches them.
|
|
3
3
|
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { btwQuestion } from '../btw.js';
|
|
5
6
|
import { pipeFrom } from '../http/respond.js';
|
|
6
7
|
import { splitId, qualify } from '../machines.js';
|
|
7
8
|
import { openInTerminal, shq } from '../terminal.js';
|
|
@@ -34,6 +35,11 @@ export function register(r, app) {
|
|
|
34
35
|
return c.send(200, { command: argv.map(shq).join(' ') });
|
|
35
36
|
}
|
|
36
37
|
const body = m === 'GET' ? undefined : await c.body();
|
|
38
|
+
// A follow-up that moves the task here or to another machine (services/move.js).
|
|
39
|
+
if (action === 'reply' && typeof body?.machine === 'string') {
|
|
40
|
+
if (body.machine !== mid && btwQuestion(body.text) === null) return c.send(200, await app.mover.move(c.params.id, body.machine, { ...body, text: String(body.text || '').trim() }));
|
|
41
|
+
delete body.machine;
|
|
42
|
+
}
|
|
37
43
|
if (action === 'reply' && body?.attachments?.length) body.attachments = await app.launcher.pushUploads(mid, body.attachments);
|
|
38
44
|
const out = await machines.json(mid, m, sub, body, action === 'wait' ? { timeout: (MAX_WAIT_S + 30) * 1000 } : undefined);
|
|
39
45
|
if (!action && m === 'GET') return c.send(200, machines.session(mid, out));
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Sessions: list and launch, one session (read, edit, delete), and what can be done to it:
|
|
2
|
-
// wait, presentation, reply, queue, answer, revert, rollback, exec, takeover, stop, file.
|
|
2
|
+
// wait, presentation, reply, btw, queue, answer, revert, rollback, exec, takeover, stop, file.
|
|
3
3
|
// Ids of the form <id>@<machine> are passed through to that machine's wisp (proxy.js).
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { adapters, listAgents } from '../adapters/index.js';
|
|
8
|
+
import { btwQuestion } from '../btw.js';
|
|
8
9
|
import { cleanupCheckpoints, revertFileChange, rollbackToTurn } from '../checkpoint.js';
|
|
9
10
|
import { execCommand } from '../proc.js';
|
|
10
11
|
import { ensureWorktree, removeWorktree } from '../git.js';
|
|
11
12
|
import { MIME } from '../http/static.js';
|
|
12
13
|
import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
|
|
13
|
-
import { summarize, workdir } from '../runner.js';
|
|
14
|
+
import { alsoDirs, summarize, workdir } from '../runner.js';
|
|
14
15
|
import { normalizeTeam } from '../subagents.js';
|
|
15
16
|
import { openInTerminal, shq } from '../terminal.js';
|
|
16
17
|
import { bad, clip, expand, MODES } from '../util.js';
|
|
@@ -46,6 +47,10 @@ export function register(r, app) {
|
|
|
46
47
|
}
|
|
47
48
|
return { agent, ...(model !== undefined && { model: String(model || '') }), ...(effort !== undefined && { effort: String(effort || '') }) };
|
|
48
49
|
};
|
|
50
|
+
// Archiving removes clean worktrees: the task's own and its other folders' come back before it runs again.
|
|
51
|
+
const ensureTrees = async (s) => {
|
|
52
|
+
for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
|
|
53
|
+
};
|
|
49
54
|
const turnOf = (s, turnId) => (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
50
55
|
|
|
51
56
|
r.route('GET', '/sessions', async (c) => {
|
|
@@ -57,8 +62,10 @@ export function register(r, app) {
|
|
|
57
62
|
let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
58
63
|
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
59
64
|
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
// A task in several folders (also) shows under each of their projects.
|
|
66
|
+
const dirs = (x) => [x.cwd, ...(x.also || []).map((a) => a.cwd)];
|
|
67
|
+
if (q.project) list = list.filter((x) => [x, ...(x.also || [])].some((p) => p.projectId === q.project || p.project === q.project));
|
|
68
|
+
if (cwd) list = list.filter((x) => dirs(x).some((d) => d === cwd || d?.startsWith(cwd + path.sep)));
|
|
62
69
|
if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
|
|
63
70
|
if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
|
|
64
71
|
c.send(200, list);
|
|
@@ -90,6 +97,8 @@ export function register(r, app) {
|
|
|
90
97
|
if (body.state === 'done' && s.state !== 'running') s.state = 'done';
|
|
91
98
|
if (body.state === 'review' && s.state === 'done') s.state = 'review';
|
|
92
99
|
if (typeof body.unread === 'boolean') s.unread = body.unread;
|
|
100
|
+
// Moved to another place by the wisp that shows it (services/move.js): where it went.
|
|
101
|
+
if (body.movedTo && typeof body.movedTo === 'object') s.movedTo = { id: String(body.movedTo.id || ''), place: String(body.movedTo.place || '') };
|
|
93
102
|
// The team can change between turns; the next turn's lead sees the new roster.
|
|
94
103
|
if (body.subagents !== undefined && !s.parentId) {
|
|
95
104
|
s.subagents = normalizeTeam(body.subagents, adapters);
|
|
@@ -100,7 +109,8 @@ export function register(r, app) {
|
|
|
100
109
|
if (s.state === 'done' || s.archived) app.retro.schedule(s, 0); // finished with it: learn now
|
|
101
110
|
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
102
111
|
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
103
|
-
if (
|
|
112
|
+
if (!runner.live.has(s.id) && (s.archived || body.discardWorktree))
|
|
113
|
+
for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !x.pr && 'force' });
|
|
104
114
|
app.sessionChanged(s);
|
|
105
115
|
c.send(200, summarize(s, store));
|
|
106
116
|
}),
|
|
@@ -110,6 +120,7 @@ export function register(r, app) {
|
|
|
110
120
|
'/sessions/:id',
|
|
111
121
|
one(async (c, s) => {
|
|
112
122
|
runner.stop(s);
|
|
123
|
+
runner.stopSides(s);
|
|
113
124
|
await runner.live.get(s.id)?.done;
|
|
114
125
|
cleanupCheckpoints(workdir(s), s.id);
|
|
115
126
|
for (const cid of Object.values(s.children || {})) {
|
|
@@ -117,7 +128,7 @@ export function register(r, app) {
|
|
|
117
128
|
if (store.get(cid)) (store.delete(cid), app.broadcast('removed', { id: cid }));
|
|
118
129
|
}
|
|
119
130
|
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
120
|
-
|
|
131
|
+
for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: true, branch: true }).catch(() => {});
|
|
121
132
|
store.delete(s.id);
|
|
122
133
|
app.broadcast('removed', { id: s.id });
|
|
123
134
|
c.send(200, { ok: true });
|
|
@@ -177,16 +188,42 @@ export function register(r, app) {
|
|
|
177
188
|
'POST',
|
|
178
189
|
'/sessions/:id/reply',
|
|
179
190
|
one(async (c, s) => {
|
|
180
|
-
const { text, attachments, queue, mode, agent, model, effort } = await c.body();
|
|
191
|
+
const { text, attachments, queue, mode, agent, model, effort, machine } = await c.body();
|
|
181
192
|
if (!text?.trim()) return c.send(400, { error: 'text required' });
|
|
182
193
|
notTour(s, 'follow up');
|
|
183
194
|
if (mode !== undefined && !MODES.has(mode)) return c.send(400, { error: 'mode must be plan, edits or auto.' });
|
|
195
|
+
// machine: carry the task on on a machine instead (services/move.js). '' is here, where it is.
|
|
196
|
+
// A /btw side question never moves it.
|
|
197
|
+
if (typeof machine === 'string' && machine && btwQuestion(text) === null) return c.send(200, await app.mover.move(s.id, machine, { text: text.trim(), attachments, mode, agent, model, effort }));
|
|
184
198
|
// agent / model / effort: run this follow-up (and the ones after it) on another harness or model.
|
|
185
199
|
const run = agent !== undefined || model !== undefined || effort !== undefined ? await followUpRun(s, { agent, model, effort }) : undefined;
|
|
186
200
|
c.send(200, await launcher.replyTo(s, text.trim(), app.uploads.resolve(attachments), queue, mode, run));
|
|
187
201
|
}),
|
|
188
202
|
);
|
|
189
203
|
|
|
204
|
+
// Pack the task's work to move it to another place (services/move.js): bundles, as uploads.
|
|
205
|
+
r.route(
|
|
206
|
+
'POST',
|
|
207
|
+
'/sessions/:id/pack',
|
|
208
|
+
one(async (c, s) => c.send(200, await app.mover.pack(s))),
|
|
209
|
+
{ doc: false },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// A side question (/btw): answered next to the run, which carries on. text may keep its /btw.
|
|
213
|
+
r.route(
|
|
214
|
+
'POST',
|
|
215
|
+
'/sessions/:id/btw',
|
|
216
|
+
one(async (c, s) => {
|
|
217
|
+
const { text, attachments } = await c.body();
|
|
218
|
+
c.send(200, await launcher.askBtw(s, btwQuestion(text) ?? String(text || ''), app.uploads.resolve(attachments)));
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
r.route(
|
|
222
|
+
'DELETE',
|
|
223
|
+
'/sessions/:id/btw',
|
|
224
|
+
one((c, s) => (runner.dropBtw(s, c.query.get('item') || '') ? c.send(200, { ok: true }) : c.send(404, { error: 'No such side question.' }))),
|
|
225
|
+
);
|
|
226
|
+
|
|
190
227
|
r.route(
|
|
191
228
|
'DELETE',
|
|
192
229
|
'/sessions/:id/queue',
|
|
@@ -213,7 +250,7 @@ export function register(r, app) {
|
|
|
213
250
|
one(async (c, s) => {
|
|
214
251
|
const { turnId, allow } = await c.body();
|
|
215
252
|
notTour(s, 'approve');
|
|
216
|
-
await
|
|
253
|
+
await ensureTrees(s);
|
|
217
254
|
c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
|
|
218
255
|
}),
|
|
219
256
|
);
|
|
@@ -295,8 +332,8 @@ export function register(r, app) {
|
|
|
295
332
|
one(async (c, s) => {
|
|
296
333
|
const adapter = adapters[s.agent];
|
|
297
334
|
if (!adapter?.resumeCommand || !s.agentSessionId) return c.send(400, { error: 'This session cannot be resumed in a terminal.' });
|
|
298
|
-
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
299
|
-
await
|
|
335
|
+
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort, dirs: alsoDirs(s) });
|
|
336
|
+
await ensureTrees(s);
|
|
300
337
|
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
301
338
|
await fn(s, argv);
|
|
302
339
|
c.send(200, { command });
|
|
@@ -10,20 +10,10 @@ import { envDisabled, feedbackAvailable } from '../telemetry.js';
|
|
|
10
10
|
import { TERMINALS } from '../terminal.js';
|
|
11
11
|
import { normalizeWebhooks } from '../services/webhooks.js';
|
|
12
12
|
import { cloneRepo, gh, git, parseRepo } from '../git.js';
|
|
13
|
+
import { checkout, cloneRoot, discoverRepos, originAt } from '../services/places.js';
|
|
13
14
|
import { expand } from '../util.js';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
|
|
17
|
-
|
|
18
|
-
// Where a pasted repo URL is cloned: defaults.cloneDir, else the first usual repo folder that
|
|
19
|
-
// exists (not Documents/Downloads/Desktop), else ~/code.
|
|
20
|
-
export function cloneRoot(config) {
|
|
21
|
-
if (config.defaults?.cloneDir) return expand(config.defaults.cloneDir);
|
|
22
|
-
const dir = REPO_ROOTS.slice(0, -3)
|
|
23
|
-
.map((r) => path.join(os.homedir(), r))
|
|
24
|
-
.find((d) => fs.existsSync(d) && fs.statSync(d).isDirectory());
|
|
25
|
-
return dir || path.join(os.homedir(), 'code');
|
|
26
|
-
}
|
|
16
|
+
export { cloneRoot };
|
|
27
17
|
|
|
28
18
|
export function register(r, app) {
|
|
29
19
|
const { store, telemetry, prefs, retro, remote, machines } = app;
|
|
@@ -119,12 +109,30 @@ export function register(r, app) {
|
|
|
119
109
|
cloned = { url: repo.url, existed: out.existed };
|
|
120
110
|
}
|
|
121
111
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
|
|
122
|
-
|
|
112
|
+
// Its origin: the same repo added on another place joins that project, and a machine
|
|
113
|
+
// without a checkout of it can clone it.
|
|
114
|
+
const origin = await originAt(dir);
|
|
115
|
+
const p = store.addProject(dir, { name: body.name, repo: origin || undefined });
|
|
123
116
|
app.broadcast('config', {});
|
|
124
|
-
c.send(201,
|
|
117
|
+
c.send(201, { ...p, ...(cloned && { cloned }), ...(origin && { origin }) });
|
|
125
118
|
});
|
|
119
|
+
// Another wisp (this computer is one of its machines) needs a checkout of `repo` here: one that's
|
|
120
|
+
// already here, else a fresh clone. It's added as a project here too.
|
|
121
|
+
r.route(
|
|
122
|
+
'POST',
|
|
123
|
+
'/projects/checkout',
|
|
124
|
+
async (c) => {
|
|
125
|
+
const { repo, name } = await c.body();
|
|
126
|
+
const out = await checkout(store, String(repo || ''));
|
|
127
|
+
const p = store.addProject(out.dir, { name: name ? String(name) : undefined, repo: String(repo) });
|
|
128
|
+
app.broadcast('config', {});
|
|
129
|
+
c.send(200, { path: out.dir, existed: !!out.existed, name: p.name });
|
|
130
|
+
},
|
|
131
|
+
{ doc: false },
|
|
132
|
+
);
|
|
133
|
+
r.route('GET', '/projects/origin', async (c) => c.send(200, { origin: await originAt(expand(c.query.get('path') || '.')) }), { doc: false });
|
|
126
134
|
// Find git repos in the usual places so adding projects is one click.
|
|
127
|
-
r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.
|
|
135
|
+
r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => store.pathAt(p)).filter(Boolean)))), { doc: false });
|
|
128
136
|
// The user's GitHub repos (gh's login), newest first, minus ones already added: one click clones.
|
|
129
137
|
// Empty when gh isn't installed or signed in.
|
|
130
138
|
r.route(
|
|
@@ -136,7 +144,7 @@ export function register(r, app) {
|
|
|
136
144
|
.catch(() => null);
|
|
137
145
|
if (!list) return c.send(200, []);
|
|
138
146
|
const origins = await Promise.all(
|
|
139
|
-
store.config.projects.
|
|
147
|
+
store.config.projects.map((p) => p.repo || (store.pathAt(p) ? git(store.pathAt(p), ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '') : '')),
|
|
140
148
|
);
|
|
141
149
|
const have = new Set(origins.map((o) => parseRepo(o)?.gh?.toLowerCase()).filter(Boolean));
|
|
142
150
|
c.send(
|
|
@@ -169,25 +177,3 @@ export function register(r, app) {
|
|
|
169
177
|
c.send(200, { ok: true });
|
|
170
178
|
});
|
|
171
179
|
}
|
|
172
|
-
|
|
173
|
-
// Git repos up to two levels under the usual folders, newest first, skipping known ones.
|
|
174
|
-
function discoverRepos(known) {
|
|
175
|
-
const found = [];
|
|
176
|
-
const walk = (dir, depth) => {
|
|
177
|
-
if (found.length >= 60) return;
|
|
178
|
-
let entries;
|
|
179
|
-
try {
|
|
180
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
181
|
-
} catch {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (entries.some((e) => e.name === '.git')) {
|
|
185
|
-
if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (depth <= 0) return;
|
|
189
|
-
for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
|
|
190
|
-
};
|
|
191
|
-
for (const r of REPO_ROOTS) walk(path.join(os.homedir(), r), 2);
|
|
192
|
-
return found.sort((a, b) => b.mtime - a.mtime).slice(0, 24);
|
|
193
|
-
}
|