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,46 @@
1
+ // Per-user settings in ~/.quilt/settings.json: your colour, AI tool and session
2
+ // defaults. And the one relay Quilt uses.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { quiltHome } from './legacy.js'
6
+
7
+ /** The relay every session uses. QUILT_SERVER overrides it, for development and tests only. */
8
+ export const HOSTED_RELAY = 'wss://relay.heyquilt.com'
9
+ // The hosted relay's older address points at the same relay, so its sessions reopen as they are.
10
+ const HOSTED_ALIASES = [HOSTED_RELAY, 'wss://cowove-relay.fly.dev']
11
+ // Settings from when you could pick a relay. Ignored, and dropped the next time settings are saved.
12
+ const RETIRED = ['relay', 'relayKey', 'relayMode', 'publicUrl']
13
+
14
+ const file = () => path.join(quiltHome(), 'settings.json')
15
+
16
+ export function getSettings () {
17
+ let s = {}
18
+ try { s = JSON.parse(fs.readFileSync(file(), 'utf8')) || {} } catch {}
19
+ for (const k of RETIRED) delete s[k]
20
+ return s
21
+ }
22
+
23
+ export function saveSettings (patch) {
24
+ const next = { ...getSettings(), ...patch }
25
+ for (const k of Object.keys(next)) if (next[k] === undefined || next[k] === null || next[k] === '') delete next[k]
26
+ fs.mkdirSync(path.dirname(file()), { recursive: true })
27
+ fs.writeFileSync(file(), JSON.stringify(next, null, 2), { mode: 0o600 })
28
+ return next
29
+ }
30
+
31
+ export function relayUrl () {
32
+ return process.env.QUILT_SERVER || HOSTED_RELAY
33
+ }
34
+
35
+ export function isHostedRelay (server) {
36
+ return HOSTED_ALIASES.includes(String(server || '').replace(/\/+$/, ''))
37
+ }
38
+
39
+ /**
40
+ * A saved session on any relay other than Quilt's own (or the QUILT_SERVER relay in use): one that
41
+ * ran on someone's own computer, or through a tunnel to it. Quilt no longer runs those, and must
42
+ * never send this computer's pass to them.
43
+ */
44
+ export function unsupportedRelay (server) {
45
+ return !!server && !isHostedRelay(server) && server !== relayUrl()
46
+ }
package/src/setup.js ADDED
@@ -0,0 +1,184 @@
1
+ // Wires quilt into AI coding tools in the current project: registers the MCP
2
+ // server for Claude Code / Cursor and adds pairing guidance to the agent
3
+ // instruction files. These files live in the project, so they sync to
4
+ // everyone in the session.
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { hookSettings, isQuiltHook } from './hooks.js'
8
+ import { registerEverywhere } from './integrations.js'
9
+ import { TASK_WORKFLOW_MD, CHECKLIST_SCAFFOLD, extractChecklist } from './agent-task-workflow.js'
10
+
11
+ const START = '<!-- quilt:start -->'
12
+ const END = '<!-- quilt:end -->'
13
+
14
+ export const AGENT_GUIDE = `${START}
15
+ ## Live pair session (quilt)
16
+
17
+ This project is being edited **live by more than one person at once**, each
18
+ with their own AI coding tool. Files can change underneath you at any time.
19
+
20
+ - Before starting a task, check what your collaborators are doing: call the
21
+ \`quilt_status\` MCP tool, or run \`quilt status\` in a shell, or read
22
+ \`.quilt/STATUS.md\`.
23
+ - The session has a shared task board (To do, In progress, QA, Done). Read it with
24
+ \`quilt_tasks\`. Add work with \`quilt_add_task\`. Move a task to In progress
25
+ when you start it and to QA with \`qaNotes\` when you finish (\`quilt_move_task\`).
26
+ ${TASK_WORKFLOW_MD}
27
+ - Read the project's rules and design system with \`quilt_project_rules\` before your
28
+ first change: the same rules whatever tool each person uses.
29
+ - See what a partner's AI is doing with \`quilt_partner_feed\`, and where people
30
+ are working with \`quilt_list_files\` (recent edits and claims).
31
+ - Announce what you're working on (\`quilt_set_focus\` / \`quilt focus "..."\`).
32
+ - Share your work with \`quilt_share\`: when you start a request (what was asked,
33
+ your plan) and when you finish (what you did, the files you changed). Partners
34
+ see it in their feed, it goes on the task board, and the host won't commit under you.
35
+ - Before you change files, call \`quilt_before_edit\` with their paths. It tells you
36
+ whether each one is yours to edit (claiming free ones for you) and shows what
37
+ people said about those files in chat. Don't edit a file it refuses.
38
+ - You're a member of your own in the session, apart from your person and their other
39
+ AI sessions, named after your work: "<their first name> · <label>", from your git
40
+ branch or the first thing you say you're doing. Rename yourself with
41
+ \`quilt_name_session\`. Your messages, inbox, claims and duties are your own. People
42
+ see all of a person's AI sessions as one, "<person>'s AI": write to another person's AI
43
+ by that name ("@Daniel's AI"), and what is written to your person's AI reaches whichever
44
+ of their sessions was active last (with none of them live, their cloud AI, if they
45
+ connected one: their own AI tool on their own cloud VM).
46
+ - Claims follow edits, whatever tool you are: the moment you change a file nobody
47
+ holds, Quilt claims it for you, and lets go when you finish (your AI goes idle, or
48
+ the file has been quiet for a few minutes). Claim ahead only for a larger change
49
+ across several files (\`quilt_claim\` / \`quilt claim <path>\`) and release it when
50
+ done (\`quilt_release\` / \`quilt release <path>\`).
51
+ - Your unfinished work is boxed: in a folder that is a git repository, a file you
52
+ change stays in a box until you commit it. Everyone sees the change, but it is only
53
+ in your folder, so nobody else's git can commit it. Committing it (or putting it
54
+ back) opens the box by itself; finishing or releasing does not. \`quilt_unpack\`
55
+ shares a box early when someone needs it: decide yourself, and ask your person first
56
+ for a large, risky or critical change or when the project's rules say so. A file
57
+ boxed by someone else isn't in your folder yet: don't build on it; ask for it with
58
+ \`quilt_request_file\`.
59
+ - If a file is claimed by someone else, your edit is refused or undone, and the next
60
+ quilt tool you call tells you so. Don't retry or work around it: ask for it in its
61
+ file queue (\`quilt_request_file\`: a title like "Working on X for task Y" and up to
62
+ 300 characters on your plan), then carry on with other work. You're woken when it's
63
+ handed to you, with the holder's context.
64
+ - When someone waits in the queue for a file you hold, every quilt answer says so.
65
+ Finish your change, then hand it off with \`quilt_handoff\` and your context (what
66
+ you changed, what's left, gotchas). You can't finish or release it before. A claim
67
+ whose holder does nothing for 20 minutes goes to the next one waiting.
68
+ - Answer collaborators' messages (\`quilt_read_messages\`): help with their change,
69
+ or say when you'll be done. New ones are shown at the top of
70
+ every quilt answer. When you finish, call \`quilt_set_work\` with "done": it is
71
+ refused until everyone who wrote to you has an answer.
72
+ - \`quilt_inbox\` lists what is waiting for you: mentions of you (@yourname), direct
73
+ messages and tasks handed to you. Read it when you start and act on each one.
74
+ - Before moving a ticket to QA, run the checks under "Verifying a change" (in
75
+ AGENTS.md; add them there if the section is missing) and pass a change description
76
+ plus self-validation as \`qaNotes\` to \`quilt_move_task\`. QA without notes is refused.
77
+ Moving to Done still needs \`verified\`.
78
+ - Always re-read a file right before editing it; never rely on an old copy.
79
+ - Prefer small, focused edits over rewriting whole files.
80
+ - Git works as usual here: pull and push yourself, and commit only the files you
81
+ changed (never \`git add -A\` or \`git commit -a\`: the folder holds other people's
82
+ unfinished work). An agent's folder is a checkout Quilt made, and its git signs in
83
+ to GitHub through Quilt within the session owner's policy. Taking a task puts your
84
+ folder on a branch of your own for it (quilt/<you>/<task>); push it and Quilt opens
85
+ the pull request; Done merges it once checks pass (a person moves it to Done unless
86
+ the owner lets agents push anywhere). Pushes and merges on GitHub reach the session
87
+ within seconds, so never pull by hand. Quilt recognises git
88
+ commands, so a stash or reset on this computer never erases the session's
89
+ work (it comes back a moment later), a git conflict pauses this folder until
90
+ you resolve it and \`git add\` it, and checking out another branch moves this
91
+ folder to that branch's work in the session (the old branch keeps its own).
92
+ Deleting a file deletes it for everyone.
93
+ - If git refuses to pull because untracked files would be overwritten, those
94
+ files came from the session: \`quilt_status\` lists them under "Pulling" and
95
+ says whether they match the commits. Make way and pull with
96
+ \`rm <files> && git pull --autostash\`: Quilt keeps them for everyone and the
97
+ pull lands them (where they differ, Quilt merges the two afterwards).
98
+ ${END}`
99
+
100
+ function upsertBlock (file, block) {
101
+ let text = ''
102
+ try { text = fs.readFileSync(file, 'utf8') } catch {}
103
+ const re = new RegExp(`${START}[\\s\\S]*?${END}`)
104
+ const next = re.test(text) ? text.replace(re, block) : (text ? text.replace(/\s*$/, '\n\n') : '') + block + '\n'
105
+ if (next !== text) fs.writeFileSync(file, next)
106
+ return next !== text
107
+ }
108
+
109
+ /**
110
+ * Points a tool's own rules file (CLAUDE.md, GEMINI.md) at AGENTS.md, the one set of rules for
111
+ * every tool (project-rules.js): an "@AGENTS.md" line on top, which Claude Code and Gemini CLI
112
+ * read in. Quilt's guide block comes in with AGENTS.md, so it leaves this file; the rest of the
113
+ * file is the owner's and stays. `create`: write the file when it isn't there. True when written.
114
+ */
115
+ export function pointAtAgents (file, { create = false } = {}) {
116
+ let text = null
117
+ try { text = fs.readFileSync(file, 'utf8') } catch {}
118
+ if (text === null && !create) return false
119
+ const own = (text || '').replace(new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`), '\n').replace(/^\s+/, '')
120
+ const next = /^\s*@AGENTS\.md\s*$/m.test(own) ? own.replace(/\s*$/, '\n') : `@AGENTS.md\n${own.trim() ? `\n${own.replace(/\s*$/, '\n')}` : ''}`
121
+ if (next === text) return false
122
+ fs.writeFileSync(file, next)
123
+ return true
124
+ }
125
+
126
+ /** Where Quilt's Claude Code hooks live: this person's own settings, which never sync or get committed. */
127
+ export const HOOKS_FILE = '.claude/settings.local.json'
128
+
129
+ /**
130
+ * Puts Quilt's hooks into the project's .claude/settings.local.json, replacing earlier
131
+ * Quilt entries and leaving other hooks and settings alone. Returns true when the file changed.
132
+ */
133
+ export function installHooks (root) {
134
+ const file = path.join(root, HOOKS_FILE)
135
+ let prev = ''
136
+ try { prev = fs.readFileSync(file, 'utf8') } catch {}
137
+ let json = {}
138
+ try { json = JSON.parse(prev) || {} } catch {}
139
+ if (typeof json !== 'object' || Array.isArray(json)) json = {}
140
+ const hooks = (json.hooks && typeof json.hooks === 'object' && !Array.isArray(json.hooks)) ? json.hooks : {}
141
+ const ours = isQuiltHook
142
+ for (const [event, entries] of Object.entries(hookSettings())) {
143
+ const kept = (Array.isArray(hooks[event]) ? hooks[event] : [])
144
+ .map((e) => (e && Array.isArray(e.hooks) ? { ...e, hooks: e.hooks.filter((h) => !ours(h)) } : e))
145
+ .filter((e) => e && (!Array.isArray(e.hooks) || e.hooks.length))
146
+ hooks[event] = [...kept, ...entries]
147
+ }
148
+ json.hooks = hooks
149
+ const text = JSON.stringify(json, null, 2) + '\n'
150
+ if (prev === text) return false
151
+ fs.mkdirSync(path.dirname(file), { recursive: true })
152
+ fs.writeFileSync(file, text)
153
+ return true
154
+ }
155
+
156
+ /**
157
+ * Adds the "Verifying a change" template to AGENTS.md when neither guide has that
158
+ * section yet, outside Quilt's block so owners can edit it. Returns true when written.
159
+ */
160
+ export function scaffoldChecklist (root) {
161
+ const read = (f) => { try { return fs.readFileSync(path.join(root, f), 'utf8') } catch { return '' } }
162
+ if (extractChecklist(read('AGENTS.md')) || extractChecklist(read('CLAUDE.md'))) return false
163
+ const file = path.join(root, 'AGENTS.md')
164
+ const text = read('AGENTS.md')
165
+ fs.writeFileSync(file, (text ? text.replace(/\s*$/, '\n\n') : '') + CHECKLIST_SCAFFOLD)
166
+ return true
167
+ }
168
+
169
+ export function setup (root, { home } = {}) {
170
+ const changed = []
171
+ // MCP servers go in each AI tool's own settings on this computer (with this install's path),
172
+ // not in the project, whose files sync to everyone. See integrations.js.
173
+ for (const r of registerEverywhere(home ? { home } : {})) {
174
+ if (r.result === 'added' || r.result === 'updated') changed.push(`${r.file} (${r.name} MCP server)`)
175
+ else if (r.result === 'skipped' || r.result === 'failed') changed.push(`${r.file}: left alone, not plain JSON (${r.name})`)
176
+ }
177
+ if (installHooks(root)) changed.push(`${HOOKS_FILE} (Claude Code hooks: files are claimed as you edit them)`)
178
+ if (upsertBlock(path.join(root, 'AGENTS.md'), AGENT_GUIDE)) changed.push('AGENTS.md (Cursor, Codex, and other agents)')
179
+ // Claude Code and Gemini CLI read AGENTS.md through a pointer, so every tool has the same rules.
180
+ if (pointAtAgents(path.join(root, 'CLAUDE.md'), { create: true })) changed.push('CLAUDE.md (Claude Code: reads AGENTS.md)')
181
+ if (pointAtAgents(path.join(root, 'GEMINI.md'))) changed.push('GEMINI.md (Gemini CLI: reads AGENTS.md)')
182
+ if (scaffoldChecklist(root)) changed.push('AGENTS.md ("Verifying a change": fill in what proves a change works here)')
183
+ return changed
184
+ }
@@ -0,0 +1,46 @@
1
+ // An app opened from Finder or the Dock gets a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin),
2
+ // so git from Homebrew or Xcode's tools isn't found. The PATH your login shell sets up is
3
+ // read once at startup and put in front of it.
4
+ import { execFile } from 'node:child_process'
5
+ import path from 'node:path'
6
+
7
+ const MARK = '__QUILT_PATH__'
8
+
9
+ /**
10
+ * The shell's PATH entries first, then the current ones, each once, in order;
11
+ * on macOS and Linux (`sep` ":"), /usr/bin is always there (the system git lives in it).
12
+ */
13
+ export function mergePath (shellPath, currentPath, sep = path.delimiter) {
14
+ const seen = new Set()
15
+ const out = []
16
+ for (const dir of [...String(shellPath || '').split(sep), ...String(currentPath || '').split(sep), ...(sep === ':' ? ['/usr/bin'] : [])]) {
17
+ if (!dir || seen.has(dir)) continue
18
+ seen.add(dir)
19
+ out.push(dir)
20
+ }
21
+ return out.join(sep)
22
+ }
23
+
24
+ /** The PATH printed between two markers (a shell's startup files may print their own lines), or null. */
25
+ export function pathFromOutput (out) {
26
+ const m = new RegExp(`${MARK}(.*?)${MARK}`, 's').exec(String(out || ''))
27
+ return m && m[1].trim() ? m[1].trim() : null
28
+ }
29
+
30
+ /** Your login shell's PATH, or null when it can't be read within `timeoutMs`. Never rejects. */
31
+ export function loginShellPath ({ shell = process.env.SHELL || '/bin/zsh', timeoutMs = 3000 } = {}) {
32
+ if (process.platform === 'win32') return Promise.resolve(null)
33
+ return new Promise((resolve) => {
34
+ execFile(shell, ['-ilc', `echo "${MARK}\${PATH}${MARK}"`], { timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, (err, stdout) => {
35
+ resolve(err ? null : pathFromOutput(stdout))
36
+ })
37
+ })
38
+ }
39
+
40
+ /** Sets process.env.PATH to your login shell's PATH merged with the current one. Not on Windows: its PATH is left as it is. */
41
+ export async function adoptLoginShellPath (opts) {
42
+ if (process.platform === 'win32') return process.env.PATH
43
+ const shellPath = await loginShellPath(opts)
44
+ process.env.PATH = mergePath(shellPath, process.env.PATH)
45
+ return process.env.PATH
46
+ }
package/src/status.js ADDED
@@ -0,0 +1,199 @@
1
+ // Renders a session status snapshot as Markdown. Used for .quilt/STATUS.md,
2
+ // `quilt status`, and the MCP `quilt_status` tool, so every tool sees the same view.
3
+
4
+ import { branchesMarkdown } from './branches.js'
5
+ import { taskMarkdown } from './tasks.js'
6
+ import { mergeAction } from './merges.js'
7
+ import { catchUpMarkdown } from './catchup.js'
8
+ import { DEFAULT_KEY } from './branchdocs.js'
9
+
10
+ /** A shell word for a path (quoted only when it needs to be). */
11
+ const shellWord = (p) => /^[\w./@+-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`
12
+
13
+ /** The one line that makes way for a pull over files the session put here, and pulls. */
14
+ export function makeWayCommand (paths) {
15
+ return `rm ${paths.map(shellWord).join(' ')} && git pull --autostash`
16
+ }
17
+
18
+ /**
19
+ * What to tell a person's AI when the upstream adds files the session already
20
+ * put in this folder (git refuses to pull over untracked files). `adds`: [{ path, same, waiting }].
21
+ */
22
+ export function pullAdvice ({ upstream, behind, adds }) {
23
+ const inWay = adds.filter((a) => !a.waiting)
24
+ const differ = adds.filter((a) => !a.same)
25
+ const list = (xs) => xs.map((a) => a.path).join(', ')
26
+ const lines = [`${upstream} has ${behind} commit${behind === 1 ? '' : 's'} adding ${list(adds)}, which the session already put in this folder${differ.length ? '' : ' with the same content'}.`]
27
+ if (inWay.length) lines.push(`git won't pull over untracked files: make way and pull with \`${makeWayCommand(inWay.map((a) => a.path))}\`. Quilt keeps them for everyone and doesn't share the removal (a plain delete at any other time deletes for everyone).`)
28
+ else lines.push('They are out of the way: pull now (`git pull --autostash`). Quilt keeps them for everyone meanwhile.')
29
+ for (const a of differ) lines.push(`${a.path} differs from the session's: after the pull Quilt merges the two (a merge record if they clash).`)
30
+ return lines.join(' ')
31
+ }
32
+
33
+ const ago = (ts) => {
34
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
35
+ if (s < 60) return `${s}s ago`
36
+ if (s < 3600) return `${Math.round(s / 60)}m ago`
37
+ return `${Math.round(s / 3600)}h ago`
38
+ }
39
+
40
+ export function renderStatus (st, { asAi = false, mentionYours = false } = {}) {
41
+ const out = []
42
+ out.push(`# Quilt pair session: room \`${st.room}\``)
43
+ out.push('')
44
+ out.push(`Relay: ${st.connected ? 'connected' : '**disconnected** (edits are kept and will sync on reconnect)'} · ${st.fileCount} shared files`)
45
+ if (!st.connected && st.problem) out.push(`⚠️ ${st.problem}`)
46
+ out.push(`You: **${st.me.name}** (${st.me.tool})${st.me.persona ? `, one of ${st.me.of}'s AI sessions` : ''}${st.me.focus ? ` · focus: ${st.me.focus}` : ''}`)
47
+ if (st.workspaceName || st.workspace) out.push(`Workspace: ${st.workspaceName || st.workspace}`)
48
+ out.push('')
49
+
50
+ const away = catchUpMarkdown(st.catchUp, { ago })
51
+ if (away.length) out.push(...away, '')
52
+
53
+ out.push('## Partners online')
54
+ if (!st.peers.length) out.push('_Nobody else is connected right now._')
55
+ for (const p of st.peers.filter((x) => x.name !== st.me.name)) {
56
+ const tools = [p.tool, ...(p.agents || [])].filter((t) => t && t !== 'unknown')
57
+ const kind = p.persona ? ` [AI session of ${p.mine && st.me.persona ? 'your person' : p.of}]` : p.kind === 'agent' ? ' [AI agent]' : ''
58
+ out.push(`- **${p.name}**${kind}${tools.length ? ` (${[...new Set(tools)].join(', ')})` : ''}${p.focus ? `: working on: ${p.focus}` : ''}`)
59
+ const ai = aiLine(p.agent)
60
+ if (ai) out.push(` - AI: ${ai}`)
61
+ const editing = p.editing.slice(0, 8)
62
+ if (editing.length) out.push(` - recently edited: ${editing.map((e) => `\`${e.path}\` (${e.secondsAgo}s ago)`).join(', ')}`)
63
+ }
64
+ out.push('')
65
+ if (st.away && st.away.length) {
66
+ out.push('## Away')
67
+ out.push('_Agents that work in turns, between turns: write to them as usual, they read it at the start of their next turn._')
68
+ for (const a of st.away) out.push(`- **${a.name}** [AI agent]: last here ${ago(a.lastSeen)}`)
69
+ out.push('')
70
+ }
71
+
72
+ out.push('## Tasks')
73
+ out.push(taskMarkdown(st.tasks, st.me.name, { tool: st.me.tool, asAi, mentionYours }))
74
+ out.push('')
75
+
76
+ if (st.branches && st.branches.length) {
77
+ out.push('## Branches')
78
+ out.push(branchesMarkdown(st.branches, { limit: 6 }))
79
+ out.push('')
80
+ }
81
+
82
+ const openCommits = (st.commits || []).filter((r) => r.state === 'open')
83
+ if (openCommits.length) {
84
+ out.push('## Commit requests')
85
+ for (const r of openCommits) {
86
+ const who = r.by === st.me.name ? 'you' : r.by
87
+ const branch = r.branch && r.branch !== DEFAULT_KEY ? ` on \`${r.branch}\`` : ''
88
+ out.push(`- [${r.id}] ${ago(r.ts)}: ${who} asked for a commit${branch}: ${r.message}${r.error ? `. Last try: ${r.error}` : ''}`)
89
+ if (Array.isArray(r.files) && r.files.length) out.push(` Files: ${r.files.slice(0, 20).join(', ')}${r.files.length > 20 ? `, and ${r.files.length - 20} more` : ''}`)
90
+ if (r.description) out.push(` ${String(r.description).replace(/\s+/g, ' ')}`)
91
+ }
92
+ if (st.canCommit) out.push('This folder has git: commit one in the app (Commit and push), or by hand and close it with quilt_commit_request_done.')
93
+ out.push('')
94
+ }
95
+
96
+ const pull = st.git && st.git.pull
97
+ if (pull && pull.adds && pull.adds.length) {
98
+ out.push('## Pulling')
99
+ out.push(pullAdvice(pull))
100
+ for (const a of pull.adds) out.push(`- \`${a.path}\`: ${a.waiting ? 'out of the way, waiting for your pull' : a.same ? 'same content as the session\'s' : 'differs from the session\'s'}`)
101
+ out.push('')
102
+ }
103
+
104
+ // Boxes (docs/superpowers/specs/2026-10-16-git-boxes-design.md): unfinished work only in its owner's folder.
105
+ const boxed = st.claims.filter((c) => c.box)
106
+ if (boxed.length) {
107
+ out.push('## Boxed work')
108
+ for (const c of boxed) out.push(c.by === st.me.name ? `- \`${c.pattern}\`: your unfinished work: in your folder only, until you commit it (or quilt_unpack)` : `- \`${c.pattern}\`: ${c.by}'s unfinished work: not in your folder until they commit or unpack it`)
109
+ out.push('')
110
+ }
111
+ const plain = st.claims.filter((c) => !c.box)
112
+ out.push('## Claimed files')
113
+ if (!plain.length) out.push('_No claims._')
114
+ for (const c of plain) {
115
+ const who = c.by === st.me.name ? 'you' : c.by
116
+ out.push(`- \`${c.pattern}\`: ${who}${c.note ? `: ${c.note}` : ''} (${ago(c.ts)})`)
117
+ }
118
+ out.push('')
119
+
120
+ const merges = (st.merges || []).filter((m) => m.state !== 'done')
121
+ if (merges.length) {
122
+ out.push('## Merges to settle')
123
+ for (const m of merges) {
124
+ const who = m.by === st.me.name ? 'you' : m.by
125
+ const otherName = m.others[0] ? (m.others[0] === st.me.name ? 'you' : m.others[0]) : null
126
+ const action = mergeAction(m)
127
+ const theirsPart = m.theirsHash === null ? ', it was deleted in the session' : otherName ? `, ${otherName} changed it in the session` : ''
128
+ out.push(`- \`${m.path}\` (id ${m.id}): ${who} ${action}${theirsPart}${m.kind === 'ai' ? '; merged by AI, needs a look' : ''}. See quilt_merges.`)
129
+ }
130
+ out.push('')
131
+ }
132
+
133
+ out.push('## Recent activity')
134
+ const act = st.activity.slice(-15).reverse()
135
+ if (!act.length) out.push('_Nothing yet._')
136
+ for (const a of act) {
137
+ const who = a.by === st.me.name ? 'you' : a.by
138
+ if (a.kind === 'pulled') out.push(`- ${ago(a.ts)}: ${who} pulled ${a.detail || 'commits'}`)
139
+ else if (a.kind === 'brought') out.push(`- ${ago(a.ts)}: ${who} brought in ${a.detail || 'commits'}${a.branch ? ` on \`${a.branch}\`` : ''}`)
140
+ else if (a.kind === 'switched') out.push(`- ${ago(a.ts)}: ${who} switched to \`${a.branch}\``)
141
+ else out.push(`- ${ago(a.ts)}: ${who} ${a.kind} \`${a.path}\`${a.detail ? ` (${a.detail})` : ''}`)
142
+ }
143
+ out.push('')
144
+
145
+ out.push('## Changes')
146
+ const changes = st.changes || []
147
+ if (!changes.length) out.push('_No changes yet._')
148
+ for (const p of changes) {
149
+ const who = p.name === st.me.name ? 'you' : `**${p.name}**`
150
+ const line = (g, label) => {
151
+ const n = g.fileCount ?? g.files.length
152
+ const files = g.files.map((f) => `\`${f.path}\` (${fileChange(f)})`)
153
+ if (n > g.files.length) files.push(`and ${n - g.files.length} more`)
154
+ out.push(`- ${who}${label}: ${n} file${n === 1 ? '' : 's'}, +${g.added} -${g.removed} (${ago(g.ts)}): ${files.join(', ')}`)
155
+ }
156
+ // Their own edits, then what their git pulls brought (other people's commits), never mixed.
157
+ if ((p.fileCount ?? p.files.length) || !p.pulled) line(p, '')
158
+ if (p.pulled) line(p.pulled, ' pulled from git')
159
+ }
160
+ out.push('')
161
+
162
+ out.push(`## Messages${st.unread ? ` (${st.unread} unread)` : ''}`)
163
+ if (!st.chat.length) out.push('_No messages._')
164
+ for (const m of st.chat.slice(-10)) out.push(`- ${renderMessage(m, st.me.name)}`)
165
+ out.push('')
166
+ return out.join('\n')
167
+ }
168
+
169
+ function aiLine (a) {
170
+ if (!a) return ''
171
+ if (a.sharing === false) return 'sharing paused'
172
+ if (a.status === 'unavailable') return `${a.tool || 'feed'} unavailable`
173
+ if (!a.tool) return ''
174
+ return `${a.tool} ${a.status === 'working' ? 'is working' : 'idle'} (see quilt_partner_feed)`
175
+ }
176
+
177
+ export function renderMessage (m, me) {
178
+ const from = m.by === me ? 'you' : m.by
179
+ const to = m.to ? ` → ${m.to === me ? 'you' : m.to} (direct, id ${m.id}${m.re ? `, answers ${m.re}` : ''})` : ''
180
+ let line = `${ago(m.ts)} ${m.unread ? '🆕 ' : ''}**${from}${to}:** ${m.text}`
181
+ if (m.file) {
182
+ line += ` 📎 \`${m.file.name}\` (${formatBytes(m.file.size)})`
183
+ line += m.file.localPath ? `, saved at \`${m.file.localPath}\`` : `, id \`${m.id}\``
184
+ }
185
+ return line
186
+ }
187
+
188
+ export function formatBytes (n) {
189
+ if (n < 1024) return `${n} B`
190
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
191
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
192
+ }
193
+
194
+ /** One file's change in a few words: "deleted", "new, +5" or "+12 -1". */
195
+ function fileChange (f) {
196
+ if (f.kind === 'deleted') return 'deleted'
197
+ if (f.kind === 'created') return `new, +${f.added}${f.removed ? ` -${f.removed}` : ''}`
198
+ return `+${f.added} -${f.removed}`
199
+ }
@@ -0,0 +1,109 @@
1
+ // Summaries of your AI chat, made on your computer before anything is
2
+ // shared: your prompts and your AI's replies become a sentence or two. Uses
3
+ // the Claude Code CLI (`claude -p`) when it's installed and signed in, and
4
+ // falls back to shortening the text when it isn't.
5
+ import { spawn } from 'node:child_process'
6
+ import os from 'node:os'
7
+
8
+ const INSTRUCTIONS = {
9
+ prompt: 'Summarize this request a developer gave their AI coding assistant in one plain sentence of at most 20 words. Output only the sentence.',
10
+ reply: 'Summarize this reply from an AI coding assistant in one or two plain sentences, at most 35 words in total. Mention files or decisions if there are any. Output only the summary.',
11
+ task: 'Name the coding task in this request as a short to-do title: 2 to 6 words, starting with a verb, like "Add dark mode" or "Fix login redirect". Output only the title.'
12
+ }
13
+ const MAX_TASK_TITLE = 80
14
+ const SHORT_ENOUGH = 140 // characters: already short, share as is
15
+ const TIMEOUT_MS = 45000
16
+ const RETRY_AFTER_MS = 10 * 60 * 1000 // after the CLI fails, shorten instead for a while
17
+
18
+ /** The first sentence (or first `max` characters) of text. */
19
+ export function shorten (text, max = 160) {
20
+ const flat = String(text || '').replace(/```[\s\S]*?```/g, ' [code] ').replace(/\s+/g, ' ').trim()
21
+ const sentence = flat.match(/^.*?[.!?](\s|$)/)
22
+ const first = sentence && sentence[0].length >= 20 ? sentence[0].trim() : flat
23
+ if (first.length <= max) return first
24
+ const cut = first.slice(0, max - 1)
25
+ return `${cut.slice(0, cut.lastIndexOf(' ') > max / 2 ? cut.lastIndexOf(' ') : cut.length).replace(/[\s,;:]+$/, '')}…`
26
+ }
27
+
28
+ /** Runs a command with text on stdin (in a temp folder, so it never shows up as a chat in the project). */
29
+ function runCli (cmd, args, input) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn(cmd, args, { cwd: os.tmpdir(), env: process.env, stdio: ['pipe', 'pipe', 'pipe'] })
32
+ let out = ''
33
+ let err = ''
34
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('timed out')) }, TIMEOUT_MS)
35
+ child.stdout.on('data', (d) => { out += d })
36
+ child.stderr.on('data', (d) => { err += d })
37
+ child.on('error', (e) => { clearTimeout(timer); reject(e.code === 'ENOENT' ? new Error(`${cmd} is not installed`) : e) })
38
+ child.on('close', (code) => {
39
+ clearTimeout(timer)
40
+ if (code === 0 && out.trim()) resolve(out.trim())
41
+ else reject(new Error((err || out).trim().split('\n').pop() || `${cmd} exited with ${code}`))
42
+ })
43
+ child.stdin.end(input)
44
+ })
45
+ }
46
+
47
+ /** The summarizing command: QUILT_SUMMARY_CMD (for tests or other tools) or Claude Code's CLI. */
48
+ function command (kind) {
49
+ const custom = process.env.QUILT_SUMMARY_CMD
50
+ if (custom) {
51
+ const [cmd, ...args] = custom.split(' ').filter(Boolean)
52
+ return [cmd, [...args, INSTRUCTIONS[kind]]]
53
+ }
54
+ return ['claude', ['-p', '--model', 'haiku', '--no-session-persistence', INSTRUCTIONS[kind]]]
55
+ }
56
+
57
+ /**
58
+ * Returns summarize(kind, text) -> Promise<{ text, how: 'ai'|'shortened'|'as-is' }>.
59
+ * `onWarn` hears (once per failure spell) why summaries fell back to shortening.
60
+ */
61
+ export function createSummarizer ({ onWarn = () => {}, run = runCli } = {}) {
62
+ let brokenUntil = 0
63
+ return async function summarize (kind, text) {
64
+ text = String(text || '')
65
+ if (!INSTRUCTIONS[kind]) return { text, how: 'as-is' }
66
+ if (text.length <= SHORT_ENOUGH) return { text, how: 'as-is' }
67
+ if (Date.now() >= brokenUntil) {
68
+ try {
69
+ const [cmd, args] = command(kind)
70
+ const out = await run(cmd, args, text.slice(0, 20000))
71
+ return { text: out.replace(/\s+/g, ' ').trim().slice(0, 400), how: 'ai' }
72
+ } catch (err) {
73
+ brokenUntil = Date.now() + RETRY_AFTER_MS
74
+ onWarn(/auth|log ?in|sign ?in|OAuth/i.test(err.message)
75
+ ? 'Summaries are shortened for now: your claude CLI isn\'t signed in (run `claude` once in a terminal to sign in).'
76
+ : `Summaries are shortened for now: ${err.message}`)
77
+ }
78
+ }
79
+ return { text: shorten(text), how: 'shortened' }
80
+ }
81
+ }
82
+
83
+ /** One clean line from the model: no quotes, "Title:" label or trailing period. Empty when nothing is left. */
84
+ export function cleanTaskTitle (out) {
85
+ const line = String(out || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean) || ''
86
+ return line.replace(/^(task|title)\s*:\s*/i, '').replace(/^["'`*]+|["'`*]+$/g, '').replace(/[.!]+$/, '').trim().slice(0, MAX_TASK_TITLE)
87
+ }
88
+
89
+ /**
90
+ * Returns taskTitle(request, files) -> Promise<string|null>: a short title for an
91
+ * auto-created task ("Add dark mode"), or null when the CLI isn't available.
92
+ * Unlike summaries, short requests are titled too: "okay, go ahead" is short but no title.
93
+ */
94
+ export function createTaskTitler ({ onWarn = () => {}, run = runCli } = {}) {
95
+ let brokenUntil = 0
96
+ return async function taskTitle (request, files = []) {
97
+ const text = String(request || '').trim()
98
+ if (!text || Date.now() < brokenUntil) return null
99
+ const input = files.length ? `${text.slice(0, 4000)}\n\nFiles it changed: ${files.slice(0, 10).join(', ')}` : text.slice(0, 4000)
100
+ try {
101
+ const [cmd, args] = command('task')
102
+ return cleanTaskTitle(await run(cmd, args, input)) || null
103
+ } catch (err) {
104
+ brokenUntil = Date.now() + RETRY_AFTER_MS
105
+ onWarn(`Task titles use the start of your prompt for now: ${err.message}`)
106
+ return null
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,42 @@
1
+ // Where each branch stands with GitHub (relay-sync.js syncStatus), in words for agents: the
2
+ // quilt_sync_status tool, hosted (relay-mcp.js) and in a folder (mcp.js), from the branch list.
3
+
4
+ const short = (sha) => (typeof sha === 'string' && sha ? sha.slice(0, 7) : '')
5
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
6
+
7
+ /** One line for branch entry `b` ({ key, relay: { repo, sync, ... } }), or null when it doesn't follow GitHub. */
8
+ export function syncLine (b) {
9
+ const r = b && b.relay
10
+ const s = r && r.sync
11
+ if (!s) return null
12
+ const repo = String(r.repo || '').replace(/^github\.com\//, '')
13
+ const at = s.revision ? ` at ${short(s.revision)}` : ''
14
+ const head = `${b.key}${repo ? ` (${repo})` : ''}`
15
+ switch (s.state) {
16
+ case 'synced': return `${head}: in step with GitHub${at}; nothing uncommitted.`
17
+ case 'github-pending': return `${head}: has GitHub's latest${at}; ${plural(s.pending || 0, 'file')} changed in the session and not committed (quilt_git_commit commits yours).`
18
+ case 'conflict-held': return `${head}: ${s.why || `${plural(s.held || 0, 'file')} held for a merge`}${at}; the merger proposes with quilt_clash (quilt_clash status lists them).`
19
+ case 'pr-pending': return `${head}: ${r.leased ? 'another session holds this branch' : 'protected on GitHub'}, so commits go through ${s.pr && s.pr.number ? `pull request #${s.pr.number}${s.pr.url ? ` (${s.pr.url})` : ''}` : 'a pull request (opened with the next commit)'}${at}.`
20
+ case 'offline': return `${head}: GitHub unreachable${s.since ? ` since ${new Date(s.since).toISOString().slice(11, 16)} UTC` : ''}; the session keeps working, and commits asked for go when it answers.`
21
+ case 'permission-held': return `${head}: ${s.why || 'GitHub refused the relay'}.`
22
+ default: return `${head}: not syncing with GitHub (${s.why || 'paused'}).`
23
+ }
24
+ }
25
+
26
+ /** The files of a branch that are GitHub's version, not edited in the session since: one line, or ''. */
27
+ export function fromGithubLine (s) {
28
+ const files = s && Array.isArray(s.fromGithubFiles) ? s.fromGithubFiles : []
29
+ if (!files.length) return ''
30
+ const more = (s.fromGithub || files.length) - files.length
31
+ return ` From GitHub, not edited here since: ${files.map((f) => `${f.path} (${short(f.sha)}${f.by ? ` by ${f.by}` : ''})`).join(', ')}${more > 0 ? `, and ${more} more` : ''}.`
32
+ }
33
+
34
+ /** Every branch that follows GitHub, the caller's first: one line each. */
35
+ export function syncStatusText (list, { mine = '' } = {}) {
36
+ const rows = (Array.isArray(list) ? list : []).filter((b) => b && b.relay && b.relay.sync)
37
+ if (!rows.length) return 'No branch of this session syncs with GitHub (none follows a GitHub repository, or the relay has no git sync).'
38
+ rows.sort((a, b) => (a.key === mine ? -1 : b.key === mine ? 1 : 0))
39
+ return rows.map((b) => [syncLine(b), fromGithubLine(b.relay.sync)].filter(Boolean).join('\n')).filter(Boolean).join('\n')
40
+ }
41
+
42
+ export const SYNC_STATUS_DESCRIPTION = 'Where each branch of the session stands with GitHub: in step (with the commit it is at), files changed and not committed, files held for a merge, commits going through a pull request (a protected branch, or another session holds it), GitHub unreachable, or sync paused and why. Pushes and merges on GitHub come in by themselves, and the files that are GitHub\'s version, not edited here since, are listed with the commit that brought them; nothing goes to GitHub until someone commits.'