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.
- package/CHANGELOG.md +38 -1
- package/README.md +6 -5
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/git.js +114 -2
- package/server/handoff.js +2 -1
- package/server/machines.js +2 -2
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +21 -3
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/sessions.js +12 -0
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +142 -22
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +12 -8
- package/server/services/phone.js +1 -1
- package/server/services/ship.js +83 -4
- package/server/store.js +23 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +42 -2
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +46 -0
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +150 -28
- package/web/js/features/live.js +3 -3
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/runners.js +3 -1
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
package/server/routes/ship.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
337
|
+
const out = this.answers.get(askId);
|
|
259
338
|
this.answers.delete(askId);
|
|
260
|
-
return
|
|
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
|
|
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
|
-
|
|
414
|
-
|
|
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${
|
|
517
|
+
prompt: handoff ? `${handoff}\n\n${text}` : text,
|
|
432
518
|
attachments,
|
|
433
519
|
cwd: workdir(session),
|
|
434
520
|
resumeId: session.agentSessionId,
|
|
435
|
-
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
|
+
}
|
package/server/services/auto.js
CHANGED
|
@@ -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
|
-
|
|
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 : '
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
package/server/services/phone.js
CHANGED
|
@@ -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 || '
|
|
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
|
},
|
package/server/services/ship.js
CHANGED
|
@@ -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 }) =>
|
|
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: '
|
|
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 });
|
package/server/triggers.js
CHANGED
|
@@ -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' : '
|
|
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 {
|