heyquilt 0.3.90 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
package/README.md CHANGED
@@ -32,8 +32,9 @@ within milliseconds. Your agents can also see what the other agents are doing.
32
32
  WebSockets, and only lets in people signed in to heyquilt.com. If your
33
33
  connection drops, keep working: Quilt keeps a local copy of the shared state
34
34
  and merges your offline edits line by line when you reconnect, like git.
35
- Edits to the same lines land in the **Merges** bar for the people involved
36
- to settle.
35
+ Nobody is ever asked to merge: where two people changed the same lines,
36
+ Quilt keeps the newer edit, keeps the other in the file's history, and tells
37
+ whoever wrote it. The same goes for commits pulled or pushed to GitHub.
37
38
  - **Watch each other's AI, live.** The app shows your partner's AI conversation
38
39
  as it happens: their prompts, the AI's replies, and one-line actions like
39
40
  "Edited src/app.ts" or "Ran npm test". Quilt reads Claude Code's and Cursor's
@@ -70,11 +71,11 @@ within milliseconds. Your agents can also see what the other agents are doing.
70
71
 
71
72
  ### Download the app
72
73
 
73
- Get Quilt for [Mac (Apple silicon)](https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/quilt-mac-arm64.dmg),
74
- [Mac (Intel)](https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/quilt-mac-x64.dmg),
75
- [Windows](https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/quilt-windows-x64.exe) or
76
- Linux ([x86_64](https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/quilt-linux-x86_64.AppImage),
77
- [ARM](https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/quilt-linux-arm64.AppImage): an
74
+ Get Quilt for [Mac (Apple silicon)](https://heyquilt.com/download/quilt-mac-arm64.dmg),
75
+ [Mac (Intel)](https://heyquilt.com/download/quilt-mac-x64.dmg),
76
+ [Windows](https://heyquilt.com/download/quilt-windows-x64.exe) or
77
+ Linux ([x86_64](https://heyquilt.com/download/quilt-linux-x86_64.AppImage),
78
+ [ARM](https://heyquilt.com/download/quilt-linux-arm64.AppImage): an
78
79
  AppImage, `chmod +x` it), and open it.
79
80
  Everything happens in the app: start a session, send the invite link, and
80
81
  partners click it to join. Closing the window keeps your sessions syncing from
@@ -97,7 +98,7 @@ A Linux machine with no desktop (a server, a cloud VM, a container, an agent's s
97
98
  gets the command line with its own Node.js, so nothing else needs installing:
98
99
 
99
100
  ```bash
100
- curl -fsSL https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/install.sh | sh
101
+ curl -fsSL https://heyquilt.com/install.sh | sh
101
102
  quilt login
102
103
  ```
103
104
 
@@ -514,9 +515,11 @@ person with `@name`. Messages are kept in the room, so anyone offline sees
514
515
  them when they reconnect, and `quilt status` shows your unread count.
515
516
 
516
517
  Files you **send** (screenshots, logs, exports, a PDF spec) go through the relay
517
- as attachments. They are *not* added to the shared project folder. Recipients
518
- get them automatically in `.quilt/inbox/`, including files sent while they
519
- were offline. The limit is 100 MB per file.
518
+ as attachments. They are *not* added to the shared project. Everyone who can
519
+ see the message gets the file in the `Chat files` folder of their session
520
+ folder, including files sent while they were offline or before they joined.
521
+ Quilt never syncs that folder and git ignores it, so it never reaches GitHub:
522
+ only the people in the session have it. The limit is 100 MB per file.
520
523
 
521
524
  Direct messages and files are only shown to the sender and recipient, but they
522
525
  travel through the shared room, so they're private from other collaborators'
package/RELEASES.md CHANGED
@@ -11,6 +11,135 @@ Format: `## <version> — <YYYY-MM-DD>`, an optional one-line summary, then bull
11
11
  Lead each bullet with a short bold phrase. Inline `code` and **bold** are rendered;
12
12
  nothing else is.
13
13
 
14
+ ## 0.3.106 — 2026-10-19
15
+
16
+ 0.3.105 didn't publish (GitHub didn't run its builds), so it comes with this one.
17
+
18
+ - **Agents' folders are git checkouts again.** The relay's newer git no longer came with the part that serves git to agents (`git http-backend`), so agents' and cloud AIs' folders synced without git. It's back.
19
+ - **A relay that can't serve git says so.** If its git ever lacks that part again, the relay logs it at start and agents' folders use GitHub's git instead of failing; a failed git request through the relay is logged with what git said.
20
+ - **An agent's main is the session's main.** When several branches in a session started from main, the relay could give agents' folders an old main from one of them. Now they get the session's own main.
21
+ - **Your cloud AI joins the sessions you already have open.** A cloud AI connected while sessions were open now joins them too, instead of only the sessions you start afterwards, so it can hear and answer there.
22
+
23
+ ## 0.3.105 — 2026-10-19
24
+
25
+ Your cloud AI's sign-in shows its link in seconds.
26
+
27
+ - **Every AI tool is ready before you connect.** The spare cloud machine now reads Claude Code, Codex and Cursor into memory before anyone connects, then sleeps. Connecting wakes it as yours, so the sign-in link shows in a few seconds instead of sitting at "Starting Claude's own sign-in…" while the machine read the tool off its disk.
28
+ - **Switching tools signs the new one in at once.** Changing Claude Code to Codex or Cursor no longer shows the old tool's link, and the new tool's sign-in starts straight away instead of after about 25 seconds.
29
+ - **Even right after someone else connects.** A spare that is still getting its tools ready when you connect stops and starts your sign-in at once.
30
+ - **A code Claude doesn't take shows a new link.** Pasting a wrong or expired code used to leave "Checking the code" up forever. Now Claude's sign-in starts over by itself and shows its new link, with a note that the code wasn't taken.
31
+ - **`npx heyquilt` is always current.** The npm package is published with every release, so agents in a sandbox running `npx heyquilt sync` get the same version as the app.
32
+
33
+ ## 0.3.104 — 2026-10-19
34
+
35
+ 0.3.102 and 0.3.103 didn't publish (a test of the release itself failed), so they come with this one.
36
+
37
+ - **Downloads and updates come from heyquilt.com (0.3.102).** Installers, the Linux one-liner (`curl -fsSL https://heyquilt.com/install.sh | sh`) and the app's update check no longer depend on the GitHub repository, which is private.
38
+ - **git never waits on Quilt (0.3.103).** `git fetch`, `git push` and `git ls-remote` in a folder Quilt signs in to GitHub no longer hang when Quilt is slow to answer.
39
+
40
+ ## 0.3.103 — 2026-10-19
41
+
42
+ - **git never waits on Quilt.** In a folder where Quilt signs git in to GitHub, `git fetch`, `git push` and `git ls-remote` could hang for minutes when Quilt was slow to answer. Quilt now answers within a few seconds or steps aside, and your own sign-in (a keychain, `gh`) is used instead.
43
+
44
+ ## 0.3.102 — 2026-10-19
45
+
46
+ Downloads and updates come from heyquilt.com.
47
+
48
+ - **Downloads work with Quilt's code kept private.** The installers, the Linux one-liner (`curl -fsSL https://heyquilt.com/install.sh | sh`) and the app's update check now go through heyquilt.com instead of the GitHub repository, which is private. A pinned version still works: `heyquilt.com/download/v0.3.102/<file>`.
49
+ - **The update check asks Quilt, not GitHub.** Your app learns about new versions from Quilt's own servers, and "Update Quilt" downloads from heyquilt.com.
50
+
51
+ ## 0.3.101 — 2026-10-18
52
+
53
+ Three updates in one: 0.3.98, 0.3.99 and 0.3.100 were built but couldn't be published, so they come with this one.
54
+
55
+ - **Nobody is asked to merge any more (0.3.99).** When your offline edits, a `git pull`, a push to GitHub or an AI's sandbox meet the session's work, Quilt combines them itself. Where both changed the same lines the newer edit stays, and whoever's lines were replaced gets a message listing them; they're kept in the file's history. The Merges bar, Keep mine / Keep theirs and merge tasks are gone.
56
+ - **Your cloud AI is ready in seconds (0.3.100).** A spare machine waits for the next person, so connecting takes about 4 seconds, and Claude's sign-in link shows again.
57
+ - **An AI's edits stay in their box (0.3.98)**, even with several of its conversations going at once.
58
+ - **Releases publish reliably.** Builds now go straight onto the release instead of through GitHub's build storage, which had filled up and stopped the last three.
59
+
60
+ ## 0.3.100 — 2026-10-18
61
+
62
+ Your cloud AI is ready in seconds.
63
+
64
+ - **A cloud machine waits ready for you.** Quilt keeps a spare machine made and stopped ahead of time (a stopped machine costs only its disk). Connecting a cloud AI gives you that one, running in about 4 seconds instead of the 15 to 20 a new machine took, and another spare is made for the next person.
65
+ - **Claude's sign-in shows its link again.** Claude Code now prints its sign-in link on claude.com, which the cloud machine didn't recognise, so the sign-in sat at "Starting Claude's own sign-in…". It shows the link and takes your code again.
66
+
67
+ ## 0.3.99 — 2026-10-18
68
+
69
+ Nobody is asked to merge any more.
70
+
71
+ - **Quilt merges on its own, every time.** When two versions of a file meet (you worked offline, `git pull` brought commits in, someone pushed to GitHub, or an AI's sandbox synced), Quilt combines them itself. Changes to different lines both go in, including changes on neighbouring lines. Where both sides changed the same lines, the newer edit keeps them; a pulled commit counts as new as when it was made, not when git wrote it. An edit always beats a deletion. Nobody is asked to pick a side, and nobody's AI is handed a merge to do.
72
+ - **Nothing is lost, and whoever lost lines is told.** The merge is in the file's history like any edit. The version that lost is kept, and its writer gets one message listing the lines that were replaced, which they can put back with an ordinary edit if they want them.
73
+ - **A file someone holds still waits for them.** Your change to a file another person or AI is working on goes in, merged, the moment they let go of it.
74
+ - **GitHub too.** Commits pushed to GitHub outside Quilt come into the session at once, combined with the session's work the same way (a commit counts as new as when it was made); there are no clash tasks, no merger and reviewer, and no file held for a merge or for failing checks. A local commit the relay pushes for you is combined with GitHub's newer commits the same way, and conflict markers a git merge left in a file are settled (both sides' lines kept) before it goes to GitHub. `quilt_clash` stays for older guides and says there is nothing to do.
75
+ - **Gone: the Merges bar and the "to merge" pill**, Keep mine / Keep theirs / Edit by hand / Send to, conflict markers in shared files, merge tasks and their merger and reviewer, and handing a merge to your AI. `quilt_merges` now says what Quilt merged; `quilt_resolve_merge` answers that there is nothing to settle.
76
+
77
+ ## 0.3.98 — 2026-10-18
78
+
79
+ - **A new heyquilt.com.** The website is dark by default, like the app, with a light mode a click away. The homepage shows Quilt working instead of describing it: a live Loom where three people's AIs work at once, wait for a file and hand it over with notes; side by side, what happens to two AIs on one file with and without Quilt; then the steps, what Quilt does, how agents join, what stays yours, and answers to the usual questions. It's drawn in the page rather than from screenshots, so it's sharp at any size and quick to load, and it moves only while you're looking, and not at all with reduced motion.
80
+ - **Fixed: an AI's edits could escape their box.** With several Claude Code or Cursor conversations going at once, one finishing its turn made Quilt think your AI had stopped, so another conversation's edits counted as yours and weren't boxed: half a change could reach everyone's folder before it was committed. Quilt now counts your AI as working while any of its conversations is mid-turn.
81
+
82
+ ## 0.3.97 — 2026-10-18
83
+
84
+ Files sent in chat get a folder of their own.
85
+
86
+ - **A Chat files folder only the session has.** Files sent in a session's chat now land in a `Chat files` folder in your session folder, instead of hidden in `.quilt/inbox/`. Quilt never syncs it and git ignores it, so it never reaches GitHub or the workspace: everyone who can see the message gets the file, and a file sent to one person goes only to them. Files you send are kept there too, and files sent before you joined or while you were away come in when you open the session. A file you delete from the folder stays deleted; open it in chat to get it back.
87
+ - **See them in the file tree.** The app's file tree shows the Chat files folder, marked session only, with who sent each file. Open one to read it, see a picture, or download it, and AIs see them in `quilt_list_files`.
88
+ - **git no longer hangs on Quilt's sign-in.** In the desktop app, the helper Quilt gives git to sign in to GitHub, and its pre-push hook, could open as a second copy of the app instead of answering, so `git fetch`, `git pull` and `git push` waited for ever. They answer at once now.
89
+ - **A file you had open is there after a reload.** A file tab put back when the app reloads now shows the file, instead of "Loading…" until you clicked it again.
90
+ - **A stopped session lets go of its folder.** If git's `index.lock` came and went just as a session stopped, Quilt could keep watching the folder, and `quilt` stayed running after you left. It now stops watching for good.
91
+
92
+ ## 0.3.96 — 2026-10-18
93
+
94
+ - **Your commits can't delete what someone just pushed.** When Quilt put your local commits on GitHub and someone pushed again a moment later, your folder could jump to their newer commit before its files arrived, so a `git commit -a` made right then recorded those files as deleted. Your folder now follows the commit Quilt pushed, and their newer work comes in the usual way, files first.
95
+
96
+ ## 0.3.95 — 2026-10-18
97
+
98
+ The relay scales on real load, both ways.
99
+
100
+ - **Sessions go where there's room.** Each relay machine reports its memory, event-loop lag, CPU and traffic, and new sessions go to the least loaded machine under its target. The number of sessions is now only a hard ceiling.
101
+ - **Busy machines get relief.** A relay machine over its load target for a while moves its lightest sessions to a quieter machine, or to a new one. It never moves a session during a git push or commit, and never moves the same session twice within 30 minutes.
102
+ - **Idle machines go away.** Relay machines left empty for 30 minutes, including a heavy session's own machine after that session ends, are stopped and removed. Two lightly loaded machines merge into one, and at least one machine always stays.
103
+
104
+ ## 0.3.94 — 2026-10-18
105
+
106
+ Secrets your team can use but nobody can read.
107
+
108
+ - **Team, workspace and session secrets.** Add API keys in session settings, on the workspace page, or for the whole team on heyquilt.com (your org's Secrets page). Once saved a value is never shown again, to anyone: replace or delete it. Each secret goes to one service only (for example `api.stripe.com`), and each shows when it was last used.
109
+ - **`quilt run -- <command>`.** Runs your project with its secrets without ever handing them over: the command gets `STRIPE_KEY=quilt` and `STRIPE_BASE_URL` pointing at Quilt's vault, which adds the real key on the way to Stripe. Point an SDK at the base URL (OpenAI and Anthropic's are set for you).
110
+ - **People-only secrets ask you first.** Mark a secret People only and every run that needs it asks you to allow it in the app; agents never get it.
111
+ - **Turn inherited secrets off.** A session owner can switch workspace and team secrets off for their session, and a workspace can switch team secrets off, for a leak or when they aren't needed. Choose which agents may use secrets at all.
112
+ - **Import a `.env` file** straight into the vault, then delete it.
113
+ - **For agents:** a new `quilt_secret_names` tool lists names and where they go, never values. `quilt git-token` no longer prints a token.
114
+
115
+ ## 0.3.93 — 2026-10-18
116
+
117
+ Sandbox keys you can see and manage.
118
+
119
+ - **An agent's keys in one table.** In the app (Settings → Agents, open an agent) and on heyquilt.com, each agent lists its sandbox keys: which session, when it was last used, its use over the last seven days, and whether it is in use, idle or revoked.
120
+ - **New key, Rotate, Revoke.** Make a key for a session in place; it is shown once, with Copy. Rotate swaps it for a new one with the same name and session; Revoke stops it at once; Remove clears a revoked one.
121
+
122
+ ## 0.3.92 — 2026-10-18
123
+
124
+ Everything from 0.3.88 to 0.3.91, which did not publish: git for everyone, boxes, your AI on a cloud machine, and the relay on many machines.
125
+
126
+ - **A new session follows its workspace's git rule at once.** A session made in a workspace, or moved into or out of one, picks up the workspace's, team's or org's git rule right away instead of up to ten minutes later. Deleting a workspace lifts its rule from its sessions at once too.
127
+ - **Your AI on a cloud machine.** Settings → Your AI: connect Claude Code, Codex or Cursor and it runs on a cloud machine of your own, so it keeps taking tasks, answering @mentions and messages while your computer is closed. Sign the tool in right in the card with its own login: Quilt never sees it. See its status, its sessions and today's use against its daily cap; change its model, instructions and cap; wake it or disconnect it. Included in every plan for now.
128
+ - **See who has unfinished work.** In the people panel, each person's boxed files are listed under them with what changed, opened in place. Ask for someone's boxed file there, or unpack your own to put it in everyone's folder. Boxed files have an amber mark in the file tree.
129
+ - **One control for git, everywhere.** Off for everyone, on with agents pushing their own branches, or on with agents pushing any branch: the same three-way control in session, workspace, org and default settings, and for teams and orgs on heyquilt.com. Choices a higher rule doesn't allow are greyed, with who set the rule.
130
+ - **Your AI's unfinished work stays in a box.** When an AI edits a file in a git folder, the change is boxed: everyone sees it, but it is only in its owner's folder, so nobody else's AI can commit it. It reaches everyone when its owner commits it, puts the file back, unpacks it (`quilt_unpack`) or hands the file off. `quilt_status` lists boxed work, and a box whose owner has been away a day gets one reminder a day.
131
+ - **Hosted agents' writes box too**, until they commit with `quilt_git_commit`.
132
+ - **The relay runs on many machines.** Each session lives on one machine, and new sessions go to the least busy. A heavy session (big or busy) gets a machine of its own and moves there; people reconnect by themselves in a few seconds.
133
+ - **Fixed: hosted agents' and branch leases' files could be removed** by the relay's sweep of idle sessions.
134
+ - **Everyone pushes once one person connects GitHub.** When the session owner (or a workspace, team or org rule) has git on and someone has connected GitHub, everyone in the session, people and agents alike, can `git push` with nothing to set up. Each person gets a one-hour token for that repository only, taken back when they leave or git is turned off. Your own git settings and keychain are never touched.
135
+ - **Off means off for everyone.** Turning git off now stops people's pushes too, not just agents'. Commits from the app's commit panel still go.
136
+ - **Team git rules.** A team can set git for the sessions its members own. Org, team and workspace rules are ceilings: a session owner can tighten them, never loosen them, and is told who set the rule.
137
+ - **No more `gh auth login`.** Quilt no longer tells you to sign in to GitHub in another tool; it says whose connection git goes through.
138
+
139
+ ## 0.3.91 — 2026-10-18
140
+
141
+ - **A new session follows its workspace's git rule at once.** A session made in a workspace, or moved into or out of one, picks up the workspace's, team's or org's git rule right away instead of up to ten minutes later. Deleting a workspace lifts its rule from its sessions at once too.
142
+
14
143
  ## 0.3.90 — 2026-10-18
15
144
 
16
145
  Your AI on a cloud machine, boxes you can see, and one control for git.
@@ -48,9 +177,9 @@ A new look for Quilt: the Patch Q mark, and the app and heyquilt.com in ink, ver
48
177
  - **A cleaner light mode.** Light mode drops the cream for neutral greys with a hint of ink. The top bar, Files, chat and the sidebar are white, and the work (the Loom, the board, a file, Home, Settings) sits on a cool grey, so its white cards and the Loom's lane heads stand out with firm lines and a soft shadow. The task board sits on white, with pale columns and borderless cards; column titles are quiet, with a coloured dot for the status; the open tab is underlined in vermilion; and type is a step lighter. Every card, Loom cards included, lifts the same way. Edits, locks and In progress are now blue, so vermilion only marks the open file, what needs you and what's live. Errors are crimson, so they no longer look like links. Small grey text, warnings and edits are easier to read, and every text colour now passes WCAG AA. Workspace covers lose their gloss, and the workspace swatches in the sidebar have an edge so you can see them. One person colour moved from rust to indigo, so nobody's lane looks like an alert. In dark mode, counts on vermilion badges are readable now.
49
178
  - **Code in the file view is no longer ruled into boxes.** Every line of a file or a merge was drawn as a little card, with a border and a shadow, because it shared a class name with the Loom's cards.
50
179
  - **Every wait shows the mark piecing itself together.** Loading files, people, invites, repositories, changes, agents and the rules check, signing in, and updating Quilt now show the mark being pieced together, patch by patch, instead of plain "Loading…" text. With reduced motion turned on, it fades gently instead.
51
- - **Tell your conversations apart in the Loom.** When someone has several AI conversations going at once, each gets its own piece of the Q, shown on its cards and in a single row in their lane's header. One working right now has its piece run a lap of the Q and come home. Names come from the AI tool itself (the thread names in Claude Code, the chat names in Cursor), and you can rename one with the pencil (on this computer). Click a conversation to follow just it: its name opens out, the Loom shows its latest card and everything else steps back. Click again or press Esc to see everything.
180
+ - **Tell your conversations apart in the Loom.** When someone has several AI conversations going at once, each gets its own piece of the Q, shown on its cards and in a single row in their lane's header. One working right now has its piece run a lap of the Q and come home. Names come from the AI tool itself (the thread names in Claude Code, the chat names in Cursor), and you can rename one with the pencil (on this computer). Click a conversation to follow just it: it moves to the front with its name readable, the others shrink to small marks beside it (or a "+N" in a narrow lane), the Loom shows its latest card and everything else steps back. Click again or press Esc to see everything.
52
181
  - **A working AI pieces its lane's quilt.** In the Loom, the needle under a working AI's lane is now a patch being pieced on the lane's thread and sewn down into a Q, slot by slot in that person's colour, the vermilion corner last. Each piece hangs from the end of the thread before it drops in, so the thread no longer runs through the pieces, the quilt or its label. With reduced motion turned on, it shows the finished Q.
53
- - **Back to the latest in one click.** Scroll up the Loom to read back and a Latest button appears at the bottom; click it to glide back down to what's happening now. If anything new comes in while you're up there, it says New activity instead.
182
+ - **Back to the latest in one click.** Scroll up the Loom to read back and a Latest button appears at the bottom; click it to glide back down to what's happening now. If anything new comes in while you're up there, it says New activity instead. The New activity button in an AI's chat also takes a click anywhere on it now: pressing it slid it sideways, so a click on its arrow missed.
54
183
  - **Something live glows like the mark's corner.** The dot that pulses while an AI is working is now the mark's vermilion half-square, and the update bar's dot, the release notes' versions and their bullets use it too.
55
184
  - **Crisp at the smallest sizes.** The favicon, menu-bar icon and smallest app icons are drawn pixel by pixel at 16, 24 and 32 px, so they stay sharp instead of blurring. The full design spec is in `brand/SPEC.md`.
56
185
  - **heyquilt.com, re-branded to match.** The website moves to ink, vermilion and linen too, in light and dark. The patchwork behind its screenshots is now an ink quilt on the mark's own grid, finished with the vermilion corner, and the screenshots, blog pictures, favicons and link previews all show the new look. Buttons that are working and pages that are loading show the mark piecing itself together, and a missing page shows the mark with a piece gone.
package/bin/quilt.js CHANGED
@@ -40,9 +40,9 @@ Usage:
40
40
  Show unread messages, or read back: the conversation
41
41
  with one person, a search, earlier messages
42
42
  quilt get <message-id> [dest] Download a shared file again
43
- quilt git-token Agents: a short-lived GitHub token for this folder's repository,
44
- for gh or GitHub's API (GH_TOKEN=$(quilt git-token) gh pr create);
45
- git itself signs in through Quilt without it
43
+ quilt run -- <command> [args...] Run a command with the session's secrets: it gets NAME=quilt and
44
+ NAME's base URL (STRIPE_BASE_URL), and its requests go through
45
+ Quilt's vault; it never sees a value
46
46
  quilt commit-request <message> [--files a,b] [--task id]
47
47
  Ask a person to commit for you (when agents may not push here)
48
48
  quilt commits Open commit requests and recent commits
@@ -99,6 +99,7 @@ async function main () {
99
99
  case 'commits': return commits()
100
100
  case 'github': return githubStatusCmd()
101
101
  case 'git-token': return gitToken()
102
+ case 'run': return runCmd()
102
103
  case 'git-credential': return gitCredential()
103
104
  case 'git-pre-push': return gitPrePush()
104
105
  case 'chat': return chat()
@@ -212,11 +213,16 @@ async function apiCmd () {
212
213
  github: (await import('../src/api/github-app.js')).appConfig(env),
213
214
  // Fly's Machines API for the relay (RELAY_FLY_API_TOKEN, QUILT_RELAY_APP): heavy sessions get a relay machine of their own.
214
215
  relayFly: (await import('../src/api/relay-machines.js')).relayFlyFromEnv(env),
216
+ // The load each shared relay machine stays under, and when the cluster scales down (QUILT_RELAY_TARGET_*, QUILT_RELAY_*; relay-cluster.js TARGETS).
217
+ relayTargets: (await import('../src/api/relay-cluster.js')).relayTargetsFromEnv(env),
215
218
  // Where agents reach this API (invite links point here).
216
219
  apiUrl: env.QUILT_API_PUBLIC_URL || (values.memory ? `http://${host}:${port}` : 'https://api.heyquilt.com'),
217
220
  siteUrl: env.QUILT_SITE_URL || 'http://localhost:3000',
218
221
  trustProxy: /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''), log: console.log,
219
222
  workspaces: env.QUILT_WORKSPACES === '1' || env.QUILT_WORKSPACES === 'true',
223
+ // Secrets: the vault (quilt-vault) and the secret it calls this API with; off when not set.
224
+ vaultSecret: env.QUILT_VAULT_SECRET || '',
225
+ vaultUrl: env.QUILT_VAULT_URL || 'https://vault.heyquilt.com',
220
226
  // People's cloud AI machines (FLY_API_TOKEN, AI_VM_APP, AI_VM_IMAGE, AI_VM_REGION); off when not set.
221
227
  fly: (await import('../src/api/fly-machines.js')).flyFromEnv(env),
222
228
  fileStore: makeFileStore(
@@ -227,6 +233,7 @@ async function apiCmd () {
227
233
  console.log(`quilt accounts API listening on ${api.url}`)
228
234
  if (!env.RELAY_API_SECRET) console.log('RELAY_API_SECRET is not set: the relay cannot report sessions for the dashboard')
229
235
  if (!env.FLY_API_TOKEN || !env.AI_VM_APP || !env.AI_VM_IMAGE) console.log('FLY_API_TOKEN, AI_VM_APP or AI_VM_IMAGE is not set: people cannot connect a cloud AI')
236
+ if (!env.QUILT_VAULT_SECRET) console.log('QUILT_VAULT_SECRET is not set: secrets are off')
230
237
  const shutdown = async () => { await api.close(); process.exit(0) }
231
238
  process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown)
232
239
  }
@@ -340,6 +347,7 @@ async function join () {
340
347
  prefer: values.prefer === 'local' ? 'local' : 'remote',
341
348
  kind: auth.kind,
342
349
  passes: auth.passes,
350
+ apiAuth: auth.apiAuth,
343
351
  identity: auth.identity,
344
352
  inviteServer: saved.room === conn.room ? saved.inviteServer : undefined,
345
353
  onLog: (m) => console.log(`[${stamp()}] ${m}`),
@@ -599,7 +607,12 @@ async function commitRequest () {
599
607
 
600
608
  // git in an agent's folder runs these (installGitHelpers, gitstate.js); `--dir` is the folder, whose
601
609
  // running session answers. They never print anything but what git expects, and never fail a git
602
- // command when Quilt isn't running: git then behaves as it would without Quilt.
610
+ // command when Quilt isn't running: git then behaves as it would without Quilt. Nor do they make git
611
+ // wait: a session that doesn't answer within HELPER_WAIT is taken as no session, and the whole helper
612
+ // exits by HELPER_LIMIT whatever is still pending (the env overrides are for tests).
613
+ const HELPER_WAIT = Number(process.env.QUILT_HELPER_WAIT_MS) || 4000
614
+ const HELPER_LIMIT = Number(process.env.QUILT_HELPER_LIMIT_MS) || 10000
615
+ function helperDeadline () { setTimeout(() => process.exit(0), HELPER_LIMIT).unref() }
603
616
  function helperDir () {
604
617
  const i = argv.indexOf('--dir')
605
618
  const dir = i >= 0 ? argv[i + 1] : process.cwd()
@@ -611,7 +624,13 @@ async function helperDaemon (dir) {
611
624
  const d = findDaemon(dir)
612
625
  return d ? { d, call } : null
613
626
  }
614
- const readStdin = () => new Promise((resolve) => { let s = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', () => resolve(s)); process.stdin.on('error', () => resolve(s)) })
627
+ // git closes stdin after what it sends; one that stays open is read for HELPER_WAIT at most.
628
+ const readStdin = () => new Promise((resolve) => {
629
+ let s = ''
630
+ const done = () => { clearTimeout(t); process.stdin.pause(); resolve(s) }
631
+ const t = setTimeout(done, HELPER_WAIT)
632
+ process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', done); process.stdin.on('error', done)
633
+ })
615
634
 
616
635
  /**
617
636
  * git's credential helper protocol: `get` answers with the session's token for the host; store and erase
@@ -624,12 +643,13 @@ async function gitCredential () {
624
643
  const fallback = argv.includes('--fallback')
625
644
  if (fallback) argv.splice(argv.indexOf('--fallback'), 1)
626
645
  if (argv[0] !== 'get') return
646
+ helperDeadline()
627
647
  const input = await readStdin()
628
648
  const fields = Object.fromEntries(input.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)] }))
629
649
  try {
630
650
  const h = await helperDaemon(dir)
631
651
  if (h) {
632
- const r = await h.call(h.d, 'POST', '/git/credential', { host: fields.host || '', protocol: fields.protocol || '' })
652
+ const r = await h.call(h.d, 'POST', '/git/credential', { host: fields.host || '', protocol: fields.protocol || '' }, { timeoutMs: HELPER_WAIT })
633
653
  if (r && r.username && r.password) return process.stdout.write(`username=${r.username}\npassword=${r.password}\n`)
634
654
  }
635
655
  } catch {}
@@ -642,7 +662,7 @@ async function ownCredential (input) {
642
662
  const { execFile } = await import('node:child_process')
643
663
  const away = os.tmpdir()
644
664
  const out = await new Promise((resolve) => {
645
- const c = execFile('git', ['credential', 'fill'], { cwd: away, encoding: 'utf8', timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_CEILING_DIRECTORIES: path.dirname(away) } }, (err, stdout) => resolve(err ? '' : stdout))
665
+ const c = execFile('git', ['credential', 'fill'], { cwd: away, encoding: 'utf8', timeout: Math.max(1000, HELPER_LIMIT - HELPER_WAIT - 1000), killSignal: 'SIGKILL', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_CEILING_DIRECTORIES: path.dirname(away) } }, (err, stdout) => resolve(err ? '' : stdout))
646
666
  c.stdin.end(input.endsWith('\n\n') ? input : `${input.replace(/\n*$/, '')}\n\n`)
647
667
  })
648
668
  const lines = out.split('\n').filter((l) => /^(username|password)=/.test(l))
@@ -653,22 +673,35 @@ async function ownCredential (input) {
653
673
  async function gitPrePush () {
654
674
  const dir = helperDir()
655
675
  const [remote = 'origin'] = argv
676
+ helperDeadline()
656
677
  const input = await readStdin()
657
678
  const refs = input.split('\n').filter(Boolean).map((l) => { const [localRef, localSha, remoteRef, remoteSha] = l.trim().split(/\s+/); return { localRef, localSha, remoteRef, remoteSha } })
658
679
  try {
659
680
  const h = await helperDaemon(dir)
660
681
  if (!h) return
661
- const r = await h.call(h.d, 'POST', '/git/push-check', { remote, refs })
682
+ const r = await h.call(h.d, 'POST', '/git/push-check', { remote, refs }, { timeoutMs: HELPER_WAIT })
662
683
  if (r && r.ok === false) { process.stderr.write(`Quilt: ${r.why || 'this push is not allowed here'}\n`); process.exitCode = 1 }
663
684
  } catch {}
664
685
  }
665
686
 
666
687
  /** A short-lived token for this folder's repository (agents), for gh or GitHub's API. */
688
+ /** `quilt git-token` no longer prints a token (a value in a terminal is a value in an AI's context). */
667
689
  async function gitToken () {
668
- const { d, call } = await daemonOrFail()
669
- const r = await call(d, 'POST', '/git/credential', { host: 'github.com' }).catch((err) => fail(err.message))
670
- if (!r || !r.password) fail('No GitHub token for this folder: it needs to be an agent\'s folder in a session that follows a GitHub repository, with GitHub connected (quilt github says).')
671
- console.log(r.password)
690
+ const { GIT_TOKEN_GONE } = await import('../src/secret-run.js')
691
+ fail(GIT_TOKEN_GONE)
692
+ }
693
+
694
+ /** `quilt run -- <command>`: the command gets its secrets' stand-ins and gateways (src/secret-run.js). */
695
+ async function runCmd () {
696
+ const { RUN_USAGE, runSource, runWithSecrets } = await import('../src/secret-run.js')
697
+ const command = argv[0] === '--' ? argv.slice(1) : argv
698
+ if (!command.length || (argv[0] !== '--' && ['-h', '--help', 'help'].includes(command[0]))) return console.log(RUN_USAGE)
699
+ const { findDaemon, call } = await import('../src/control.js')
700
+ // The session syncing this folder first (its person or agent), else a sandbox's key.
701
+ const daemon = findDaemon()
702
+ const source = runSource({ key: daemon ? null : await envKey(), daemon, call })
703
+ if (!source) fail('quilt run needs a session: run it in a folder Quilt is syncing (quilt join, or the app), or in a sandbox with QUILT_AGENT_KEY.')
704
+ try { process.exitCode = await runWithSecrets({ command, source }) } catch (err) { fail(err.message) }
672
705
  }
673
706
 
674
707
  async function githubStatusCmd () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.90",
3
+ "version": "0.3.106",
4
4
  "description": "Real-time, tool-agnostic pair vibe coding: sync a project folder live between collaborators using any AI coding tool.",
5
5
  "author": {
6
6
  "name": "Daniel Carmichael",
@@ -13,10 +13,10 @@
13
13
  "cowove": "bin/quilt.js"
14
14
  },
15
15
  "scripts": {
16
- "test": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test test/unit/*.test.js test/integration/*.test.js test/e2e/*.test.js",
16
+ "test": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test --test-timeout=600000 test/unit/*.test.js test/integration/*.test.js test/e2e/*.test.js",
17
17
  "test:unit": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test test/unit/*.test.js",
18
18
  "test:integration": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test test/integration/*.test.js",
19
- "test:e2e": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test test/e2e/*.test.js",
19
+ "test:e2e": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test --test-timeout=600000 test/e2e/*.test.js",
20
20
  "test:stress": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test --test-concurrency=1 test/stress/*.test.js",
21
21
  "test:file": "node --disable-warning=ExperimentalWarning --import ./test/helpers/setup.js --test",
22
22
  "test:coverage": "node scripts/coverage.mjs",
package/src/account.js CHANGED
@@ -186,6 +186,34 @@ export async function listAgents ({ token, api = apiUrl(), fetch: fetchImpl = gl
186
186
  return r.agents
187
187
  }
188
188
 
189
+ // Sandbox keys (QUILT_AGENT_KEY) on one of your agents: its Keys table in Settings › Agents.
190
+ const sandbox$ = (id, rest = '') => `/v1/agents/${encodeURIComponent(id)}/sandbox-keys${rest}`
191
+
192
+ /** An agent's sandbox keys ({ keys, sessions }): never the keys themselves. */
193
+ export async function listSandboxKeys ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
194
+ const r = await call(fetchImpl, api, 'GET', sandbox$(id), null, token)
195
+ if (!Array.isArray(r.keys) || !Array.isArray(r.sessions)) throw new Error(BAD_REPLY)
196
+ return r
197
+ }
198
+ /** A new sandbox key for a session (`session`, its invite link, or `room`, one a key already has): { envKey, key }, shown once. */
199
+ export async function createSandboxKey ({ token, id, name, session, room, sessionName, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
200
+ const r = await call(fetchImpl, api, 'POST', `/v1/agents/${encodeURIComponent(id)}/env-key`, { name, ...(session ? { session } : { room }), ...(sessionName ? { sessionName } : {}) }, token)
201
+ if (!r.envKey) throw new Error(BAD_REPLY)
202
+ return r
203
+ }
204
+ /** Rotate: the same key made again (shown once), the old one revoked. */
205
+ export async function rotateSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
206
+ const r = await call(fetchImpl, api, 'POST', sandbox$(id, `/${encodeURIComponent(keyId)}/rotate`), {}, token)
207
+ if (!r.envKey) throw new Error(BAD_REPLY)
208
+ return r
209
+ }
210
+ export async function revokeSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
211
+ return call(fetchImpl, api, 'POST', sandbox$(id, `/${encodeURIComponent(keyId)}/revoke`), {}, token)
212
+ }
213
+ export async function removeSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
214
+ return call(fetchImpl, api, 'DELETE', sandbox$(id, `/${encodeURIComponent(keyId)}`), null, token)
215
+ }
216
+
189
217
  /** This account's GitHub connection (Quilt's GitHub App): { available, connected, login? }. */
190
218
  export async function githubStatus ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
191
219
  return call(fetchImpl, api, 'GET', '/v1/me/github', null, token)
@@ -36,7 +36,11 @@ export function startClaudeCodeReader ({ dir, chatDir, onEntries, onState, onLog
36
36
  const slug = slugFor(chatRoot)
37
37
  const files = new Map() // path -> { offset, partial, seen: Set<path> }
38
38
  let status = null
39
- let lastLineAt = 0
39
+ // Each conversation mid-turn, by its transcript file, with when it last wrote: the tool is working
40
+ // while any of them is, so one conversation ending its turn never makes another's edits look like a
41
+ // person's (they'd go unboxed). A conversation silent for IDLE_AFTER_MS counts as done.
42
+ const midTurn = new Map()
43
+ const settle = () => setStatus(midTurn.size ? 'working' : 'idle')
40
44
  let stopped = false
41
45
  let failed = false
42
46
  let first = true
@@ -127,8 +131,10 @@ export function startClaudeCodeReader ({ dir, chatDir, onEntries, onState, onLog
127
131
  if (!r) continue
128
132
  entries.push(...r.entries)
129
133
  if (r.relevant) {
130
- lastLineAt = now()
131
- if (!backfill) setStatus(r.endTurn ? 'idle' : 'working')
134
+ if (!backfill) {
135
+ if (r.endTurn) midTurn.delete(file); else midTurn.set(file, now())
136
+ settle()
137
+ }
132
138
  }
133
139
  }
134
140
  return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
@@ -161,7 +167,8 @@ export function startClaudeCodeReader ({ dir, chatDir, onEntries, onState, onLog
161
167
  }
162
168
  }
163
169
  first = false
164
- if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
170
+ for (const [f, at] of midTurn) if (now() - at > IDLE_AFTER_MS) midTurn.delete(f)
171
+ settle()
165
172
  if (out.length) onEntries(out.sort((a, b) => a.ts - b.ts))
166
173
  } catch (err) {
167
174
  if (!failed) { failed = true; onLog(`Claude Code feed error: ${err.message}`) }
@@ -48,7 +48,11 @@ export function startCursorTranscriptReader ({ dir, onEntries, onState, onLog =
48
48
  const root = projectsDir || cursorProjectsDir()
49
49
  const files = new Map() // path -> { offset, partial, lineNo }
50
50
  let status = null
51
- let lastLineAt = 0
51
+ // Each conversation mid-turn, by its transcript file, with when it last wrote: the tool is working
52
+ // while any of them is, so one conversation ending its turn never makes another's edits look like a
53
+ // person's (they'd go unboxed). A conversation silent for IDLE_AFTER_MS counts as done.
54
+ const midTurn = new Map()
55
+ const settle = () => setStatus(midTurn.size ? 'working' : 'idle')
52
56
  let stopped = false
53
57
  let failed = false
54
58
  let first = true
@@ -112,8 +116,10 @@ export function startCursorTranscriptReader ({ dir, onEntries, onState, onLog =
112
116
  if (!r) continue
113
117
  entries.push(...r.entries)
114
118
  if (r.relevant) {
115
- lastLineAt = now()
116
- setStatus(r.endTurn ? 'idle' : 'working')
119
+ {
120
+ if (r.endTurn) midTurn.delete(file); else midTurn.set(file, now())
121
+ settle()
122
+ }
117
123
  }
118
124
  }
119
125
  return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
@@ -157,7 +163,8 @@ export function startCursorTranscriptReader ({ dir, onEntries, onState, onLog =
157
163
  }
158
164
  }
159
165
  first = false
160
- if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
166
+ for (const [f, at] of midTurn) if (now() - at > IDLE_AFTER_MS) midTurn.delete(f)
167
+ settle()
161
168
  if (out.length) onEntries(out)
162
169
  } catch (err) {
163
170
  if (!failed) { failed = true; onLog(`Cursor feed error: ${err.message}`) }
@@ -16,9 +16,14 @@ export const SIGN_IN = {
16
16
  exe: ['claude'],
17
17
  args: ['setup-token'],
18
18
  env: {},
19
- urlHost: /^(claude\.ai|console\.anthropic\.com)$/,
19
+ // Claude's sign-in page has moved between hosts (claude.ai, then claude.com/cai/oauth).
20
+ urlHost: /^((www\.)?claude\.(ai|com)|console\.anthropic\.com)$/,
20
21
  needsCode: true,
21
22
  token: /sk-ant-oat01-[A-Za-z0-9_-]{20,}/,
23
+ // A code it can't use: "OAuth error: … Press Enter to retry", and Enter shows a new link.
24
+ // Its screen is redrawn a few letters at a time, so only fragments are looked for.
25
+ refused: /rror|status code|retry/i,
26
+ retry: '\r',
22
27
  status: { file: 'claude-token' }
23
28
  },
24
29
  Codex: {
@@ -55,10 +60,11 @@ export function readSignIn (tool, text) {
55
60
  const out = { url: '', code: '', needsCode: false, token: '' }
56
61
  if (!t) return out
57
62
  const s = plain(text)
63
+ // The latest link: a sign-in that starts over prints a new one.
58
64
  for (const m of s.matchAll(/https:\/\/[^\s"'<>]+/g)) {
59
65
  try {
60
66
  const u = new URL(m[0].replace(/[).,;]+$/, ''))
61
- if (t.urlHost.test(u.hostname)) { out.url = u.href; break }
67
+ if (t.urlHost.test(u.hostname)) out.url = u.href
62
68
  } catch {}
63
69
  }
64
70
  if (t.code) {
@@ -71,6 +77,12 @@ export function readSignIn (tool, text) {
71
77
  return out
72
78
  }
73
79
 
80
+ /** Whether what a tool printed after a pasted code says it didn't take it. */
81
+ export function codeRefused (tool, text) {
82
+ const t = SIGN_IN[tool]
83
+ return !!(t && t.refused && t.refused.test(plain(text)))
84
+ }
85
+
74
86
  /** Where a tool's token is kept on the machine (only Claude Code hands one over). */
75
87
  export const tokenFile = (home, tool) => (SIGN_IN[tool]?.status?.file ? path.join(home, '.quilt', 'ai-vm', SIGN_IN[tool].status.file) : null)
76
88