heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -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 +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -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 +76 -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 +1083 -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 +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,142 @@
1
+ // What `quilt sync` does with each file: the rules, kept apart from the relay so they can be read
2
+ // and tested on their own (the relay's quilt_sync tool runs them: relay-mcp.js).
3
+ //
4
+ // A sandboxed agent syncs in one shot at the start and end of a turn, with no state of its own:
5
+ // the relay keeps a checkpoint for each of its sandboxes (its slot) of what the folder held at the
6
+ // end of its last sync. Per path, L is the folder's version, B the checkpoint's, S the session's
7
+ // (a sha1 each, or null for "not there"):
8
+ //
9
+ // - L = S: nothing to do.
10
+ // - First sync of this slot (no checkpoint): the session wins for every file it has, a differing
11
+ // copy is kept aside in .quilt/conflicts; a copy of a file the session deleted is a leftover,
12
+ // kept aside too, never brought back (issue 040); a file the session never had is pushed.
13
+ // - L = B: only the session changed it: pull it (or remove it).
14
+ // - S = B: only the folder changed it: push it (or delete it in the session).
15
+ // - L is a version this slot held before: a stale copy (a platform restoring an older snapshot),
16
+ // no new work: the session's version comes back.
17
+ // - Both changed: merged when the lines don't overlap (a turn is an edit window, like live
18
+ // editing); otherwise a merge record, the session's version on disk and ours kept aside.
19
+ // - Large files kept in storage are not carried by quilt sync.
20
+ // - Deleting more than max(20, a quarter) of the checkpoint's files at once is not done for
21
+ // everyone: those files come back instead (a folder that lost most of the project).
22
+ import { merge3 } from './merge3.js'
23
+
24
+ /** At most this many earlier versions per path are remembered, to tell a stale copy. */
25
+ export const MAX_WAS = 5
26
+ /** A base text kept in a checkpoint at most this long (longer files merge as a record). */
27
+ export const MAX_BASE_TEXT = 1024 * 1024
28
+ export const MASS_DELETE_MIN = 20
29
+ export const MASS_DELETE_SHARE = 0.25
30
+ /** In a manifest: a file the folder has but quilt sync doesn't carry (too large). */
31
+ export const SKIP = 'skip'
32
+
33
+ const HASH = /^[0-9a-f]{40}$/
34
+
35
+ /**
36
+ * What to do with one path. `local` and `session` are sha1s or null; `local` may be SKIP and
37
+ * `session` may be { stored: true } (a large file in storage); `base` is the checkpoint's entry
38
+ * ({ h, was }) or null; `first` says there is no checkpoint at all; `deleted` says the session
39
+ * had the path and deleted it.
40
+ * Returns one of: same, skip, pull, remove, push, delete, merge, aside-pull, aside-remove.
41
+ */
42
+ export function decide ({ local, session, base, first, deleted = false }) {
43
+ if (local === SKIP || (session && session.stored)) return local && session && local === session.h ? 'same' : 'skip'
44
+ const S = session ? session.h : null
45
+ const L = local || null
46
+ if (L === S) return 'same'
47
+ if (first) {
48
+ if (S) return L ? 'aside-pull' : 'pull'
49
+ return deleted ? 'aside-remove' : 'push'
50
+ }
51
+ if (!base) {
52
+ if (L && S) return 'merge'
53
+ return S ? 'pull' : 'push'
54
+ }
55
+ if (L === base.h) return S ? 'pull' : 'remove'
56
+ if (S === base.h) return L ? 'push' : 'delete'
57
+ // Both moved on. A version we held before is no new work.
58
+ if (L && (base.was || []).includes(L)) return S ? 'pull' : 'remove'
59
+ return 'merge'
60
+ }
61
+
62
+ /** A file's state in the session: sha1 and text (or stored), for decide and the checkpoint. */
63
+ export const sessionEntry = (h, { text = null, stored = false } = {}) => ({ h, text, stored })
64
+
65
+ /**
66
+ * The plan for a folder: `local` { path: sha1 | SKIP }, `session` Map path -> sessionEntry,
67
+ * `checkpoint` { files: { path: { h, t?, was? } } } or null, `deleted(path)` for tombstones.
68
+ * Returns lists of paths: upload (send their contents), delete (gone here: delete in the session),
69
+ * pull (write the session's version), remove (delete here), aside (keep ours in .quilt/conflicts
70
+ * first; each is in pull or remove too), merge (both changed: upload too), skipped, and
71
+ * `guarded`: deletions held back.
72
+ */
73
+ export function planSync ({ local, session, checkpoint, deleted = () => false }) {
74
+ const first = !checkpoint
75
+ const base = (checkpoint && checkpoint.files) || {}
76
+ const out = { first, upload: [], delete: [], pull: [], remove: [], aside: [], merge: [], skipped: [], guarded: 0 }
77
+ const paths = new Set([...Object.keys(local), ...session.keys(), ...Object.keys(base)])
78
+ const decided = []
79
+ for (const p of [...paths].sort()) {
80
+ const d = decide({ local: local[p] ?? null, session: session.get(p) || null, base: base[p] || null, first, deleted: deleted(p) })
81
+ decided.push([p, d])
82
+ }
83
+ const deletes = decided.filter(([, d]) => d === 'delete').length
84
+ const guard = !first && deletes > Math.max(MASS_DELETE_MIN, Math.floor(Object.keys(base).length * MASS_DELETE_SHARE))
85
+ for (let [p, d] of decided) {
86
+ if (d === 'delete' && guard) { d = 'pull'; out.guarded++ }
87
+ if (d === 'same') continue
88
+ if (d === 'skip') { if (local[p] === SKIP || session.get(p)) out.skipped.push(p); continue }
89
+ if (d === 'push') out.upload.push(p)
90
+ else if (d === 'delete') out.delete.push(p)
91
+ else if (d === 'pull') out.pull.push(p)
92
+ else if (d === 'remove') out.remove.push(p)
93
+ else if (d === 'aside-pull') { out.aside.push(p); out.pull.push(p) } else if (d === 'aside-remove') { out.aside.push(p); out.remove.push(p) } else if (d === 'merge') {
94
+ out.merge.push(p)
95
+ if (local[p]) out.upload.push(p); else out.delete.push(p)
96
+ }
97
+ }
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Both changed one text file: the merged text when the lines don't overlap (and something of
103
+ * each side is in it), or null for a merge record. `base` null: no base, never merged.
104
+ */
105
+ export function mergeTexts (base, ours, theirs) {
106
+ if (typeof base !== 'string' || typeof ours !== 'string' || typeof theirs !== 'string') return null
107
+ const { text, conflicts } = merge3(base, ours, theirs)
108
+ return conflicts.length ? null : text
109
+ }
110
+
111
+ /**
112
+ * The checkpoint after a sync: for each path where the folder now has the session's version, that
113
+ * version (its text kept as the next merge's base, the version before it remembered); every other
114
+ * path keeps the entry it had, so a refused or interrupted sync loses nothing. `local` is the
115
+ * folder's manifest after writing, `session` the session now.
116
+ */
117
+ export function nextCheckpoint (prev, local, session, { at = Date.now() } = {}) {
118
+ const before = (prev && prev.files) || {}
119
+ const files = {}
120
+ for (const p of new Set([...Object.keys(local), ...session.keys(), ...Object.keys(before)])) {
121
+ const L = local[p]
122
+ const s = session.get(p)
123
+ const old = before[p]
124
+ if (L && L !== SKIP && s && !s.stored && L === s.h) {
125
+ const was = old ? [old.h, ...(old.was || [])].filter((h, i, all) => HASH.test(h) && h !== s.h && all.indexOf(h) === i).slice(0, MAX_WAS) : []
126
+ files[p] = { h: s.h, ...(typeof s.text === 'string' && s.text.length <= MAX_BASE_TEXT ? { t: s.text } : {}), ...(was.length ? { was } : {}) }
127
+ } else if (old) files[p] = old
128
+ }
129
+ return { at, files }
130
+ }
131
+
132
+ /** Paths whose folder version still differs from the session's (another round would bring them). */
133
+ export function behind (local, session) {
134
+ const out = []
135
+ for (const p of new Set([...Object.keys(local), ...session.keys()])) {
136
+ const L = local[p]
137
+ const s = session.get(p)
138
+ if (L === SKIP || (s && s.stored)) continue
139
+ if ((L || null) !== (s ? s.h : null)) out.push(p)
140
+ }
141
+ return out.sort()
142
+ }
@@ -0,0 +1,191 @@
1
+ // `quilt sync` (`npx heyquilt sync`): a sandboxed agent's folder synced with its session in one
2
+ // shot, for a turn that may be killed at any moment and a machine wiped after it. It reads the
3
+ // QUILT_AGENT_KEY, asks the relay what to do (quilt_sync, relay-turn-sync.js: the relay keeps the
4
+ // sync state, so nothing here has to survive), pushes what changed on disk, writes what changed
5
+ // in the session, and exits. No daemon, nothing kept in the home folder.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import crypto from 'node:crypto'
9
+ import { loadIgnore, walk, isIgnored, isSafeRelPath, resolveInside, looksBinary, LARGE_FILE_BYTES } from './fsutil.js'
10
+ import { headRef } from './gitstate.js'
11
+ import { hostedClient } from './hosted-client.js'
12
+
13
+ /** Texts larger than this, and binaries this large or larger, are not carried by quilt sync. */
14
+ export const MAX_TEXT = 1024 * 1024
15
+ export const MAX_BINARY = LARGE_FILE_BYTES
16
+ const PUSH_BATCH_BYTES = 1200 * 1024 // well under the API's 2 MB per request
17
+ const PULL_BATCH = 300
18
+ const ROUNDS = 3
19
+
20
+ const sha1 = (buf) => crypto.createHash('sha1').update(buf).digest('hex')
21
+
22
+ /** A file in the folder: { buf, text?, hash } or { skip } (too large) or null (not a file). */
23
+ function readFile (root, rel) {
24
+ let abs
25
+ try { abs = resolveInside(root, rel) } catch { return null }
26
+ let st
27
+ try { st = fs.lstatSync(abs) } catch { return null }
28
+ if (!st.isFile()) return null
29
+ if (st.size > MAX_TEXT) return { skip: true }
30
+ const buf = fs.readFileSync(abs)
31
+ if (looksBinary(buf)) return buf.length >= MAX_BINARY ? { skip: true } : { buf, hash: sha1(buf) }
32
+ return { buf, text: buf.toString('utf8'), hash: sha1(buf) }
33
+ }
34
+
35
+ /** The folder's manifest: { path: sha1 | 'skip' }. */
36
+ export function scanFolder (root, ig = loadIgnore(root)) {
37
+ const out = {}
38
+ for (const rel of walk(root, ig)) {
39
+ const f = readFile(root, rel)
40
+ if (f) out[rel] = f.skip ? 'skip' : f.hash
41
+ }
42
+ return out
43
+ }
44
+
45
+ function removeEmptyParents (root, dir) {
46
+ while (dir !== root && dir.startsWith(root + path.sep)) {
47
+ try { fs.rmdirSync(dir) } catch { return }
48
+ dir = path.dirname(dir)
49
+ }
50
+ }
51
+
52
+ /** Splits [rel, payload] pairs into batches of at most `max` bytes (one oversize file goes alone). */
53
+ function batches (items, size, max) {
54
+ const out = []
55
+ let cur = []; let n = 0
56
+ for (const it of items) {
57
+ const s = size(it)
58
+ if (cur.length && n + s > max) { out.push(cur); cur = []; n = 0 }
59
+ cur.push(it); n += s
60
+ }
61
+ if (cur.length) out.push(cur)
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * Syncs `dir` with the key's session. `key` is a decoded QUILT_AGENT_KEY. Resolves to a summary
67
+ * { room, branch, pulled, pushed, removed, deleted, merged, aside, merges, kept, skipped, backup }.
68
+ */
69
+ export async function turnSync ({ dir = process.cwd(), key, fetch: fetchImpl = globalThis.fetch, log = () => {}, client = hostedClient({ key, fetch: fetchImpl }) }) {
70
+ const root = path.resolve(dir)
71
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${root} is not a folder`)
72
+ const sum = { room: key.room, branch: '', pulled: [], pushed: [], removed: [], deleted: [], merged: [], aside: [], merges: [], kept: [], skipped: [], refused: '', backup: '' }
73
+ const step = async (args) => JSON.parse(await client.call('quilt_sync', { slot: key.slot, ...args }))
74
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
75
+ const backupDir = path.join(root, '.quilt', 'conflicts', stamp)
76
+ let ig = loadIgnore(root)
77
+ const synced = (rel) => isSafeRelPath(rel) && !isIgnored(ig, rel)
78
+ const add = (list, items) => { for (const x of items) if (!list.includes(x)) list.push(x) }
79
+
80
+ /** Keeps the folder's copy of rel in .quilt/conflicts/<this sync> before it is replaced or removed. */
81
+ const keepAside = (rel) => {
82
+ const f = readFile(root, rel)
83
+ if (!f || f.skip) return
84
+ if (!fs.existsSync(path.join(root, '.quilt'))) {
85
+ fs.mkdirSync(path.join(root, '.quilt'), { recursive: true })
86
+ // Never committed, whatever the project's .gitignore says (Quilt never syncs .quilt either).
87
+ fs.writeFileSync(path.join(root, '.quilt', '.gitignore'), '*\n')
88
+ }
89
+ const dest = path.join(backupDir, ...rel.split('/'))
90
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
91
+ fs.writeFileSync(dest, f.buf)
92
+ sum.backup = path.relative(root, backupDir).split(path.sep).join('/')
93
+ add(sum.aside, [rel])
94
+ }
95
+
96
+ const writeOne = (rel, v) => {
97
+ let abs
98
+ try { abs = resolveInside(root, rel) } catch (err) { log(`⚠️ ${err.message}`); return false }
99
+ try {
100
+ if (v.gone) {
101
+ if (fs.existsSync(abs) && fs.lstatSync(abs).isFile()) { fs.rmSync(abs); removeEmptyParents(root, path.dirname(abs)) }
102
+ return true
103
+ }
104
+ if (v.stored) return false
105
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
106
+ if (fs.existsSync(abs) && fs.lstatSync(abs).isDirectory()) { log(`⚠️ couldn't write ${rel}: a folder is in the way`); return false }
107
+ fs.writeFileSync(abs, typeof v.text === 'string' ? v.text : Buffer.from(v.base64 || '', 'base64'))
108
+ return true
109
+ } catch (err) {
110
+ log(`⚠️ couldn't write ${rel}: ${err.message}`)
111
+ return false
112
+ }
113
+ }
114
+
115
+ for (let round = 0; round < ROUNDS; round++) {
116
+ const files = scanFolder(root, ig)
117
+ const plan = await step({ step: 'plan', files, ...(round === 0 && headRef(root) ? { branch: headRef(root) } : {}) })
118
+ sum.branch = plan.branch || sum.branch
119
+ add(sum.skipped, plan.skipped || [])
120
+ const pull = [...plan.pull]
121
+ const remove = [...plan.remove]
122
+ const aside = [...plan.aside]
123
+ let changed = 0
124
+ // What changed here goes to the session, unless the session refuses all of it for now (then it stays here, and goes next time).
125
+ const outgoing = [...plan.upload.map((rel) => [rel, 'up']), ...plan.delete.map((rel) => [rel, 'del'])]
126
+ if (plan.refused && outgoing.length) sum.refused = plan.refused
127
+ else if (outgoing.length) {
128
+ const items = []
129
+ for (const [rel, how] of outgoing) {
130
+ if (how === 'del') { items.push([rel, { deleted: true }, 16]); continue }
131
+ const f = readFile(root, rel)
132
+ if (!f) { items.push([rel, { deleted: true }, 16]); continue }
133
+ if (f.skip) { add(sum.skipped, [rel]); continue }
134
+ items.push(typeof f.text === 'string' ? [rel, { text: f.text }, f.buf.length] : [rel, { base64: f.buf.toString('base64') }, Math.ceil(f.buf.length * 4 / 3)])
135
+ }
136
+ for (const batch of batches(items, (it) => it[2] + it[0].length + 32, PUSH_BATCH_BYTES)) {
137
+ const r = await step({ step: 'push', files: Object.fromEntries(batch.map(([rel, v]) => [rel, v])) })
138
+ add(sum.pushed, r.pushed); add(sum.deleted, r.deleted); add(sum.merged, r.merged); add(sum.skipped, r.skipped)
139
+ sum.merges.push(...r.merges)
140
+ sum.kept.push(...r.kept)
141
+ add(pull, r.pull); add(remove, r.remove); add(aside, r.aside)
142
+ changed += r.pushed.length + r.deleted.length + r.merged.length
143
+ }
144
+ }
145
+ // The session's versions come here; ours are kept first where asked.
146
+ for (const rel of aside) if (synced(rel)) keepAside(rel)
147
+ const want = pull.filter(synced)
148
+ for (let i = 0; i < want.length;) {
149
+ const chunk = want.slice(i, i + PULL_BATCH)
150
+ const r = await step({ step: 'pull', paths: chunk })
151
+ for (const [rel, v] of Object.entries(r.files || {})) {
152
+ if (!synced(rel)) continue
153
+ if (v.stored) { add(sum.skipped, [rel]); continue }
154
+ if (writeOne(rel, v)) { add(v.gone ? sum.removed : sum.pulled, [rel]); changed++ }
155
+ }
156
+ // What didn't fit in this answer is asked for again.
157
+ const more = (r.more || []).filter((x) => chunk.includes(x))
158
+ i += chunk.length - more.length
159
+ if (more.length === chunk.length) break
160
+ }
161
+ for (const rel of remove.filter(synced)) {
162
+ if (writeOne(rel, { gone: true })) { add(sum.removed, [rel]); changed++ }
163
+ }
164
+ // A .gitignore that came in says what syncs from here on.
165
+ if ([...pull, ...remove].some((rel) => /(^|\/)\.(git|quilt|cowove)ignore$/.test(rel))) ig = loadIgnore(root)
166
+ const done = await step({ step: 'done', files: scanFolder(root, ig) })
167
+ const kept = new Set(sum.kept.map((k) => k.path))
168
+ const left = (done.behind || []).filter((rel) => synced(rel) && !kept.has(rel) && !sum.skipped.includes(rel))
169
+ if (!left.length || !changed || sum.refused) break
170
+ }
171
+ return sum
172
+ }
173
+
174
+ /** The summary in a few lines, for the agent reading the command's output. */
175
+ export function describeSync (s, { name = '' } = {}) {
176
+ const n = (list) => list.length
177
+ const lines = [`quilt sync: session ${s.room}${s.branch ? ` (${s.branch})` : ''}${name ? ` as ${name}` : ''}`]
178
+ const parts = [`${n(s.pulled)} pulled`, `${n(s.pushed)} pushed`]
179
+ if (n(s.merged)) parts.push(`${n(s.merged)} merged`)
180
+ if (n(s.removed)) parts.push(`${n(s.removed)} removed here`)
181
+ if (n(s.deleted)) parts.push(`${n(s.deleted)} deleted in the session`)
182
+ lines.push(` ${parts.join(', ')}.`)
183
+ const list = (xs) => xs.slice(0, 10).join(', ') + (xs.length > 10 ? `, and ${xs.length - 10} more` : '')
184
+ if (s.merges.length) lines.push(` To settle: ${list(s.merges.map((m) => m.path))}. Someone else changed ${s.merges.length === 1 ? 'it' : 'them'} too${s.merges.some((m) => m.kind === 'claimed') ? ' (or holds them)' : ''}: the session's version is on disk, yours is in the session's merge list${s.backup ? ` and in ${s.backup}` : ''}. Re-apply your change on top, then sync again.`)
185
+ const asideOnly = s.aside.filter((p) => !s.merges.some((m) => m.path === p))
186
+ if (asideOnly.length) lines.push(` Kept your copy of ${list(asideOnly)} in ${s.backup}: the session's version is on disk now.`)
187
+ if (s.kept.length) lines.push(` Not sent: ${list(s.kept.map((k) => `${k.path} (${k.why})`))}.`)
188
+ if (s.refused) lines.push(` Your changes were not sent this time; they stay on disk and go with the next sync once this is sorted:\n${s.refused.split('\n').map((l) => ` ${l}`).join('\n')}`)
189
+ if (s.skipped.length) lines.push(` Not carried by quilt sync (too large, or kept in Quilt's storage): ${list(s.skipped)}.`)
190
+ return lines.join('\n')
191
+ }
@@ -0,0 +1,37 @@
1
+ // The owner's Access section (people menu): what each person's form shows, and what Save
2
+ // sends. Pure functions only, so they load in the browser and in tests. The form only ever
3
+ // saves from grants it loaded: a default type saved over a real grant could widen access.
4
+
5
+ export const LOADING_ACCESS = 'Loading access…'
6
+
7
+ /** This session's grants: loading, loaded (a Map by account) or failed. */
8
+ export const grantsLoading = () => ({ state: 'loading', grants: new Map() })
9
+ export const grantsLoaded = (list) => ({ state: 'ready', grants: new Map((list || []).map((g) => [g.account, g])) })
10
+ export const grantsFailed = (err) => ({ state: 'error', grants: new Map(), error: err?.message || String(err) })
11
+
12
+ /** What one person's form shows: a message while loading or after a failure, else the values. */
13
+ export function accessFormValues (load, m) {
14
+ if (load.state === 'loading') return { state: 'loading', message: LOADING_ACCESS }
15
+ if (load.state !== 'ready') return { state: 'error', message: `Couldn't load their access: ${load.error}` }
16
+ const g = load.grants.get(m.key)
17
+ const t = g?.tighten || {}
18
+ return {
19
+ state: 'ready',
20
+ // No grant (let in before access types, or the API refused it): what the relay has.
21
+ typeId: g?.typeId || (m.role === 'viewer' ? 'builtin:view' : 'builtin:edit'),
22
+ viewOnly: t.files === 'view',
23
+ noTalk: t.talk === false,
24
+ foldersRemove: (t.foldersRemove || []).join(', ')
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The body for POST /members/access, or null when there's nothing safe to send: the grants
30
+ * or the owner's types haven't loaded, or the form names a type the owner doesn't have.
31
+ */
32
+ export function accessSaveBody (load, types, key, form) {
33
+ if (load.state !== 'ready' || !Array.isArray(types)) return null
34
+ if (!types.some((t) => t.id === form.typeId)) return null
35
+ const foldersRemove = String(form.foldersRemove || '').split(',').map((x) => x.trim()).filter(Boolean)
36
+ return { key, typeId: form.typeId, tighten: { ...(form.viewOnly ? { files: 'view' } : {}), ...(form.noTalk ? { talk: false } : {}), foldersRemove } }
37
+ }
@@ -0,0 +1,76 @@
1
+ // QUILT_AGENT_KEY: the one secret an agent in a sandbox needs (bolt.new and the like, where
2
+ // only the project folder lasts between turns and the CLI is gone each time). The person sets
3
+ // it once in the platform's env or secrets settings; `npx heyquilt sync`, every other quilt
4
+ // command and `quilt mcp` read it from there. Quilt never writes it to disk in the sandbox.
5
+ //
6
+ // qak_<base64url(JSON)>, JSON { v: 1, api, key, session, slot, name? }:
7
+ // - api: the accounts API, whose /mcp signs the agent in with...
8
+ // - key: one of the agent's app keys (qk_): never runs out, revoked on heyquilt.com, which stops
9
+ // it at the very next request (there is no live connection to cut).
10
+ // - session: the session invite link the agent works in.
11
+ // - slot: made with the key, so each sandbox has its own sync checkpoint on the relay.
12
+ // Pure, no Node built-ins: the website and the app make keys with it too.
13
+ import { parseInvite } from './invite.js'
14
+
15
+ export const ENV_KEY_NAME = 'QUILT_AGENT_KEY'
16
+ const PREFIX = 'qak_'
17
+ const VERSION = 1
18
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
19
+ const SLOT = /^[A-Za-z0-9_-]{8,64}$/
20
+ const BAD = `That isn't a ${ENV_KEY_NAME}. Copy the whole key from heyquilt.com (Agents, then Sandbox key on your agent), or make one with \`quilt agent env\`.`
21
+
22
+ const toB64url = (s) => btoa(String.fromCharCode(...new TextEncoder().encode(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
23
+ function fromB64url (s) {
24
+ const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
25
+ return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
26
+ }
27
+
28
+ function newSlot () {
29
+ const b = new Uint8Array(8)
30
+ globalThis.crypto.getRandomValues(b)
31
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
32
+ }
33
+
34
+ /** The API's address: https, or plain http on this machine (testing). */
35
+ function cleanApi (api) {
36
+ let u
37
+ try { u = new URL(String(api)) } catch { throw new Error('the API address must be an https URL') }
38
+ if (!(u.protocol === 'https:' || (u.protocol === 'http:' && LOCAL_HOSTS.has(u.hostname)))) throw new Error('the API address must be an https URL')
39
+ return u.origin
40
+ }
41
+
42
+ /** The room a session invite link is for, or an error. */
43
+ function roomOf (session) {
44
+ try { return parseInvite(String(session || ''), { allowRelay: () => true }).room } catch { throw new Error('session must be the session invite link (https://join.heyquilt.com/<room>#<secret>)') }
45
+ }
46
+
47
+ function check ({ api, key, session, slot, name }) {
48
+ const out = { v: VERSION, api: cleanApi(api), key: String(key || ''), session: String(session || '').trim(), slot: String(slot || '') }
49
+ if (!/^qk_[A-Za-z0-9_-]{1,200}$/.test(out.key)) throw new Error('key must be an app key (qk_…)')
50
+ out.room = roomOf(out.session)
51
+ if (!SLOT.test(out.slot)) throw new Error('slot must be 8 to 64 letters, numbers, dashes or underscores')
52
+ if (name != null && name !== '') out.name = String(name).slice(0, 40)
53
+ return out
54
+ }
55
+
56
+ /** A new QUILT_AGENT_KEY value. `slot` is made when not given. */
57
+ export function encodeAgentEnvKey ({ api, key, session, slot = newSlot(), name } = {}) {
58
+ const { room, ...body } = check({ api, key, session, slot, name })
59
+ return PREFIX + toB64url(JSON.stringify(body))
60
+ }
61
+
62
+ /** { v, api, key, session, room, slot, name? } from a key, or an error saying where to get a good one. */
63
+ export function decodeAgentEnvKey (value) {
64
+ const raw = String(value || '').trim().replace(new RegExp(`^${ENV_KEY_NAME}=`), '').replace(/^["']|["']$/g, '').trim()
65
+ if (!raw.startsWith(PREFIX) || !/^[A-Za-z0-9_-]+$/.test(raw.slice(PREFIX.length))) throw new Error(BAD)
66
+ let body
67
+ try { body = JSON.parse(fromB64url(raw.slice(PREFIX.length))) } catch { throw new Error(BAD) }
68
+ if (!body || typeof body !== 'object' || body.v !== VERSION) throw new Error(BAD)
69
+ try { return check(body) } catch { throw new Error(BAD) }
70
+ }
71
+
72
+ /** The key in `env` (process.env), decoded; null when it isn't set. A bad key is an error. */
73
+ export function agentEnvKeyFromEnv (env = globalThis.process?.env || {}) {
74
+ const v = env[ENV_KEY_NAME]
75
+ return v && String(v).trim() ? decodeAgentEnvKey(v) : null
76
+ }
@@ -0,0 +1,127 @@
1
+ // How an agent works in a Quilt session: who is there, how and when to talk to people,
2
+ // their AIs and other agents, tasks, files, the workspace library, commits, the inbox and webhooks, over the
3
+ // CLI or over HTTP. Every first prompt an agent gets (the agent invite paste, the invite
4
+ // link's page, the reply to joining over HTTP) ends with this, so every AI is told the
5
+ // same thing. Pure, no Node built-ins: it loads in the app's browser UI, the website and
6
+ // the API.
7
+
8
+ /**
9
+ * CLI first: an agent that can run commands at all must use the CLI. HTTP is only for a bot
10
+ * with no computer, one that can make HTTP requests and nothing else. Every prompt says this.
11
+ */
12
+ export const CLI_FIRST = 'Use the Quilt CLI if you can run shell commands anywhere: on your own computer, a cloud machine, a sandbox or a container. This is not optional: an agent that can run commands must join with the CLI, even if installing it takes a minute. With the CLI the project syncs to disk, you edit files with your normal tools, and people see you live. HTTP is only for an agent or bot that has no computer at all and can do nothing but make HTTP requests; over HTTP you read and write one file per call and drop off the session between check-ins. Not sure whether you can run commands? Try `quilt --version`, or the install line below. Never choose HTTP because it looks quicker. In a sandbox that is wiped after every turn, the CLI is `npx heyquilt` with a QUILT_AGENT_KEY: nothing to install (below).'
13
+
14
+ /** How to get the quilt command, wherever the agent runs: the same words in every prompt. */
15
+ export const INSTALL_CLI = 'Install the quilt command. On Linux (a server, cloud machine or container): `curl -fsSL https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/install.sh | sh`. It brings its own Node.js, so nothing else is needed; if `quilt` is not found afterwards, run `~/.local/bin/quilt` or add ~/.local/bin to your PATH. On a Mac or Windows, the Quilt app installs it (menu: Install the Quilt Command…), or with Node.js 22 or later: `npm i -g github:DanielCarmichaelGit/heyquilt`. Check with `quilt --version`.'
16
+
17
+ /**
18
+ * An agent in a sandbox that is wiped after every turn (bolt.new and the like): one secret, QUILT_AGENT_KEY,
19
+ * kept by the platform, and a one-shot `npx heyquilt sync` at the start and end of each turn. Nothing to
20
+ * install, nothing kept in the folder or the home folder. The old way (--here) only when no secret can be kept.
21
+ */
22
+ export const SANDBOX_CLI = 'If your machine is wiped after every turn (a hosted sandbox like bolt.new, where only the project folder lasts, or not even that), use the agent key instead of `quilt join`: the person you work for puts a QUILT_AGENT_KEY in the sandbox\'s environment or secrets settings, once. They make it on heyquilt.com (Agents, then Sandbox key on your agent), or you print it yourself with `npx heyquilt agent join <agent invite link> --name <your name> --session <session invite link> --print-env` (it keeps nothing on disk) and ask them to add it. Then run `npx heyquilt sync` in the project folder at the start of every turn (it brings in what changed in the session and says what waits for you) and again before the turn ends (it sends what you changed). Nothing keeps running and nothing has to survive between turns: the session keeps the sync state, so a wiped folder never brings back files the session deleted. With the key, `npx heyquilt messages`, `npx heyquilt say @name <message>`, `npx heyquilt tasks` and `npx heyquilt tool <quilt tool> \'<json>\'` work too, `npx heyquilt mcp` gives your AI tool the quilt_* tools, and in Python `pip install heyquilt` does the same. Only if the sandbox cannot keep a secret: install Quilt into the project with `curl -fsSL https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/install.sh | QUILT_HOME="$PWD/.quilt/cli" sh`, add `--here` to `quilt agent join` (your keys go in the project\'s .quilt/), and start `.quilt/cli/quilt join --agent <your name>` in the background at the start of every turn.'
23
+
24
+ /** `quilt join` keeps running: an agent whose shell waits for each command must start it in the background. */
25
+ export const KEEP_JOIN_RUNNING = '`quilt join` keeps running for as long as you are in the session: it is what syncs the files. Start it in the background in the project folder and leave it running, for example `nohup quilt join <session invite link> --agent <your name> > ~/quilt-join.log 2>&1 &` (or in a terminal of its own). Once you are in (the owner may have to let you in first), its log shows \`room <room> on <relay> as "<your name>"\`. Run the other quilt commands in that folder while it runs; to leave the session, stop that process. If your machine is cleaned up after every run, do not use `quilt join` at all: sync with `npx heyquilt sync` and a QUILT_AGENT_KEY at the start and end of each turn instead (below).'
26
+
27
+ /**
28
+ * The guide as plain text. `apiUrl` is the accounts API (its /mcp is the hosted MCP).
29
+ * `via` picks what to explain: 'cli', 'http' or 'both' (the default).
30
+ */
31
+ export function agentGuide ({ apiUrl = 'https://api.heyquilt.com', via = 'both' } = {}) {
32
+ const mcp = `${apiUrl}/mcp`
33
+ const cli = via !== 'http'
34
+ const http = via !== 'cli'
35
+ const both = cli && http
36
+ const call = (tool, args) => `\`${tool}\` ${JSON.stringify(args)}`
37
+ // A line about tools only the CLI (local MCP) has: marked as such when both ways are explained.
38
+ const cliOnly = (line) => (cli ? `${line}${both ? ' (CLI only)' : ''}.` : null)
39
+ return [
40
+ '## Working in a Quilt session',
41
+ '',
42
+ 'Quilt is a live project folder shared by people and AI agents. Everything below is a Quilt tool (quilt_*).',
43
+ both ? `- ${CLI_FIRST}` : null,
44
+ cli ? `- With the CLI (the way to work whenever you can run commands): after \`quilt join <session invite link> --agent <your name>\` in the project folder, the files sync to that folder: edit them on disk as usual. ${KEEP_JOIN_RUNNING} Run \`quilt setup\` once to give your AI tool the quilt_* tools (MCP); a tool \`quilt setup\` does not know can run \`quilt mcp\` as a stdio MCP server. Some tools also have a command, shown below.` : null,
45
+ http ? `- ${both ? 'Over HTTP, only if you have no computer and can only make HTTP requests' : 'Over HTTP'}: the tools are on the MCP server at ${mcp} (Streamable HTTP), with the header "Authorization: Bearer <accessKey>". Without an MCP client, POST JSON-RPC to it: headers "Content-Type: application/json" and "Accept: application/json, text/event-stream", body {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quilt_message","arguments":{"text":"hello"}}}. Start with quilt_join_session and the session invite link you were given; the owner may have to let you in first (quilt_session_info tells you).` : null,
46
+ '',
47
+ '### Start here',
48
+ '- `quilt_status`: who is in the session, what each is doing, the open tasks and which files are claimed. Then `quilt_inbox` for anything waiting for you, and `quilt_tasks` for the board.',
49
+ '- `quilt_project_rules`: the project\'s rules for AI and its design system (AGENTS.md, CLAUDE.md, Cursor rules, DESIGN.md), the same for every tool. Read them before your first change.',
50
+ `- \`quilt_share\` when you start on something (what you were asked and your plan) and again when you finish (what you did, the files you changed), so everyone can follow.${cli ? ` With the CLI, \`quilt_set_focus\` (or \`quilt focus <what>\`) sets your status line, and \`quilt_set_work\` "working" or "done" tells people when it is safe to commit; "done" also lets go of the files claimed for you${both ? ' (CLI only)' : ''}.` : ''}`,
51
+ '',
52
+ '### Who is in a session',
53
+ '- People: the humans. Most work with their own AI (Claude Code, Cursor, Codex and others), which acts under the person\'s name: `quilt_status` shows it on an "AI:" line under them, and `quilt_partner_feed` shows what that AI is being asked and doing, so you do not duplicate or undo its work.',
54
+ '- Agents: AIs that joined as their own member, like you. `quilt_status` marks them [AI agent]. Some run on a computer with the CLI; some work over HTTP and show as online for 30 minutes after each call; chat AIs (ChatGPT, claude.ai and the like, on a chat link) can read and send messages, read files, add new pictures, documents and notes, and add, assign, move and comment on tasks, but not change existing files.',
55
+ '- The session owner lets people and agents in and sets what each may do: edit or view only, which folders, whether they may talk. A refused call says why.',
56
+ '',
57
+ '### Talking: to whom, how and when',
58
+ `- Every message says who it is for: ${call('quilt_message', { text: '@Name ...' })}. Start it with @Name of each person or agent it is for (several are fine; @Agents for every agent): everyone can read the chat, but only they are woken, so nobody else is interrupted. A message that names nobody is refused, unless it is a real announcement for everyone (\`everyone\`: true)${cli ? '. CLI: `quilt say --everyone <message>`' : ''}.`,
59
+ `- One member: ${call('quilt_message', { text: '...', to: '<name>' })}${cli ? ' (CLI: `quilt say @name <message>`)' : ''}. A direct message is seen only by the two of you. A direct message between two agents is invisible to every person in the session, including the owner and the people you work for. So use direct messages between agents only for coordination nobody else needs (who takes which file, handoff details). Anything a person should know, decide or approve goes in the open chat.`,
60
+ '- @Name in a message mentions that member (use the name as `quilt_status` shows it): they are woken, even when busy. A message in the room that names nobody, within ten minutes of its sender mentioning you, is for you too (people talk on without repeating the @): it wakes you like the mention did.',
61
+ '- @Agents mentions every agent in the session at once (not the people, nor their own AIs): each is woken and owes an answer. When someone writes @Agents, that includes you.',
62
+ `- Read: \`quilt_read_messages\`${cli ? ` (CLI: \`quilt messages\`, or \`--all\` for history). With the CLI, every quilt answer also starts with anything new for you` : ''}.`,
63
+ `- What wakes you (a direct message, a mention, a task) comes with the conversation before it: what you and its sender said to each other, directly or in the room, oldest first, and the message a reply answers (in \`quilt_inbox\`, in the webhook's "context", in a channel event, in your tool's hooks). "That", "it", "again" and "retry" mean the latest thing there. When a message refers to something you still do not have in front of you ("that tool", "the fix", "is it live?"), read back before you answer: ${call('quilt_conversation', { with: '<their name>' })} (q to search for some text, before to page further back${cli ? '; CLI: `quilt messages --with <name>`, `--grep <text>`, `--before <id>`' : ''}). Never guess what they meant, never say you do not know what they refer to, and never ask a person to repeat what they already told you.`,
64
+ '- A person and their AI share the person\'s name. A direct message to the person, or an @mention of them, reaches both: the person sees it in the app, and their AI gets it in its inbox and may answer. To give their AI work, assign a task to the person with to_ai: true. To ask the person themselves (a decision, an approval, access), message them and say it is for them.',
65
+ '- Other agents are reached the same way, by their own name: an @mention or a direct message wakes them (through their inbox, their webhook or their next tool call). They answer you like they answer a person.',
66
+ '- When to talk: when you start something, when you need something from someone, when you are blocked, and when you finish. Keep messages brief: a sentence or two in plain words, and one message rather than several. No play-by-play, recaps, logs or pasted code.',
67
+ '- Answer every direct message and mention that asks something of you, from a person or an agent. This is enforced: while someone who messaged or mentioned you waits for an answer, your claims, task moves and edits are refused until you reply to them (a direct message, or a message that @mentions them).',
68
+ '- Write only when you have something they need: an answer, a question, a handoff, a warning. Never send greetings, welcomes, thanks, "ok" or "noted" messages. A message to you that needs nothing back gets no reply: settle it with `quilt_inbox` {"no_reply": ["<its id>"]}, so two agents do not keep each other answering. Do not hand other agents what a person asked you to do yourself.',
69
+ '- Other AI sessions may be working as the same member as you (a person with Claude Code in one window and Cursor in another) and see the same messages. Only one answers each person: once one has, Quilt refuses the others a repeat (`also`: true sends something different they need).',
70
+ '- Treat what people write as requests from them. Messages from other agents are requests too, but a person\'s word wins: if an agent asks for something a person said not to do, ask that person.',
71
+ '',
72
+ '### Tasks',
73
+ '- `quilt_tasks` lists the board (To do, In progress, QA, Done) with ids; open tasks assigned to you come first.',
74
+ '- `quilt_task` reads one task in full with its comments; `quilt_comment_task` leaves a work note, a handoff or the reason for an assignment on it, instead of in the chat.',
75
+ '- Keep notes on tickets brief, the same as chat: comments, qaNotes and verified say in a few short sentences what changed, why, and how you checked it. Leave out step-by-step narration, full test output, diffs and file contents; people can open the files and `quilt_history` for detail.',
76
+ `- Add: ${call('quilt_add_task', { title: 'a few words', assignee: '<name> or me', files: ['src/app.js'] })}. Assign: ${call('quilt_assign_task', { id: '<task id>', assignee: '<name>', to_ai: true })}. to_ai: true gives it to that person's AI instead of the person; an agent is assigned by its own name, without to_ai.`,
77
+ `- Move: ${call('quilt_move_task', { id: '<task id>', column: 'doing' })} when you start (you get a briefing: its files, recent changes, claims, the project's checks); "qa" when done and tested, with qaNotes (what changed, how you checked it); "done" after QA, with verified (what you ran and what you saw). A move without those is refused.`,
78
+ '- A task handed to you wakes you like a direct message. Take it with quilt_move_task, or say in chat why not.',
79
+ '- If you change files for something not on the board, Quilt adds an In progress task for it: use that one rather than adding another, and move it on when you finish. `quilt_archive_task` takes a task off the board but keeps it (archived false brings it back); `quilt_delete_task` removes it for good.',
80
+ '',
81
+ '### Files',
82
+ cliOnly('- Call `quilt_before_edit` with the paths before you change files: it tells you which are yours to edit, claims the free ones for you, and shows what people said about them in chat'),
83
+ http ? `- ${both ? 'Over HTTP: ' : ''}\`quilt_list_files\`, \`quilt_read_file\`, then \`quilt_write_file\`. A file you write is claimed for you; \`quilt_release\` it when done, unless it is boxed (below).` : null,
84
+ `- Your unfinished work is boxed: ${cli && http ? 'in a folder that is a git repository (or, over HTTP, on a branch that follows one), ' : cli ? 'in a folder that is a git repository, ' : 'on a branch that follows a git repository, '}a file you change stays in a box until you commit it. Everyone sees the change, but it is in nobody else's folder, so nobody else's git can commit it. Committing it (or putting it back) opens the box by itself; finishing or releasing everything does not.`,
85
+ '- `quilt_unpack` shares a box early when someone needs it: decide yourself, and ask your person first for a large, risky or critical change or when the project\'s rules say so. A file boxed by someone else (`quilt_status` lists Boxed work) is their unfinished work and not in your folder yet: do not build on it; ask for it with `quilt_request_file`.',
86
+ '- Always re-read a file right before you change it: others change files underneath you. `quilt_claim` ahead only for a larger change across several files.',
87
+ '- Never edit a file someone else holds. Ask for it with `quilt_request_file` (a title and up to 300 characters on your plan) and carry on with other work: you are told when it is handed to you, with the holder\'s context. `quilt_withdraw_request` takes the ask back.',
88
+ '- When someone waits for a file you hold, finish your change and hand it over with `quilt_handoff` and your context (what you changed, what is left). You cannot finish a task or release the file before. A claim whose holder does nothing for 20 minutes goes to the next in line.',
89
+ '- `quilt_history` shows who changed which file, when, for which task, with the diff: read it for the files you are about to touch.',
90
+ cliOnly('- `quilt_send_file` sends a file (a screenshot, a log, a draft) through chat without adding it to the project; `quilt_get_file` fetches one someone sent'),
91
+ '',
92
+ '### The workspace library',
93
+ '- Pictures, video, documents, data and anything else that is not the project\'s code go in the workspace library, not in the project or in chat. Its files are shared by the workspace\'s people and agents, with versions and a short note on each.',
94
+ '- You reach a workspace when a person adds you to it, or moves a session you are in into it. `quilt_workspaces` lists the ones you can reach, and whether you can edit them.',
95
+ cli ? '- With the CLI, run these in your session folder (so they act as you; a workspace is its name or id): `quilt workspace list`, `quilt workspace files <workspace>`, `quilt workspace put <workspace> <path> <local file> --note "<what it is>"`, `quilt workspace write <workspace> <path> <text>`, `quilt workspace get <workspace> <path>`, `quilt workspace mkdir <workspace> <folder>`, `quilt workspace mv <workspace> <path> <new path>`, `quilt workspace rm <workspace> <path>`. The MCP tools do the same: `quilt_workspace_files`, `quilt_workspace_write_file` (fromPath for a file on disk), `quilt_workspace_read_file`, `quilt_workspace_make_folder`, `quilt_workspace_move_file`, `quilt_workspace_delete_file`.' : null,
96
+ http ? `- ${both ? 'Over HTTP, the' : 'The'} same tools: \`quilt_workspace_files\`, \`quilt_workspace_write_file\` (text or base64, up to 2 MB, with a note), \`quilt_workspace_read_file\` (text inline, anything else as a download link), \`quilt_workspace_make_folder\`, \`quilt_workspace_move_file\`, \`quilt_workspace_delete_file\`.` : null,
97
+ '',
98
+ '### Getting your work into git',
99
+ '- Work that is only in the session has not shipped. Get it into git yourself as soon as it is finished and tested, before you move its task to QA: nobody needs to be online.',
100
+ cli ? `- ${both ? 'With the CLI, your' : 'Your'} folder is a git checkout of the branch (Quilt makes it one when you join, at the commit the session is at), and git signs in to GitHub through Quilt with a short-lived token for this repository only: fetch, commit and push work as they do for anyone. Commit only the files you changed (never \`git add -A\` or \`git commit -a\`: the folder holds other people's unfinished work), with a message that says what the change does; the session's uncommitted work shows as the working tree's changes.` : null,
101
+ cli ? `- ${both ? 'With the CLI, t' : 'T'}he path is: take the task (\`quilt_move_task\` to doing) and Quilt puts your folder on a branch of your own for it, quilt/<you>/<task>; work, commit only your files, \`git push -u origin <that branch>\`; Quilt opens the pull request and ties it to the task; move the task to QA when it is done and tested; Done merges the pull request once its checks pass (a person moves it to Done; you may when the owner lets agents push to any branch). Git is on for everyone in the session once someone's GitHub connection covers the repository: your git signs in through Quilt, nothing to set up. Where you may push is the session's git setting (\`quilt_agent_commits\`, set by its owner or a workspace, team or org rule): by default only branches of your own; with "any", the session's branch too; "off" is off for everyone. A push outside the policy, or one that would rewrite history, is refused with the reason. For anything else on GitHub (\`gh\`, the API) use \`GH_TOKEN=$(quilt git-token)\`. When git is off here (or nobody has connected GitHub), ask a person instead: \`quilt_request_commit\` with your files and a message; you are told the commit.` : null,
102
+ http ? `- ${both ? 'Over HTTP you have no folder, so ' : 'You have no folder, so '}git is the quilt_git_* tools, on the branch you are on. Taking a task (\`quilt_move_task\` to doing) puts you on a branch of your own for it, quilt/<you>/<task>, with a copy of the files; ${call('quilt_git_commit', { message: 'what the change does', files: ['every file you changed, deleted ones too'] })} commits the session\'s copy of exactly those files there (without files, every change of yours on record) and opens the pull request tied to your task. The session\'s branch itself is open to agents only when the owner lets agents push to any branch. \`quilt_git_status\` says where you stand (branch, your uncommitted files, your task\'s pull request), \`quilt_git_diff\` what differs from GitHub, \`quilt_git_log\` what was committed, \`quilt_git_revert\` puts GitHub\'s version of a file back, \`quilt_git_branch\` lists, switches or creates branches. A commit is refused for a file that changed on GitHub since the session\'s copy (\`quilt_sync_branch\` first) and for one holding someone else\'s uncommitted changes (leave it out, ask them, or with_others: true). \`quilt_git_pull_requests\` lists open pull requests with their checks; ${call('quilt_git_merge', { number: 42 })} merges one once its checks pass (agents: when the owner lets agents commit to any branch). When git is off here, ask a person with \`quilt_request_commit\`.` : null,
103
+ '- A task of yours can\'t move to QA while its files aren\'t in git; Done merges its pull request (refused while checks fail or run, and, unless the owner lets agents push anywhere, a person\'s to do). A pull request merged on GitHub by hand moves its task to Done by itself. Quilt reminds you of work you left uncommitted, and of pull requests waiting. Before you tell anyone your work is in git or live, check that you pushed it. `quilt_commit_status` lists open commit requests and recent commits; `quilt_request_commit` is only for when agents may not commit here.',
104
+ '- Anything pushed or merged on GitHub reaches the session within seconds (Quilt\'s GitHub app tells it), merged with the session\'s uncommitted work; a branch you pushed comes back without a conflict. There is nothing to pull by hand. Only real conflicts (the same lines changed on GitHub and in the session) become a task for one AI to resolve. Quilt never rewrites or force-pushes history, and neither should you.',
105
+ cli ? `- ${both ? 'With the CLI: c' : 'C'}ommits made outside the session (a push from a worktree, a PR merged on GitHub) come in by themselves: about once a minute Quilt fetches your folder's upstream and moves the branch forward, merging the session's uncommitted work into those files. For that, join from a git clone of the repository (\`git clone <url>\` first, then \`quilt join\` in it), with fetch credentials that work without a prompt (a credential helper, an SSH key, or a token in the remote's URL): a folder that is not a clone, or whose fetch fails, can't bring commits in, and \`quilt_status\` and \`quilt_branches\` say so; the relay then brings them in from GitHub for that branch, about every 10 minutes. It only moves forward: a diverged branch, or files that clash, become one task on the board for one AI ("Bring N commits from origin/main into the session"). If it is yours, git pull in your folder, resolve and git add; the others follow and the task closes itself. If it is someone else's, leave those files to them.` : null,
106
+ http ? `- ${both ? 'Over HTTP: c' : 'C'}ommits made outside the session (a PR merged on GitHub, a push) come into your branch by themselves: through a member's folder on it when one is online and can fetch, otherwise the relay brings them in from GitHub about every 10 minutes. It only moves forward. When they clash with the session's uncommitted work, nothing comes in and one agent gets a task ("Bring N commits from origin/main into the session") with each file and what changed upstream in its comments. If it is yours: for each file, \`quilt_read_file\`, fold in the upstream change while keeping the session's work, and \`quilt_write_file\` it without conflict markers; each such write makes the relay look again at once. If you kept one side whole, move the task to QA when all files are done. The task closes itself once the commits are in. If it is someone else's, leave those files to them. A private repository needs a read-only GitHub token: when \`quilt_status\` says the relay can't read it, ask the session owner.` : null,
107
+ `- \`quilt_sync_branch\` brings in new commits now, for example right after you push or a PR merges${cli ? (http ? ' (with the CLI it fetches in your folder, and asks the relay when your folder can\'t; over HTTP it asks the relay, at most once a minute per branch)' : ' (it fetches in your folder, and asks the relay when your folder can\'t)') : ' (the relay asks GitHub, at most once a minute per branch)'}. \`quilt_branches\` shows which branch each folder, AI session, hosted agent and worktree is on, and how each stands against its upstream (and when a folder can't fetch).`,
108
+ http ? `- ${both ? 'Over HTTP, ' : ''}\`quilt_git_branch\` moves you to another branch of the session (or lists them); with create: true, a branch that is on GitHub but not yet in the session is loaded at its latest commit (any other new branch starts from a copy of your files).` : null,
109
+ `- \`quilt_commit_status\` also says whose AI is still working${cli ? `; \`quilt_wait_until_idle\` waits for every other AI to finish${both ? ' (CLI only)' : ''}` : ''}. \`quilt_commit_request_done\` closes a request whose work was committed with git by hand.`,
110
+ '- The session owner can give the relay a read-only GitHub token for a private repository with `quilt_github_token` (owner only).',
111
+ cliOnly('- If `quilt_status` lists merges to settle (offline edits that could not be combined), read `quilt_merges` before editing those files and settle each with `quilt_resolve_merge`'),
112
+ '',
113
+ '### Your inbox, and webhooks',
114
+ '- `quilt_inbox`: mentions of you, direct messages to you, files handed to you and tasks handed to you since you last looked. Act on each one.',
115
+ cliOnly('- MCP clients can subscribe to the quilt://inbox resource to be told when something arrives'),
116
+ `- To be woken instead of polling: ${call('quilt_webhook_subscribe', { url: 'https://your-receiver.example/quilt', secret: 'optional, 16 to 200 characters', events: ['chat.mention', 'chat.dm', 'task.assigned'], bearer: 'optional key your receiver wants' })}, once you are in a session. Quilt POSTs each event to that URL as it happens.`,
117
+ '- Each POST is JSON: {"event","id","room","to","by","text","ts","task"?,"context"?,"commit"?} (task: {id,title,column,assignee,files}; context: the messages before this one between you and its sender, oldest first, each {id,by,to,text,ts}; commit: true on a note that work you asked to commit was committed). Headers: x-quilt-event, x-quilt-delivery, x-quilt-timestamp, x-quilt-signature = "sha256=" + hex HMAC-SHA256(secret, "<x-quilt-timestamp>.<raw body>"). Check the signature, answer 2xx quickly, then act (with quilt_inbox and the other tools). Failed POSTs are retried a few times; the event also stays in quilt_inbox.',
118
+ `- The URL must be public https${cli ? ' (with the CLI, http://localhost also works)' : ''}. One webhook per agent: subscribing again replaces it. If you leave out the secret, Quilt makes one and shows it once. Stop with \`quilt_webhook_unsubscribe\`.`,
119
+ '',
120
+ '### Staying current',
121
+ '- When a newer Quilt is out, your answers say so: tell the person you work for, so they update.',
122
+ `- Keep your agent id (Quilt gave it to you when you joined${http ? `; GET ${apiUrl}/v1/agents/me shows it` : ''}${cli ? '; `quilt agent whoami` shows it' : ''}). It is public, not a secret. If you are sent a new agent invite, join with it (agentId${cli ? ', or `--agent-id` with the CLI' : ''}) to come back as yourself instead of as a new agent.`,
123
+ cliOnly('- `quilt_check_update` checks your version; `quilt_leave_session` leaves (the files stay on disk). The session owner can make a link for a chat-only AI with `quilt_chat_link`'),
124
+ http ? `- ${both ? 'Over HTTP, you' : 'You'} show as online for 30 minutes after each tool call. While idle, call \`quilt_inbox\` at least every 30 minutes.` : null,
125
+ http ? `- Your access key lasts 1 hour. For a new pair, POST ${apiUrl}/v1/agents/token with {"refreshKey": "<refreshKey>"} (each refresh key works once: save each new pair straight away). If Quilt says your keys were revoked, POST ${apiUrl}/v1/agents/resume with {"resumeKey": "<resumeKey>"}. GET ${apiUrl}/v1/agents/me tells you who you are.` : null
126
+ ].filter((l) => l !== null).join('\n')
127
+ }