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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.18.0] - 2026-10-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Merge conflicts on shipped PRs fix themselves.** When something lands on `main` and your shipped PR no longer merges, the task used to sit there until you noticed on GitHub. Now wisp sees it on its PR check (every minute, or when you press refresh): GitHub reports the PR as conflicting, and wisp confirms it locally with `git merge-tree` against a fresh fetch of the base, so a stale GitHub answer can't start a run. The task then gets a follow-up asking it to merge `origin/main` in (no rebase, since the branch is pushed), resolve each conflict keeping both sides' intent, run the cheap checks and commit. When that run ends, wisp checks the merge is complete: no unmerged files, the base is in the branch, no leftover `<<<<<<<` markers. Then it pushes to the PR, and the card shows the usual Push → PR track. While it works, the PR row reads *Fixing merge conflicts* and hides Merge. If the agent stops halfway, nothing is pushed: the card comes back unread with *Conflicts not fixed* and the reason, plus a **Fix conflicts** button to try again. wisp tries once per PR head and at most 3 times per task, so a base that keeps moving can't run up the bill. On by default; turn it off in Settings → General → **Fix PR conflicts** (`defaults.fixConflicts: "off"`). API: `conflict` on the PR, `conflictFix` on the session summary, `POST /api/sessions/:id/fix-conflicts`.
|
|
13
|
+
|
|
14
|
+
- **Tasks without a folder.** The launcher has a **No folder** chip next to your projects, for general asks that have nothing to do with a repo (explain a concept, draft a message, research something). The agent runs in an empty scratch directory under `~/.wisp/scratch`, with no worktree, and the task shows under *General*. The folder is not added to your projects. The Worktree toggle hides while No folder is picked, and deleting the task deletes its scratch folder. API: `POST /api/sessions {"prompt","scratch":true}`.
|
|
15
|
+
|
|
16
|
+
- **Add a project from a GitHub URL.** Paste `https://github.com/owner/repo` (or `git@github.com:owner/repo.git`, a link to any page inside the repo, or any other git URL) into Projects and the button turns into **Clone**. Click it and the dialog closes: the launcher already points at the repo (a chip with a spinner, *cloning…*) and the prompt has focus, so you write the task while it clones. Send it before the clone finishes and the task card shows *Cloning owner/repo*, then starts in the clone. If the clone fails, a toast says why, the launcher goes back to the project you had picked and your task comes back into the box. A feed filtered to another project switches to All, so the launcher isn't pulled back to that project. GitHub repos go through `gh repo clone` when gh is installed, so private repos work with your gh login; otherwise plain `git clone`. Repos land in `~/code` (or the first of `~/dev`, `~/Developer`, `~/projects`, `~/src`, `~/repos`… that exists). Click the path in the hint to change it (`defaults.cloneDir`). If the folder there already is that repo, wisp reuses it; another folder with that name gets a `-2` suffix. When gh is signed in, the dialog also lists **Your GitHub repos**, newest push first and minus ones already added, so cloning one is a single click. On a machine tab the clone happens on that machine. CLI: `wisp add https://github.com/owner/repo`. API: `POST /api/projects {"repo":"…"}`; a URL in `path` works too.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Project chips stay on one row with tens of projects.** The launcher's *in* row used to show every project as a chip (30 projects meant 6 rows on desktop and 14 on a phone), and the top bar cut off whatever didn't fit with no way to scroll to it. Now both show your most recent projects as chips, and the rest go behind a **+N** chip. Fewer chips show when the space is narrow, and a chip is never cut in half. The project you picked, the one you're filtering by, and projects with tasks that need you always keep their chip. **+N** opens a searchable list of every project: type to filter by name or path, use ↑/↓ and Enter to pick. It also has *No folder* and *Other folder…* in the launcher, and *All projects* and *Manage projects…* in the top bar. In the top bar, **+N** shows a badge counting the tasks that need you in the projects it hides. When a phone is too narrow even for *All*, the top bar shows one chip with the current filter that opens the list.
|
|
21
|
+
|
|
22
|
+
- **Ship it no longer holds you in its dialog.** Clicking *Commit, push & open PR* used to leave the dialog open with a disabled *Shipping…* button until the push and `gh pr create` were done. Now the dialog closes right away, and the card's PR row (just above the composer) shows a live track: **Commit → Push → Open PR**, with a spinner on the step that's running and a check on each one that's done. The server reports each step as `shipping` on the session summary, so other tabs see it too, and the card head shows a *Shipping* pill while the card is collapsed. When the PR is up, the row turns into the PR row with a short highlight, and a toast links to it. If a step fails, the row turns red and says which step failed and why, with **Retry**, which reopens the dialog with your commit message and description still filled in. A second ship while one is running is refused with 409.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **The launcher is there from the first frame.** On start, the launcher used to sit as an empty strip for one to five seconds, because the page waited for harness detection (`GET /api/agents` runs every CLI, and opencode lists its models) before drawing it. Now `index.html` draws the launcher's shape with a prompt box that already has focus, so you can start typing before the scripts load. The full launcher fills in about 50 ms later, at the same height, and keeps what you typed. Harnesses come from the last list seen (kept in the browser) and refresh when detection answers. `GET /api/agents` now answers from the last detection right away and checks again in the background, sending an `agents` event if anything changed. Warm calls dropped from about 1.2 s to 2 ms. `?fresh=1` and Re-check still wait for a full detection. Empty feed sections no longer flash their headings before the feed loads.
|
|
27
|
+
|
|
28
|
+
## [0.17.0] - 2026-10-19
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Approve what an agent's permissions block, from wisp.** In Edits mode a coding agent used to be refused quietly when it wanted something its permissions don't cover, like `npm publish`, a write outside the project or a network call from Codex's sandbox. You only found out from a half-done report. Now:
|
|
33
|
+
- **Claude Code waits for you.** wisp sends Claude's permission prompts to its own `approve` tool (`--permission-prompt-tool`). The task card shows *Needs your approval*: *Allow Claude Code to run `npm publish`?*, with **Allow**, **Always allow in this task** and **Deny**. The number keys pick an option. You can also type what to do instead: that denies the call and passes your words to Claude. Claude keeps its context, and the desktop notification says *needs your approval*. *Always allow* lets the same command or file through for the rest of the task without asking again.
|
|
34
|
+
- **Every other harness gets an Allow & retry card.** When Codex, opencode, Grok, Cursor or Antigravity is refused, the report shows *Blocked by permissions* under its headline, listing each refused action. wisp reads these refusals from the harness itself: opencode's and Grok's stderr, Cursor's rejected tool calls, Antigravity's `PERMISSION_DENIED` steps and Claude's `permission_denials`. A new `blocked` block in the wisp protocol lets an agent report a refusal it can only see in a command's output, such as Codex's sandbox. **Allow & retry** sends a follow-up that runs those actions. For Claude and opencode only the exact actions are allowed (`--allowedTools "Bash(npm publish)"` or an opencode permission rule). Codex, Grok, Cursor and Antigravity can't allow a single action from the command line, so that one follow-up runs in Full auto, and the card says so. The task stays in Edits. **Deny** closes the card. API: `POST /api/sessions/:id/approve {"turnId","allow"}`. A live approval is answered like a question, through `POST /api/sessions/:id/answer {"text":"Allow"}`, `wisp reply` or the phone buttons.
|
|
35
|
+
- **Mention another task in a prompt.** Right-click a task (or open its ⋯ menu) and pick **Copy task ID**, then paste the ID into the launcher, a follow-up box or the Rerun dialog. It shows as a chip with the task's title, like pasting an issue link in Linear, and stays a chip in the sent prompt, where clicking it opens that task. Backspace removes the whole chip and Cmd+Z brings it back. Pasting several IDs at once makes a chip for each. The agent gets the title and ID, and two new read-only tools sit next to `ask_user`: `wisp_session` reads a task (its title, state, harness, folder and branch, every request made in it, and a turn's report as markdown) and `wisp_sessions` lists your other tasks (filter by `query`, `state`, `project`, `archived`). Cursor and Antigravity runs can't take injected tools, so for them wisp puts each mentioned task's latest report in the prompt instead. Prompts store a mention as `@[Title](wisp:<id>)`, so API and CLI callers can write one too. Task titles show `@Title`. The bridge routes (`GET /api/internal/sessions/:id`, `GET /api/internal/session/:id?of=<id>`) only take the per-run token.
|
|
36
|
+
- **Agents can answer questions about your wisp usage.** Every task now gets a read-only `wisp_stats` tool next to `ask_user`. Ask a task "which tools do my tasks use?" or "what did Codex cost me this week?" and the agent gets a table of tool calls by kind (shell, read, edit, search, web, subagents, other MCP tools, questions), by harness, by project and by day. It also gets turns, cost, tokens and success rate, taken from the same numbers as Settings → Usage. Agents can narrow it with `days` (1–365, default 30), `agent` (a harness id) and `project` (a name or id, or `current` for the task's own project). Before, an agent had no tool for this and would either say it had no access or dig through `~/.wisp/sessions` by hand. Kinds are wisp's categories, not each harness's own tool names. The bridge route (`GET /api/internal/stats/:id`) only takes the per-run token, like `ask_user`. Cursor and Antigravity runs can't take injected MCP tools, so they don't get it.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Mark done and the PR row are where you look.** An opened task now has Mark done (or Reopen) in its top bar, next to Ship it, so you can close it out without scrolling to the composer. The PR row (checks, GitHub, Merge) moved from the top of the card to right above the composer, so after reading the result it's in view without scrolling back up.
|
|
41
|
+
- **Full auto is the default mode.** New launches, API and CLI calls without a mode, scheduled and CI triggers, and recipes run with no permission prompts; tasks in git repos still get their own worktree and a checkpoint before every turn. If your saved launcher mode was Edits (the old default), wisp switches it to Full auto once; a Plan pick, or anything you choose afterwards, sticks. Issue triggers still start in Plan. The mode button now shows just an icon for Full auto and spells out Plan and Edits.
|
|
42
|
+
|
|
8
43
|
## [0.16.0] - 2026-10-19
|
|
9
44
|
|
|
10
45
|
### Added
|
|
@@ -299,7 +334,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
299
334
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
300
335
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
301
336
|
|
|
302
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
337
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.18.0...HEAD
|
|
338
|
+
[0.18.0]: https://github.com/M0xoo/wisp/compare/v0.17.0...v0.18.0
|
|
339
|
+
[0.17.0]: https://github.com/M0xoo/wisp/compare/v0.16.0...v0.17.0
|
|
303
340
|
[0.16.0]: https://github.com/M0xoo/wisp/compare/v0.15.0...v0.16.0
|
|
304
341
|
[0.15.0]: https://github.com/M0xoo/wisp/compare/v0.14.0...v0.15.0
|
|
305
342
|
[0.14.0]: https://github.com/M0xoo/wisp/compare/v0.13.0...v0.14.0
|
package/README.md
CHANGED
|
@@ -54,10 +54,10 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
54
54
|
| 🤝 **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
55
|
| ⏪ **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
56
|
| ⚖️ **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.
|
|
57
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.18.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
58
58
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
59
59
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
60
|
-
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
60
|
+
| 🔐 **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. |
|
|
61
61
|
| 🔌 **Integrations** | An MCP server (`wisp mcp`), an agent skill, a scriptable CLI, a JS SDK, an OpenAPI-described HTTP API, and webhooks. Hand tasks to wisp from Claude Code, Codex, opencode, Cursor, scripts or CI. |
|
|
62
62
|
| 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
|
|
63
63
|
|
|
@@ -83,7 +83,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
|
|
|
83
83
|
wisp "fix the flaky auth test"
|
|
84
84
|
```
|
|
85
85
|
|
|
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.
|
|
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.18.0 bash`).
|
|
87
87
|
|
|
88
88
|
From source:
|
|
89
89
|
|
|
@@ -116,6 +116,7 @@ The CLI talks to a running wisp, or starts one if none is running. It works from
|
|
|
116
116
|
wisp # open wisp (or focus the running one)
|
|
117
117
|
wisp "fix the flaky auth test" # launch a task in this folder (it becomes a project)
|
|
118
118
|
wisp add [path] # add a folder as a project (default: cwd)
|
|
119
|
+
wisp add <repo-url> # clone a repo (GitHub URL, git@…, any git URL) into ~/code, then add it
|
|
119
120
|
wisp --mode plan "audit deps" # permission mode: plan | edits | auto
|
|
120
121
|
wisp --agent codex "add a test" # harness: claude | codex | opencode | cursor | antigravity | grok
|
|
121
122
|
```
|
|
@@ -131,7 +132,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question; --mod
|
|
|
131
132
|
wisp stop <id>
|
|
132
133
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
133
134
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
134
|
-
wisp run release 0.
|
|
135
|
+
wisp run release 0.18.0 # run a recipe; values fill its {params} (or version=0.18.0)
|
|
135
136
|
wisp recipes # list recipes
|
|
136
137
|
wisp usage [--days 7] # spend and outcomes
|
|
137
138
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -334,7 +335,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
334
335
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
335
336
|
```
|
|
336
337
|
|
|
337
|
-
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.
|
|
338
|
+
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.18.0` fills the fields in order, `wisp run release version=0.18.0` by name.
|
|
338
339
|
|
|
339
340
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
340
341
|
|
package/bin/wisp.js
CHANGED
|
@@ -236,8 +236,10 @@ async function interactive() {
|
|
|
236
236
|
|
|
237
237
|
let openPath = '/';
|
|
238
238
|
if (command === 'add') {
|
|
239
|
-
|
|
240
|
-
const
|
|
239
|
+
// A repo URL is cloned by the server (into ~/code unless defaults.cloneDir says otherwise).
|
|
240
|
+
const url = /^(https?:|ssh:|git:|git@|(www\.)?github\.com\/)/i.test(words[1] || '');
|
|
241
|
+
if (url) console.log(` ◌ cloning ${words[1]}…`);
|
|
242
|
+
const p = await api('/projects', url ? { repo: words[1] } : { path: path.resolve(words[1] || process.cwd()) }).catch((e) => console.error(` ✗ ${e.message}`));
|
|
241
243
|
if (p) console.log(` ✓ project added: ${p.name} (${p.path})`);
|
|
242
244
|
if (running || electron) process.exit(0);
|
|
243
245
|
} else if (words.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"app": "node bin/wisp.js",
|
|
21
21
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
22
22
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
23
|
-
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js",
|
|
23
|
+
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js test/clone.test.js",
|
|
24
24
|
"test:real": "node test/real-clis.mjs",
|
|
25
25
|
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
|
|
26
26
|
},
|
|
@@ -105,7 +105,11 @@ export default {
|
|
|
105
105
|
onEvent({ type: 'tool', ...describeTool(name, params, cwd) });
|
|
106
106
|
}
|
|
107
107
|
if (!done) return;
|
|
108
|
-
if (info.error)
|
|
108
|
+
if (info.error) {
|
|
109
|
+
onEvent({ type: 'tool_error', name, text: clip(info.error.message || info.error.type || 'failed', 400) });
|
|
110
|
+
// Refused by its permissions (no --dangerously-skip-permissions): the card offers a retry.
|
|
111
|
+
if (/PERMISSION/i.test(info.error.type || '')) onEvent({ type: 'blocked', tool: name, detail: String(params.CommandLine ?? fileOf(params) ?? params.Url ?? '') });
|
|
112
|
+
}
|
|
109
113
|
else if (editing) {
|
|
110
114
|
const change = files.change(file);
|
|
111
115
|
if (change) onEvent({ type: 'change', ...change });
|
|
@@ -29,6 +29,8 @@ const PERMISSION_MODES = {
|
|
|
29
29
|
export default {
|
|
30
30
|
id: 'claude',
|
|
31
31
|
name: 'Claude Code',
|
|
32
|
+
// Asks wisp before a tool call its permission mode won't allow on its own (Edits mode).
|
|
33
|
+
approveTool: 'mcp__wisp__approve',
|
|
32
34
|
models: [
|
|
33
35
|
{ id: '', label: 'Default', hint: 'Whatever Claude Code is set to' },
|
|
34
36
|
{ id: 'opus', label: 'Opus', hint: 'Most capable, for hard problems' },
|
|
@@ -46,8 +48,14 @@ export default {
|
|
|
46
48
|
return detectVersion(BIN);
|
|
47
49
|
},
|
|
48
50
|
|
|
51
|
+
// What a permission prompt is about, for the approval card and for matching "always allow".
|
|
52
|
+
permission(tool, input, cwd) {
|
|
53
|
+
return { tool, action: actionOf(tool, input, cwd), detail: detailOf(tool, input, cwd), why: tool === 'Bash' ? input.description || '' : '' };
|
|
54
|
+
},
|
|
55
|
+
|
|
49
56
|
// Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
|
|
50
|
-
|
|
57
|
+
// grants: --allowedTools rules (Bash(npm publish)) for what the user allowed after a denial.
|
|
58
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
51
59
|
const args = [
|
|
52
60
|
'-p',
|
|
53
61
|
'--output-format', 'stream-json',
|
|
@@ -58,10 +66,11 @@ export default {
|
|
|
58
66
|
if (effort) args.push('--effort', effort);
|
|
59
67
|
if (chromeEnabled()) args.push('--chrome');
|
|
60
68
|
if (resumeId) args.push('--resume', resumeId);
|
|
61
|
-
if (Object.keys(mcpServers).length) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
69
|
+
if (Object.keys(mcpServers).length) args.push('--mcp-config', JSON.stringify({ mcpServers }));
|
|
70
|
+
// In Edits mode, what would prompt goes to wisp's approve tool (the user decides) instead of being denied.
|
|
71
|
+
if (mcpServers.wisp && mode === 'edits') args.push('--permission-prompt-tool', this.approveTool);
|
|
72
|
+
const allowed = [...Object.keys(mcpServers).map((n) => `mcp__${n}`), ...grants.filter((g) => typeof g === 'string')];
|
|
73
|
+
if (allowed.length) args.push('--allowedTools', ...allowed);
|
|
65
74
|
// Its built-in subagents (Task, or Agent in newer versions) would bypass the user's team.
|
|
66
75
|
if (delegating) args.push('--disallowedTools', 'Task', 'Agent', 'Workflow');
|
|
67
76
|
const dirs = [...new Set(attachments.map((a) => path.dirname(a.path)))];
|
|
@@ -132,6 +141,11 @@ export default {
|
|
|
132
141
|
return;
|
|
133
142
|
}
|
|
134
143
|
sawResult = true;
|
|
144
|
+
// Refused tool calls (no approve tool here, or the user said no): the card offers a retry.
|
|
145
|
+
for (const d of msg.permission_denials || []) {
|
|
146
|
+
const input = d.tool_input || {};
|
|
147
|
+
onEvent({ type: 'blocked', tool: d.tool_name, detail: detailOf(d.tool_name, input, cwd), rule: ruleOf(d.tool_name, input) });
|
|
148
|
+
}
|
|
135
149
|
onEvent({ ...ev, background: background.size }); // > 0: another turn and result follow when they finish
|
|
136
150
|
}
|
|
137
151
|
};
|
|
@@ -207,11 +221,40 @@ function describeTool(name, input, cwd) {
|
|
|
207
221
|
return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
208
222
|
case 'mcp__wisp__subagent_results':
|
|
209
223
|
return { kind: 'agent', label: 'Waiting on subagents' };
|
|
224
|
+
case 'mcp__wisp__wisp_stats':
|
|
225
|
+
return { kind: 'tool', label: 'Reading wisp stats' };
|
|
226
|
+
case 'mcp__wisp__wisp_sessions':
|
|
227
|
+
return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
228
|
+
case 'mcp__wisp__wisp_session':
|
|
229
|
+
return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
|
|
210
230
|
default:
|
|
211
231
|
return { kind: 'tool', label: name.startsWith('mcp__') ? `Using ${name.split('__').slice(1).join(' · ')}` : `Using ${name}` };
|
|
212
232
|
}
|
|
213
233
|
}
|
|
214
234
|
|
|
235
|
+
// A permission prompt's subject: "run `npm publish`", "edit src/a.ts", "fetch https://…".
|
|
236
|
+
function actionOf(tool, input, cwd) {
|
|
237
|
+
const f = rel(input.file_path || input.notebook_path || input.path, cwd);
|
|
238
|
+
if (tool === 'Bash') return `run \`${clip(input.command, 200)}\``;
|
|
239
|
+
if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) && f) return `${tool === 'Write' ? 'write' : 'edit'} ${f}`;
|
|
240
|
+
if (tool === 'WebFetch') return `fetch ${clip(input.url, 120)}`;
|
|
241
|
+
if (tool === 'WebSearch') return `search the web for “${clip(input.query, 80)}”`;
|
|
242
|
+
if (tool === 'ExitPlanMode') return 'leave plan mode and start making changes';
|
|
243
|
+
if (tool.startsWith('mcp__')) return `use ${tool.split('__').slice(1).join(' · ')}`;
|
|
244
|
+
return `use ${tool}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The exact thing being allowed, so "always allow" matches only the same command or file.
|
|
248
|
+
function detailOf(tool, input, cwd) {
|
|
249
|
+
if (tool === 'Bash') return String(input.command ?? '');
|
|
250
|
+
const f = input.file_path || input.notebook_path || input.path;
|
|
251
|
+
if (f) return rel(f, cwd);
|
|
252
|
+
return String(input.url ?? input.query ?? input.pattern ?? '');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// An --allowedTools rule for a retry: the exact command for Bash, the tool for the rest.
|
|
256
|
+
const ruleOf = (tool, input) => (tool === 'Bash' && input.command ? `Bash(${input.command})` : tool);
|
|
257
|
+
|
|
215
258
|
// Capture edit inputs so wisp can show real diffs without asking the agent for them.
|
|
216
259
|
function captureChange(name, input, cwd) {
|
|
217
260
|
const file = input.file_path;
|
package/server/adapters/codex.js
CHANGED
|
@@ -187,5 +187,8 @@ export function unwrapShell(cmd) {
|
|
|
187
187
|
function describeMcp(item) {
|
|
188
188
|
if (item.server === 'wisp' && item.tool === 'ask_user') return { kind: 'wait', label: 'Waiting for your answer' };
|
|
189
189
|
if (item.server === 'wisp' && (item.tool === 'delegate' || item.tool === 'subagent_results')) return { kind: 'agent', label: 'Waiting on subagents' };
|
|
190
|
+
if (item.server === 'wisp' && item.tool === 'wisp_stats') return { kind: 'tool', label: 'Reading wisp stats' };
|
|
191
|
+
if (item.server === 'wisp' && item.tool === 'wisp_sessions') return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
192
|
+
if (item.server === 'wisp' && item.tool === 'wisp_session') return { kind: 'tool', label: 'Reading a wisp task' };
|
|
190
193
|
return { kind: 'tool', label: `Using ${item.server} · ${item.tool}` };
|
|
191
194
|
}
|
|
@@ -71,6 +71,8 @@ export default {
|
|
|
71
71
|
const [outcome, value = {}] = resultOf(body.result);
|
|
72
72
|
if (outcome && outcome !== 'success') {
|
|
73
73
|
onEvent({ type: 'tool_error', name: name.replace(/ToolCall$/, ''), text: clip(value.error || value.message || value.reason || value.stderr || outcome, 400) });
|
|
74
|
+
// Refused by its permissions (no --force): the card offers to retry it.
|
|
75
|
+
if (/reject|denied|permission/i.test(outcome)) onEvent({ type: 'blocked', tool: name.replace(/ToolCall$/, ''), detail: String(args.command ?? args.path ?? args.url ?? '') });
|
|
74
76
|
} else if (name === 'editToolCall' || name === 'deleteToolCall') {
|
|
75
77
|
const change = editChange(args.path || value.path, value, cwd, files);
|
|
76
78
|
if (change) onEvent({ type: 'change', ...change });
|
package/server/adapters/grok.js
CHANGED
|
@@ -19,6 +19,9 @@ const PERMISSION_MODES = {
|
|
|
19
19
|
auto: 'bypassPermissions',
|
|
20
20
|
};
|
|
21
21
|
const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
22
|
+
// A headless run refuses what would need approval and names it on stderr, once per call:
|
|
23
|
+
// "permission required: Edit calc.py was not run, as a headless run cannot ask for approval".
|
|
24
|
+
const REFUSED = /permission required: (\S+) ?(.*?) was not run\b/g;
|
|
22
25
|
|
|
23
26
|
export default {
|
|
24
27
|
id: 'grok',
|
|
@@ -75,6 +78,7 @@ export default {
|
|
|
75
78
|
let session = null;
|
|
76
79
|
let sawResult = false;
|
|
77
80
|
let error = null;
|
|
81
|
+
let carry = ''; // a stderr line still being written
|
|
78
82
|
|
|
79
83
|
const handle = spawnJsonl({
|
|
80
84
|
bin: BIN,
|
|
@@ -82,6 +86,11 @@ export default {
|
|
|
82
86
|
cwd,
|
|
83
87
|
env: { ...process.env, GROK_DISABLE_AUTOUPDATER: '1' },
|
|
84
88
|
onEvent,
|
|
89
|
+
onStderr(chunk) {
|
|
90
|
+
const lines = (carry + chunk).split('\n');
|
|
91
|
+
carry = lines.pop();
|
|
92
|
+
for (const [, tool, detail] of lines.join('\n').matchAll(REFUSED)) onEvent({ type: 'blocked', tool, detail: detail.replace(/ \(workflow .*\)$/, '') });
|
|
93
|
+
},
|
|
85
94
|
onJson(msg) {
|
|
86
95
|
if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
|
|
87
96
|
// Subagent traffic carries its parent's tool id; only the main conversation is shown.
|
|
@@ -232,6 +241,12 @@ function describeMcp(tool, input) {
|
|
|
232
241
|
return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
233
242
|
case 'wisp__subagent_results':
|
|
234
243
|
return { kind: 'agent', label: 'Waiting on subagents' };
|
|
244
|
+
case 'wisp__wisp_stats':
|
|
245
|
+
return { kind: 'tool', label: 'Reading wisp stats' };
|
|
246
|
+
case 'wisp__wisp_sessions':
|
|
247
|
+
return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
248
|
+
case 'wisp__wisp_session':
|
|
249
|
+
return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
|
|
235
250
|
default:
|
|
236
251
|
return { kind: 'tool', label: `Using ${tool.split('__').join(' · ') || 'an integration'}` };
|
|
237
252
|
}
|
package/server/adapters/index.js
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
// efforts list wins); omit if the CLI has none
|
|
8
8
|
// askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
|
|
9
9
|
// null = can't inject MCP, ask via a question block
|
|
10
|
+
// approveTool?: string its permission prompts go to wisp's `approve` MCP
|
|
11
|
+
// tool (live approval); omit if it can't route them
|
|
12
|
+
// permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
|
|
10
13
|
// detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
|
|
11
14
|
// resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
|
|
12
|
-
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
|
|
15
|
+
// run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, grants, onEvent }): { stop(), done: Promise }
|
|
16
|
+
// grants: `rule`s from its own blocked events the
|
|
17
|
+
// user allowed, for this turn only
|
|
13
18
|
// }
|
|
14
19
|
//
|
|
15
20
|
// onEvent receives normalized events:
|
|
@@ -19,6 +24,8 @@
|
|
|
19
24
|
// { type: 'text', text } intermediate assistant prose
|
|
20
25
|
// { type: 'thinking' }
|
|
21
26
|
// { type: 'change', file, op, edits: [{ before, after }] }
|
|
27
|
+
// { type: 'blocked', tool, detail, rule? } a call its permissions refused; rule: what allows
|
|
28
|
+
// exactly that on a retry (none: retry in Full auto)
|
|
22
29
|
// { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
|
|
23
30
|
//
|
|
24
31
|
// mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
|
|
@@ -38,6 +45,7 @@ function checked(a) {
|
|
|
38
45
|
typeof a.name !== 'string' && 'name',
|
|
39
46
|
!Array.isArray(a.models) && 'models',
|
|
40
47
|
a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
|
|
48
|
+
a.approveTool !== undefined && (typeof a.approveTool !== 'string' || typeof a.permission !== 'function') && 'approveTool + permission()',
|
|
41
49
|
typeof a.detect !== 'function' && 'detect()',
|
|
42
50
|
typeof a.run !== 'function' && 'run()',
|
|
43
51
|
a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
|
|
@@ -17,6 +17,10 @@ let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
|
|
|
17
17
|
// through, then stop and tell the user what to do.
|
|
18
18
|
const rateLimitGrace = () => Number(process.env.WISP_RATE_LIMIT_GRACE_MS) || 60_000;
|
|
19
19
|
const RATE_LIMITED = /Rate limit exceeded|Too Many Requests|status(?:Code)?[=: ]+429\b/i;
|
|
20
|
+
// `run` answers every permission prompt itself and says so on stderr (cli/cmd/run.ts):
|
|
21
|
+
// "! permission requested: bash (npm publish); auto-rejecting".
|
|
22
|
+
const REJECTED = /permission requested: ([\w.-]+) \((.*?)\); auto-rejecting/g;
|
|
23
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
20
24
|
|
|
21
25
|
export default {
|
|
22
26
|
id: 'opencode',
|
|
@@ -61,7 +65,8 @@ export default {
|
|
|
61
65
|
return { ...found, models: [this.models[0], ...models] };
|
|
62
66
|
},
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
// grants: { permission, patterns } the user allowed after `run` rejected them, for this turn.
|
|
69
|
+
run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
|
|
65
70
|
// The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
|
|
66
71
|
// Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
|
|
67
72
|
const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
|
|
@@ -78,14 +83,20 @@ export default {
|
|
|
78
83
|
mcp[name] = { type: 'local', command: [srv.command, ...(srv.args || [])], environment: srv.env || {}, enabled: true, timeout: ASK_TIMEOUT_MS };
|
|
79
84
|
}
|
|
80
85
|
const env = { ...process.env };
|
|
81
|
-
|
|
86
|
+
const allow = grants.filter((g) => g && typeof g.permission === 'string' && Array.isArray(g.patterns));
|
|
87
|
+
if (Object.keys(mcp).length || allow.length) {
|
|
82
88
|
const inline = env.OPENCODE_CONFIG_CONTENT ? safeJson(env.OPENCODE_CONFIG_CONTENT) : {};
|
|
89
|
+
const permission = typeof inline.permission === 'object' ? { ...inline.permission } : inline.permission ? { '*': inline.permission } : {};
|
|
90
|
+
// Its built-in `task` subagents would bypass the user's team.
|
|
91
|
+
if (delegating) permission.task = 'deny';
|
|
92
|
+
for (const g of allow) {
|
|
93
|
+
const was = permission[g.permission];
|
|
94
|
+
permission[g.permission] = { ...(typeof was === 'object' ? was : was && { '*': was }), ...Object.fromEntries(g.patterns.map((p) => [String(p), 'allow'])) };
|
|
95
|
+
}
|
|
83
96
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
84
97
|
...inline,
|
|
85
|
-
mcp: { ...inline.mcp, ...mcp },
|
|
86
|
-
|
|
87
|
-
// Its built-in `task` subagents would bypass the user's team.
|
|
88
|
-
...(delegating && { permission: { ...(typeof inline.permission === 'object' ? inline.permission : inline.permission && { '*': inline.permission }), task: 'deny' } }),
|
|
98
|
+
...(Object.keys(mcp).length && { mcp: { ...inline.mcp, ...mcp }, experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS } }),
|
|
99
|
+
...(Object.keys(permission).length && { permission }),
|
|
89
100
|
});
|
|
90
101
|
}
|
|
91
102
|
|
|
@@ -101,6 +112,7 @@ export default {
|
|
|
101
112
|
|
|
102
113
|
let limitTimer = null;
|
|
103
114
|
let limited = false;
|
|
115
|
+
let carry = ''; // a stderr line still being written
|
|
104
116
|
const proc = spawnJsonl({
|
|
105
117
|
bin: BIN,
|
|
106
118
|
args,
|
|
@@ -109,6 +121,13 @@ export default {
|
|
|
109
121
|
input,
|
|
110
122
|
onEvent,
|
|
111
123
|
onStderr(chunk) {
|
|
124
|
+
// Whole lines only: a chunk can end mid-line.
|
|
125
|
+
const lines = (carry + chunk).split('\n');
|
|
126
|
+
carry = lines.pop();
|
|
127
|
+
for (const [, permission, list] of lines.join('\n').replace(ANSI, '').matchAll(REJECTED)) {
|
|
128
|
+
const patterns = list.split(', ').filter(Boolean);
|
|
129
|
+
onEvent({ type: 'blocked', tool: permission, detail: patterns.join(', '), rule: { permission, patterns } });
|
|
130
|
+
}
|
|
112
131
|
if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
|
|
113
132
|
limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
|
|
114
133
|
},
|
|
@@ -216,6 +235,12 @@ function describeTool(name, input, cwd) {
|
|
|
216
235
|
return { kind: 'agent', label: `Delegated to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
|
|
217
236
|
case 'wisp_subagent_results':
|
|
218
237
|
return { kind: 'agent', label: 'Collected subagent reports' };
|
|
238
|
+
case 'wisp_wisp_stats':
|
|
239
|
+
return { kind: 'tool', label: 'Reading wisp stats' };
|
|
240
|
+
case 'wisp_wisp_sessions':
|
|
241
|
+
return { kind: 'tool', label: 'Listing wisp tasks' };
|
|
242
|
+
case 'wisp_wisp_session':
|
|
243
|
+
return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
|
|
219
244
|
default:
|
|
220
245
|
return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
|
|
221
246
|
}
|
package/server/client.js
CHANGED
|
@@ -136,7 +136,7 @@ export class WispClient {
|
|
|
136
136
|
if (!agent || !mode || model === undefined) {
|
|
137
137
|
const { defaults = {} } = await this.config();
|
|
138
138
|
agent ||= defaults.agent || 'auto';
|
|
139
|
-
mode ||= defaults.mode || '
|
|
139
|
+
mode ||= defaults.mode || 'auto';
|
|
140
140
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
141
141
|
}
|
|
142
142
|
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
package/server/git.js
CHANGED
|
@@ -129,7 +129,7 @@ export async function removeWorktree(wt, { force = false, branch = false } = {})
|
|
|
129
129
|
|
|
130
130
|
// --- Ship it: commit, push, open a PR, follow it, merge it -----------------------------------
|
|
131
131
|
|
|
132
|
-
const PR_FIELDS = 'number,url,title,state,isDraft,reviewDecision,mergeStateStatus,statusCheckRollup,headRefOid,baseRefName,mergedAt';
|
|
132
|
+
const PR_FIELDS = 'number,url,title,state,isDraft,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,headRefOid,baseRefName,mergedAt';
|
|
133
133
|
const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
134
134
|
const FAIL = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
|
|
135
135
|
|
|
@@ -149,6 +149,8 @@ export function prSummary(j) {
|
|
|
149
149
|
draft: !!j.isDraft,
|
|
150
150
|
review: j.reviewDecision || '', // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ''
|
|
151
151
|
mergeState: j.mergeStateStatus || '',
|
|
152
|
+
// GitHub can't merge it into its base: both sides changed the same lines (UNKNOWN while it computes).
|
|
153
|
+
conflict: j.mergeable === 'CONFLICTING' || j.mergeStateStatus === 'DIRTY',
|
|
152
154
|
head: j.headRefOid,
|
|
153
155
|
base: j.baseRefName,
|
|
154
156
|
mergedAt: j.mergedAt || null,
|
|
@@ -162,23 +164,58 @@ export async function prView(cwd, ref) {
|
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
// Commit whatever the session left uncommitted, push its branch and open (or update) its PR.
|
|
165
|
-
|
|
167
|
+
// `onStep` hears each stage as it starts ('commit', 'push', 'pr'), so the card can follow along.
|
|
168
|
+
export async function ship(wt, { title, body = '', draft = false, pr, onStep = () => {} }) {
|
|
166
169
|
await ensureWorktree(wt);
|
|
167
170
|
const dir = wt.path;
|
|
168
171
|
if (await quiet(git(dir, ['status', '--porcelain']))) {
|
|
169
172
|
if (!String(title || '').trim()) throw bad('A commit message is required.');
|
|
173
|
+
onStep('commit');
|
|
170
174
|
await git(dir, ['add', '-A']);
|
|
171
175
|
await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
|
|
172
176
|
}
|
|
173
177
|
const { ahead } = await worktreeStatus(wt);
|
|
174
178
|
if (!ahead) throw bad('Nothing to ship: this branch has no changes.');
|
|
175
179
|
if (!wt.remote) throw bad('This repo has no origin remote to push to.');
|
|
180
|
+
onStep('push');
|
|
176
181
|
await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
|
|
182
|
+
onStep('pr');
|
|
177
183
|
if (pr?.state === 'OPEN') return prView(dir, pr.number);
|
|
178
184
|
const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
|
|
179
185
|
return prView(dir, url);
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
// Does the branch really conflict with its freshly fetched base? Checked before spending an agent
|
|
189
|
+
// run on GitHub's word, which can lag a push. true / false, or null when this git can't tell (< 2.38).
|
|
190
|
+
export async function conflictsWithBase(wt) {
|
|
191
|
+
if (!wt?.remote || !fs.existsSync(wt.path)) return null;
|
|
192
|
+
await quiet(git(wt.path, ['fetch', '--quiet', wt.remote, wt.base], { timeout: 30_000 }));
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
execFile(GIT(), ['merge-tree', '--write-tree', '--name-only', '--no-messages', `refs/remotes/${wt.remote}/${wt.base}`, 'HEAD'], { cwd: wt.path, timeout: 30_000, env: { ...process.env, ...QUIET_ENV } }, (err) =>
|
|
195
|
+
resolve(!err ? false : err.code === 1 ? true : null),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// After an agent was asked to merge the base into its branch: is it in, with nothing left half
|
|
201
|
+
// done? A merge it resolved but left uncommitted is committed here. { ok } or { ok: false, why }.
|
|
202
|
+
export async function baseMerged(wt) {
|
|
203
|
+
const dir = wt.path;
|
|
204
|
+
const unmerged = await quiet(git(dir, ['diff', '--name-only', '--diff-filter=U']));
|
|
205
|
+
if (unmerged) return { ok: false, why: `Still conflicted: ${unmerged.split('\n').join(', ')}` };
|
|
206
|
+
if (await quiet(git(dir, ['rev-parse', '--quiet', '--verify', 'MERGE_HEAD']))) {
|
|
207
|
+
await git(dir, ['add', '-A']);
|
|
208
|
+
await git(dir, ['commit', '--quiet', '--no-verify', '--no-edit']);
|
|
209
|
+
}
|
|
210
|
+
const base = `refs/remotes/${wt.remote}/${wt.base}`;
|
|
211
|
+
const merged = await git(dir, ['merge-base', '--is-ancestor', base, 'HEAD']).then(() => true, () => false);
|
|
212
|
+
if (!merged) return { ok: false, why: `${wt.remote}/${wt.base} is not merged into the branch.` };
|
|
213
|
+
// Conflict markers staged as if resolved: git no longer sees them, so look in the PR's diff.
|
|
214
|
+
const marked = await quiet(git(dir, ['diff', '--name-only', '-G^<<<<<<< ', `${base}...HEAD`]));
|
|
215
|
+
if (marked) return { ok: false, why: `Conflict markers left in ${marked.split('\n').join(', ')}` };
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
182
219
|
// Squash-merge the exact head that was reviewed, then tidy up and catch the user's checkout up.
|
|
183
220
|
export async function merge(wt, number) {
|
|
184
221
|
const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
|
|
@@ -205,3 +242,78 @@ export async function syncBase(root, base) {
|
|
|
205
242
|
return { ok: false, text: `Local ${base} was not updated: ${err.message}` };
|
|
206
243
|
}
|
|
207
244
|
}
|
|
245
|
+
|
|
246
|
+
// --- cloning: "add a project" from a repo URL instead of a folder ---
|
|
247
|
+
|
|
248
|
+
// A repo the user pasted: https://github.com/o/r(/tree/…), github.com/o/r, git@host:o/r.git,
|
|
249
|
+
// ssh:// git:// file:// URLs, and (shorthand) o/r for GitHub. Null when it's not a repo, e.g. a path.
|
|
250
|
+
export function parseRepo(input, { shorthand = false } = {}) {
|
|
251
|
+
const s = String(input || '').trim();
|
|
252
|
+
const strip = (p) => p.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
253
|
+
const of = (url, host, repoPath) => {
|
|
254
|
+
const parts = strip(repoPath).split('/').filter(Boolean);
|
|
255
|
+
if (!parts.length) return null;
|
|
256
|
+
const gh = host === 'github.com' && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
|
|
257
|
+
return { url, host, name: gh ? parts[1] : parts.at(-1), gh, path: parts.join('/') };
|
|
258
|
+
};
|
|
259
|
+
let m;
|
|
260
|
+
if (/^(https?|ssh|git|file):\/\//i.test(s) || /^(www\.)?github\.com\//i.test(s)) {
|
|
261
|
+
let u;
|
|
262
|
+
try {
|
|
263
|
+
u = new URL(/^\w+:\/\//.test(s) ? s : `https://${s}`);
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const host = u.hostname.replace(/^www\./, '').toLowerCase();
|
|
268
|
+
if (u.protocol === 'file:') return of(u.href, '', u.pathname);
|
|
269
|
+
if (!host) return null;
|
|
270
|
+
// A GitHub page inside the repo (/tree/main, /pull/3) still means the repo.
|
|
271
|
+
if (host === 'github.com') {
|
|
272
|
+
const [o, r] = u.pathname.split('/').filter(Boolean);
|
|
273
|
+
return o && r ? of(`https://github.com/${o}/${strip(r)}.git`, host, `${o}/${r}`) : null;
|
|
274
|
+
}
|
|
275
|
+
return u.pathname.split('/').filter(Boolean).length >= 2 || u.protocol !== 'https:' ? of(s, host, u.pathname) : null;
|
|
276
|
+
}
|
|
277
|
+
if ((m = s.match(/^[\w.-]+@([\w.-]+):(?!\/\/)(.+)$/))) return of(s, m[1].toLowerCase(), m[2]);
|
|
278
|
+
if (shorthand && (m = s.match(/^([\w-]+)\/([\w.-]+)$/))) return of(`https://github.com/${m[1]}/${strip(m[2])}.git`, 'github.com', `${m[1]}/${m[2]}`);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The same repo, however it was spelled (https vs ssh, .git or not, case of the host).
|
|
283
|
+
const sameRepo = (a, b) => {
|
|
284
|
+
const key = (u) => {
|
|
285
|
+
const r = parseRepo(u);
|
|
286
|
+
return r && (r.gh ? `github.com/${r.gh.toLowerCase()}` : `${r.host}/${r.path}`);
|
|
287
|
+
};
|
|
288
|
+
return !!key(a) && key(a) === key(b);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Clone `repo` (from parseRepo) into `parent`/<name>. A folder there that already is this repo is
|
|
292
|
+
// reused; any other one gets a -2, -3… suffix. GitHub repos go through `gh repo clone` when gh is
|
|
293
|
+
// there, so private repos work with the user's gh login; otherwise (or if gh fails) plain git.
|
|
294
|
+
export async function cloneRepo(repo, parent, { timeout = 10 * 60_000 } = {}) {
|
|
295
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
296
|
+
let dir = path.join(parent, repo.name);
|
|
297
|
+
for (let n = 2; fs.existsSync(dir); n++) {
|
|
298
|
+
const origin = await quiet(git(dir, ['remote', 'get-url', 'origin']));
|
|
299
|
+
if (origin && (await repoRoot(dir)) === fs.realpathSync(dir) && sameRepo(origin, repo.url)) return { dir, existed: true };
|
|
300
|
+
dir = path.join(parent, `${repo.name}-${n}`);
|
|
301
|
+
}
|
|
302
|
+
const attempts = [];
|
|
303
|
+
if (repo.gh) attempts.push(() => gh(parent, ['repo', 'clone', repo.gh, dir], { timeout }));
|
|
304
|
+
attempts.push(() => run(GIT(), ['clone', '--quiet', repo.url, dir], { cwd: parent, timeout }));
|
|
305
|
+
let last;
|
|
306
|
+
for (const attempt of attempts) {
|
|
307
|
+
try {
|
|
308
|
+
await attempt();
|
|
309
|
+
return { dir, existed: false };
|
|
310
|
+
} catch (err) {
|
|
311
|
+
last = err;
|
|
312
|
+
fs.rmSync(dir, { recursive: true, force: true }); // a half clone would block the next try
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const what = repo.gh || repo.url;
|
|
316
|
+
if (/not found|does not exist|could not read username|authentication|permission denied|403/i.test(last.message))
|
|
317
|
+
throw bad(`Couldn’t clone ${what}: it doesn’t exist, or this login can’t see it.${repo.gh ? ' Private repo? Check `gh auth status`.' : ''}`);
|
|
318
|
+
throw bad(`Couldn’t clone ${what}: ${last.message}`);
|
|
319
|
+
}
|
package/server/handoff.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// those turns out (requests, reports, files touched) and goes ahead of the follow-up's prompt.
|
|
4
4
|
// A harness that ran earlier in the task resumes its own session and only hears what it missed.
|
|
5
5
|
|
|
6
|
+
import { textMentions } from './mentions.js';
|
|
6
7
|
import { blocksToMarkdown } from './render.js';
|
|
7
8
|
import { clip, truncate } from './util.js';
|
|
8
9
|
|
|
@@ -27,7 +28,7 @@ function turnText(t, n, name) {
|
|
|
27
28
|
return truncate(
|
|
28
29
|
[
|
|
29
30
|
`### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
|
|
30
|
-
`**User:** ${t.prompt}`,
|
|
31
|
+
`**User:** ${textMentions(t.prompt)}`,
|
|
31
32
|
report && `**Report:**\n\n${report}`,
|
|
32
33
|
files.length && `**Files it edited:** ${files.join(', ')}`,
|
|
33
34
|
]
|