heyquilt 0.0.1 → 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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,30 @@
1
+ // What a team membership grants: editor or viewer, and for agents, optional
2
+ // folders (the relay's scope rules: up to 20 relative path prefixes).
3
+ import { HttpError, stripInvisible } from './http.js'
4
+
5
+ export const ACCESS = ['editor', 'viewer']
6
+ export const MAX_SCOPES = 20
7
+ export const MAX_SCOPE_LENGTH = 200
8
+ const BAD_FOLDER = 'Folders must be paths inside the project, like src or docs.'
9
+
10
+ export function accessOf (v) {
11
+ if (!ACCESS.includes(v)) throw new HttpError(400, 'access must be editor or viewer')
12
+ return v
13
+ }
14
+
15
+ /** Folders as the relay wants them: trimmed, no leading "./" or trailing "/", each once. */
16
+ export function cleanScopes (input) {
17
+ if (!Array.isArray(input)) throw new HttpError(400, BAD_FOLDER)
18
+ const out = []
19
+ for (const raw of input) {
20
+ if (typeof raw !== 'string') throw new HttpError(400, BAD_FOLDER)
21
+ const s = stripInvisible(raw).join('').trim().replace(/^(\.\/)+/, '').replace(/\/+$/, '')
22
+ if (!s) continue
23
+ if (s.startsWith('/') || s.includes('\\') || s.length > MAX_SCOPE_LENGTH || /^[A-Za-z]:/.test(s)) throw new HttpError(400, BAD_FOLDER)
24
+ // Every segment must be a real folder name: no "", ".", or ".." anywhere in the path.
25
+ if (s.split('/').some((seg) => seg === '' || seg === '.' || seg === '..')) throw new HttpError(400, BAD_FOLDER)
26
+ if (!out.includes(s)) out.push(s)
27
+ }
28
+ if (out.length > MAX_SCOPES) throw new HttpError(400, 'An agent can be limited to at most 20 folders.')
29
+ return out
30
+ }
@@ -0,0 +1,31 @@
1
+ // Secrets the accounts API hands out. Only their hashes are stored, so a leaked
2
+ // database can't be used to sign in as anyone.
3
+ import crypto from 'node:crypto'
4
+
5
+ export const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ' // no 0/O, 1/I/L
6
+
7
+ export function newToken (prefix) {
8
+ return prefix + crypto.randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ export function hashToken (token) {
12
+ return crypto.createHash('sha256').update(String(token)).digest('hex')
13
+ }
14
+
15
+ export function sameHash (a, b) {
16
+ const x = Buffer.from(String(a), 'hex'); const y = Buffer.from(String(b), 'hex')
17
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
18
+ }
19
+
20
+ /** The short code a person matches between the app and the website, e.g. 7F3K-9QXM. */
21
+ export function newUserCode () {
22
+ let s = ''
23
+ for (let i = 0; i < 8; i++) s += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]
24
+ return `${s.slice(0, 4)}-${s.slice(4)}`
25
+ }
26
+
27
+ export function normalizeUserCode (input) {
28
+ const s = String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
29
+ if (s.length !== 8 || [...s].some((ch) => !CODE_ALPHABET.includes(ch))) return null
30
+ return `${s.slice(0, 4)}-${s.slice(4)}`
31
+ }
@@ -0,0 +1,62 @@
1
+ // Who may do what in a workspace (the spec's "Access" section), and the field cleaners
2
+ // the routes share. Pure apart from store reads.
3
+ import { HttpError, stripInvisible } from './http.js'
4
+ import { agentReach } from './agent-placement.js'
5
+
6
+ export const COLORS = ['lilac', 'mint', 'peach', 'rose', 'periwinkle', 'sky']
7
+ export const ACCESS = ['edit', 'view']
8
+ const MAX_DESCRIPTION = 500
9
+
10
+ export function cleanColor (value) {
11
+ if (value === undefined || value === null || value === '') return ''
12
+ if (!COLORS.includes(value)) throw new HttpError(400, 'Pick one of the workspace colours.')
13
+ return value
14
+ }
15
+
16
+ /** A colour for a workspace that was made without one: the same name always gets the same one. */
17
+ export function autoColor (name) {
18
+ let h = 0
19
+ for (const c of String(name || '')) h = (h * 31 + c.codePointAt(0)) >>> 0
20
+ return COLORS[h % COLORS.length]
21
+ }
22
+
23
+ export function cleanDescription (value) {
24
+ const chars = stripInvisible(String(value ?? ''))
25
+ if (chars.length > MAX_DESCRIPTION) throw new HttpError(400, `Keep the description under ${MAX_DESCRIPTION} characters.`)
26
+ return chars.join('').trim()
27
+ }
28
+
29
+ export function cleanAccess (value) {
30
+ if (!ACCESS.includes(value)) throw new HttpError(400, 'Access is edit or view.')
31
+ return value
32
+ }
33
+
34
+ /**
35
+ * `account`'s place in `workspace`: { access, admin, via } or null.
36
+ * Personal: the owner is admin. Org: Workspaces: Update is admin; Workspaces: Read alone
37
+ * is view. A member row gives its access and beats org Read; in an org workspace it counts
38
+ * only while its person or agent is still in the org. Next, an agent the workspace's owner
39
+ * placed there (src/api/agent-placement.js) gets its placement's access, with `scopes`
40
+ * (folder limits) and via 'placed' or 'global'. `orgGrants` is the caller's org access
41
+ * ({ can }) or null when they aren't in the org.
42
+ */
43
+ export async function workspaceAccess (store, workspace, account, { orgGrants = null } = {}) {
44
+ if (workspace.ownerUserId && account === `person:${workspace.ownerUserId}`) return { access: 'edit', admin: true, via: 'owner' }
45
+ if (workspace.orgId && orgGrants && orgGrants.can('workspaces', 'u')) return { access: 'edit', admin: true, via: 'org' }
46
+ const member = await store.workspaceMember(workspace.id, account)
47
+ if (member && await stillInOrg(store, workspace, account, orgGrants)) return { access: member.access, admin: false, via: 'member' }
48
+ if (account.startsWith('agent:')) {
49
+ const r = await agentReach(store, workspace, account.slice(6))
50
+ if (r) return { access: r.access, admin: false, via: r.via, scopes: r.scopes }
51
+ }
52
+ if (workspace.orgId && orgGrants && orgGrants.can('workspaces', 'r')) return { access: 'view', admin: false, via: 'org' }
53
+ return null
54
+ }
55
+
56
+ /** Whether a member row still counts: always in a personal workspace; in an org's, only for its people and agents. */
57
+ export async function stillInOrg (store, workspace, account, orgGrants) {
58
+ if (!workspace.orgId) return true
59
+ const [kind, id] = account.split(':')
60
+ if (kind === 'agent') return !!(await store.memberByAgent(workspace.orgId, id))
61
+ return !!orgGrants
62
+ }
@@ -0,0 +1,60 @@
1
+ // Who is calling a workspace route, and what they may do in the workspace. Shared by
2
+ // the workspace routes and the workspace file routes.
3
+ import { HttpError, needId } from './http.js'
4
+ import { verifyPass, passPublicKey } from '../passes.js'
5
+ import { orgGrantsFor } from './org-access.js'
6
+ import { workspaceAccess } from './workspace-access.js'
7
+
8
+ const NOT_FOUND = 'no such workspace'
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
10
+ const QUILT_PASS = /^QuiltPass\s+(\S+)$/i
11
+ const BAD_PASS = 'this pass is not good here; sign the agent in again'
12
+
13
+ /** Whether a caller's access lets them see the workspace's files (the spec's Access rule 2):
14
+ * explicit members (even view) and admins do; an org member who reaches the workspace only
15
+ * through Workspaces: Read sees the page, but not its files. */
16
+ export const canSeeFiles = (access) => !(access.via === 'org' && access.access === 'view')
17
+
18
+ export function workspaceReach ({ store, person, agentAuth, bearer, passKey = '', now = Date.now }) {
19
+ const passCheckKey = passKey ? passPublicKey(passKey) : null
20
+
21
+ /**
22
+ * A hosted agent, by the pass the relay holds for it (`Authorization: QuiltPass <pass>`):
23
+ * only an agent's pass, signed by this API and not expired, for an agent not revoked.
24
+ * Only these routes (workspaces, their files, the agent's webhook) take passes.
25
+ */
26
+ async function passCaller (pass) {
27
+ const p = passCheckKey ? verifyPass(pass, passCheckKey, { now: now() }) : null
28
+ if (!p || p.kind !== 'agent' || !UUID.test(p.sub)) throw new HttpError(401, BAD_PASS)
29
+ const agent = await store.agentById(p.sub)
30
+ if (!agent || agent.revokedAt) throw new HttpError(401, 'this agent was revoked')
31
+ return { account: `agent:${agent.id}`, userId: null, agent }
32
+ }
33
+
34
+ /** The caller as an account string: a person (website or linked computer) or an agent (qa_ key or pass). */
35
+ async function caller (req) {
36
+ const pass = (String(req.headers.authorization || '').match(QUILT_PASS) || [])[1]
37
+ if (pass) return passCaller(pass)
38
+ if (bearer(req).startsWith('qa_')) { const { agent } = await agentAuth.agentFromRequest(req); return { account: `agent:${agent.id}`, userId: null, agent } }
39
+ const p = await person(req)
40
+ return { account: `person:${p.userId}`, userId: p.userId }
41
+ }
42
+
43
+ /** The caller's org access for a workspace's org, or null (a person outside it, or an agent). Never throws. */
44
+ const grantsIn = (ws, me) => (ws.orgId ? orgGrantsFor(store, ws.orgId, me.account) : null)
45
+
46
+ /** A workspace the caller may at least see, with their access; 404 otherwise (outsiders can't probe ids). */
47
+ async function reach (req, id) {
48
+ const me = await caller(req)
49
+ const ws = await store.workspaceById(needId(id, 'workspace'))
50
+ const orgGrants = ws && await grantsIn(ws, me)
51
+ const access = ws && await workspaceAccess(store, ws, me.account, { orgGrants })
52
+ if (!access) throw new HttpError(404, NOT_FOUND)
53
+ // Who may delete it, as the DELETE route decides: the owner of a personal one; for an
54
+ // org's, a person whose role holds Workspaces: Delete (never an agent).
55
+ const canDelete = ws.orgId ? !!(me.userId && orgGrants && orgGrants.can('workspaces', 'd')) : access.via === 'owner'
56
+ return { me, ws, access, canDelete, needAdmin () { if (!access.admin) throw new HttpError(403, "you don't manage this workspace") } }
57
+ }
58
+
59
+ return { caller, grantsIn, reach }
60
+ }
@@ -0,0 +1,166 @@
1
+ // How each AI tool runs on its own ("Work on its own", autowork.js): the command-line tool
2
+ // that ships with it, its arguments to start a chat and to carry one on, and what it prints.
3
+ // One table for every tool, so nothing here is one tool's code path: a tool without an entry
4
+ // can't work on its own, and Quilt opens work in it or leaves it for the person's chat instead.
5
+ //
6
+ // A preset's functions are pure (arguments in, arguments out; text in, a result out), so the
7
+ // tests check every tool the same way without running any of them.
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import crypto from 'node:crypto'
12
+
13
+ /** Claude Code's tools a run may use: Quilt's, and reading; with files, editing and commands too (no git push). */
14
+ const CLAUDE_READ = ['mcp__quilt', 'Read', 'Glob', 'Grep', 'LS', 'TodoWrite']
15
+ const CLAUDE_WRITE = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash']
16
+
17
+ /** The last JSON object a tool printed (some print one, some a line per event). */
18
+ function lastJson (out, pick = () => true) {
19
+ const lines = String(out || '').split('\n').map((l) => l.trim()).filter(Boolean)
20
+ for (let i = lines.length - 1; i >= 0; i--) {
21
+ if (!lines[i].startsWith('{')) continue
22
+ try { const j = JSON.parse(lines[i]); if (j && pick(j)) return j } catch {}
23
+ }
24
+ return null
25
+ }
26
+
27
+ const num = (n) => (Number.isFinite(Number(n)) ? Number(n) : 0)
28
+
29
+ /**
30
+ * Each preset: `tool` (the AI tool name people pick in their profile), `exe` (the command),
31
+ * `models` (offered in settings, the first is our default; anything else may be typed),
32
+ * `limitsFiles` (whether "Let it change files" off really keeps it to reading),
33
+ * `start(o)` / `resume(o)` → argv, `parse(stdout)` → { chatId, text, usage, error },
34
+ * `takeOver(chatId)` → how the person carries the chat on themselves.
35
+ * `o`: { prompt, model, changeFiles, chatId }.
36
+ */
37
+ export const AUTO_TOOLS = [
38
+ {
39
+ id: 'claude',
40
+ tool: 'Claude Code',
41
+ exe: 'claude',
42
+ models: ['sonnet', 'opus', 'haiku'],
43
+ limitsFiles: true,
44
+ // Claude takes a chat id we choose, so a resume needs nothing from its output.
45
+ newChatId: () => crypto.randomUUID(),
46
+ start: (o) => ['-p', o.prompt, '--session-id', o.chatId, ...claudeFlags(o)],
47
+ resume: (o) => ['-p', o.prompt, '--resume', o.chatId, ...claudeFlags(o)],
48
+ parse (out) {
49
+ const j = lastJson(out, (x) => x.type === 'result')
50
+ if (!j) return { chatId: null, text: '', usage: null, error: firstLine(out) || 'it printed nothing Quilt could read' }
51
+ const u = j.usage || {}
52
+ return {
53
+ chatId: j.session_id || null,
54
+ text: j.is_error ? '' : String(j.result || ''),
55
+ usage: { input: num(u.input_tokens) + num(u.cache_creation_input_tokens) + num(u.cache_read_input_tokens), output: num(u.output_tokens), costUsd: Number.isFinite(j.total_cost_usd) ? j.total_cost_usd : null },
56
+ error: j.is_error ? String(j.result || 'the run failed') : null
57
+ }
58
+ },
59
+ takeOver: (chatId) => ({ link: `claude://resume?session=${chatId}`, command: `claude --resume ${chatId}` })
60
+ },
61
+ {
62
+ id: 'cursor',
63
+ tool: 'Cursor',
64
+ exe: 'cursor-agent',
65
+ models: ['auto', 'sonnet-4', 'gpt-5'],
66
+ // Cursor's print mode can always write files; without --force it only refuses commands.
67
+ limitsFiles: false,
68
+ newChatId: () => null, // Cursor names the chat; it is in the result
69
+ start: (o) => ['-p', '--output-format', 'json', ...cursorFlags(o), o.prompt],
70
+ resume: (o) => ['-p', '--output-format', 'json', '--resume', o.chatId, ...cursorFlags(o), o.prompt],
71
+ parse (out) {
72
+ const j = lastJson(out, (x) => x.type === 'result')
73
+ if (!j) return { chatId: null, text: '', usage: null, error: firstLine(out) || 'it printed nothing Quilt could read' }
74
+ const u = j.usage
75
+ return {
76
+ chatId: j.session_id || j.chatId || null,
77
+ text: j.is_error ? '' : String(j.result || ''),
78
+ usage: u ? { input: num(u.input_tokens ?? u.inputTokens), output: num(u.output_tokens ?? u.outputTokens), costUsd: null } : null,
79
+ error: j.is_error ? String(j.result || 'the run failed') : null
80
+ }
81
+ },
82
+ takeOver: (chatId) => ({ link: null, command: `cursor-agent --resume ${chatId}` })
83
+ },
84
+ {
85
+ id: 'codex',
86
+ tool: 'Codex',
87
+ exe: 'codex',
88
+ models: ['gpt-5-codex', 'gpt-5'],
89
+ limitsFiles: true,
90
+ newChatId: () => null, // Codex names the thread when it starts
91
+ start: (o) => ['exec', ...codexFlags(o), o.prompt],
92
+ resume: (o) => ['exec', ...codexFlags(o), 'resume', o.chatId, o.prompt],
93
+ parse (out) {
94
+ const lines = String(out || '').split('\n').map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
95
+ let chatId = null; let text = ''; let error = null
96
+ const usage = { input: 0, output: 0, costUsd: null }
97
+ let counted = false
98
+ for (const e of lines) {
99
+ if (e.type === 'thread.started' && e.thread_id) chatId = e.thread_id
100
+ else if (e.type === 'item.completed' && e.item && e.item.type === 'agent_message') text = String(e.item.text || '')
101
+ else if (e.type === 'turn.completed' && e.usage) { usage.input += num(e.usage.input_tokens); usage.output += num(e.usage.output_tokens); counted = true }
102
+ else if (e.type === 'turn.failed' || e.type === 'error') error = String((e.error && e.error.message) || e.message || 'the run failed')
103
+ }
104
+ if (!lines.length) error = firstLine(out) || 'it printed nothing Quilt could read'
105
+ return { chatId, text, usage: counted ? usage : null, error }
106
+ },
107
+ takeOver: (chatId) => ({ link: null, command: `codex resume ${chatId}` })
108
+ },
109
+ {
110
+ id: 'copilot',
111
+ tool: 'GitHub Copilot',
112
+ exe: 'copilot',
113
+ models: ['claude-sonnet-4.5', 'gpt-5'],
114
+ limitsFiles: true,
115
+ newChatId: () => null,
116
+ // Copilot's print mode names no chat Quilt can carry on: a reply starts a new run, with
117
+ // what was asked and answered in its prompt (autowork.js).
118
+ resumes: false,
119
+ start: (o) => ['-p', o.prompt, '-s', '--no-ask-user', '--allow-all-tools', ...(o.changeFiles ? ['--deny-tool', 'shell(git push)'] : ['--deny-tool', 'write', '--deny-tool', 'shell']), ...(o.model ? ['--model', o.model] : [])],
120
+ parse (out) {
121
+ const text = String(out || '').trim()
122
+ return { chatId: null, text, usage: null, error: text ? null : 'it printed nothing' }
123
+ },
124
+ takeOver: () => null
125
+ }
126
+ ]
127
+
128
+ function claudeFlags (o) {
129
+ return [
130
+ '--output-format', 'json',
131
+ ...(o.model ? ['--model', o.model] : []),
132
+ // Print mode never stops to ask: anything not allowed here is refused.
133
+ '--allowedTools', ...(o.changeFiles ? [...CLAUDE_READ, ...CLAUDE_WRITE] : CLAUDE_READ),
134
+ ...(o.changeFiles ? ['--disallowedTools', 'Bash(git push:*)'] : [])
135
+ ]
136
+ }
137
+
138
+ function cursorFlags (o) {
139
+ return [...(o.model && o.model !== 'auto' ? ['--model', o.model] : []), ...(o.changeFiles ? ['--force'] : [])]
140
+ }
141
+
142
+ function codexFlags (o) {
143
+ return ['--json', '--skip-git-repo-check', '--sandbox', o.changeFiles ? 'workspace-write' : 'read-only', ...(o.model ? ['-m', o.model] : [])]
144
+ }
145
+
146
+ function firstLine (out) {
147
+ return String(out || '').split('\n').map((l) => l.trim()).find(Boolean)?.slice(0, 300) || ''
148
+ }
149
+
150
+ /** The preset for an AI tool name ("Claude Code", "Cursor"...), or null when it can't work on its own. */
151
+ export function autoToolFor (tool) {
152
+ return AUTO_TOOLS.find((t) => t.tool === tool) || null
153
+ }
154
+
155
+ /**
156
+ * Where a preset's command is on this computer, or null. Apps started from the Dock don't see
157
+ * the shell's PATH, so the usual install folders are looked in too.
158
+ */
159
+ export function findExe (exe, { platform = process.platform, home = os.homedir(), exists = fs.existsSync, env = process.env } = {}) {
160
+ const p = platform === 'win32' ? path.win32 : path.posix
161
+ const names = platform === 'win32' ? [`${exe}.exe`, `${exe}.cmd`] : [exe]
162
+ const dirs = [p.join(home, '.local', 'bin'), p.join(home, '.claude', 'local'), p.join(home, '.cursor', 'bin'), '/opt/homebrew/bin', '/usr/local/bin',
163
+ ...String(env.PATH || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)]
164
+ for (const d of dirs) for (const n of names) if (exists(p.join(d, n))) return p.join(d, n)
165
+ return null
166
+ }