heyquilt 0.3.122 → 0.3.140

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 (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
package/bin/quilt.js CHANGED
@@ -28,7 +28,7 @@ Usage:
28
28
  quilt sync [--dir <folder>] Agents in a sandbox: sync this folder with the session once and exit
29
29
  (needs QUILT_AGENT_KEY; run it at the start and end of every turn).
30
30
  With the key, status, messages, say, tasks, task, history, claim,
31
- release, focus, commits, commit-request, tool and mcp work too
31
+ release, focus, tool and mcp work too
32
32
  quilt setup Connect Claude Code / Cursor / others via MCP
33
33
  quilt status Show collaborators, claims, activity, chat
34
34
  quilt history [path] [--by name] [--since 2h] [--task id] [--diff] [-n 30]
@@ -46,16 +46,12 @@ Usage:
46
46
  quilt run -- <command> [args...] Run a command with the session's secrets: it gets NAME=quilt and
47
47
  NAME's base URL (STRIPE_BASE_URL), and its requests go through
48
48
  Quilt's vault; it never sees a value
49
- quilt commit-request <message> [--files a,b] [--task id]
50
- Ask a person to commit for you (when agents may not push here)
51
- quilt commits Open commit requests and recent commits
52
- quilt github Whether agents can push here through GitHub, and the link that connects it
49
+ quilt commit <message> In a session that is a quilt: commit the square this folder follows
53
50
  quilt focus <what you're doing> Tell collaborators what you're working on
54
51
  quilt claim <path|glob> [reason] Mark files as yours for now
55
52
  quilt release <path|glob|*> Release a claim
56
53
  quilt chat-link [name] [--minutes N] Owner: a link for a chat-only AI (ChatGPT, claude.ai, Grok); 10 minutes
57
54
  quilt chat-link extend <name> <minutes> Owner: keep a chat link working for that long from now
58
- quilt github-token [--clear] Owner: a read-only GitHub token (read from stdin) so the relay brings in commits while everyone's offline
59
55
  quilt invite Print this session's invite code
60
56
  quilt workspace list|files|get|put|write|mkdir|mv|rm The workspace library (quilt workspace for help)
61
57
  quilt stop Shut down everything quilt is running (relay, app, syncs)
@@ -108,13 +104,8 @@ async function main () {
108
104
  case 'webhook': return webhookCmd()
109
105
  case 'wake': return wakeCmd()
110
106
  case 'get': return getFile()
111
- case 'commit-request': return commitRequest()
112
- case 'commits': return commits()
113
- case 'github': return githubStatusCmd()
114
- case 'git-token': return gitToken()
107
+ case 'commit': if (!argv.join(' ').trim()) fail('Usage: quilt commit <message>'); return simple('/square-commit', { message: argv.join(' ') }, (r) => r.text)
115
108
  case 'run': return runCmd()
116
- case 'git-credential': return gitCredential()
117
- case 'git-pre-push': return gitPrePush()
118
109
  case 'chat': return chat()
119
110
  case 'focus': return simple('/focus', { text: argv.join(' ') }, () => 'focus updated')
120
111
  case 'claim': return simple('/claim', { pattern: argv[0], note: argv.slice(1).join(' ') }, (r) =>
@@ -130,20 +121,6 @@ async function main () {
130
121
  const name = argv.filter((a, k) => !(i >= 0 && (k === i || k === i + 1))).join(' ')
131
122
  return simple('/chat-link', { name, minutes }, (r) => `Chat link for ${r.name}, until ${new Date(r.expiresAt).toLocaleTimeString()}:\n\n ${r.url}\n\nPaste it into ChatGPT, claude.ai or Grok with "Open this link and follow it to join our Quilt session."\nAnyone with it can act as ${r.name}. Extend it with \`quilt chat-link extend ${r.name} <minutes>\`; once it runs out, make a new one.`)
132
123
  }
133
- case 'github-token': {
134
- // Read from stdin, never the command line (where shell history and ps would keep it).
135
- if (argv[0] === '--clear') return simple('/github-token', { token: '' }, () => 'The relay no longer has a GitHub token for this session.')
136
- if (argv.length) fail('usage: quilt github-token [--clear] (the token is read from stdin: pbpaste | quilt github-token)')
137
- if (process.stdin.isTTY) process.stderr.write('Paste a read-only GitHub token (contents: read) and press Enter: ')
138
- const token = await new Promise((resolve) => {
139
- let buf = ''
140
- process.stdin.setEncoding('utf8')
141
- process.stdin.on('data', (d) => { buf += d; if (process.stdin.isTTY && buf.includes('\n')) { process.stdin.pause(); resolve(buf) } })
142
- process.stdin.on('end', () => resolve(buf))
143
- })
144
- if (!token.trim()) fail('no token given')
145
- return simple('/github-token', { token: token.trim() }, () => 'The relay has the token: it brings in commits from your private repository while nobody\'s folder is online.')
146
- }
147
124
  case 'session': return sessionCmd()
148
125
  case 'invite': return invite()
149
126
  case 'workspace': case 'workspaces': return workspaceCmd()
@@ -234,6 +211,8 @@ async function apiCmd () {
234
211
  siteUrl: env.QUILT_SITE_URL || 'http://localhost:3000',
235
212
  trustProxy: /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''), log: console.log,
236
213
  workspaces: env.QUILT_WORKSPACES === '1' || env.QUILT_WORKSPACES === 'true',
214
+ // New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8): apps read it from /v1/features.
215
+ quiltSourceDefault: env.QUILT_SOURCE_DEFAULT === '1' || env.QUILT_SOURCE_DEFAULT === 'true',
237
216
  // Secrets: the vault (quilt-vault) and the secret it calls this API with; off when not set.
238
217
  vaultSecret: env.QUILT_VAULT_SECRET || '',
239
218
  vaultUrl: env.QUILT_VAULT_URL || 'https://vault.heyquilt.com',
@@ -242,7 +221,7 @@ async function apiCmd () {
242
221
  // Its own machines through Fly's Machines API (QUILT_API_FLY_TOKEN): more as load climbs, fewer as it drops (src/api/scaler.js).
243
222
  apiFly: (await import('../src/api/api-machines.js')).apiFlyFromEnv(env),
244
223
  ...(env.QUILT_API_MAX_MACHINES ? { maxMachines: Number(env.QUILT_API_MAX_MACHINES) } : {}),
245
- // Seam is its own service (docs/seam-service.md): its old routes here go on to it.
224
+ // Seam is its own service (Seam's docs/seam-service.md): its old routes here go on to it.
246
225
  ...(env.QUILT_SEAM_URL !== undefined ? { seamUrl: env.QUILT_SEAM_URL } : {}),
247
226
  ...(env.QUILT_SEAM_CLIENT_ID ? { seamClientId: env.QUILT_SEAM_CLIENT_ID } : {}),
248
227
  ...(env.QUILT_SEAM_SITE_URL ? { seamSiteUrl: env.QUILT_SEAM_SITE_URL } : {}),
@@ -355,6 +334,7 @@ async function join () {
355
334
 
356
335
  const { unsupportedRelay } = await import('../src/settings.js')
357
336
  let conn
337
+ let fresh = false // a session this command starts: a quilt from the start when the API says so (src/quilt-default.js)
358
338
  if (positionals[0]) {
359
339
  try { conn = decodeInvite(positionals[0]) } catch (err) { fail(err.message) }
360
340
  } else if (values.room) {
@@ -366,6 +346,7 @@ async function join () {
366
346
  } else {
367
347
  if (saved.server) console.log("This folder's last session ran on your computer's own relay, which Quilt no longer supports. Starting a new session.")
368
348
  conn = newConn()
349
+ fresh = true
369
350
  console.log('starting a new session')
370
351
  }
371
352
 
@@ -407,6 +388,7 @@ async function join () {
407
388
  apiAuth: auth.apiAuth,
408
389
  identity: auth.identity,
409
390
  inviteServer: saved.room === conn.room ? saved.inviteServer : undefined,
391
+ newSession: fresh,
410
392
  onLog: (m) => console.log(`[${stamp()}] ${m}`),
411
393
  onDebug: process.env.QUILT_DEBUG ? (m) => console.log(`[${stamp()}] debug: ${m}`) : undefined,
412
394
  onFatal: (err) => fail(whyStopped(err))
@@ -471,6 +453,12 @@ async function withAgentKey () {
471
453
  return true
472
454
  }
473
455
 
456
+ /** All of stdin, for `quilt tool <name> -` (the tool's arguments as JSON). */
457
+ const readStdin = () => new Promise((resolve) => {
458
+ let s = ''
459
+ process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', () => resolve(s)); process.stdin.on('error', () => resolve(s))
460
+ })
461
+
474
462
  /** The sandbox's QUILT_AGENT_KEY (src/ui/agent-env-key.js), or null; a malformed one stops here. */
475
463
  async function envKey () {
476
464
  const { agentEnvKeyFromEnv } = await import('../src/ui/agent-env-key.js')
@@ -582,7 +570,7 @@ async function doSetup () {
582
570
  console.log('\nAn AI tool that was already open picks up the "quilt" MCP server when it restarts.')
583
571
  }
584
572
 
585
- // `quilt session migrate [--dry-run]` (plans/quilt-source-control/16): what making this folder's
573
+ // `quilt session migrate [--dry-run]` (Seam's plans/quilt-source-control/16): what making this folder's
586
574
  // session a quilt (Seam) would do, read only, or, without --dry-run, the move itself. The relay
587
575
  // does both, behind QUILT_SOURCE_URL there, and only for the session's owner. --switch then moves
588
576
  // the session's files into its squares (layout 3); --back returns it to its branches. `follow <folder>`
@@ -592,11 +580,45 @@ async function sessionCmd () {
592
580
  if (!argv[1]) fail('Usage: quilt session follow <folder>')
593
581
  return simple('/quilt-follow', { dir: path.resolve(argv[1]) }, (r) => r.text)
594
582
  }
595
- if (argv[0] !== 'migrate') fail('Usage: quilt session migrate [--dry-run | --switch | --back] | quilt session follow <folder>')
583
+ if (argv[0] !== 'migrate') fail('Usage: quilt session migrate [--dry-run | --link-only | --switch | --back [--carried <square,...>] | --shadow <quilt> | --unshadow] | quilt session follow <folder>')
596
584
  if (argv.includes('--dry-run')) return simple('/quilt-plan', {}, (r) => r.text)
597
- if (argv.includes('--switch')) return simple('/quilt-switch', {}, (r) => r.text)
598
- if (argv.includes('--back')) return simple('/quilt-unswitch', {}, (r) => r.text)
599
- return simple('/quilt-migrate', {}, (r) => r.text)
585
+ if (argv.includes('--shadow')) {
586
+ const q = argv[argv.indexOf('--shadow') + 1]
587
+ if (!q || q.startsWith('-')) fail('Usage: quilt session migrate --shadow <quilt> (the quilt an import of this session\'s git remote fills)')
588
+ return simple('/quilt-shadow', { quilt: q }, (r) => r.text)
589
+ }
590
+ if (argv.includes('--unshadow')) return simple('/quilt-shadow', { quilt: null }, (r) => r.text)
591
+ if (argv.includes('--switch')) return sessionMove('/quilt-switch', {}, 'into its squares')
592
+ if (argv.includes('--back')) {
593
+ // --carried <id,id>: open squares whose work was carried over by hand (seam diff <square> | git apply).
594
+ const i = argv.indexOf('--carried')
595
+ const carried = i >= 0 && argv[i + 1] ? argv[i + 1].split(',').map((x) => x.trim().replace(/^sq:/, '')).filter(Boolean) : []
596
+ return sessionMove('/quilt-unswitch', { carried }, 'back to its branches')
597
+ }
598
+ // "Make this a quilt": made a quilt and moved into its squares in one go (16 flag 9); --link-only stops after the first.
599
+ if (argv.includes('--link-only')) return sessionMove('/quilt-migrate', {}, 'into Seam')
600
+ return sessionMove('/quilt-make', {}, 'into Seam')
601
+ }
602
+
603
+ /**
604
+ * A move of the session (into Seam, or back), through this folder's app. The relay does it; on a large
605
+ * session that takes minutes, so it says so first. An app that can't follow a square (older than
606
+ * 0.3.127) is sent away by the relay at the end of the move, and this command's answer is lost with it:
607
+ * refused here before anything moves. If the app stops answering meanwhile, the relay still finishes
608
+ * (or undoes) the move on its own: said, with how to see which.
609
+ */
610
+ async function sessionMove (route, body, where) {
611
+ const { d, call } = await daemonOrFail()
612
+ let info = null
613
+ try { info = await call(d, 'GET', '/info') } catch (err) { fail(err.message) }
614
+ const { moveRefusal } = await import('../src/control.js')
615
+ const refused = moveRefusal(info, (await import('../src/releases.js')).currentVersion())
616
+ if (refused) fail(refused)
617
+ console.error(`Moving the session ${where}. The relay does it; a large session takes a few minutes. Keep Quilt open until this answers.`)
618
+ try { console.log((await call(d, 'POST', route, body)).text) } catch (err) {
619
+ if (err.cut) fail(`Quilt's app for ${d.dir} stopped answering while the relay moved the session (${err.code || 'the connection closed'}), so this command never heard how it went. The relay finishes the move, or undoes it, on its own. Open the session in Quilt again (if it says the session needs a newer version of Quilt, update Quilt first); then quilt status says whether this folder follows its square.`)
620
+ fail(err.message)
621
+ }
600
622
  }
601
623
 
602
624
  async function daemonOrFail () {
@@ -614,12 +636,16 @@ async function status () {
614
636
  async function history () {
615
637
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { by: { type: 'string' }, since: { type: 'string' }, task: { type: 'string' }, diff: { type: 'boolean' }, n: { type: 'string', short: 'n' } } })
616
638
  const { formatHistory } = await import('../src/history.js')
617
- await simple('/history', { path: positionals[0], by: values.by, since: values.since, task: values.task, limit: Number(values.n || 30) }, (r) => formatHistory(r.entries, { withDiff: !!values.diff }))
639
+ await simple('/history', { path: positionals[0], by: values.by, since: values.since, task: values.task, limit: Number(values.n || 30), with_diff: !!values.diff }, (r) => `${formatHistory(r.entries, { withDiff: !!values.diff })}${r.note ? `\n\n${r.note}` : ''}`)
618
640
  }
619
641
 
620
642
  async function simple (route, body, format, method = 'POST') {
621
643
  const { d, call } = await daemonOrFail()
622
- try { console.log(format(await call(d, method, route, body))) } catch (err) { fail(err.message) }
644
+ try { console.log(format(await call(d, method, route, body))) } catch (err) {
645
+ // The app doesn't know the route: it runs an older Quilt than this command.
646
+ if (err.status === 404 && err.message === 'not found') fail(`Quilt's app for ${d.dir} runs an older Quilt that doesn't have this yet. Update Quilt (or restart it on the new version), then try again.`)
647
+ fail(err.message)
648
+ }
623
649
  }
624
650
 
625
651
  /** Splits "@bob rest of message" into { to: 'bob', rest: [...] }. */
@@ -734,99 +760,6 @@ async function wakeCmd () {
734
760
  return simple('/wake', { args }, (r) => r.text)
735
761
  }
736
762
 
737
- async function commitRequest () {
738
- const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { files: { type: 'string' }, task: { type: 'string' } } })
739
- const files = values.files ? values.files.split(',').map((f) => f.trim()).filter(Boolean) : undefined
740
- await simple('/commit-request', { message: positionals.join(' '), files, task: values.task }, (r) =>
741
- `asked for a commit [${r.id}] of ${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 12).join(', ')}${r.files.length > 12 ? ', …' : ''}\n${r.committer}`)
742
- }
743
-
744
- // git in an agent's folder runs these (installGitHelpers, gitstate.js); `--dir` is the folder, whose
745
- // running session answers. They never print anything but what git expects, and never fail a git
746
- // command when Quilt isn't running: git then behaves as it would without Quilt. Nor do they make git
747
- // wait: a session that doesn't answer within HELPER_WAIT is taken as no session, and the whole helper
748
- // exits by HELPER_LIMIT whatever is still pending (the env overrides are for tests).
749
- const HELPER_WAIT = Number(process.env.QUILT_HELPER_WAIT_MS) || 4000
750
- const HELPER_LIMIT = Number(process.env.QUILT_HELPER_LIMIT_MS) || 10000
751
- function helperDeadline () { setTimeout(() => process.exit(0), HELPER_LIMIT).unref() }
752
- function helperDir () {
753
- const i = argv.indexOf('--dir')
754
- const dir = i >= 0 ? argv[i + 1] : process.cwd()
755
- if (i >= 0) argv.splice(i, 2)
756
- return dir
757
- }
758
- async function helperDaemon (dir) {
759
- const { findDaemon, call } = await import('../src/control.js')
760
- const d = findDaemon(dir)
761
- return d ? { d, call } : null
762
- }
763
- // git closes stdin after what it sends; one that stays open is read for HELPER_WAIT at most.
764
- const readStdin = () => new Promise((resolve) => {
765
- let s = ''
766
- const done = () => { clearTimeout(t); process.stdin.pause(); resolve(s) }
767
- const t = setTimeout(done, HELPER_WAIT)
768
- process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', done); process.stdin.on('error', done)
769
- })
770
-
771
- /**
772
- * git's credential helper protocol: `get` answers with the session's token for the host; store and erase
773
- * are nothing to do. `--fallback` (a person's folder, git for everyone): when no Quilt session answers
774
- * (the app is closed) or it has no token, the person's own helpers are asked instead, as git would have
775
- * without Quilt: `git credential fill` from outside the folder, so only their global and system config count.
776
- */
777
- async function gitCredential () {
778
- const dir = helperDir()
779
- const fallback = argv.includes('--fallback')
780
- if (fallback) argv.splice(argv.indexOf('--fallback'), 1)
781
- if (argv[0] !== 'get') return
782
- helperDeadline()
783
- const input = await readStdin()
784
- const fields = Object.fromEntries(input.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)] }))
785
- try {
786
- const h = await helperDaemon(dir)
787
- if (h) {
788
- const r = await h.call(h.d, 'POST', '/git/credential', { host: fields.host || '', protocol: fields.protocol || '' }, { timeoutMs: HELPER_WAIT })
789
- if (r && r.username && r.password) return process.stdout.write(`username=${r.username}\npassword=${r.password}\n`)
790
- }
791
- } catch {}
792
- if (fallback) await ownCredential(input)
793
- }
794
-
795
- /** The person's own credentials for what git asked (`input`, git's credential fields), or nothing: never a prompt. */
796
- async function ownCredential (input) {
797
- const os = await import('node:os')
798
- const { execFile } = await import('node:child_process')
799
- const away = os.tmpdir()
800
- const out = await new Promise((resolve) => {
801
- const c = execFile('git', ['credential', 'fill'], { cwd: away, encoding: 'utf8', timeout: Math.max(1000, HELPER_LIMIT - HELPER_WAIT - 1000), killSignal: 'SIGKILL', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_CEILING_DIRECTORIES: path.dirname(away) } }, (err, stdout) => resolve(err ? '' : stdout))
802
- c.stdin.end(input.endsWith('\n\n') ? input : `${input.replace(/\n*$/, '')}\n\n`)
803
- })
804
- const lines = out.split('\n').filter((l) => /^(username|password)=/.test(l))
805
- if (lines.length === 2) process.stdout.write(`${lines.join('\n')}\n`)
806
- }
807
-
808
- /** The pre-push hook: refs on stdin (local ref, local sha, remote ref, remote sha), the remote's name and URL as arguments. Exits 1 with the reason when the push may not go. */
809
- async function gitPrePush () {
810
- const dir = helperDir()
811
- const [remote = 'origin'] = argv
812
- helperDeadline()
813
- const input = await readStdin()
814
- const refs = input.split('\n').filter(Boolean).map((l) => { const [localRef, localSha, remoteRef, remoteSha] = l.trim().split(/\s+/); return { localRef, localSha, remoteRef, remoteSha } })
815
- try {
816
- const h = await helperDaemon(dir)
817
- if (!h) return
818
- const r = await h.call(h.d, 'POST', '/git/push-check', { remote, refs }, { timeoutMs: HELPER_WAIT })
819
- if (r && r.ok === false) { process.stderr.write(`Quilt: ${r.why || 'this push is not allowed here'}\n`); process.exitCode = 1 }
820
- } catch {}
821
- }
822
-
823
- /** A short-lived token for this folder's repository (agents), for gh or GitHub's API. */
824
- /** `quilt git-token` no longer prints a token (a value in a terminal is a value in an AI's context). */
825
- async function gitToken () {
826
- const { GIT_TOKEN_GONE } = await import('../src/secret-run.js')
827
- fail(GIT_TOKEN_GONE)
828
- }
829
-
830
763
  /** `quilt run -- <command>`: the command gets its secrets' stand-ins and gateways (src/secret-run.js). */
831
764
  async function runCmd () {
832
765
  const { RUN_USAGE, runSource, runWithSecrets } = await import('../src/secret-run.js')
@@ -840,30 +773,6 @@ async function runCmd () {
840
773
  try { process.exitCode = await runWithSecrets({ command, source }) } catch (err) { fail(err.message) }
841
774
  }
842
775
 
843
- async function githubStatusCmd () {
844
- const { d, call } = await daemonOrFail()
845
- const st = await call(d, 'GET', '/status')
846
- const mine = (st.branches || []).find((b) => st.git && b.name === st.git.key) || (st.branches || []).find((b) => b.default)
847
- const g = mine && mine.relay && mine.relay.github
848
- const repo = mine && mine.relay && mine.relay.repo
849
- if (!repo) return console.log('This session does not follow a GitHub repository (no member\'s folder is a clone of one), so there is nothing to connect.')
850
- const words = { ok: `Git is on for everyone here, through @${g && g.login}'s connection: people and agents push with git, nothing to set up`, 'not-connected': 'not connected: nobody in the session has connected GitHub yet', 'not-installed': 'Quilt is not installed on this repository on GitHub yet', 'no-access': `no connected GitHub account (@${g && g.login}) may write to it`, 'not-github': 'not on GitHub' }
851
- console.log(`${repo}: ${g ? words[g.state] || g.state : 'not checked yet'}`)
852
- if (g && g.state === 'ok') return
853
- const { readAccount } = await import('../src/account.js')
854
- const token = readAccount()?.token
855
- if (!token) return console.log('Sign in (quilt login), then run this again for the link that connects GitHub.')
856
- const { githubConnectUrl } = await import('../src/account.js')
857
- try { console.log(`Connect it (one click, once, by someone whose GitHub account can write to the repository):\n ${await githubConnectUrl({ token, repo })}`) } catch (err) { console.log(`Could not get the link: ${err.message}`) }
858
- }
859
-
860
- async function commits () {
861
- const { d, call } = await daemonOrFail()
862
- const { describeCommitRequests } = await import('../src/commit.js')
863
- const c = await call(d, 'GET', '/commits')
864
- console.log(plain(describeCommitRequests(c, { canCommit: !!c.canCommit })))
865
- }
866
-
867
776
  async function getFile () {
868
777
  if (!argv[0]) fail('usage: quilt get <message-id> [dest]')
869
778
  await simple('/get', { id: argv[0], dest: argv[1] ? path.resolve(argv[1]) : undefined }, (r) => `saved to ${r.path}`)
@@ -982,4 +891,5 @@ function fail (msg) {
982
891
  process.exit(1)
983
892
  }
984
893
 
985
- main().catch((err) => fail(err.stack || err.message))
894
+ // An app that can't be reached is said in words (control.js unreachable), not as a stack.
895
+ main().catch((err) => fail(err.unreachable ? err.message : err.stack || err.message))
package/desktop/main.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // keep syncing from the menu bar when the window is closed, and invite links
3
3
  // (quilt://join?invite=…) open straight into the join screen.
4
4
  import '../src/quiet-warnings.js'
5
- import { app, BrowserWindow, Tray, Menu, shell, dialog, ipcMain, nativeTheme, systemPreferences } from 'electron'
5
+ import { app, BrowserWindow, Tray, Menu, shell, dialog, ipcMain, nativeTheme, systemPreferences, desktopCapturer } from 'electron'
6
6
  import { execFile } from 'node:child_process'
7
7
  import fs from 'node:fs'
8
8
  import os from 'node:os'
@@ -18,6 +18,7 @@ import { registerOnStart } from '../src/integrations.js'
18
18
  import { adoptLoginShellPath } from '../src/shell-path.js'
19
19
  import { getSettings } from '../src/settings.js'
20
20
  import { allowPermission, checkPermission } from './media-permission.js'
21
+ import { shareHandler } from './screen-share.js'
21
22
 
22
23
  adoptLegacyEnv()
23
24
  // Chromium's own error log goes to the terminal the app runs in. In a voice call it reports every
@@ -40,6 +41,7 @@ let tray = null
40
41
  let quitting = false
41
42
  let pendingInvite = null
42
43
  let rendererReady = false
44
+ let shareWired = false // the screen share handler (and its answer channel), set up with the first window
43
45
 
44
46
  /** The invite link inside a quilt:// URL, or null. */
45
47
  function inviteFrom (url) {
@@ -206,10 +208,22 @@ function createWindow () {
206
208
  const ses = win.webContents.session
207
209
  ses.setPermissionRequestHandler((wc, permission, done, details) => {
208
210
  if (!allowPermission(permission, details, origin)) return done(false)
209
- if (permission === 'media' && process.platform === 'darwin') return systemPreferences.askForMediaAccess('microphone').then(done, () => done(false))
211
+ if (permission === 'media' && process.platform === 'darwin' && details.mediaTypes?.includes('audio')) return systemPreferences.askForMediaAccess('microphone').then(done, () => done(false))
210
212
  done(true)
211
213
  })
212
214
  ses.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => checkPermission(permission, requestingOrigin, details, origin))
215
+ // Sharing a screen in a call: the Mac's own picker where there is one (macOS 15+), otherwise the
216
+ // page shows what can be shared (screen-share.js).
217
+ if (!shareWired) {
218
+ shareWired = true
219
+ ses.setDisplayMediaRequestHandler(shareHandler({
220
+ win: () => win,
221
+ appOrigin: origin,
222
+ capturer: desktopCapturer,
223
+ ipcMain,
224
+ screenAccess: () => process.platform === 'darwin' ? systemPreferences.getMediaAccessStatus('screen') : 'granted'
225
+ }), { useSystemPicker: true })
226
+ }
213
227
  win.webContents.setWindowOpenHandler(({ url }) => {
214
228
  if (/^https?:/.test(url) && !url.startsWith(origin)) shell.openExternal(url)
215
229
  return { action: 'deny' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.122",
3
+ "version": "0.3.140",
4
4
  "description": "Real-time, tool-agnostic pair vibe coding: sync a project folder live between collaborators using any AI coding tool.",
5
5
  "author": {
6
6
  "name": "Daniel Carmichael",
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@fontsource/ibm-plex-mono": "^5.3.0",
39
39
  "@fontsource/poppins": "^5.3.0",
40
- "@heyquilt/seam": "^0.17.0",
40
+ "@heyquilt/seam": "0.237.313",
41
41
  "@modelcontextprotocol/sdk": "^1.30.1",
42
42
  "@noble/hashes": "^2.4.0",
43
43
  "@supabase/supabase-js": "^2.117.2",
@@ -84,6 +84,7 @@
84
84
  "desktop/preload.cjs",
85
85
  "desktop/updater.js",
86
86
  "desktop/media-permission.js",
87
+ "desktop/screen-share.js",
87
88
  "desktop/icons/*.png",
88
89
  "desktop/icons/*.ico",
89
90
  "package.json",
package/src/account.js CHANGED
@@ -224,35 +224,6 @@ export async function removeSandboxKey ({ token, id, keyId, api = apiUrl(), fetc
224
224
  return call(fetchImpl, api, 'DELETE', sandbox$(id, `/${encodeURIComponent(keyId)}`), null, token)
225
225
  }
226
226
 
227
- /** This account's GitHub connection (Quilt's GitHub App): { available, connected, login? }. */
228
- export async function githubStatus ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
229
- return call(fetchImpl, api, 'GET', '/v1/me/github', null, token)
230
- }
231
-
232
- /** Where to send this person to connect GitHub (install Quilt's app on their repositories). */
233
- export async function githubConnectUrl ({ token, repo = '', scope = '', id = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
234
- const r = await call(fetchImpl, api, 'POST', '/v1/github/connect', { ...(repo ? { repo } : {}), ...(scope ? { scope, id } : {}) }, token)
235
- if (!r.url) throw new Error(BAD_REPLY)
236
- return r.url
237
- }
238
-
239
- /** A workspace's GitHub (plan §2): its connections, access mode and repositories, and its org's switch. */
240
- export async function workspaceGithub ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
241
- return call(fetchImpl, api, 'GET', `/v1/workspaces/${encodeURIComponent(id)}/github`, null, token)
242
- }
243
- export async function setWorkspaceGithub ({ token, id, access, repos, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
244
- // Only what is given changes: the access mode (with its repositories) and/or what agents may commit ('' for each session).
245
- const body = { ...(access !== undefined ? { access, repos } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
246
- return call(fetchImpl, api, 'PUT', `/v1/workspaces/${encodeURIComponent(id)}/github`, body, token)
247
- }
248
- export async function removeWorkspaceGithub ({ token, id, connectedBy = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
249
- return call(fetchImpl, api, 'DELETE', `/v1/workspaces/${encodeURIComponent(id)}/github/connection`, connectedBy ? { connectedBy } : {}, token)
250
- }
251
- export async function setOrgGithub ({ token, slug, requireGithub, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
252
- const body = { ...(requireGithub !== undefined ? { requireGithub: !!requireGithub } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
253
- return call(fetchImpl, api, 'PUT', `/v1/orgs/${encodeURIComponent(slug)}/github`, body, token)
254
- }
255
-
256
227
  // Access types, grants and session invites, as this computer's account (see
257
228
  // docs/superpowers/specs/2026-10-02-access-types-and-invites-design.md). Each throws with
258
229
  // .status when the API says no (401 once the token is revoked).
@@ -98,7 +98,7 @@ function agoText (ts, now) {
98
98
  * @param {string} [o.me]
99
99
  * @param {number} [o.now]
100
100
  */
101
- export function pickupBrief ({ task, history = [], claims = [], checklist = '', me = '', now = Date.now() }) {
101
+ export function pickupBrief ({ task, history = [], claims = [], checklist = '', me = '', now = Date.now(), workflow = TASK_WORKFLOW }) {
102
102
  const files = task?.files || []
103
103
  const lines = [`Picked up "${task?.title || 'this ticket'}" [${task?.id || ''}].`]
104
104
  lines.push(files.length ? `Files: ${files.join(', ')}` : 'Files: none listed. Add them with quilt_assign_task once you know which you will touch.')
@@ -124,7 +124,7 @@ export function pickupBrief ({ task, history = [], claims = [], checklist = '',
124
124
  lines.push('', 'Claims to respect (ask for the file with quilt_request_file instead of editing it):')
125
125
  for (const c of others) lines.push(`- ${c.pattern} by ${c.by}${c.note ? ` (${c.note})` : ''}`)
126
126
  }
127
- lines.push('', TASK_WORKFLOW, '', checklistBlock(checklist))
127
+ lines.push('', workflow, '', checklistBlock(checklist))
128
128
  return lines.join('\n')
129
129
  }
130
130
 
@@ -19,3 +19,16 @@ export function toolLabel (client) {
19
19
  if (/gemini/i.test(n)) return 'Other'
20
20
  return n || 'AI agent'
21
21
  }
22
+
23
+ // The tools a person can be shown as using.
24
+ export const KNOWN_TOOLS = new Set(['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'Zed'])
25
+
26
+ /**
27
+ * The tool to show for a person: the one they set, unless the AIs connected through their app
28
+ * (MCP client names) are all another tool. A set tool can be a stale guess (Brandon showed as
29
+ * Windsurf while his Claude Code was connected); the AI that connects is what they really use.
30
+ */
31
+ export function toolInUse (tool, agents = []) {
32
+ const used = [...agents].map(toolLabel).filter((t) => KNOWN_TOOLS.has(t))
33
+ return used.length && !used.includes(tool) ? used[used.length - 1] : tool
34
+ }
package/src/ai-vm.js CHANGED
@@ -526,9 +526,17 @@ export class AiVm {
526
526
  kind: 'agent',
527
527
  passes: auth.passes,
528
528
  identity: auth.identity,
529
+ // The accounts API (and Seam, through it) as this cloud AI, with its own key (seam-access.js).
530
+ apiAuth: auth.apiAuth,
529
531
  agentFeed: false,
530
532
  onLog: (line) => this.log(`[${room}] ${line}`),
531
- onFatal: (err) => { this.log(`[${room}] stopped: ${err.message}`); this.runs.delete(room) }
533
+ // Stopped, not only forgotten: a session left running keeps its timers and the whole room in memory.
534
+ onFatal: (err) => {
535
+ this.log(`[${room}] stopped: ${err.message}`)
536
+ const r = this.runs.get(room)
537
+ this.runs.delete(room)
538
+ if (r) r.run.stop().catch(() => {})
539
+ }
532
540
  })
533
541
  this.runs.set(room, { run, link })
534
542
  // What reaches it in the session counts as activity: the machine stays up while there is work,
@@ -136,6 +136,9 @@ export function summarize ({ me, sessions, visits, now, tz = 'UTC' }) {
136
136
  people.sort((a, b) => b.togetherMs - a.togetherMs || a.name.localeCompare(b.name))
137
137
  const ownerRows = s.ownerAccount ? inRoom.filter((v) => v.account === s.ownerAccount) : []
138
138
  const open = inRoom.some((v) => v.endedAt == null)
139
+ // Who is in it right now, among me and the people listed (an open visit).
140
+ const listed = new Set([me, ...people.map((p) => p.account)])
141
+ const here = [...new Set(inRoom.filter((v) => v.endedAt == null && listed.has(v.account)).map((v) => v.account))]
139
142
  rows.push({
140
143
  room: s.room,
141
144
  name: s.name || '',
@@ -143,6 +146,8 @@ export function summarize ({ me, sessions, visits, now, tz = 'UTC' }) {
143
146
  mine: !!s.ownerAccount && s.ownerAccount === me,
144
147
  createdAt: s.createdAt,
145
148
  lastActiveAt: open ? now : s.lastActiveAt,
149
+ live: open,
150
+ here,
146
151
  myTotalMs: total(mine),
147
152
  people
148
153
  })
@@ -30,7 +30,8 @@ export function createMemoryStore ({ now = Date.now } = {}) {
30
30
  const orgGithub = new Map() // org id -> { requireGithub, updatedAt }
31
31
  const teamGithub = new Map() // team id -> { agentCommits, updatedAt }
32
32
  const settings = new Map() // the API's own settings (a webhook secret it made), key -> value
33
- const leases = new Map() // "repo\0branch" -> { room, holder, generation, heartbeatAt } (git_sync_leases)
33
+ const leases = new Map() // key -> { room, holder, generation, heartbeatAt } (relay_leases)
34
+ const authSessions = new Map()
34
35
  const users = new Map(); const orgs = new Map(); const roles = new Map(); const members = new Map()
35
36
  const teams = new Map(); const teamMembers = new Map(); const invites = new Map(); const requests = new Map()
36
37
  const agentInvites = new Map(); const keyRows = new Map(); const appKeys = new Map()
@@ -389,41 +390,19 @@ export function createMemoryStore ({ now = Date.now } = {}) {
389
390
  },
390
391
 
391
392
  // The address a person signs in with, and whether they've confirmed it.
392
- async userEmail (userId) { const u = users.get(userId); return u ? { ...u } : null },
393
- async githubLink (userId) { const g = githubLinks.get(userId); return g ? { ...g } : null },
394
- async setGithubLink (userId, { githubId, login }) { githubLinks.set(userId, { githubId, login, linkedAt: now() }) },
395
- async deleteGithubLink (userId) { githubLinks.delete(userId) },
396
- // GitHub connected for a scope (github_connections): an org, a workspace or a person; newest first.
397
- async githubConnections ({ scope, scopeId }) { return all(githubConnections, (c) => c.scope === scope && c.scopeId === scopeId).sort((a, b) => b.createdAt - a.createdAt).map(copy) },
398
- async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) { githubConnections.set(`${scope}\0${scopeId}\0${connectedBy}`, { scope, scopeId, connectedBy, installationId, createdAt: now() }) },
399
- async removeGithubConnection ({ scope, scopeId, connectedBy }) { githubConnections.delete(`${scope}\0${scopeId}\0${connectedBy}`) },
400
- // A workspace's access mode (workspace_github): 'open', or 'repos' with the repositories it allows.
401
- async workspaceGithub (workspaceId) { const w = workspaceGithub.get(workspaceId); return w ? { access: w.access, repos: [...w.repos], agentCommits: w.agentCommits || '' } : { access: 'open', repos: [], agentCommits: '' } },
402
- // Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
403
- async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
404
- const w = workspaceGithub.get(workspaceId) || { access: 'open', repos: [], agentCommits: '' }
405
- workspaceGithub.set(workspaceId, { access: access ?? w.access, repos: repos ? [...repos] : w.repos, agentCommits: agentCommits ?? w.agentCommits ?? '', updatedAt: now() })
406
- },
407
- // An org's GitHub switch (org_github): whether it requires GitHub.
408
- async teamGithub (teamId) { const r = teamGithub.get(teamId); return { agentCommits: (r && r.agentCommits) || '' } },
409
- async setTeamGithub (teamId, { agentCommits }) { teamGithub.set(teamId, { agentCommits: agentCommits || '', updatedAt: now() }) },
410
- // The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
411
- async teamGitRules (memberId) {
412
- return all(teamMembers, (tm) => tm.memberId === memberId)
413
- .map((tm) => ({ team: teams.get(tm.teamId), rule: teamGithub.get(tm.teamId) }))
414
- .filter((x) => x.team && x.rule && x.rule.agentCommits)
415
- .map((x) => ({ teamId: x.team.id, name: x.team.name, agentCommits: x.rule.agentCommits }))
416
- },
417
- async orgGithub (orgId) { const o = orgGithub.get(orgId); return { requireGithub: !!(o && o.requireGithub), agentCommits: (o && o.agentCommits) || '' } },
418
- async setOrgGithub (orgId, { requireGithub, agentCommits }) {
419
- const o = orgGithub.get(orgId) || { requireGithub: false, agentCommits: '' }
420
- orgGithub.set(orgId, { requireGithub: requireGithub ?? o.requireGithub, agentCommits: agentCommits ?? o.agentCommits ?? '', updatedAt: now() })
421
- },
393
+ async userEmail (userId) { const u = users.get(userId); return u ? { email: u.email, confirmed: u.confirmed } : null },
394
+ // Email notices (notify.js): what each person turned on, and what was already sent (kept on the account).
395
+ setNotify (userId, notify) { const u = users.get(userId); if (u) u.notify = { ...notify } },
396
+ async notifyUsers () { return all(users, (u) => u.confirmed && u.notify).map((u) => ({ userId: [...users].find(([, x]) => x === u)[0], email: u.email, notify: { ...u.notify }, sent: { ...(u.notifySent || {}) } })) },
397
+ async setNotifySent (userId, sent) { const u = users.get(userId); if (u) u.notifySent = { ...sent } },
398
+ // A person's browser sign-ins (supabase-store.js: auth.sessions), for signing out one at a time.
399
+ addAuthSession (userId, s) { authSessions.set(s.id, { userId, createdAt: now(), lastActiveAt: now(), userAgent: '', ip: '', ...s }) },
400
+ async authSessions (userId) { return all(authSessions, (s) => s.userId === userId).sort((a, b) => b.lastActiveAt - a.lastActiveAt).map(({ userId: _, ...s }) => ({ ...s })) },
401
+ async endAuthSession (userId, id) { const s = authSessions.get(id); if (!s || s.userId !== userId) return false; authSessions.delete(id); return true },
422
402
  // The API's own settings (app_settings): a value it keeps across starts.
423
403
  async getSetting (key) { return settings.has(key) ? settings.get(key) : null },
424
- // One live session per repository and branch (git_sync_leases; mirrors the SQL functions).
425
- async takeLease ({ repo, branch, room, holder = '', ttlMs }) {
426
- const k = `${repo}\0${branch}`
404
+ // Relay leases (relay_leases; mirrors the SQL functions). `key`: the relay's name for what it holds.
405
+ async takeLease ({ key: k, room, holder = '', ttlMs }) {
427
406
  const cur = leases.get(k)
428
407
  const t = now()
429
408
  if (!cur) { leases.set(k, { room, holder, generation: 1, heartbeatAt: t }); return { ok: true, generation: 1, room } }
@@ -433,15 +412,14 @@ export function createMemoryStore ({ now = Date.now } = {}) {
433
412
  leases.set(k, { room, holder, generation: cur.generation + 1, heartbeatAt: t })
434
413
  return { ok: true, generation: cur.generation + 1, room }
435
414
  },
436
- async heartbeatLease ({ repo, branch, room, generation, ttlMs }) {
437
- const cur = leases.get(`${repo}\0${branch}`)
415
+ async heartbeatLease ({ key: k, room, generation, ttlMs }) {
416
+ const cur = leases.get(k)
438
417
  if (!cur || cur.room !== room || cur.generation !== generation) return { ok: false, lost: true, room: cur ? cur.room : '' }
439
418
  if (cur.heartbeatAt < now() - ttlMs) return { ok: false, expired: true }
440
419
  cur.heartbeatAt = now()
441
420
  return { ok: true, generation }
442
421
  },
443
- async releaseLease ({ repo, branch, room, generation }) {
444
- const k = `${repo}\0${branch}`
422
+ async releaseLease ({ key: k, room, generation }) {
445
423
  const cur = leases.get(k)
446
424
  if (cur && cur.room === room && cur.generation === generation) leases.delete(k)
447
425
  return { ok: true }