wisp-agents 0.20.0 → 0.21.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 +12 -1
- package/README.md +8 -4
- package/bin/wisp.js +4 -3
- package/package.json +1 -1
- package/server/adapters/claude.js +5 -4
- package/server/adapters/codex.js +5 -2
- package/server/client.js +3 -2
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +1 -0
- package/server/routes/sessions.js +15 -8
- package/server/routes/ship.js +19 -24
- package/server/runner.js +25 -5
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +35 -3
- package/server/services/ship.js +97 -10
- package/skills/wisp/SKILL.md +2 -1
- package/web/css/feed.css +3 -0
- package/web/css/launcher.css +12 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +12 -0
- package/web/js/core/state.js +4 -2
- package/web/js/features/actions.js +1 -1
- package/web/js/features/feed.js +9 -9
- package/web/js/features/launcher.js +62 -18
- package/web/js/features/places.js +10 -0
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +4 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/turns.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.21.0] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **One task across several folders.** A task can now work in more than one project at a time, for example an API and the web app that uses it. In the launcher, ⌘-click (Ctrl- or Shift-click) a second project chip, or click the **+** on its corner or next to it in the project list. One agent starts in the first folder and can read and edit the others: Claude Code and Codex get them as `--add-dir`, and every harness is told the paths. Each git repo gets its own worktree. The card reads `api + web` and shows up under either project's filter. **Ship it** opens a pull request in each repo that has changes, and **Merge all** merges them together and syncs each local base branch. CLI: `wisp "…" --also ~/code/web` (repeatable). API: `"also": [folders]` on `POST /sessions`. MCP: `also` on `wisp_launch`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The launcher's project row no longer throws while it isn't laid out.** Refitting the chips of a launcher that had just been painted over (or folded into the dock) read the position of a chip that wasn't there.
|
|
17
|
+
|
|
8
18
|
## [0.20.0] - 2026-10-19
|
|
9
19
|
|
|
10
20
|
### Added
|
|
@@ -381,7 +391,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
381
391
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
382
392
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
383
393
|
|
|
384
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
394
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.21.0...HEAD
|
|
395
|
+
[0.21.0]: https://github.com/M0xoo/wisp/compare/v0.20.0...v0.21.0
|
|
385
396
|
[0.20.0]: https://github.com/M0xoo/wisp/compare/v0.19.0...v0.20.0
|
|
386
397
|
[0.19.0]: https://github.com/M0xoo/wisp/compare/v0.18.1...v0.19.0
|
|
387
398
|
[0.18.1]: https://github.com/M0xoo/wisp/compare/v0.18.0...v0.18.1
|
package/README.md
CHANGED
|
@@ -49,12 +49,13 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
49
49
|
| 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, cmux, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
|
|
50
50
|
| 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
|
|
51
51
|
| 🧠 **Learns as you go** | After a session goes quiet, a background retro records your preferences and the agent's lessons (a mistake and the way that worked), globally and per project. Later runs get them in their prompt. See [Learning](#learning). |
|
|
52
|
+
| 🗂️ **One task, several folders** | ⌘-click a second project in the launcher (or its **+**) and one agent works in both, e.g. the API and the web app that uses it. Each git repo gets its own worktree, and **Ship it** opens a PR in each. |
|
|
52
53
|
| 🌿 **A worktree per task** | In a git repo each task gets its own worktree and branch from `origin/main`, so parallel agents never collide. |
|
|
53
54
|
| 🚢 **Ship it** | One click commits, pushes and opens the PR. The card follows CI and reviews; **Merge** squash-merges and syncs your local main. |
|
|
54
55
|
| 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
|
|
55
56
|
| ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
|
|
56
57
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
57
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
58
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.21.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
58
59
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
59
60
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
60
61
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** (no permission prompts, the default) for each launch. `Shift+Tab` in the prompt cycles modes. In Edits mode, anything the permissions block comes to you: Claude Code waits for **Allow / Always / Deny**, and the other harnesses show an **Allow & retry** card. |
|
|
@@ -83,7 +84,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
|
|
|
83
84
|
wisp "fix the flaky auth test"
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.
|
|
87
|
+
The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://mokh.xyz/install.sh | WISP_VERSION=0.21.0 bash`).
|
|
87
88
|
|
|
88
89
|
From source:
|
|
89
90
|
|
|
@@ -132,7 +133,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question; --mod
|
|
|
132
133
|
wisp stop <id>
|
|
133
134
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
134
135
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
135
|
-
wisp run release 0.
|
|
136
|
+
wisp run release 0.21.0 # run a recipe; values fill its {params} (or version=0.21.0)
|
|
136
137
|
wisp recipes # list recipes
|
|
137
138
|
wisp usage [--days 7] # spend and outcomes
|
|
138
139
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -146,6 +147,7 @@ wisp skill install # install the wisp skill for Claude Code (--ag
|
|
|
146
147
|
| `--agent`, `-a` | `claude` · `codex` · `opencode` · `cursor` · `antigravity` · `grok` (default: the agent last picked in the launcher) |
|
|
147
148
|
| `--model` · `--effort` | Model id and thinking level for the launch |
|
|
148
149
|
| `--cwd`, `-C DIR` | Project folder (default: the current one) |
|
|
150
|
+
| `--also DIR` | Another folder the same task works in; repeat for more (each git repo gets its own worktree and PR) |
|
|
149
151
|
| `--wait`, `-w` | Wait for the run to finish or ask, then print the presentation |
|
|
150
152
|
| `--no-worktree` | Edit the folder in place instead of a fresh worktree (default in git repos: a worktree) |
|
|
151
153
|
| `--compare a[:model],…` | Also run the task on these harnesses, each in its own worktree |
|
|
@@ -329,6 +331,8 @@ In a git repo, every launch (except Plan mode) runs in its own [git worktree](ht
|
|
|
329
331
|
|
|
330
332
|
**Compare** (the **vs** button next to the harness picker) launches the same task on a second harness. Both get their own worktree and show up as one task in the feed, with a row per run; **Compare** on the task shows them side by side, and **Keep this** archives the others and deletes their worktrees.
|
|
331
333
|
|
|
334
|
+
**Several folders.** A task can span projects: ⌘/Ctrl-click (or Shift-click) another project chip in the launcher, or use the **+** on its corner or in the project list. The agent starts in the first folder and gets the others as extra working directories (`--add-dir` for Claude Code and Codex; the rest are told the paths). Each git repo gets its own worktree on the task's branch name, the card reads `api + web` and lists under either project's filter, and **Ship it** commits, pushes and opens a PR per repo with changes, each noting the others. **Merge all** squash-merges them in order and syncs each local base branch. From the CLI or API: `wisp "…" --also ~/code/web`, `"also": ["~/code/web"]` on `POST /sessions`, or `also` on the `wisp_launch` MCP tool. wisp fixes merge conflicts only on the first repo's PR, and checkpoints and rollback cover the first folder.
|
|
335
|
+
|
|
332
336
|
Turn worktrees off in Settings → *Worktree per task*, with the launcher's **Worktree / In place** toggle, or per launch (`--no-worktree`, `"worktree": false`).
|
|
333
337
|
|
|
334
338
|
## Subagents
|
|
@@ -347,7 +351,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
347
351
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
348
352
|
```
|
|
349
353
|
|
|
350
|
-
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.
|
|
354
|
+
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.21.0` fills the fields in order, `wisp run release version=0.21.0` by name.
|
|
351
355
|
|
|
352
356
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
353
357
|
|
package/bin/wisp.js
CHANGED
|
@@ -37,12 +37,12 @@ const HELP = `usage:
|
|
|
37
37
|
wisp -- "ls the repo" a task that starts with a command name
|
|
38
38
|
|
|
39
39
|
launch options: --mode plan|edits|auto --agent claude|codex|opencode|cursor|antigravity|grok --model ID --effort LEVEL
|
|
40
|
-
--cwd DIR --wait --json --no-worktree --compare agent[:model],…
|
|
40
|
+
--cwd DIR --also DIR (repeatable) --wait --json --no-worktree --compare agent[:model],…
|
|
41
41
|
options: --port N --browser --no-open --json --version`;
|
|
42
42
|
|
|
43
43
|
const COMMANDS = new Set(['add', 'ls', 'list', 'show', 'wait', 'reply', 'stop', 'mcp', 'skill', 'ship', 'merge', 'run', 'recipes', 'usage']);
|
|
44
44
|
const argv = process.argv.slice(2);
|
|
45
|
-
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null, model: null, effort: null, cwd: null, json: false, wait: false, queue: undefined, state: null, limit: null, project: false, worktree: undefined, compare: null, draft: false, days: null };
|
|
45
|
+
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null, model: null, effort: null, cwd: null, also: [], json: false, wait: false, queue: undefined, state: null, limit: null, project: false, worktree: undefined, compare: null, draft: false, days: null };
|
|
46
46
|
const words = [];
|
|
47
47
|
let literal = false;
|
|
48
48
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -57,6 +57,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
57
57
|
else if (a === '--model') flags.model = argv[++i];
|
|
58
58
|
else if (a === '--effort') flags.effort = argv[++i];
|
|
59
59
|
else if (a === '--cwd' || a === '-C') flags.cwd = path.resolve(argv[++i].replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
60
|
+
else if (a === '--also') flags.also.push(path.resolve(argv[++i].replace(/^~(?=$|[\\/])/, os.homedir())));
|
|
60
61
|
else if (a === '--state') flags.state = argv[++i];
|
|
61
62
|
else if (a === '--limit') flags.limit = Number(argv[++i]);
|
|
62
63
|
else if (a === '--json') flags.json = true;
|
|
@@ -114,7 +115,7 @@ async function scripted(cmd, args) {
|
|
|
114
115
|
};
|
|
115
116
|
switch (cmd) {
|
|
116
117
|
case 'launch': {
|
|
117
|
-
const s = await wisp.launch({ prompt: args.join(' '), cwd, agent: flags.agent || undefined, mode: flags.mode || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined, worktree: flags.worktree, compare: flags.compare || undefined });
|
|
118
|
+
const s = await wisp.launch({ prompt: args.join(' '), cwd, also: flags.also, agent: flags.agent || undefined, mode: flags.mode || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined, worktree: flags.worktree, compare: flags.compare || undefined });
|
|
118
119
|
if (s.group && !flags.wait) return out(s, s.group.map((x) => ` ◌ launched on ${x.agent}: ${x.title} (${x.id})`).join('\n'));
|
|
119
120
|
if (!flags.wait) return out(s, ` ◌ launched: ${s.title} (${s.id})`);
|
|
120
121
|
if (!flags.json) console.error(` ◌ launched: ${s.title} (${s.id}) — waiting…`);
|
package/package.json
CHANGED
|
@@ -41,8 +41,8 @@ export default {
|
|
|
41
41
|
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
42
42
|
|
|
43
43
|
// Interactive command to take over a session in a terminal.
|
|
44
|
-
resumeCommand({ resumeId, model, effort }) {
|
|
45
|
-
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
|
|
44
|
+
resumeCommand({ resumeId, model, effort, dirs = [] }) {
|
|
45
|
+
return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), ...dirs.flatMap((d) => ['--add-dir', d])];
|
|
46
46
|
},
|
|
47
47
|
|
|
48
48
|
detect() {
|
|
@@ -56,7 +56,8 @@ export default {
|
|
|
56
56
|
|
|
57
57
|
// Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
|
|
58
58
|
// grants: --allowedTools rules (Bash(npm publish)) for what the user allowed after a denial.
|
|
59
|
-
|
|
59
|
+
// dirs: the task's other folders, which it may read and edit like cwd.
|
|
60
|
+
run({ prompt, attachments = [], cwd, dirs: extra = [], resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
60
61
|
const args = [
|
|
61
62
|
'-p',
|
|
62
63
|
'--output-format', 'stream-json',
|
|
@@ -74,7 +75,7 @@ export default {
|
|
|
74
75
|
if (allowed.length) args.push('--allowedTools', ...allowed);
|
|
75
76
|
// Its built-in subagents (Task, or Agent in newer versions) would bypass the user's team.
|
|
76
77
|
if (delegating) args.push('--disallowedTools', 'Task', 'Agent', 'Workflow');
|
|
77
|
-
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
78
|
+
const dirs = [...new Set([...extra, ...attachments.map((a) => path.dirname(a.path))])];
|
|
78
79
|
if (dirs.length) args.push('--add-dir', ...dirs);
|
|
79
80
|
args.push('--verbose'); // terminates the variadic flags above
|
|
80
81
|
prompt += attachmentNote(attachments, 'Read images and PDFs with the Read tool');
|
package/server/adapters/codex.js
CHANGED
|
@@ -32,11 +32,14 @@ export default {
|
|
|
32
32
|
return detectVersion(BIN);
|
|
33
33
|
},
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// dirs: the task's other folders, writable like cwd.
|
|
36
|
+
run({ prompt, attachments = [], cwd, dirs: extra = [], resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
|
|
36
37
|
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
37
38
|
// `resume` doesn't take --sandbox, so set it through config for both paths.
|
|
38
39
|
if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
39
40
|
else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
|
|
41
|
+
// `resume` doesn't take --add-dir either: the other folders are writable roots through config.
|
|
42
|
+
if (extra.length && mode !== 'auto') args.push('-c', `sandbox_workspace_write.writable_roots=${toml(extra)}`);
|
|
40
43
|
if (model) args.push('-m', model);
|
|
41
44
|
if (effort) args.push('-c', `model_reasoning_effort=${toml(effort)}`);
|
|
42
45
|
for (const [name, srv] of Object.entries(mcpServers)) {
|
|
@@ -48,7 +51,7 @@ export default {
|
|
|
48
51
|
const images = attachments.filter((a) => IMAGE.test(a.mime));
|
|
49
52
|
for (const img of images) args.push('-i', img.path);
|
|
50
53
|
if (!resumeId) {
|
|
51
|
-
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
54
|
+
const dirs = [...new Set([...extra, ...attachments.map((a) => path.dirname(a.path))])];
|
|
52
55
|
for (const d of dirs) args.push('--add-dir', d);
|
|
53
56
|
}
|
|
54
57
|
if (resumeId) args.push('resume', resumeId);
|
package/server/client.js
CHANGED
|
@@ -132,14 +132,15 @@ export class WispClient {
|
|
|
132
132
|
// worktree: true/false overrides the default (on in git repos). compare: [{ agent, model, effort }]
|
|
133
133
|
// runs the same task on more harnesses at once, each in its own worktree. subagents:
|
|
134
134
|
// [{ name, agent, model, effort, use }] (or preset: a saved team's id) the lead can delegate to.
|
|
135
|
-
|
|
135
|
+
// also: more folders the same task works in (each git repo gets its own worktree and PR).
|
|
136
|
+
async launch({ prompt, cwd = process.cwd(), also, agent, mode, model, effort, attachments, worktree, compare, subagents, preset } = {}) {
|
|
136
137
|
if (!agent || !mode || model === undefined) {
|
|
137
138
|
const { defaults = {} } = await this.config();
|
|
138
139
|
agent ||= defaults.agent || 'auto';
|
|
139
140
|
mode ||= defaults.mode || 'auto';
|
|
140
141
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
141
142
|
}
|
|
142
|
-
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
143
|
+
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), also: also?.length ? also.map((d) => path.resolve(d)) : undefined, agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// Ship it: commit the session's worktree, push its branch, open (or update) the PR.
|
package/server/mcp-wisp.js
CHANGED
|
@@ -23,6 +23,7 @@ const TOOLS = [
|
|
|
23
23
|
properties: {
|
|
24
24
|
prompt: { type: 'string', description: 'The task, written as you would brief a colleague: goal, constraints, how to verify.' },
|
|
25
25
|
cwd: { type: 'string', description: 'Absolute path of the project folder. Default: this MCP server’s working directory.' },
|
|
26
|
+
also: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of more folders the same agent works in (e.g. the API next to its web app). Git repos each get a worktree, and Ship it opens a PR in each.' },
|
|
26
27
|
agent: { type: 'string', enum: AGENTS, description: 'Harness to run; auto lets wisp pick harness and model from what worked before. Default: the user’s last pick in wisp.' },
|
|
27
28
|
mode: { type: 'string', enum: ['plan', 'edits', 'auto'], description: 'plan = read-only, edits = auto-accept file edits, auto = no permission prompts. Default: the user’s pick.' },
|
|
28
29
|
model: { type: 'string', description: 'Model id for that harness. Default: the user’s pick.' },
|
|
@@ -116,7 +117,7 @@ async function callTool(name, a = {}) {
|
|
|
116
117
|
switch (name) {
|
|
117
118
|
case 'wisp_launch': {
|
|
118
119
|
if (!a.prompt?.trim()) throw new Error('prompt is required');
|
|
119
|
-
const s = await c.launch({ prompt: a.prompt, cwd: a.cwd || process.cwd(), agent: a.agent, mode: a.mode, model: a.model, effort: a.effort, subagents: a.subagents, preset: a.preset });
|
|
120
|
+
const s = await c.launch({ prompt: a.prompt, cwd: a.cwd || process.cwd(), also: Array.isArray(a.also) ? a.also : undefined, agent: a.agent, mode: a.mode, model: a.model, effort: a.effort, subagents: a.subagents, preset: a.preset });
|
|
120
121
|
if (a.wait) return waitFor(s.id);
|
|
121
122
|
return `Launched session \`${s.id}\` (“${s.title}”) on ${s.agent} in ${s.cwd}, mode ${s.mode}. Call wisp_wait or wisp_get with this id for the result.`;
|
|
122
123
|
}
|
package/server/openapi.js
CHANGED
|
@@ -304,6 +304,7 @@ export function openapi({ version, port }) {
|
|
|
304
304
|
properties: {
|
|
305
305
|
prompt: { type: 'string' },
|
|
306
306
|
cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
|
|
307
|
+
also: { type: 'array', maxItems: 8, items: { type: 'string' }, description: 'More folders the same task works in (absolute; ~ allowed). The agent gets each; git repos get their own worktree, and Ship it opens a PR in each' },
|
|
307
308
|
scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
|
|
308
309
|
agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
|
|
309
310
|
important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
|
|
@@ -10,7 +10,7 @@ import { execCommand } from '../proc.js';
|
|
|
10
10
|
import { ensureWorktree, removeWorktree } from '../git.js';
|
|
11
11
|
import { MIME } from '../http/static.js';
|
|
12
12
|
import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
|
|
13
|
-
import { summarize, workdir } from '../runner.js';
|
|
13
|
+
import { alsoDirs, summarize, workdir } from '../runner.js';
|
|
14
14
|
import { normalizeTeam } from '../subagents.js';
|
|
15
15
|
import { openInTerminal, shq } from '../terminal.js';
|
|
16
16
|
import { bad, clip, expand, MODES } from '../util.js';
|
|
@@ -46,6 +46,10 @@ export function register(r, app) {
|
|
|
46
46
|
}
|
|
47
47
|
return { agent, ...(model !== undefined && { model: String(model || '') }), ...(effort !== undefined && { effort: String(effort || '') }) };
|
|
48
48
|
};
|
|
49
|
+
// Archiving removes clean worktrees: the task's own and its other folders' come back before it runs again.
|
|
50
|
+
const ensureTrees = async (s) => {
|
|
51
|
+
for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
|
|
52
|
+
};
|
|
49
53
|
const turnOf = (s, turnId) => (turnId ? s.turns.find((t) => t.id === turnId) : s.turns.at(-1));
|
|
50
54
|
|
|
51
55
|
r.route('GET', '/sessions', async (c) => {
|
|
@@ -57,8 +61,10 @@ export function register(r, app) {
|
|
|
57
61
|
let list = [...here, ...(await machines.sessions(q))].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
58
62
|
if (q.state) list = list.filter((x) => q.state.split(',').includes(x.state));
|
|
59
63
|
if (q.agent) list = list.filter((x) => x.agent === q.agent);
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// A task in several folders (also) shows under each of their projects.
|
|
65
|
+
const dirs = (x) => [x.cwd, ...(x.also || []).map((a) => a.cwd)];
|
|
66
|
+
if (q.project) list = list.filter((x) => [x, ...(x.also || [])].some((p) => p.projectId === q.project || p.project === q.project));
|
|
67
|
+
if (cwd) list = list.filter((x) => dirs(x).some((d) => d === cwd || d?.startsWith(cwd + path.sep)));
|
|
62
68
|
if (q.archived !== undefined) list = list.filter((x) => x.archived === (q.archived === 'true' || q.archived === '1'));
|
|
63
69
|
if (Number(q.limit) > 0) list = list.slice(0, Number(q.limit));
|
|
64
70
|
c.send(200, list);
|
|
@@ -100,7 +106,8 @@ export function register(r, app) {
|
|
|
100
106
|
if (s.state === 'done' || s.archived) app.retro.schedule(s, 0); // finished with it: learn now
|
|
101
107
|
// Archived: a clean worktree goes (its branch stays, a follow-up brings it back).
|
|
102
108
|
// discardWorktree: a compare run you didn't keep; its changes go too.
|
|
103
|
-
if (
|
|
109
|
+
if (!runner.live.has(s.id) && (s.archived || body.discardWorktree))
|
|
110
|
+
for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: !!body.discardWorktree, branch: !!body.discardWorktree && !x.pr && 'force' });
|
|
104
111
|
app.sessionChanged(s);
|
|
105
112
|
c.send(200, summarize(s, store));
|
|
106
113
|
}),
|
|
@@ -117,7 +124,7 @@ export function register(r, app) {
|
|
|
117
124
|
if (store.get(cid)) (store.delete(cid), app.broadcast('removed', { id: cid }));
|
|
118
125
|
}
|
|
119
126
|
// Its worktree goes with it; the branch only if it holds nothing beyond its base.
|
|
120
|
-
|
|
127
|
+
for (const x of [s, ...(s.also || [])]) if (x.worktree) await removeWorktree(x.worktree, { force: true, branch: true }).catch(() => {});
|
|
121
128
|
store.delete(s.id);
|
|
122
129
|
app.broadcast('removed', { id: s.id });
|
|
123
130
|
c.send(200, { ok: true });
|
|
@@ -213,7 +220,7 @@ export function register(r, app) {
|
|
|
213
220
|
one(async (c, s) => {
|
|
214
221
|
const { turnId, allow } = await c.body();
|
|
215
222
|
notTour(s, 'approve');
|
|
216
|
-
await
|
|
223
|
+
await ensureTrees(s);
|
|
217
224
|
c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
|
|
218
225
|
}),
|
|
219
226
|
);
|
|
@@ -295,8 +302,8 @@ export function register(r, app) {
|
|
|
295
302
|
one(async (c, s) => {
|
|
296
303
|
const adapter = adapters[s.agent];
|
|
297
304
|
if (!adapter?.resumeCommand || !s.agentSessionId) return c.send(400, { error: 'This session cannot be resumed in a terminal.' });
|
|
298
|
-
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
|
|
299
|
-
await
|
|
305
|
+
const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort, dirs: alsoDirs(s) });
|
|
306
|
+
await ensureTrees(s);
|
|
300
307
|
const command = `cd ${shq(workdir(s))} && ${argv.map(shq).join(' ')}`;
|
|
301
308
|
await fn(s, argv);
|
|
302
309
|
c.send(200, { command });
|
package/server/routes/ship.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Ship it. GET: what would ship (branch, changes, draft message). POST { title, body, draft }:
|
|
2
|
-
// commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge).
|
|
2
|
+
// commit, push, open or update the PR. Then follow it (pr) and squash-merge it (merge). A task in
|
|
3
|
+
// several repos (also) ships, follows and merges a PR in each (services/ship.js).
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import { worktreeStatus } from '../git.js';
|
|
6
|
+
import { reposOf } from '../services/ship.js';
|
|
5
7
|
|
|
6
8
|
export function register(r, app) {
|
|
7
9
|
const { store, runner, telemetry } = app;
|
|
@@ -11,46 +13,40 @@ export function register(r, app) {
|
|
|
11
13
|
return fn(c, s);
|
|
12
14
|
};
|
|
13
15
|
const NO_TREE = 'Ship it works on worktree sessions: launch with a worktree to get one.';
|
|
16
|
+
const hasTrees = (s) => !!(s.worktree || s.also?.some((a) => a.worktree));
|
|
14
17
|
|
|
15
18
|
r.route(
|
|
16
19
|
'GET',
|
|
17
20
|
'/sessions/:id/ship',
|
|
18
21
|
one(async (c, s) => {
|
|
19
|
-
if (!s
|
|
22
|
+
if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
|
|
20
23
|
app.ship.recheck(s);
|
|
21
|
-
|
|
24
|
+
const repos = await Promise.all(reposOf(s, store).map(async (r) => ({ repo: r.name, branch: r.wt.branch, base: r.wt.base, remote: r.wt.remote, ...(await worktreeStatus(r.wt)), pr: r.at.pr || null })));
|
|
25
|
+
// The first repo's fields at the top level (as before one task could span repos), each in `repos`.
|
|
26
|
+
const sum = (k) => repos.reduce((n, r) => n + r[k], 0);
|
|
27
|
+
c.send(200, { ...repos[0], ...(repos.length > 1 && { dirty: sum('dirty'), ahead: sum('ahead'), repos }), ...app.ship.draft(s) });
|
|
22
28
|
}),
|
|
23
29
|
);
|
|
24
30
|
r.route(
|
|
25
31
|
'POST',
|
|
26
32
|
'/sessions/:id/ship',
|
|
27
33
|
one(async (c, s) => {
|
|
28
|
-
if (!s
|
|
34
|
+
if (!hasTrees(s)) return c.send(400, { error: NO_TREE });
|
|
29
35
|
if (runner.live.has(s.id)) return c.send(409, { error: 'Still working: wait for it to finish, or stop it.' });
|
|
30
36
|
if (s.shipping) return c.send(409, { error: 'Already shipping: wait for it to land.' });
|
|
31
37
|
const body = await c.body();
|
|
32
38
|
const draft = app.ship.draft(s);
|
|
33
|
-
// The card follows along: s.shipping names the stage running now, sent with each summary.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
|
|
40
|
-
} finally {
|
|
41
|
-
delete s.shipping;
|
|
42
|
-
app.sessionChanged(s, { now: true });
|
|
43
|
-
}
|
|
44
|
-
telemetry.track('ship', { agent: s.agent, draft: !!body.draft });
|
|
45
|
-
app.ship.recheck(s);
|
|
46
|
-
c.send(200, s.pr);
|
|
39
|
+
// The card follows along: s.shipping names the stage (and repo) running now, sent with each summary.
|
|
40
|
+
const pr = await app.ship.shipAll(s, { title: body.title ?? draft.title, body: body.body ?? draft.body, draft: !!body.draft });
|
|
41
|
+
telemetry.track('ship', { agent: s.agent, draft: !!body.draft, ...(pr.prs && { repos: pr.prs.length }) });
|
|
42
|
+
c.send(200, pr);
|
|
47
43
|
}),
|
|
48
44
|
);
|
|
49
45
|
r.route(
|
|
50
46
|
'POST',
|
|
51
47
|
'/sessions/:id/pr',
|
|
52
48
|
one(async (c, s) => {
|
|
53
|
-
if (!s.pr) return c.send(404, { error: 'No pull request yet.' });
|
|
49
|
+
if (!s.pr && !s.also?.some((a) => a.pr)) return c.send(404, { error: 'No pull request yet.' });
|
|
54
50
|
c.send(200, await app.ship.refreshPr(s));
|
|
55
51
|
}),
|
|
56
52
|
);
|
|
@@ -76,13 +72,12 @@ export function register(r, app) {
|
|
|
76
72
|
'POST',
|
|
77
73
|
'/sessions/:id/merge',
|
|
78
74
|
one(async (c, s) => {
|
|
79
|
-
if (!s.
|
|
80
|
-
const
|
|
81
|
-
s.pr = pr;
|
|
75
|
+
if (!reposOf(s, store).some((r) => r.at.pr)) return c.send(400, { error: 'No pull request to merge.' });
|
|
76
|
+
const out = await app.ship.mergeAll(s);
|
|
82
77
|
app.launcher.markDone(s);
|
|
83
78
|
telemetry.track('merge', { agent: s.agent });
|
|
84
79
|
app.sessionChanged(s, { now: true });
|
|
85
|
-
c.send(200,
|
|
80
|
+
c.send(200, out);
|
|
86
81
|
}),
|
|
87
82
|
);
|
|
88
83
|
}
|
package/server/runner.js
CHANGED
|
@@ -46,11 +46,21 @@ export { newId };
|
|
|
46
46
|
// Where the agent actually works: its own worktree when it has one, else the project folder.
|
|
47
47
|
export const workdir = (s) => s.worktree?.cwd || s.cwd;
|
|
48
48
|
|
|
49
|
-
//
|
|
49
|
+
// Where the agent works in the task's other folders (also): their worktrees, else the folders.
|
|
50
|
+
export const alsoDirs = (s) => (s.also || []).map((a) => a.worktree?.cwd || a.cwd);
|
|
51
|
+
|
|
52
|
+
// Worktree sessions get told where they are, so they don't go looking for the main checkout. A task
|
|
53
|
+
// with other folders (also) gets their paths: that's where the work in them goes.
|
|
50
54
|
function workspaceNote(s) {
|
|
51
55
|
const wt = s.worktree;
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
const also = s.also || [];
|
|
57
|
+
const out = [];
|
|
58
|
+
if (wt) out.push(`You are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`);
|
|
59
|
+
if (also.length) {
|
|
60
|
+
const line = (a) => (a.worktree ? `- \`${a.worktree.cwd}\`: a worktree of \`${a.cwd}\` on branch \`${a.worktree.branch}\` (from \`${a.worktree.baseRef}\`). Work here, not in \`${a.cwd}\`.` : `- \`${a.cwd}\``);
|
|
61
|
+
out.push(`This task spans ${also.length + 1} folders. Besides your working directory (${workdir(s)}), you work in:\n${also.map(line).join('\n')}\n\nRead and change files in all of them as the task needs, using absolute paths outside your working directory. Run each folder's commands (tests, builds) from inside that folder.${also.some((a) => a.worktree) ? ' Leave the changes there uncommitted too; Ship it opens a pull request per repo.' : ''}`);
|
|
62
|
+
}
|
|
63
|
+
return out.length ? `\n\n## Workspace\n\n${out.join('\n\n')}` : '';
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// A follow-up's { agent, model, effort }, or null when it's what the session already runs on.
|
|
@@ -102,6 +112,13 @@ export function summarize(s, store) {
|
|
|
102
112
|
question: s.ask?.question || '',
|
|
103
113
|
approval: !!s.ask?.permission, // the question is a live approval (Allow / Deny)
|
|
104
114
|
branch: s.worktree?.branch || '',
|
|
115
|
+
// The task's other folders: their project, worktree branch and PR, for the card and the filter.
|
|
116
|
+
...(s.also?.length && {
|
|
117
|
+
also: s.also.map((a) => {
|
|
118
|
+
const p = store?.projectFor(a.cwd);
|
|
119
|
+
return { cwd: a.cwd, project: p?.name || path.basename(a.cwd), projectId: p?.id || null, color: p?.color || 'slate', branch: a.worktree?.branch || '', pr: a.pr ? { number: a.pr.number, url: a.pr.url, state: a.pr.state, draft: a.pr.draft, review: a.pr.review, checks: a.pr.checks, conflict: !!a.pr.conflict } : null };
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
105
122
|
toShip: s.toShip || null, // { dirty, ahead, unpushed } of the worktree, null until checked
|
|
106
123
|
shipping: s.shipping || null, // { step: 'start' | 'commit' | 'push' | 'pr', update } while Ship it runs
|
|
107
124
|
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,
|
|
@@ -165,7 +182,8 @@ export class Runner extends EventEmitter {
|
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
168
|
-
|
|
185
|
+
// also: the task's other folders, [{ cwd, worktree }] (services/launch.js).
|
|
186
|
+
create({ id = newId(), prompt, cwd, also = [], agent = 'claude', mode = 'auto', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '', route = null, auto = null, scout = null }) {
|
|
169
187
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
170
188
|
const now = Date.now();
|
|
171
189
|
const session = {
|
|
@@ -186,6 +204,7 @@ export class Runner extends EventEmitter {
|
|
|
186
204
|
queue: [],
|
|
187
205
|
turns: [],
|
|
188
206
|
...(worktree && { worktree }),
|
|
207
|
+
...(also.length && { also }),
|
|
189
208
|
...(groupId && { groupId }),
|
|
190
209
|
...(origin && { origin }),
|
|
191
210
|
...(subagents.length && { subagents, children: {} }),
|
|
@@ -195,7 +214,7 @@ export class Runner extends EventEmitter {
|
|
|
195
214
|
...(route && { route }), // how Auto picked agent, model and level (server/router.js)
|
|
196
215
|
};
|
|
197
216
|
this.store.put(session);
|
|
198
|
-
if (!parentId) this.store.touchProject(
|
|
217
|
+
if (!parentId) for (const dir of [...also.map((a) => a.cwd), cwd]) this.store.touchProject(dir); // a subagent's cwd may be the lead's worktree
|
|
199
218
|
this.#startTurn(session, prompt, attachments);
|
|
200
219
|
return session;
|
|
201
220
|
}
|
|
@@ -541,6 +560,7 @@ export class Runner extends EventEmitter {
|
|
|
541
560
|
prompt: handoff ? `${handoff}\n\n${text}` : text,
|
|
542
561
|
attachments,
|
|
543
562
|
cwd: workdir(session),
|
|
563
|
+
dirs: alsoDirs((session.parentId && this.store.get(session.parentId)) || session), // the task's other folders (a subagent's are its lead's)
|
|
544
564
|
resumeId: session.agentSessionId,
|
|
545
565
|
mode,
|
|
546
566
|
grants,
|
package/server/services/auto.js
CHANGED
|
@@ -109,12 +109,20 @@ export function createAuto(app) {
|
|
|
109
109
|
const first = s.turns[0];
|
|
110
110
|
const id = newId();
|
|
111
111
|
if (!s.groupId) s.groupId = newId();
|
|
112
|
-
const
|
|
113
|
-
const worktree =
|
|
112
|
+
const fresh = (wt, cwd) => (wt ? createWorktree({ cwd, home: store.home, id, title: first.prompt, source: { root: wt.root, base: { ref: wt.head || wt.baseRef, branch: wt.base, remote: wt.remote, sha: wt.head } } }).catch(() => null) : null);
|
|
113
|
+
const worktree = await fresh(s.worktree, s.cwd);
|
|
114
|
+
// The task's other folders too: fresh worktrees from the same bases.
|
|
115
|
+
const also = await Promise.all(
|
|
116
|
+
(s.also || []).map(async (a) => {
|
|
117
|
+
const tree = await fresh(a.worktree, a.cwd);
|
|
118
|
+
return tree ? { cwd: a.cwd, worktree: tree } : { cwd: a.cwd };
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
114
121
|
const sib = app.runner.create({
|
|
115
122
|
id,
|
|
116
123
|
prompt: first.prompt,
|
|
117
124
|
cwd: s.cwd,
|
|
125
|
+
also,
|
|
118
126
|
agent: pick.agent,
|
|
119
127
|
model: pick.model,
|
|
120
128
|
effort: pick.effort || '',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// runs), here or on a machine, plus the follow-ups and "done" that every surface shares.
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
5
6
|
import { adapters } from '../adapters/index.js';
|
|
6
7
|
import { createWorktree, worktreeSource, ensureWorktree, removeWorktree } from '../git.js';
|
|
7
8
|
import { qualify } from '../machines.js';
|
|
@@ -11,11 +12,32 @@ import { normalizeTeam } from '../subagents.js';
|
|
|
11
12
|
import { bad, expand, MODES, newId } from '../util.js';
|
|
12
13
|
|
|
13
14
|
export const MAX_COMPARE = 4;
|
|
15
|
+
export const MAX_ALSO = 8; // extra folders one task may work in
|
|
16
|
+
|
|
17
|
+
// also: more folders the task works in besides cwd (a backend and its web app, say). Each must be
|
|
18
|
+
// a directory; repeats, cwd itself and folders inside one already listed are dropped.
|
|
19
|
+
export function alsoDirs(list, cwd) {
|
|
20
|
+
if (list === undefined || list === null) return [];
|
|
21
|
+
if (!Array.isArray(list)) throw bad('also must be a list of folders.');
|
|
22
|
+
const inside = (a, b) => a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep);
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const x of list) {
|
|
25
|
+
const raw = typeof x === 'string' ? x : x?.cwd;
|
|
26
|
+
if (!String(raw || '').trim()) continue;
|
|
27
|
+
const dir = path.resolve(expand(String(raw).trim()));
|
|
28
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw bad(`Not a directory: ${dir}`);
|
|
29
|
+
if (inside(dir, cwd) || out.some((d) => inside(dir, d))) continue;
|
|
30
|
+
for (let i = out.length - 1; i >= 0; i--) if (inside(out[i], dir)) out.splice(i, 1);
|
|
31
|
+
out.push(dir);
|
|
32
|
+
}
|
|
33
|
+
if (out.length > MAX_ALSO) throw bad(`A task can work in up to ${MAX_ALSO + 1} folders.`);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
14
36
|
|
|
15
37
|
export function createLaunch(app) {
|
|
16
38
|
const { store, runner, machines } = app;
|
|
17
39
|
|
|
18
|
-
// body: { prompt, cwd, scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
40
|
+
// body: { prompt, cwd, also: [folder], scratch: true = no folder, agent, mode, model, effort, attachments: [uploadId], worktree, compare,
|
|
19
41
|
// subagents: [{ name, agent, model, effort, use }] | preset: teamId }
|
|
20
42
|
async function launch(body, origin = null) {
|
|
21
43
|
if (!String(body.prompt || '').trim()) throw bad('prompt required');
|
|
@@ -23,6 +45,7 @@ export function createLaunch(app) {
|
|
|
23
45
|
const scratch = body.scratch === true;
|
|
24
46
|
const cwd = scratch ? store.scratchDir(newId()) : expand(body.cwd || process.cwd());
|
|
25
47
|
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) throw bad(`Not a directory: ${cwd}`);
|
|
48
|
+
const also = scratch ? [] : alsoDirs(body.also, cwd);
|
|
26
49
|
const d = store.config.defaults;
|
|
27
50
|
const preset = body.preset && store.config.teams.find((t) => t.id === body.preset);
|
|
28
51
|
if (body.preset && !preset && !body.subagents) throw bad(`no such subagent preset: ${body.preset}`);
|
|
@@ -66,7 +89,16 @@ export function createLaunch(app) {
|
|
|
66
89
|
}
|
|
67
90
|
if (failed) console.warn(`wisp: no worktree for ${cwd}: ${failed.message}`);
|
|
68
91
|
}
|
|
69
|
-
|
|
92
|
+
// The other folders: a worktree of their own per run where they're git repos (so parallel
|
|
93
|
+
// tasks don't collide there either), else the folder itself.
|
|
94
|
+
const extras = await Promise.all(
|
|
95
|
+
also.map(async (dir) => {
|
|
96
|
+
const source = wantTree && (await worktreeSource(dir).catch(() => null));
|
|
97
|
+
const made = await Promise.all(ids.map((id) => (source ? createWorktree({ cwd: dir, home: store.home, id, title: body.prompt, source }).catch((err) => console.warn(`wisp: no worktree for ${dir}: ${err.message}`)) : null)));
|
|
98
|
+
return made.map((worktree) => ({ cwd: dir, ...(worktree && { worktree }) }));
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return runners.map((r, i) => runner.create({ id: ids[i], prompt: body.prompt, cwd, also: extras.map((x) => x[i]), ...r, mode, attachments, worktree: trees[i], groupId, origin, subagents, ...(r.route && { auto: { strategy }, scout: resolved.scout || null }) }));
|
|
70
102
|
}
|
|
71
103
|
|
|
72
104
|
// Where a launch runs: body.machine, else a project folder that only exists on a machine
|
|
@@ -110,7 +142,7 @@ export function createLaunch(app) {
|
|
|
110
142
|
|
|
111
143
|
// A follow-up from anywhere (UI, CLI, phone). An archived session may have lost its worktree.
|
|
112
144
|
async function replyTo(s, text, attachments = [], queue, mode, run) {
|
|
113
|
-
await ensureWorktree(
|
|
145
|
+
for (const x of [s, ...(s.also || [])]) await ensureWorktree(x.worktree);
|
|
114
146
|
return runner.reply(s, text, attachments, { queue: queue ?? store.config.defaults.followUp === 'queue', mode, run });
|
|
115
147
|
}
|
|
116
148
|
|