wisp-agents 0.21.0 → 0.23.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +15 -10
  3. package/bin/wisp.js +10 -3
  4. package/package.json +15 -4
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +19 -0
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/router.js +35 -20
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +32 -2
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/uploads.js +2 -0
  25. package/server/runner.js +154 -8
  26. package/server/services/launch.js +32 -4
  27. package/server/services/move.js +130 -0
  28. package/server/services/phone.js +20 -1
  29. package/server/services/places.js +150 -0
  30. package/server/services/uploads.js +23 -4
  31. package/server/store.js +39 -6
  32. package/skills/wisp/SKILL.md +1 -0
  33. package/web/css/blocks.css +2 -0
  34. package/web/css/composer.css +23 -1
  35. package/web/css/feed.css +49 -1
  36. package/web/css/machines.css +4 -0
  37. package/web/css/modals.css +19 -0
  38. package/web/css/ship.css +2 -0
  39. package/web/css/transcript.css +115 -0
  40. package/web/index.html +2 -0
  41. package/web/js/core/state.js +3 -2
  42. package/web/js/features/actions.js +42 -10
  43. package/web/js/features/auto.js +1 -1
  44. package/web/js/features/btw.js +145 -0
  45. package/web/js/features/clones.js +7 -6
  46. package/web/js/features/commands.js +3 -1
  47. package/web/js/features/composer.js +162 -18
  48. package/web/js/features/feed.js +1 -1
  49. package/web/js/features/keys.js +2 -1
  50. package/web/js/features/launcher.js +104 -94
  51. package/web/js/features/place-menu.js +44 -0
  52. package/web/js/features/places.js +15 -5
  53. package/web/js/features/projects.js +14 -12
  54. package/web/js/features/runners.js +2 -2
  55. package/web/js/features/settings.js +24 -3
  56. package/web/js/features/slash.js +63 -0
  57. package/web/js/features/transcript.js +290 -0
  58. package/web/js/features/turns.js +99 -28
  59. package/web/js/slash-text.js +22 -0
  60. package/web/js/ui/kbd.js +1 -0
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 = parseRepo(u);
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
- function turnText(t, n, name) {
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 intro = fresh
59
- ? `You are taking over a task that another coding agent (${others}) has been working on. Its work is already in this folder. Below is what happened so far, oldest first; read the files and \`git diff\` for the details, then carry on with the follow-up.`
60
- : `While you were away, this task continued on another coding agent (${others}). Its work is already in this folder. Below is what happened since your last turn, oldest first; check the files and \`git diff\` before you rely on what you remember.`;
61
+ 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', {}) });
@@ -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
- this.store.config.projects = this.store.config.projects.filter((p) => p.machine !== id);
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.machine === m.id).length,
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
- const local = this.store.addProject(p.path, { name: name || p.name, machine: mid });
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/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': {
@@ -431,6 +440,22 @@ export function openapi({ version, port }) {
431
440
  stats: { type: ['object', 'null'] },
432
441
  },
433
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
+ },
434
459
  },
435
460
  },
436
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
- await say(await this.act({ sessionId, kind: 'text', text: m.text }), { reply_to_message_id: m.message_id });
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) {
package/server/router.js CHANGED
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // Every harness × model × thinking level the user has is an "arm" (claude:opus:high; no level is
4
4
  // the harness's default). The goal picks the band of levels: Cheap runs low, Balanced the default
5
- // or medium, Best high. Within the band the levels are learned like models are, and a model's
6
- // record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
5
+ // or medium, Best high. Balanced and Best both run frontier models when you have one, so the
6
+ // level is what tells them apart. Within the band the levels are learned like models are, and a
7
+ // model's record at its other levels counts half. For each kind of task (bug, ui, docs, …) wisp
7
8
  // keeps a Beta(wins, losses) estimate per arm, stacked from three sources: the shipped priors
8
9
  // (./router-priors.json, from wisp-bench), the user's history on every kind of task, and the
9
10
  // history on this kind. A launch draws a chance of success from each estimate (Thompson
@@ -27,13 +28,12 @@ const PRIORS = JSON.parse(fs.readFileSync(new URL('./router-priors.json', import
27
28
 
28
29
  export const GOALS = {
29
30
  cheap: { label: 'Cheap', value: 1, hint: 'Lowest cost that still gets it done' },
30
- balanced: { label: 'Balanced', value: 5, hint: 'Best result per dollar' },
31
- best: { label: 'Best', value: 50, hint: 'Frontier models only; cost only breaks ties' },
31
+ balanced: { label: 'Balanced', value: 5, hint: 'Frontier models at default or medium thinking' },
32
+ best: { label: 'Best', value: 50, hint: 'Frontier models at high thinking; cost only breaks ties' },
32
33
  };
33
34
  export const TYPES = ['bug', 'feature', 'ui', 'test', 'refactor', 'docs', 'release', 'question'];
34
35
  const MINUTE_USD = 0.05; // what a minute of waiting costs you, in the utility
35
36
  const PRIOR_CAP = 4; // shipped priors count as at most this many tasks, so your own soon outweigh them
36
- const COLD_START = 20; // until Auto has this many of your outcomes, Balanced and Best stay on stronger tiers
37
37
  const ALL_WEIGHT = 0.5; // your history on other kinds of task counts half
38
38
  const HALF_LIFE_MS = 30 * 864e5; // models change: older outcomes fade
39
39
  const NEW_ARM_SHARE = 0.25; // how often a new arm on probation may take an exploring slot
@@ -80,6 +80,17 @@ export const levelsFor = (efforts = [], goal = 'balanced') => {
80
80
  const have = (BANDS[goal] || BANDS.balanced).filter((e) => e === '' || efforts.includes(e));
81
81
  return have.length ? have : [''];
82
82
  };
83
+ // Some harnesses bake the level into the model id (Antigravity's gemini-4-argon-high, no separate
84
+ // selector): there the goal picks among a model's variants the same way.
85
+ const SUFFIX = /-(minimal|low|medium|high|xhigh|max)$/;
86
+ const NAMED = { cheap: ['minimal', 'low'], balanced: ['medium'], best: ['high', 'xhigh', 'max'] };
87
+ const stemOf = (k) => baseOf(k).replace(SUFFIX, '');
88
+ function byNamedLevel(cands, goal) {
89
+ const want = NAMED[goal] || NAMED.balanced;
90
+ const named = (c) => !c.efforts.length && c.model.match(SUFFIX)?.[1];
91
+ const fits = new Set(cands.filter((c) => want.includes(named(c))).map((c) => stemOf(c.key)));
92
+ return cands.filter((c) => !named(c) || want.includes(named(c)) || !fits.has(stemOf(c.key)));
93
+ }
83
94
  const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
84
95
 
85
96
  export const armKey = (agent, model = '', effort = '') => `${agent}:${model || ''}${effort ? `:${effort}` : ''}`;
@@ -99,10 +110,12 @@ export const baseOf = (k) => {
99
110
  };
100
111
  // Versions stripped: claude-sonnet-5-5 and claude-sonnet-5-6 are one family, and a new version
101
112
  // starts from what the family already showed.
102
- const family = (k) => k.replace(/[-.]?\d+([.-]\d+)*/g, '');
113
+ // A level in the id is the same model too: gemini-4-argon-high starts from what -medium showed.
114
+ const family = (k) => k.replace(SUFFIX, '').replace(/[-.]?\d+([.-]\d+)*/g, '');
103
115
 
104
116
  // How strong a model is, roughly: 1 small/fast/free, 2 everyday, 3 frontier. Best runs on tier 3
105
- // only, hand-overs step up a tier, and until Auto has your own outcomes Balanced stays on tier 2+.
117
+ // only, Balanced on the strongest tier that can run (frontier when you have one), and hand-overs
118
+ // step up a tier.
106
119
  // The shipped priors come from a small pilot that flattered cheap models; tiers keep them honest.
107
120
  const FRONTIER_DEFAULTS = new Set(['codex:']); // a harness whose default model is its frontier one
108
121
  export function tierOf(key) {
@@ -259,34 +272,38 @@ export class Router {
259
272
  // Pick an arm. agents: listAgents() of the place it runs. goal: cheap | balanced | best, which
260
273
  // also sets the band of thinking levels (levelsFor).
261
274
  // important: no exploring. exclude: arm keys already taken (compare, hand-overs): every level of
262
- // that harness · model is out. needDelegate: the lead
263
- // of a team must be a harness that can delegate. rng: for tests.
275
+ // that harness · model is out, and every variant of a model with its level in its id.
276
+ // needDelegate: the lead of a team must be a harness that can delegate. rng: for tests.
264
277
  // scouted: { type, size } from the scout, which beats the keyword guess.
265
278
  // minTier: only arms at least that strong (a hand-over steps up). Arms cooling down after a usage
266
279
  // or rate limit are skipped while any other arm can run.
267
280
  // Best runs frontier models only: if any is installed and allowed, Best never steps down a tier,
268
281
  // even when they're all taken or at their limit (null then). With none installed, the strongest.
282
+ // Balanced runs frontier models too, whatever its record of the others (Sonnet or Haiku winning
283
+ // a few tasks doesn't make them the face of Auto), and steps down a tier only while every
284
+ // frontier model is taken or at its limit, so the task still runs.
269
285
  choose({ prompt = '', attachments = 0, agents = [], goal = 'balanced', important = false, explore = true, exclude = [], needDelegate = false, allowed = null, scouted = null, minTier = 0, rng = Math.random } = {}) {
270
286
  if (!this.synced) this.sync(agents);
287
+ if (!GOALS[goal]) goal = 'balanced';
271
288
  const guess = classify(prompt, { attachments });
272
289
  const type = scouted?.type || guess.type;
273
290
  const size = scouted?.size || guess.size;
274
- const V = (GOALS[goal] || GOALS.balanced).value;
275
- const out = new Set(exclude.map(baseOf));
291
+ const V = GOALS[goal].value;
292
+ const out = new Set(exclude.map(stemOf));
276
293
  const usable = candidates(agents, new Set(Object.keys(this.state.arms))).filter((c) => (!needDelegate || c.delegates) && (!allowed || allowed.includes(c.agent)));
277
294
  const frontierOnly = goal === 'best' && usable.some((c) => tierOf(c.key) >= 3);
278
- let arms = usable
279
- .filter((c) => !out.has(c.key) && (!frontierOnly || tierOf(c.key) >= 3))
295
+ let arms = byNamedLevel(usable.filter((c) => !out.has(stemOf(c.key))), goal)
296
+ .filter((c) => !frontierOnly || tierOf(c.key) >= 3)
280
297
  .flatMap((c) => levelsFor(c.efforts, goal).map((effort) => ({ ...c, key: armKey(c.agent, c.model, effort), effort, label: `${c.label}${effort ? ` · ${effortLabel(effort)}` : ''}`, base: c.key })))
281
298
  .map((c) => ({ ...c, est: this.estimate(type, c.key), state: this.state.arms[c.base]?.state || 'new' }));
282
299
  if (!arms.length) return null;
283
300
  const free = arms.filter((x) => !this.cooling(x.key));
284
301
  if (!free.length) return null; // everything allowed is at its limit: nothing to route to now
285
302
  arms = free;
286
- // Cold start: until there are enough of your own outcomes, lean to quality (the shipped priors
287
- // are thin). Cheap is left alone: that goal asked for cheap.
288
- const cold = this.learned() < COLD_START;
289
- const floorTier = Math.max(minTier, goal === 'best' ? 3 : cold && goal !== 'cheap' ? 2 : 0);
303
+ // Balanced and Best run on the strongest tier that can run now, whatever the record of the
304
+ // weaker ones. Cheap is left alone: that goal asked for cheap.
305
+ const top = Math.max(...arms.map((x) => tierOf(x.key)));
306
+ const floorTier = Math.max(minTier, goal === 'cheap' ? 0 : top);
290
307
  for (let t = floorTier; t > 0; t--) {
291
308
  const strong = arms.filter((x) => tierOf(x.key) >= t);
292
309
  if (strong.length) {
@@ -323,7 +340,7 @@ export class Router {
323
340
  key: pick.key,
324
341
  label: pick.label,
325
342
  route: {
326
- goal: GOALS[goal] ? goal : 'balanced',
343
+ goal,
327
344
  type,
328
345
  size,
329
346
  key: pick.key,
@@ -336,7 +353,6 @@ export class Router {
336
353
  explored: byMean[0].key !== pick.key,
337
354
  trying: pick.state === 'new',
338
355
  tier: tierOf(pick.key),
339
- ...(cold && goal !== 'cheap' && goal !== 'best' && { coldStart: true }),
340
356
  propensity: round(hits / 200),
341
357
  alternatives: byMean.filter((x) => x.key !== pick.key).slice(0, 3).map((x) => ({ key: x.key, label: x.label, chance: round(x.est.mean), usd: round(x.est.usd) })),
342
358
  at: this.now(),
@@ -453,7 +469,6 @@ export class Router {
453
469
  return {
454
470
  priors: { version: PRIORS.version, source: PRIORS.source },
455
471
  learned: routes.filter((r) => r.r !== null).length,
456
- coldStart: { needed: COLD_START, have: this.learned() },
457
472
  routed: routes.length,
458
473
  arms,
459
474
  recent: routes.sort((p, q) => q.at - p.at).slice(0, 30).map((r) => ({ id: r.id, key: r.key, label: r.label, type: r.type, goal: r.goal, chance: r.chance, r: r.r, explored: r.explored, trying: r.trying, at: r.at, signals: r.signals })),
@@ -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.filter((p) => p.machine === id).map((p) => p.path));
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
  );
@@ -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,10 +1,11 @@
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';
@@ -96,6 +97,8 @@ export function register(r, app) {
96
97
  if (body.state === 'done' && s.state !== 'running') s.state = 'done';
97
98
  if (body.state === 'review' && s.state === 'done') s.state = 'review';
98
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 || '') };
99
102
  // The team can change between turns; the next turn's lead sees the new roster.
100
103
  if (body.subagents !== undefined && !s.parentId) {
101
104
  s.subagents = normalizeTeam(body.subagents, adapters);
@@ -117,6 +120,7 @@ export function register(r, app) {
117
120
  '/sessions/:id',
118
121
  one(async (c, s) => {
119
122
  runner.stop(s);
123
+ runner.stopSides(s);
120
124
  await runner.live.get(s.id)?.done;
121
125
  cleanupCheckpoints(workdir(s), s.id);
122
126
  for (const cid of Object.values(s.children || {})) {
@@ -184,16 +188,42 @@ export function register(r, app) {
184
188
  'POST',
185
189
  '/sessions/:id/reply',
186
190
  one(async (c, s) => {
187
- const { text, attachments, queue, mode, agent, model, effort } = await c.body();
191
+ const { text, attachments, queue, mode, agent, model, effort, machine } = await c.body();
188
192
  if (!text?.trim()) return c.send(400, { error: 'text required' });
189
193
  notTour(s, 'follow up');
190
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 }));
191
198
  // agent / model / effort: run this follow-up (and the ones after it) on another harness or model.
192
199
  const run = agent !== undefined || model !== undefined || effort !== undefined ? await followUpRun(s, { agent, model, effort }) : undefined;
193
200
  c.send(200, await launcher.replyTo(s, text.trim(), app.uploads.resolve(attachments), queue, mode, run));
194
201
  }),
195
202
  );
196
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
+
197
227
  r.route(
198
228
  'DELETE',
199
229
  '/sessions/:id/queue',
@@ -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
- // Where people keep their repos, for one-click "add project".
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
- const p = store.addProject(dir, { name: body.name });
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, cloned ? { ...p, cloned } : p);
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.path)))), { doc: false });
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.filter((p) => !p.machine).map((p) => git(p.path, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '')),
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
- }