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,111 @@
1
+ // Webhooks for hosted agents, delivered by the relay: it holds every hosted agent's one
2
+ // subscription (in hosted-agents.json, beside the sessions the agent is in), watches each
3
+ // room's chat and task board, and POSTs to the agent when it is mentioned, sent a direct
4
+ // message or handed a task in any of them (see webhooks.js); each POST says which session
5
+ // and workspace it is from. Delivery from the relay works even when the agent itself is
6
+ // asleep, which is the point: its webhook wakes it.
7
+ //
8
+ // The subscription keeps its own inbox state per session (what it has already been told),
9
+ // separate from quilt_inbox's, so a POSTed event still shows there when the agent asks.
10
+ import { scanInbox } from './inbox.js'
11
+ import { hostedIn, newHosted } from './hosted-agents.js'
12
+ import { readTasks } from './tasks.js'
13
+ import { deliverEvents, makeSubscription } from './webhooks.js'
14
+ import { withContext } from './conversation.js'
15
+
16
+ /** The chat `me` can see: public messages, and direct ones to or from them. */
17
+ const chatFor = (doc, me) => doc.getArray('chat').toArray().filter((m) => m && m.id && (!m.to || m.to === me || m.by === me))
18
+
19
+ /** Each event with the conversation before it, from the room's kept chat (chat-archive.js) when it has one. */
20
+ const inContext = (room, me, events) => {
21
+ const all = room.chatArchive ? room.chatArchive.with(room.doc.getArray('chat').toArray()) : room.doc.getArray('chat').toArray()
22
+ return withContext(events, all.filter((m) => m && m.id && (!m.to || m.to === me || m.by === me)), { names: [me], agent: true })
23
+ }
24
+
25
+ const scan = (doc, name, state) => scanInbox({ messages: chatFor(doc, name), tasks: readTasks(doc.getMap('tasks')), reader: { name, asAi: false, agent: true } }, state)
26
+
27
+ /**
28
+ * `hosted` is the relay's map of hosted agents (account id -> { room, ..., webhook? });
29
+ * `saveHosted` writes it. `fetch` and `delays` are for tests.
30
+ */
31
+ export function hostedWebhooks ({ hosted, saveHosted, log = () => {}, fetch, delays } = {}) {
32
+ const sending = new Map() // account -> the delivery in flight, so one agent's POSTs keep their order
33
+ const watched = new WeakSet()
34
+ const opts = { log, ...(fetch ? { fetch } : {}), ...(delays ? { delays } : {}) }
35
+
36
+ /** Looks for new events in `room` for every subscribed hosted agent in it, and POSTs them. */
37
+ const check = (room) => {
38
+ let changed = false
39
+ for (const [account, h] of hosted) {
40
+ const sub = h && h.webhook
41
+ const r = sub && hostedIn(h, room.name)
42
+ if (!r) continue
43
+ // Only a member gets told: an agent still waiting to be let in sees nothing yet.
44
+ if (room.controlled && !room.meta.members[account]) continue
45
+ // A session the subscription has not taken stock of yet (joined while the room was not loaded,
46
+ // or an entry from before this relay): what is already there is not news.
47
+ if (r.webhookState === undefined) { r.webhookState = scan(room.doc, sub.name, null).state; changed = true; continue }
48
+ const got = scan(room.doc, sub.name, r.webhookState)
49
+ r.webhookState = got.state
50
+ changed = true
51
+ if (!got.events.length) continue
52
+ const events = inContext(room, sub.name, got.events)
53
+ const prev = sending.get(account) || Promise.resolve()
54
+ const next = prev.then(() => deliverEvents(sub, events, { room: room.name, to: sub.name, workspace: room.meta.workspace || '' }, opts)).catch(() => {})
55
+ sending.set(account, next)
56
+ next.then(() => { if (sending.get(account) === next) sending.delete(account) })
57
+ }
58
+ if (changed) saveHosted()
59
+ }
60
+
61
+ /** Watches a room's chat and board; safe to call more than once for the same room. */
62
+ const watch = (room) => {
63
+ if (watched.has(room)) return
64
+ watched.add(room)
65
+ const run = () => { try { check(room) } catch (err) { log(`[${room.name}] webhooks: ${err.message}`) } }
66
+ room.doc.getArray('chat').observe(run)
67
+ room.doc.getMap('tasks').observeDeep(run)
68
+ }
69
+
70
+ /**
71
+ * Makes `account` (named `name`) its one subscription from what the agent gave, taking stock
72
+ * of every session it is in (`rooms`: the loaded ones) so nothing already there is POSTed.
73
+ * Returns it.
74
+ */
75
+ const subscribe = (account, { name, rooms = [] }, given) => {
76
+ // Set before any session too (a workspace agent waits for session.started): the entry starts here.
77
+ let h = hosted.get(account)
78
+ if (!h) { h = newHosted(); hosted.set(account, h) }
79
+ const sub = makeSubscription(given)
80
+ h.webhook = { url: sub.url, secret: sub.secret, events: sub.events, since: sub.since, name, ...(sub.bearer ? { bearer: sub.bearer } : {}) }
81
+ for (const r of Object.values(h.rooms || {})) delete r.webhookState
82
+ for (const room of rooms) takeStock(account, room)
83
+ saveHosted()
84
+ return { ...h.webhook, made: sub.made }
85
+ }
86
+
87
+ /** The agent joined `room` (again): what is there already is not news to its webhook. */
88
+ const takeStock = (account, room) => {
89
+ const h = hosted.get(account)
90
+ const r = h && h.webhook && hostedIn(h, room.name)
91
+ if (!r) return
92
+ r.webhookState = scan(room.doc, h.webhook.name, null).state
93
+ saveHosted()
94
+ }
95
+
96
+ const unsubscribe = (account) => {
97
+ const h = hosted.get(account)
98
+ const had = !!(h && h.webhook)
99
+ if (had) {
100
+ delete h.webhook
101
+ for (const r of Object.values(h.rooms || {})) delete r.webhookState
102
+ saveHosted()
103
+ }
104
+ return had
105
+ }
106
+
107
+ /** Waits for every POST in flight (tests, shutdown). */
108
+ const idle = () => Promise.all([...sending.values()])
109
+
110
+ return { check, watch, subscribe, takeStock, unsubscribe, idle }
111
+ }
@@ -0,0 +1,131 @@
1
+ // Release notes and the update check. RELEASES.md (shipped with the app) says what changed
2
+ // in each version; GitHub's latest release says whether a newer build exists.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { getSettings, saveSettings } from './settings.js'
7
+
8
+ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
+ export const REPO = 'DanielCarmichaelGit/heyquilt'
10
+ const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`
11
+ const RELEASES_PAGE = `https://github.com/${REPO}/releases`
12
+ const CACHE_MS = 10 * 60 * 1000
13
+
14
+ /** The version this app is, from package.json. */
15
+ export function currentVersion () {
16
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
17
+ }
18
+
19
+ /**
20
+ * The sections of a RELEASES.md: [{ version, date, summary, items }], in file order.
21
+ * A section starts at `## <version> — <date>`; its first plain line is the summary and
22
+ * its `- ` lines are the items. Anything before the first section is ignored.
23
+ */
24
+ export function parseReleases (md) {
25
+ const out = []
26
+ let cur = null
27
+ for (const raw of String(md).split(/\r?\n/)) {
28
+ const line = raw.trimEnd()
29
+ const head = line.match(/^## +v?(\d+\.\d+\.\d+)\s*(?:[—–-]+\s*(\d{4}-\d{2}-\d{2}))?\s*$/)
30
+ if (head) { cur = { version: head[1], date: head[2] || '', summary: '', items: [] }; out.push(cur); continue }
31
+ if (!cur) continue
32
+ const item = line.match(/^[-*] +(.*)$/)
33
+ if (item) cur.items.push(item[1].trim())
34
+ else if (line.trim() && !cur.items.length && !cur.summary && !/^#/.test(line)) cur.summary = line.trim()
35
+ }
36
+ return out
37
+ }
38
+
39
+ let local = null
40
+ /** The releases listed in this app's RELEASES.md, newest first. */
41
+ export function localReleases () {
42
+ if (local) return local
43
+ let md = ''
44
+ try { md = fs.readFileSync(path.join(ROOT, 'RELEASES.md'), 'utf8') } catch {}
45
+ local = parseReleases(md).sort((a, b) => compareVersions(b.version, a.version))
46
+ return local
47
+ }
48
+
49
+ /** Semver order: negative when a < b, 0 when equal, positive when a > b. Unparseable versions sort lowest. */
50
+ export function compareVersions (a, b) {
51
+ const num = (v) => { const m = String(v || '').match(/(\d+)\.(\d+)\.(\d+)/); return m ? m.slice(1, 4).map(Number) : null }
52
+ const x = num(a); const y = num(b)
53
+ if (!x && !y) return 0
54
+ if (!x) return -1
55
+ if (!y) return 1
56
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
57
+ return 0
58
+ }
59
+
60
+ /** Where to get the newest build for this computer. */
61
+ export function downloadUrl (platform = process.platform, arch = process.arch) {
62
+ const file = platform === 'darwin' ? `quilt-mac-${arch === 'arm64' ? 'arm64' : 'x64'}.dmg`
63
+ : platform === 'win32' ? 'quilt-windows-x64.exe'
64
+ : platform === 'linux' ? `quilt-linux-${arch === 'arm64' ? 'arm64' : 'x86_64'}.AppImage`
65
+ : null
66
+ return file ? `${RELEASES_PAGE}/latest/download/${file}` : `${RELEASES_PAGE}/latest`
67
+ }
68
+
69
+ /** The notes inside a GitHub release body written by releaseNotesBody (or by hand in that shape). */
70
+ function parseGitHubBody (body) {
71
+ const text = String(body || '').split(/\n\*\*Downloads\*\*/)[0].replace(/^\*\*What's new in [^*]*\*\*\s*/i, '')
72
+ const r = parseReleases(`## 0.0.0\n${text}`)[0] || { summary: '', items: [] }
73
+ return { summary: r.summary, items: r.items }
74
+ }
75
+
76
+ const cache = new Map() // url -> { at, release }
77
+ /**
78
+ * The newest release on GitHub: { version, url, date, summary, items }, or null when it can't be
79
+ * reached and nothing is cached. Asks at most every ten minutes; a failed refresh keeps the last answer.
80
+ */
81
+ export async function latestRelease ({ url = process.env.QUILT_RELEASES_URL || LATEST_API, now = Date.now, timeoutMs = 6000 } = {}) {
82
+ const hit = cache.get(url)
83
+ if (hit && now() - hit.at < CACHE_MS) return hit.release
84
+ try {
85
+ const res = await fetch(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': `quilt/${currentVersion()}` }, signal: AbortSignal.timeout(timeoutMs) })
86
+ if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
87
+ const r = await res.json()
88
+ const version = String(r.tag_name || '').replace(/^v/, '')
89
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('no version in the release')
90
+ const release = { version, url: r.html_url || `${RELEASES_PAGE}/tag/v${version}`, date: String(r.published_at || '').slice(0, 10), ...parseGitHubBody(r.body) }
91
+ cache.set(url, { at: now(), release })
92
+ return release
93
+ } catch {
94
+ if (hit) { hit.at = now(); return hit.release } // try again in ten minutes
95
+ return null
96
+ }
97
+ }
98
+
99
+ /** The last version whose notes this computer has seen. */
100
+ export function seenVersion () { return String(getSettings().seenRelease || '') }
101
+ export function markSeen (version) { saveSettings({ seenRelease: String(version || '') }) }
102
+
103
+ // "Don't show this again" on the update bar: the newer release it was said for. Kept in the
104
+ // settings file, not the page's storage (the app's address changes every launch), and only
105
+ // for that release: a newer one shows the bar again.
106
+ export function hiddenUpdate () { return String(getSettings().hiddenUpdate || '') }
107
+ export function hideUpdate (version) {
108
+ const v = String(version || '')
109
+ if (!/^\d+\.\d+\.\d+$/.test(v)) throw new Error('that is not a version')
110
+ saveSettings({ hiddenUpdate: v })
111
+ }
112
+
113
+ /** The GitHub release body for a RELEASES.md section: the notes, then where to download. */
114
+ export function releaseNotesBody (release) {
115
+ const lines = [`**What's new in ${release.version}**`, '']
116
+ if (release.summary) lines.push(release.summary, '')
117
+ for (const it of release.items) lines.push(`- ${it}`)
118
+ lines.push('', '**Downloads**',
119
+ '- Mac (Apple silicon): quilt-mac-arm64.dmg',
120
+ '- Mac (Intel): quilt-mac-x64.dmg',
121
+ '- Windows: quilt-windows-x64.exe',
122
+ '- Linux desktop: quilt-linux-x86_64.AppImage (Intel/AMD), quilt-linux-arm64.AppImage (ARM)',
123
+ `- Linux servers and cloud machines (no desktop): \`curl -fsSL ${RELEASES_PAGE}/latest/download/install.sh | sh\``,
124
+ '',
125
+ '**Note on signing:** these builds are unsigned (no Apple Developer ID / notarization, no Windows code-signing certificate configured). Windows SmartScreen or macOS Gatekeeper may warn on first launch.',
126
+ '',
127
+ '- **macOS:** if you see "Apple could not verify Quilt is free of malware," click **Done**, then open **System Settings → Privacy & Security**, scroll down and click **Open Anyway** next to Quilt. You only need to do this once. (On older macOS you can instead right-click the app and choose **Open**.)',
128
+ '- **Windows:** if SmartScreen shows "Windows protected your PC," click **More info**, then **Run anyway**.',
129
+ '- **Linux:** make the AppImage runnable (`chmod +x quilt-linux-*.AppImage`) and open it. If it says FUSE is missing, install `libfuse2` (or run it with `--appimage-extract-and-run`).')
130
+ return lines.join('\n') + '\n'
131
+ }
package/src/report.js ADDED
@@ -0,0 +1,129 @@
1
+ // src/report.js
2
+ // Tells Quilt what went wrong in the app (and how long actions took) so problems can be
3
+ // found without anyone filing them. Batches reports to the accounts API, scrubs anything
4
+ // personal first, and never gets in the way: it drops rather than retries, and never throws.
5
+ import { apiUrl } from './account.js'
6
+ import { currentVersion } from './releases.js'
7
+
8
+ // A path on this computer says who you are and how your disk is laid out; its last part
9
+ // (the project folder, the app) is all a report needs. A directory segment may be a few
10
+ // space-joined words of its own ("Program Files (x86)"), so each segment allows up to four.
11
+ // A UNC path (\\server\share\...) is a root of its own kind, same treatment.
12
+ const PATH = /(?:~|[A-Za-z]:|\/(?:Users|home|private|tmp|var|opt|Applications|Volumes|root|mnt|srv|data|workspace)|\\\\[^\s\\]+)(?:[\\/][^\s'"`:,\\/]+(?: [^\s'"`:,\\/]+){0,3})*/g
13
+ // Every token prefix Quilt mints: qd_ (device), qa_/qr_ (agent access/refresh), dc_ (device
14
+ // code), qi_ (org invite), qj_ (agent join). No \b: `_` is a word character, so `\b` would miss
15
+ // a token straight after an underscore (`_qd_...`); instead, the character right before the
16
+ // prefix (if any) just has to not be alphanumeric, and that character is kept in the output.
17
+ const TOKEN = /(?:^|[^A-Za-z0-9])((?:qd|qa|qr|dc|qi|qj)_[A-Za-z0-9_-]+)/g
18
+ // join.heyquilt.com and quilt:// links; also the older/relay form of an invite link, any host,
19
+ // where the secret lives after a `#` on a `/join/...` path.
20
+ const INVITE = /(?:https?:\/\/join\.heyquilt\.com\/[^\s'"`]+|quilt:\/\/[^\s'"`]+|\/join\/[^\s#'"`]+#\S+)/g
21
+ // A username:password (or token) embedded in a URL, e.g. https://x-access-token:ghp_x@github.com/.
22
+ const CREDS = /:\/\/[^/\s:@]+:[^@\s]+@/g
23
+ // GitHub tokens (classic and fine-grained) and JWTs (a Bearer token, including Supabase's).
24
+ const SECRET = /\b(?:gh[pousr]_|github_pat_)\w+|eyJ[\w-]+\.[\w-]+\.[\w-]+/g
25
+
26
+ /** `text` with paths cut to their last part, and tokens, credentials and invite links hidden. */
27
+ export function scrub (text) {
28
+ return String(text ?? '')
29
+ .replace(INVITE, '[invite]')
30
+ .replace(CREDS, '://[secret]@')
31
+ .replace(SECRET, '[secret]')
32
+ .replace(TOKEN, (m, token) => m.slice(0, m.length - token.length) + '[secret]')
33
+ // PATH can absorb a few words of trailing prose past the path's last separator (nothing
34
+ // stops it there) — that's harmless: .pop() returns whatever follows the last separator
35
+ // verbatim, spaces and all, so absorbed prose comes back unchanged. The safety invariant
36
+ // lives here, in the replacement, not in how precisely the regex draws the line.
37
+ .replace(PATH, (m) => m.split(/[\\/]/).filter(Boolean).pop() || '[path]')
38
+ }
39
+
40
+ const scrubContext = (c) => Object.fromEntries(Object.entries(c && typeof c === 'object' ? c : {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v) : v]))
41
+
42
+ export function createReporter ({
43
+ token = () => null, enabled = () => true, fetch = globalThis.fetch, api = apiUrl(), version = currentVersion(), platform = process.platform,
44
+ now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
45
+ batchSize = 20, delayMs = 10_000, maxWaiting = 50, backoffMs = 60_000, log = () => {}
46
+ } = {}) {
47
+ let waiting = []
48
+ let timer = null
49
+ let pausedUntil = 0
50
+ let inflight = null
51
+ let closed = false
52
+
53
+ function record (event) {
54
+ try {
55
+ if (closed || !enabled()) return
56
+ const { kind, name, outcome = 'ok', status, durationMs, message = '', context = {} } = event || {}
57
+ waiting.push({ kind, name: String(name || '').slice(0, 80), outcome, status, durationMs, message: scrub(message).slice(0, 500), context: scrubContext(context), occurredAt: now() })
58
+ if (waiting.length > maxWaiting) waiting.splice(0, waiting.length - maxWaiting)
59
+ if (waiting.length >= batchSize) send()
60
+ else schedule()
61
+ } catch {}
62
+ }
63
+
64
+ function schedule () {
65
+ if (closed || timer || !waiting.length) return
66
+ const delay = Math.max(delayMs, pausedUntil - now())
67
+ timer = setTimer(() => { timer = null; send() }, delay)
68
+ }
69
+
70
+ // Dropped, not retried: a report is never worth a retry storm. One quiet line.
71
+ function fail (err) {
72
+ pausedUntil = now() + backoffMs
73
+ log(`issue report not sent: ${err?.message || err}`)
74
+ }
75
+
76
+ function send () {
77
+ if (inflight || !waiting.length) return
78
+ if (now() < pausedUntil) return schedule()
79
+ if (timer) { clearTimer(timer); timer = null }
80
+ const events = waiting.splice(0, batchSize)
81
+ try {
82
+ const t = token()
83
+ inflight = Promise.resolve().then(() => fetch(`${api}/v1/issues`, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json', ...(t ? { authorization: `Bearer ${t}` } : {}) },
86
+ body: JSON.stringify({ surface: 'app', appVersion: version, platform, events })
87
+ })).then((res) => {
88
+ if (!res || !res.ok) throw new Error(`Quilt answered ${res && res.status}`)
89
+ }).catch(fail).finally(() => {
90
+ inflight = null
91
+ if (waiting.length) schedule()
92
+ })
93
+ return inflight
94
+ } catch (err) {
95
+ // token() (or anything else synchronous above) threw before any request went out:
96
+ // the batch taken off `waiting` is still dropped, same as a failed send.
97
+ fail(err)
98
+ if (waiting.length) schedule()
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Sends what is waiting now, batch after batch, until nothing is left or `timeoutMs` runs
104
+ * out. If a send has nothing to wait on — paused by backoff, or nothing left to send — it
105
+ * stops right there rather than spin: at shutdown a failed batch is dropped, not retried,
106
+ * same as any other time.
107
+ */
108
+ async function flush ({ timeoutMs = 2000 } = {}) {
109
+ const deadline = Date.now() + timeoutMs
110
+ pausedUntil = 0
111
+ while ((waiting.length || inflight) && Date.now() < deadline) {
112
+ const step = inflight || send()
113
+ if (!step) break
114
+ let timeoutId
115
+ const timeout = new Promise((res) => { timeoutId = setTimeout(res, Math.max(0, deadline - Date.now())) })
116
+ await Promise.race([step, timeout]).catch(() => {})
117
+ clearTimeout(timeoutId)
118
+ }
119
+ }
120
+
121
+ /** Stops any future scheduling and flushes what is left, so shutdown never leaves a timer armed. */
122
+ function close () {
123
+ closed = true
124
+ if (timer) { clearTimer(timer); timer = null }
125
+ return flush()
126
+ }
127
+
128
+ return { record, flush, close, waiting: () => waiting.length }
129
+ }