wisp-agents 0.16.0 → 0.18.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 (63) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +6 -5
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +5 -1
  6. package/server/adapters/claude.js +48 -5
  7. package/server/adapters/codex.js +3 -0
  8. package/server/adapters/cursor.js +2 -0
  9. package/server/adapters/grok.js +15 -0
  10. package/server/adapters/index.js +9 -1
  11. package/server/adapters/opencode.js +31 -6
  12. package/server/client.js +1 -1
  13. package/server/git.js +114 -2
  14. package/server/handoff.js +2 -1
  15. package/server/machines.js +2 -2
  16. package/server/mcp-ask.js +113 -8
  17. package/server/mentions.js +39 -0
  18. package/server/openapi.js +21 -3
  19. package/server/protocol.js +8 -1
  20. package/server/render.js +3 -1
  21. package/server/routes/internal.js +121 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/sessions.js +12 -0
  24. package/server/routes/settings.js +51 -4
  25. package/server/routes/ship.js +24 -2
  26. package/server/runner.js +142 -22
  27. package/server/services/auto.js +12 -1
  28. package/server/services/launch.js +12 -8
  29. package/server/services/phone.js +1 -1
  30. package/server/services/ship.js +83 -4
  31. package/server/store.js +23 -2
  32. package/server/triggers.js +1 -1
  33. package/server/usage.js +94 -0
  34. package/server/welcome.js +2 -2
  35. package/web/css/composer.css +22 -0
  36. package/web/css/feed.css +27 -0
  37. package/web/css/launcher.css +17 -0
  38. package/web/css/layout.css +10 -2
  39. package/web/css/modals.css +8 -0
  40. package/web/css/palette.css +22 -0
  41. package/web/css/ship.css +42 -2
  42. package/web/index.html +16 -5
  43. package/web/js/app.js +14 -3
  44. package/web/js/blocks.js +46 -0
  45. package/web/js/core/state.js +2 -1
  46. package/web/js/features/actions.js +9 -4
  47. package/web/js/features/automations.js +1 -1
  48. package/web/js/features/clones.js +79 -0
  49. package/web/js/features/compare.js +2 -1
  50. package/web/js/features/composer.js +11 -7
  51. package/web/js/features/feed.js +3 -3
  52. package/web/js/features/launcher.js +150 -28
  53. package/web/js/features/live.js +3 -3
  54. package/web/js/features/mentions.js +125 -0
  55. package/web/js/features/notify.js +1 -1
  56. package/web/js/features/project-list.js +99 -0
  57. package/web/js/features/projects.js +218 -34
  58. package/web/js/features/runners.js +3 -1
  59. package/web/js/features/settings.js +16 -2
  60. package/web/js/features/setup.js +3 -3
  61. package/web/js/features/ship.js +100 -24
  62. package/web/js/features/turns.js +44 -13
  63. package/web/js/mention-text.js +59 -0
@@ -27,11 +27,20 @@ export function register(r, app) {
27
27
  one(async (c, s) => {
28
28
  if (!s.worktree) return c.send(400, { error: NO_TREE });
29
29
  if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
30
+ if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
30
31
  const body = await c.body();
31
32
  const draft = app.ship.draft(s);
32
- s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr });
33
+ // The card follows along: s.shipping names the stage running now, sent with each summary.
34
+ s.shipping = { step: 'start', update: s.pr?.state === 'OPEN', startedAt: Date.now() };
35
+ app.sessionChanged(s);
36
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
37
+ try {
38
+ s.pr = await ship(s.worktree, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft, pr: s.pr, onStep });
39
+ } finally {
40
+ delete s.shipping;
41
+ app.sessionChanged(s, { now: true });
42
+ }
33
43
  telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
34
- app.sessionChanged(s, { now: true });
35
44
  app.ship.recheck(s);
36
45
  c.send(200, s.pr);
37
46
  }),
@@ -44,6 +53,19 @@ export function register(r, app) {
44
53
  c.send(200, await app.ship.refreshPr(s));
45
54
  }),
46
55
  );
56
+ // Try the automatic conflict fix again after it gave up (see services/ship.js).
57
+ r.route(
58
+ 'POST',
59
+ '/sessions/:id/fix-conflicts',
60
+ one(async (c, s) => {
61
+ if (s.pr?.state !== 'OPEN' || !s.worktree) return c.send(400, { error: 'No open pull request.' });
62
+ if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
63
+ s.conflictFix = { tries: 0 };
64
+ await app.ship.refreshPr(s);
65
+ if (s.conflictFix?.step !== 'fixing') return c.send(409, { error: s.pr.conflict ? 'It merges cleanly with the latest base here: GitHub may not have caught up yet.' : 'The PR has no merge conflicts now.' });
66
+ c.send(200, { conflictFix: s.conflictFix });
67
+ }),
68
+ );
47
69
  // Squash-merge the reviewed head, sync the user's base branch, clean up the worktree.
48
70
  r.route(
49
71
  'POST',
package/server/runner.js CHANGED
@@ -11,6 +11,7 @@ import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
11
11
  import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
12
12
  import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
13
13
  import { handoffNote } from './handoff.js';
14
+ import { agentPrompt, plainMentions } from './mentions.js';
14
15
  import { bad, MODES, newId } from './util.js';
15
16
 
16
17
  const MAX_ACTIVITY = 400;
@@ -19,6 +20,26 @@ const BACKGROUND_MAX_MS = 60 * 60_000; // how long a turn may wait on its backgr
19
20
  const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
20
21
  // The delegate tool lives on the same injected server, so harnesses name it like ask_user.
21
22
  const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
23
+ // The answers a live approval offers. Typed text instead is a "no, do this instead".
24
+ export const APPROVAL = { allow: 'Allow', always: 'Always allow in this task', deny: 'Deny' };
25
+ const sameAction = (a, b) => a.tool === b.tool && a.detail === b.detail;
26
+
27
+ // A live approval's answer: the button the user clicked, a yes/no typed out, or anything else,
28
+ // which denies the call and hands the agent the user's words.
29
+ export function approvalDecision(text) {
30
+ const t = String(text || '').trim();
31
+ if (t === APPROVAL.always || /^(always\b|2$)/i.test(t)) return { decision: 'allow', always: true };
32
+ if (t === APPROVAL.allow || /^(y|yes|ok|allow|approve|go ahead|1)$/i.test(t)) return { decision: 'allow' };
33
+ if (t === APPROVAL.deny || /^(n|no|deny|reject|3)$/i.test(t)) return { decision: 'deny' };
34
+ return { decision: 'deny', message: t };
35
+ }
36
+
37
+ // The follow-up that retries what a turn's permissions refused, once the user allowed it.
38
+ function retryPrompt(items, scoped) {
39
+ const list = items.map((b) => `- ${b.tool}${b.detail ? `: \`${b.detail}\`` : ''}`).join('\n');
40
+ const how = scoped ? 'I allowed exactly these for this follow-up' : 'This follow-up runs in Full auto, so nothing is blocked';
41
+ return `These actions were blocked by your permissions last turn:\n${list}\n\n${how}. Run them now and finish the task.`;
42
+ }
22
43
 
23
44
  export { newId };
24
45
 
@@ -43,7 +64,7 @@ function changedRun(session, run) {
43
64
  }
44
65
 
45
66
  function autoTitle(prompt) {
46
- const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
67
+ const line = plainMentions(prompt).trim().split('\n')[0].replace(/\s+/g, ' ');
47
68
  return line.length > 64 ? `${line.slice(0, 63)}…` : line;
48
69
  }
49
70
 
@@ -60,7 +81,8 @@ export function summarize(s, store) {
60
81
  title: s.title,
61
82
  agent: s.agent,
62
83
  cwd: s.cwd,
63
- project: s.agent === 'wisp' ? 'tour' : project?.name || path.basename(s.cwd || ''),
84
+ scratch: store?.isScratch?.(s.cwd) || undefined,
85
+ project: s.agent === 'wisp' ? 'tour' : (store?.isScratch?.(s.cwd) ? 'General' : project?.name) || path.basename(s.cwd || ''),
64
86
  projectId: project?.id || null,
65
87
  color: project?.color || 'slate',
66
88
  mode: s.mode,
@@ -78,9 +100,13 @@ export function summarize(s, store) {
78
100
  live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
79
101
  asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
80
102
  question: s.ask?.question || '',
103
+ approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
81
104
  branch: s.worktree?.branch || '',
82
105
  toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
83
- pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
106
+ shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
107
+ pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks, conflict: !!s.pr.conflict } : null,
108
+ // wisp fixing the PR's merge conflicts on its own: { step: 'fixing' | 'pushing' | 'done' | 'failed', why }
109
+ conflictFix: s.conflictFix ? { step: s.conflictFix.step, why: s.conflictFix.why || '' } : null,
84
110
  groupId: s.groupId || null,
85
111
  origin: s.origin || null, // { kind: 'recipe' | 'trigger' | 'telegram', id, name }
86
112
  // The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
@@ -122,7 +148,7 @@ export class Runner extends EventEmitter {
122
148
  }
123
149
 
124
150
  // subagents: the team a lead may delegate to. parentId/member: this session is one of them.
125
- create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
151
+ create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null }) {
126
152
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
127
153
  const now = Date.now();
128
154
  const session = {
@@ -158,11 +184,12 @@ export class Runner extends EventEmitter {
158
184
  // mode: switch the permission mode (plan / edits / auto) from this follow-up on. A queued
159
185
  // follow-up keeps its own, so the run before it isn't affected. run: { agent, model, effort }
160
186
  // switches the harness, model and thinking level the same way (see #switchRun).
161
- reply(session, text, attachments = [], { queue = false, mode, run } = {}) {
187
+ // grants / once: a retry of what the last turn's permissions blocked (see approveBlocked).
188
+ reply(session, text, attachments = [], { queue = false, mode, run, grants, once } = {}) {
162
189
  if (!MODES.has(mode) || mode === session.mode) mode = undefined;
163
190
  run = changedRun(session, run);
164
- const extra = { ...(mode && { mode }), ...(run && { run }) };
165
- if (session.ask && !mode && !run) {
191
+ const extra = { ...(mode && { mode }), ...(run && { run }), ...(grants?.length && { grants }), ...(MODES.has(once) && { once }) };
192
+ if (session.ask && !Object.keys(extra).length) {
166
193
  this.answer(session, text);
167
194
  return { answered: true };
168
195
  }
@@ -190,10 +217,29 @@ export class Runner extends EventEmitter {
190
217
  }
191
218
  if (mode) session.mode = mode;
192
219
  if (run) this.#switchRun(session, run);
193
- this.#startTurn(session, text, attachments);
220
+ this.#startTurn(session, text, attachments, extra);
194
221
  return { queued: false };
195
222
  }
196
223
 
224
+ // The card under a turn whose permissions refused something: allow retries it as a follow-up,
225
+ // with exactly those actions allowed where the harness can scope a grant, else in Full auto for
226
+ // that one follow-up. Deny just closes the card.
227
+ approveBlocked(session, { turnId, allow }) {
228
+ const turn = turnId ? session.turns.find((t) => t.id === turnId) : session.turns.at(-1);
229
+ if (!turn?.blocked?.length) throw bad('Nothing on that turn is waiting for approval.', 404);
230
+ if (turn.approval) throw bad(`Already ${turn.approval}.`, 409);
231
+ if (turn !== session.turns.at(-1) || this.live.has(session.id)) throw bad('The task has moved on since; send a follow-up instead.', 409);
232
+ turn.approval = allow ? 'allowed' : 'denied';
233
+ if (!allow) {
234
+ this.#touch(session, { turn: turn.id });
235
+ return { denied: true };
236
+ }
237
+ const scoped = turn.blocked.every((b) => b.rule !== undefined);
238
+ const out = this.reply(session, retryPrompt(turn.blocked, scoped), [], scoped ? { grants: turn.blocked.map((b) => b.rule) } : { once: 'auto' });
239
+ this.#touch(session, { turn: turn.id });
240
+ return { ...out, scoped };
241
+ }
242
+
197
243
  // A follow-up on another harness, model or thinking level. A model or level change resumes the
198
244
  // same conversation; a harness change resumes that harness's own one (if it ran earlier in this
199
245
  // task) or starts it fresh, and #startTurn hands it the turns it missed.
@@ -222,7 +268,8 @@ export class Runner extends EventEmitter {
222
268
  }
223
269
 
224
270
  // ask_user: the agent is blocked inside a tool call until answer() is called.
225
- ask(session, { question, context, options }) {
271
+ // permission: set for a live approval ({ tool, action, detail }), see requestApproval().
272
+ ask(session, { question, context, options, permission }) {
226
273
  const turn = session.turns.at(-1);
227
274
  session.ask = {
228
275
  id: newId(),
@@ -230,34 +277,66 @@ export class Runner extends EventEmitter {
230
277
  context: context ? String(context) : '',
231
278
  options: Array.isArray(options) ? options.map(String).slice(0, 6) : [],
232
279
  askedAt: Date.now(),
280
+ ...(permission && { permission }),
233
281
  };
234
282
  session.state = 'waiting';
235
283
  session.unread = true;
236
284
  delete session.archived; // a question needs the user, so it comes back to the feed
237
- if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label: `Asked you: ${session.ask.question}` });
285
+ const label = permission ? `Needs your OK to ${permission.action}` : `Asked you: ${session.ask.question}`;
286
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'ask', label });
238
287
  this.#touch(session, { turn: turn?.id });
239
288
  this.emit('finished', { session: summarize(session, this.store) });
240
289
  return session.ask.id;
241
290
  }
242
291
 
292
+ // Live approval: the harness asks before a tool call its permission mode doesn't allow on its
293
+ // own (Claude's --permission-prompt-tool). It waits on the answer like ask_user; something the
294
+ // user said "always" to earlier in this task goes through at once.
295
+ requestApproval(session, { tool, input }) {
296
+ const adapter = adapters[session.agent];
297
+ const p = adapter.permission?.(String(tool || ''), input && typeof input === 'object' ? input : {}, workdir(session)) || { tool: String(tool || 'tool'), action: `use ${tool}`, detail: '' };
298
+ const turn = session.turns.at(-1);
299
+ if ((session.grants || []).some((g) => sameAction(g, p))) {
300
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `Allowed (always in this task): ${p.action}` });
301
+ return { decision: 'allow' };
302
+ }
303
+ const id = this.ask(session, {
304
+ question: `Allow ${adapter.name} to ${p.action}?`,
305
+ context: p.why || '',
306
+ options: [APPROVAL.allow, APPROVAL.always, APPROVAL.deny],
307
+ permission: { tool: p.tool, action: p.action, detail: p.detail },
308
+ });
309
+ return { id };
310
+ }
311
+
243
312
  answer(session, text) {
244
313
  const ask = session.ask;
245
314
  if (!ask) return false;
246
315
  ask.answer = text;
247
- this.answers.set(ask.id, text);
316
+ const turn = session.turns.at(-1);
317
+ let label = `You answered: ${text}`;
318
+ if (ask.permission) {
319
+ const d = approvalDecision(text);
320
+ this.answers.set(ask.id, { answer: text, ...d });
321
+ const p = ask.permission;
322
+ if (d.always) (session.grants ||= []).push({ tool: p.tool, detail: p.detail });
323
+ // Denied here, so the turn's "blocked" card shouldn't offer it again.
324
+ if (d.decision === 'deny' && turn) (turn.denied ||= []).push({ tool: p.tool, detail: p.detail });
325
+ label = d.decision === 'allow' ? `You allowed${d.always ? ' (always in this task)' : ''}: ${p.action}` : `You denied: ${p.action}${d.message ? ` (“${d.message}”)` : ''}`;
326
+ } else this.answers.set(ask.id, { answer: text });
248
327
  delete session.ask;
249
328
  session.state = this.live.has(session.id) ? 'running' : 'review';
250
- const turn = session.turns.at(-1);
251
- if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label: `You answered: ${text}` });
329
+ if (turn) this.#onEvent(session, turn, { type: 'tool', kind: 'answer', label });
252
330
  this.#touch(session, { turn: turn?.id });
253
331
  return true;
254
332
  }
255
333
 
334
+ // { answer } once answered (plus { decision, message? } for an approval), { pending }, or { cancelled }.
256
335
  pollAnswer(askId) {
257
336
  if (this.answers.has(askId)) {
258
- const answer = this.answers.get(askId);
337
+ const out = this.answers.get(askId);
259
338
  this.answers.delete(askId);
260
- return { answer };
339
+ return out;
261
340
  }
262
341
  const open = this.store.all().some((s) => s.ask?.id === askId);
263
342
  return open ? { pending: true } : { cancelled: true };
@@ -354,7 +433,7 @@ export class Runner extends EventEmitter {
354
433
  }
355
434
 
356
435
  // The record of a new turn, with the checkpoint taken before the agent touches anything.
357
- #newTurn(session, prompt, attachments) {
436
+ #newTurn(session, prompt, attachments, mode = session.mode) {
358
437
  const id = newId();
359
438
  // A subagent's edits land in the lead's turn, whose checkpoint already covers them.
360
439
  const checkpoint =
@@ -369,7 +448,7 @@ export class Runner extends EventEmitter {
369
448
  agent: session.agent,
370
449
  model: session.model,
371
450
  effort: session.effort || '',
372
- mode: session.mode,
451
+ mode,
373
452
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
374
453
  startedAt: Date.now(),
375
454
  endedAt: null,
@@ -404,14 +483,18 @@ export class Runner extends EventEmitter {
404
483
  WISP_URL: this.bridge.url,
405
484
  WISP_TOKEN: this.bridge.token,
406
485
  WISP_SESSION: session.id,
486
+ ...(adapter.approveTool && { WISP_APPROVE: '1' }),
407
487
  ...(team.length && { WISP_TEAM: JSON.stringify(team.map((m) => ({ name: m.name, agent: adapters[m.agent]?.name || m.agent, model: m.model, effort: m.effort, use: m.use, count: m.count || 1, copies: copies(m) }))) }),
408
488
  },
409
489
  },
410
490
  };
411
491
  }
412
492
 
413
- #startTurn(session, prompt, attachments = []) {
414
- const turn = this.#newTurn(session, prompt, attachments);
493
+ // grants: harness rules allowing what the user approved, for this turn only. once: a mode for
494
+ // this turn only (an approved retry the harness can't scope runs in Full auto).
495
+ #startTurn(session, prompt, attachments = [], { grants = [], once } = {}) {
496
+ const mode = MODES.has(once) ? once : session.mode;
497
+ const turn = this.#newTurn(session, prompt, attachments, mode);
415
498
  session.turns.push(turn);
416
499
  session.state = 'running';
417
500
  session.unread = false;
@@ -427,12 +510,16 @@ export class Runner extends EventEmitter {
427
510
  const team = adapter.askTool !== null ? session.subagents || [] : [];
428
511
  // A harness that hasn't seen every earlier turn (the user switched to it) gets them first.
429
512
  const handoff = session.parentId ? '' : handoffNote(session, session.agent, session.agentSessionId, (t) => adapters[t.agent]?.name || t.agent);
513
+ // Mentioned tasks: a lead reads them with wisp_session; a run without wisp's tools gets their reports.
514
+ const tools = !session.parentId && adapter.askTool !== null && this.bridge;
515
+ const text = agentPrompt(prompt, this.store, { tool: tools ? (adapter.askTool ?? 'mcp__wisp__ask_user').replace(/ask_user$/, 'wisp_session') : null, self: session.id });
430
516
  const handle = adapter.run({
431
- prompt: handoff ? `${handoff}\n\n${prompt}` : prompt,
517
+ prompt: handoff ? `${handoff}\n\n${text}` : text,
432
518
  attachments,
433
519
  cwd: workdir(session),
434
520
  resumeId: session.agentSessionId,
435
- mode: session.mode,
521
+ mode,
522
+ grants,
436
523
  model: session.model,
437
524
  effort: session.effort || '',
438
525
  systemPrompt: this.#systemPrompt(session, adapter, team),
@@ -467,7 +554,7 @@ export class Runner extends EventEmitter {
467
554
  const next = session.queue.shift();
468
555
  if (next?.mode) session.mode = next.mode;
469
556
  if (next?.run && adapters[next.run.agent]) this.#switchRun(session, next.run);
470
- if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments);
557
+ if (next && this.store.get(session.id)) this.#startTurn(session, next.text, next.attachments, next);
471
558
  else this.#touch(session);
472
559
  };
473
560
  handle.release = release;
@@ -516,6 +603,14 @@ export class Runner extends EventEmitter {
516
603
  if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
517
604
  return;
518
605
  }
606
+ case 'blocked': {
607
+ // A tool call the harness's permissions refused. The card after the turn offers to retry it.
608
+ const item = { tool: String(ev.tool || 'tool'), detail: String(ev.detail ?? ''), ...(ev.rule !== undefined && { rule: ev.rule }) };
609
+ const list = (turn.blocked ||= []);
610
+ if (!list.some((b) => sameAction(b, item))) list.push(item);
611
+ this.store.save(session);
612
+ return;
613
+ }
519
614
  case 'background': {
520
615
  turn.background = ev.tasks;
521
616
  if (turn.interim) this.#waitItem(session, turn);
@@ -583,6 +678,7 @@ export class Runner extends EventEmitter {
583
678
  // Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
584
679
  if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
585
680
  }
681
+ if (!ev.stopped) blockedCard(turn);
586
682
  session.state = 'review';
587
683
  session.unread = !interrupted;
588
684
  this.store.save(session, { now: true });
@@ -601,3 +697,27 @@ export class Runner extends EventEmitter {
601
697
  if (turn) this.emit('turn', { sessionId: session.id, turnId: turn });
602
698
  }
603
699
  }
700
+
701
+ // What the turn's permissions refused, as one "blocked" block after the hero: the harness's own
702
+ // reports (which can carry a rule to allow exactly that) merged with the agent's "blocked" block,
703
+ // minus anything the user already denied. turn.blocked keeps the list for the retry.
704
+ function blockedCard(turn) {
705
+ const blocks = turn.presentation?.blocks || [];
706
+ const own = blocks.filter((b) => b.type === 'blocked').flatMap((b) => (Array.isArray(b.items) ? b.items : []));
707
+ const items = [...(turn.blocked || [])];
708
+ for (const i of own) {
709
+ if (!i || typeof i !== 'object') continue;
710
+ const item = { tool: String(i.tool || 'tool'), detail: String(i.detail ?? i.command ?? i.path ?? ''), ...(i.why && { why: String(i.why) }) };
711
+ const known = items.find((b) => sameAction(b, item) || (item.detail && b.detail === item.detail));
712
+ if (known) known.why ||= item.why;
713
+ else items.push(item);
714
+ }
715
+ // The agent may name a tool its own way (shell for Bash): the same command still counts.
716
+ const left = items.filter((b) => !(turn.denied || []).some((d) => sameAction(d, b) || (b.detail && d.detail === b.detail))).slice(0, 12);
717
+ if (left.length) turn.blocked = left;
718
+ else delete turn.blocked;
719
+ if (!turn.presentation) return;
720
+ const rest = blocks.filter((b) => b.type !== 'blocked');
721
+ if (left.length) rest.splice(rest[0]?.type === 'hero' ? 1 : 0, 0, { type: 'blocked', items: left.map(({ rule, ...b }) => b) });
722
+ turn.presentation.blocks = rest;
723
+ }
@@ -19,6 +19,17 @@ export function createAuto(app) {
19
19
  };
20
20
  // This computer's harnesses: the last ones detected, else detected now.
21
21
  const agents = async () => (agentsHere.length ? agentsHere : await detect());
22
+ // What the page asks for on every load: the last detection right away, checked again in the
23
+ // background (a cold probe takes seconds: opencode lists its models), and an 'agents' event
24
+ // if that changed anything, so the launcher never waits on it.
25
+ const listed = async () => {
26
+ if (!agentsHere.length) return detect();
27
+ const before = JSON.stringify(agentsHere);
28
+ detect()
29
+ .then((now) => JSON.stringify(now) !== before && app.broadcast('agents', {}))
30
+ .catch(() => {});
31
+ return agentsHere;
32
+ };
22
33
 
23
34
  const opts = () => {
24
35
  const r = store.config.defaults.router || {};
@@ -63,5 +74,5 @@ export function createAuto(app) {
63
74
  // What the Auto view shows: goals, settings, and what the router learned.
64
75
  const view = async () => ({ goals: GOALS, goal: goalOf(), settings: { explore: opts().explore, allowed: opts().allowed }, ...router.report(await agents()) });
65
76
 
66
- return { detect, agents, opts, goalOf, signals, resolve, view };
77
+ return { detect, agents, listed, opts, goalOf, signals, resolve, view };
67
78
  }
@@ -15,11 +15,13 @@ export const MAX_COMPARE = 4;
15
15
  export function createLaunch(app) {
16
16
  const { store, runner, machines } = app;
17
17
 
18
- // body: { prompt, cwd, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
18
+ // body: { prompt, cwd, scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
19
19
  // subagents: [{ name, agent, model, effort, use }] | preset: teamId }
20
20
  async function launch(body, origin = null) {
21
21
  if (!String(body.prompt || '').trim()) throw bad('prompt required');
22
- const cwd = expand(body.cwd || process.cwd());
22
+ // scratch: no project folder (general questions); the task gets an empty directory of its own.
23
+ const scratch = body.scratch === true;
24
+ const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
23
25
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
24
26
  const d = store.config.defaults;
25
27
  const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
@@ -33,11 +35,11 @@ export function createLaunch(app) {
33
35
  if (!adapters[agent]) throw bad(`unknown agent: ${agent}`);
34
36
  return { agent, model: r.model ?? (i ? d.models?.[agent] || '' : ''), effort: /^[\w-]{1,20}$/.test(r.effort || '') ? r.effort : '', ...(r.route && { route: r.route }) };
35
37
  });
36
- const mode = MODES.has(body.mode) ? body.mode : 'edits';
38
+ const mode = MODES.has(body.mode) ? body.mode : 'auto';
37
39
  // Worktrees are on by default in git repos (off in Plan mode: nothing gets written). Compare
38
40
  // runs need one each, or the harnesses would edit the same files.
39
41
  const comparing = runners.length > 1;
40
- const wantTree = body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off'));
42
+ const wantTree = !scratch && (body.worktree ?? (comparing || (mode !== 'plan' && d.worktree !== 'off')));
41
43
  // groupId: one launch sent to several machines (or here and there) shows as one task.
42
44
  const groupId = /^[a-f0-9]{12}$/.test(body.groupId || '') ? body.groupId : comparing ? newId() : null;
43
45
  const attachments = app.uploads.resolve(body.attachments);
@@ -92,10 +94,12 @@ export function createLaunch(app) {
92
94
  if (!sendBody.compare?.length && !body.compare) delete sendBody.compare;
93
95
  const out = await machines.launch(mid, { ...sendBody, attachments: await pushUploads(mid, body.attachments), ...(origin && { origin }) });
94
96
  (out.group || [out]).forEach((x, i) => routes[i] && app.router.record(qualify(x.id, mid), routes[i]));
95
- const known = store.projectFor(out.cwd, mid);
96
- (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
97
- store.saveConfig();
98
- if (!known) app.broadcast('config', {});
97
+ const known = out.scratch || store.projectFor(out.cwd, mid);
98
+ if (!out.scratch) {
99
+ (known || store.addProject(out.cwd, { machine: mid })).lastUsedAt = Date.now();
100
+ store.saveConfig();
101
+ if (!known) app.broadcast('config', {});
102
+ }
99
103
  return (out.group || [out]).map((x) => machines.summary(mid, x));
100
104
  }
101
105
 
@@ -27,7 +27,7 @@ export function phoneActions(app) {
27
27
  const d = store.config.defaults;
28
28
  const agent = d.agent || 'claude';
29
29
  const model = d.models?.[agent] ?? d.model ?? '';
30
- const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'edits' }, { kind: 'telegram', name: 'Telegram' });
30
+ const [s] = await app.launcher.launchAny({ prompt, cwd: project.path, machine: project.machine || undefined, agent, model, mode: d.mode || 'auto' }, { kind: 'telegram', name: 'Telegram' });
31
31
  app.broadcast('config', {});
32
32
  return s;
33
33
  },
@@ -1,13 +1,30 @@
1
1
  // Ship it: whether a worktree session has something to ship, the draft commit message and PR
2
- // text, and following open PRs (checks, reviews, merges) so their cards stay current.
2
+ // text, and following open PRs (checks, reviews, merges) so their cards stay current. A PR that
3
+ // starts conflicting with its base gets fixed on its own: the task is sent a follow-up to merge the
4
+ // base in, and the result is pushed to the PR when it's done (defaults.fixConflicts: 'off' to stop).
3
5
 
4
6
  import fs from 'node:fs';
5
- import { worktreeStatus, prView } from '../git.js';
7
+ import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
6
8
  import { blocksToMarkdown } from '../render.js';
7
9
  import { summarize } from '../runner.js';
8
10
 
9
11
  const PR_POLL_MS = 60_000;
10
12
  const SHIP_RECHECK_MS = 5_000;
13
+ const MAX_CONFLICT_FIXES = 3; // per task, so a base that keeps moving can't run up the bill
14
+
15
+ export const conflictPrompt = (s) => {
16
+ const { remote, base } = s.worktree;
17
+ return [
18
+ `PR #${s.pr.number} (${s.pr.url}) can no longer merge into \`${base}\`: changes that landed there since conflict with this branch. Bring the branch up to date:`,
19
+ '',
20
+ `1. \`git fetch ${remote}\`, then \`git merge ${remote}/${base}\`. Don't rebase: the branch is already pushed.`,
21
+ `2. Resolve every conflict, keeping the intent of both sides: what landed on \`${base}\` and what this task changed. Don't drop either side's changes to make a conflict go away.`,
22
+ '3. Run the checks that are cheap here (tests, type-check, build) on the merged result and fix what the merge broke.',
23
+ "4. Finish with `git commit --no-edit`. Don't push: wisp pushes to the PR when you're done.",
24
+ '',
25
+ 'If a conflict needs a decision only the user can make, ask them.',
26
+ ].join('\n');
27
+ };
11
28
 
12
29
  export function createShip(app) {
13
30
  const { store, runner } = app;
@@ -48,14 +65,68 @@ export function createShip(app) {
48
65
  const pr = await prView(dir, prev.number);
49
66
  const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
50
67
  s.pr = pr;
68
+ if (s.conflictFix && !pr.conflict && !['fixing', 'pushing'].includes(s.conflictFix.step)) delete s.conflictFix;
51
69
  if (pr.state !== prev.state) app.auto.signals(summarize(s, store));
52
70
  // Things worth coming back for: CI finished either way, a review landed, it merged or closed.
53
71
  const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
54
72
  if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
55
73
  app.sessionChanged(s);
74
+ await fixConflicts(s).catch(() => {});
56
75
  return pr;
57
76
  };
58
77
 
78
+ // The PR conflicts with its base: send the task a follow-up to merge the base in. Once per PR
79
+ // head, so a fix that didn't take isn't retried until something changes the branch.
80
+ const starting = new Set(); // a poll and a refresh at once must not both send it
81
+ const fixConflicts = async (s) => {
82
+ const pr = s.pr;
83
+ if (store.config.defaults.fixConflicts === 'off' || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
84
+ if (runner.live.has(s.id) || s.shipping || s.ask || s.archived || starting.has(s.id)) return;
85
+ const tries = s.conflictFix?.tries || 0;
86
+ if (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES) return;
87
+ starting.add(s.id);
88
+ try {
89
+ if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
90
+ } finally {
91
+ starting.delete(s.id);
92
+ }
93
+ s.conflictFix = { step: 'fixing', head: pr.head, tries: tries + 1, startedAt: Date.now() };
94
+ await app.launcher.replyTo(s, conflictPrompt(s), [], true);
95
+ app.telemetry?.track?.('conflict_fix', { agent: s.agent });
96
+ app.sessionChanged(s, { now: true });
97
+ };
98
+
99
+ // The fix-up turn ended: push it to the PR if the base really is merged in, else leave the task
100
+ // for the user with why.
101
+ const finishFix = async (s, { interrupted, errorText }) => {
102
+ const fix = s.conflictFix;
103
+ if (fix?.step !== 'fixing') return;
104
+ const fail = (why) => {
105
+ Object.assign(fix, { step: 'failed', why });
106
+ s.unread = true;
107
+ app.sessionChanged(s, { now: true });
108
+ };
109
+ if (interrupted || s.queue?.length) return fail('Stopped: the task moved on before the conflicts were fixed.');
110
+ if (errorText) return fail(`The agent run failed: ${errorText}`.slice(0, 300));
111
+ const check = await baseMerged(s.worktree).catch((err) => ({ ok: false, why: err.message }));
112
+ if (!check.ok) return fail(check.why);
113
+ fix.step = 'pushing';
114
+ s.shipping = { step: 'start', update: true, startedAt: Date.now() };
115
+ app.sessionChanged(s);
116
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
117
+ try {
118
+ s.pr = await ship(s.worktree, { title: `Merge ${s.worktree.remote}/${s.worktree.base} into ${s.worktree.branch}`, pr: s.pr, onStep });
119
+ fix.step = 'done';
120
+ } catch (err) {
121
+ Object.assign(fix, { step: 'failed', why: `Push failed: ${err.message}` });
122
+ s.unread = true;
123
+ } finally {
124
+ delete s.shipping;
125
+ app.sessionChanged(s, { now: true });
126
+ }
127
+ recheck(s);
128
+ };
129
+
59
130
  // Default commit message and PR text, from the session's latest presentation.
60
131
  const draft = (s) => {
61
132
  const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
@@ -70,7 +141,15 @@ export function createShip(app) {
70
141
  return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
71
142
  };
72
143
 
73
- runner.on('ended', ({ session }) => recheck(store.get(session.id)));
144
+ runner.on('ended', ({ session, interrupted, errorText }) => {
145
+ const s = store.get(session.id);
146
+ recheck(s);
147
+ if (s?.conflictFix?.step === 'fixing') finishFix(s, { interrupted, errorText }).catch(() => {});
148
+ });
149
+ for (const s of store.all()) {
150
+ delete s.shipping; // a ship cut short by a restart isn't running anymore
151
+ if (s.conflictFix?.step === 'pushing') Object.assign(s.conflictFix, { step: 'failed', why: 'wisp restarted while pushing the fix.' });
152
+ }
74
153
  for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
75
154
 
76
155
  const timer = setInterval(async () => {
@@ -78,5 +157,5 @@ export function createShip(app) {
78
157
  }, PR_POLL_MS);
79
158
  timer.unref();
80
159
 
81
- return { recheck, refreshPr, draft, stop: () => clearInterval(timer) };
160
+ return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
82
161
  }
package/server/store.js CHANGED
@@ -9,7 +9,7 @@ import { writeJsonAtomic } from './util.js';
9
9
 
10
10
  const DEFAULT_CONFIG = {
11
11
  projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
12
- defaults: { agent: 'auto', mode: 'edits', model: '', effort: '' }, // auto: wisp picks harness and model (server/router.js)
12
+ defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
13
13
  recipes: [], // saved prompts with {params}: server/recipes.js
14
14
  triggers: [], // schedules and GitHub watchers: server/triggers.js
15
15
  teams: [], // saved subagent presets: server/subagents.js
@@ -66,9 +66,15 @@ export class Store {
66
66
  cfg.defaults.effort = '';
67
67
  cfg.autoIntroduced = true;
68
68
  }
69
+ // Full auto became the default mode: switch once from Edits (the old default); Plan and
70
+ // any later pick stick.
71
+ if (!cfg.fullAutoIntroduced) {
72
+ if (cfg.defaults.mode === 'edits') cfg.defaults.mode = 'auto';
73
+ cfg.fullAutoIntroduced = true;
74
+ }
69
75
  return cfg;
70
76
  } catch {
71
- return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true };
77
+ return { ...structuredClone(DEFAULT_CONFIG), autoIntroduced: true, fullAutoIntroduced: true };
72
78
  }
73
79
  }
74
80
 
@@ -82,6 +88,7 @@ export class Store {
82
88
 
83
89
  // machine: a project on another computer (paths there are POSIX); null = this one.
84
90
  projectFor(cwd = '', machine = null) {
91
+ if (!machine && this.isScratch(cwd)) return null; // never inside a project, even one at ~
85
92
  const sep = machine ? '/' : path.sep;
86
93
  let best = null;
87
94
  for (const p of this.config.projects)
@@ -106,7 +113,19 @@ export class Store {
106
113
  return project;
107
114
  }
108
115
 
116
+ // Folder-less tasks run in a throwaway directory of their own under ~/.wisp/scratch.
117
+ scratchDir(id) {
118
+ const dir = path.join(this.home, 'scratch', id);
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ return dir;
121
+ }
122
+
123
+ isScratch(dir = '') {
124
+ return String(dir).startsWith(path.join(this.home, 'scratch') + path.sep);
125
+ }
126
+
109
127
  touchProject(dir) {
128
+ if (this.isScratch(dir)) return null;
110
129
  const p = this.projectFor(dir) || this.addProject(dir);
111
130
  p.lastUsedAt = Date.now();
112
131
  this.saveConfig();
@@ -141,6 +160,8 @@ export class Store {
141
160
 
142
161
  delete(id) {
143
162
  clearTimeout(this.timers.get(id));
163
+ const cwd = this.sessions.get(id)?.cwd;
164
+ if (this.isScratch(cwd)) fs.rmSync(cwd, { recursive: true, force: true }); // a folder-less task's files go with it
144
165
  this.sessions.delete(id);
145
166
  fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
146
167
  fs.rmSync(path.join(this.shots, id), { recursive: true, force: true });
@@ -100,7 +100,7 @@ export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
100
100
  if (kind === 'schedule') parseCron(cron);
101
101
  const label = kind === 'issues' ? String(v('label', 'wisp')).trim() : '';
102
102
  if (kind === 'issues' && !label) throw bad('Pick the issue label to watch.');
103
- const mode = String(v('mode', kind === 'issues' ? 'plan' : 'edits'));
103
+ const mode = String(v('mode', kind === 'issues' ? 'plan' : 'auto'));
104
104
  if (!MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
105
105
  const changedSource = prev && (kind !== prev.kind || cwd !== prev.cwd || label !== prev.label || String(v('branch')) !== prev.branch);
106
106
  return {