heyquilt 0.3.122 → 0.3.138

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 (136) hide show
  1. package/RELEASES.md +273 -17
  2. package/bin/quilt.js +55 -9
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +16 -9
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  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/server.js +13 -7
  17. package/src/api/supabase-store.js +43 -4
  18. package/src/autowork.js +182 -16
  19. package/src/connection.js +36 -9
  20. package/src/control.js +129 -15
  21. package/src/force-date.js +27 -0
  22. package/src/fsutil.js +13 -3
  23. package/src/gitstate.js +38 -2
  24. package/src/history.js +13 -3
  25. package/src/mcp.js +87 -12
  26. package/src/merge3.js +11 -3
  27. package/src/now-summary.js +113 -0
  28. package/src/pathrules.js +12 -0
  29. package/src/protocol.js +10 -2
  30. package/src/quilt-default.js +84 -0
  31. package/src/quilt-history.js +106 -0
  32. package/src/quilt-remote.js +78 -0
  33. package/src/relay-commit.js +5 -0
  34. package/src/relay-lease.js +6 -2
  35. package/src/relay-mcp.js +151 -17
  36. package/src/relay-outbound.js +1 -1
  37. package/src/release-words.js +2 -2
  38. package/src/runner.js +20 -7
  39. package/src/seam-access.js +97 -0
  40. package/src/seam-claims.js +53 -0
  41. package/src/seam-url.js +15 -0
  42. package/src/server.js +1517 -86
  43. package/src/session-follow.js +461 -27
  44. package/src/session-layout3.js +410 -4
  45. package/src/session-migrate.js +15 -11
  46. package/src/session-quilt.js +1 -1
  47. package/src/session-seam.js +233 -20
  48. package/src/session.js +848 -67
  49. package/src/source-live/bring-in.js +283 -0
  50. package/src/source-live/live-block.js +504 -33
  51. package/src/source-live/live-square.js +16 -5
  52. package/src/status.js +5 -0
  53. package/src/ui/agent-guide.js +1 -1
  54. package/src/ui/app.css +85 -20
  55. package/src/ui/app.js +4 -1
  56. package/src/ui/chat.js +9 -3
  57. package/src/ui/cloud-ai-model.js +13 -0
  58. package/src/ui/cloud-ai.js +10 -2
  59. package/src/ui/common.js +6 -2
  60. package/src/ui/fileview.js +20 -7
  61. package/src/ui/force-words.js +19 -0
  62. package/src/ui/git-rule.js +9 -0
  63. package/src/ui/home.js +0 -147
  64. package/src/ui/loom-model.js +15 -1
  65. package/src/ui/quiltview.js +213 -0
  66. package/src/ui/seam-model.js +251 -0
  67. package/src/ui/session.js +413 -31
  68. package/src/ui/voice-call.js +83 -14
  69. package/src/ui/voice-model.js +39 -0
  70. package/src/ui/voice.js +167 -17
  71. package/src/ui-server.js +80 -28
  72. package/src/update-check.js +25 -1
  73. package/src/voice.js +13 -6
  74. package/src/git.js +0 -147
  75. package/src/quilts/AGENTS.md +0 -1
  76. package/src/quilts/CLAUDE.md +0 -1
  77. package/src/quilts/accounts.js +0 -136
  78. package/src/quilts/affected.js +0 -132
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/checks.js +0 -308
  87. package/src/quilts/claims.js +0 -144
  88. package/src/quilts/cli.js +0 -536
  89. package/src/quilts/client.js +0 -137
  90. package/src/quilts/conflict-format.js +0 -190
  91. package/src/quilts/describe.js +0 -296
  92. package/src/quilts/dogfood.js +0 -82
  93. package/src/quilts/errors.js +0 -8
  94. package/src/quilts/events.js +0 -171
  95. package/src/quilts/export-git.js +0 -130
  96. package/src/quilts/export-remote.js +0 -156
  97. package/src/quilts/export.js +0 -81
  98. package/src/quilts/fastmerge.js +0 -41
  99. package/src/quilts/hash.js +0 -44
  100. package/src/quilts/history.js +0 -266
  101. package/src/quilts/ids.js +0 -12
  102. package/src/quilts/import-follow.js +0 -236
  103. package/src/quilts/import-git.js +0 -294
  104. package/src/quilts/import.js +0 -196
  105. package/src/quilts/index.js +0 -94
  106. package/src/quilts/keys.js +0 -186
  107. package/src/quilts/limits.js +0 -67
  108. package/src/quilts/lines.js +0 -49
  109. package/src/quilts/live.js +0 -50
  110. package/src/quilts/merge-smart.js +0 -114
  111. package/src/quilts/merge.js +0 -501
  112. package/src/quilts/oauth.js +0 -202
  113. package/src/quilts/objects.js +0 -144
  114. package/src/quilts/pack.js +0 -118
  115. package/src/quilts/records.js +0 -211
  116. package/src/quilts/resolutions.js +0 -69
  117. package/src/quilts/reviews.js +0 -208
  118. package/src/quilts/routes.js +0 -186
  119. package/src/quilts/runner.js +0 -95
  120. package/src/quilts/server.js +0 -144
  121. package/src/quilts/service.js +0 -963
  122. package/src/quilts/setup.js +0 -41
  123. package/src/quilts/signing.js +0 -149
  124. package/src/quilts/squares.js +0 -239
  125. package/src/quilts/store-memory.js +0 -844
  126. package/src/quilts/store-rpc.js +0 -90
  127. package/src/quilts/store-supabase.js +0 -13
  128. package/src/quilts/tags.js +0 -64
  129. package/src/quilts/textdiff.js +0 -96
  130. package/src/quilts/tokens.js +0 -66
  131. package/src/quilts/tools.js +0 -403
  132. package/src/quilts/tree.js +0 -351
  133. package/src/quilts/webhook.js +0 -116
  134. package/src/quilts/window.js +0 -121
  135. package/src/quilts/xdiff.js +0 -435
  136. package/src/ui/branches.js +0 -111
package/bin/quilt.js CHANGED
@@ -46,6 +46,7 @@ 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 <message> In a session that is a quilt: commit the square this folder follows
49
50
  quilt commit-request <message> [--files a,b] [--task id]
50
51
  Ask a person to commit for you (when agents may not push here)
51
52
  quilt commits Open commit requests and recent commits
@@ -108,6 +109,7 @@ async function main () {
108
109
  case 'webhook': return webhookCmd()
109
110
  case 'wake': return wakeCmd()
110
111
  case 'get': return getFile()
112
+ case 'commit': if (!argv.join(' ').trim()) fail('Usage: quilt commit <message>'); return simple('/square-commit', { message: argv.join(' ') }, (r) => r.text)
111
113
  case 'commit-request': return commitRequest()
112
114
  case 'commits': return commits()
113
115
  case 'github': return githubStatusCmd()
@@ -234,6 +236,8 @@ async function apiCmd () {
234
236
  siteUrl: env.QUILT_SITE_URL || 'http://localhost:3000',
235
237
  trustProxy: /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''), log: console.log,
236
238
  workspaces: env.QUILT_WORKSPACES === '1' || env.QUILT_WORKSPACES === 'true',
239
+ // New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8): apps read it from /v1/features.
240
+ quiltSourceDefault: env.QUILT_SOURCE_DEFAULT === '1' || env.QUILT_SOURCE_DEFAULT === 'true',
237
241
  // Secrets: the vault (quilt-vault) and the secret it calls this API with; off when not set.
238
242
  vaultSecret: env.QUILT_VAULT_SECRET || '',
239
243
  vaultUrl: env.QUILT_VAULT_URL || 'https://vault.heyquilt.com',
@@ -242,7 +246,7 @@ async function apiCmd () {
242
246
  // 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
247
  apiFly: (await import('../src/api/api-machines.js')).apiFlyFromEnv(env),
244
248
  ...(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.
249
+ // Seam is its own service (Seam's docs/seam-service.md): its old routes here go on to it.
246
250
  ...(env.QUILT_SEAM_URL !== undefined ? { seamUrl: env.QUILT_SEAM_URL } : {}),
247
251
  ...(env.QUILT_SEAM_CLIENT_ID ? { seamClientId: env.QUILT_SEAM_CLIENT_ID } : {}),
248
252
  ...(env.QUILT_SEAM_SITE_URL ? { seamSiteUrl: env.QUILT_SEAM_SITE_URL } : {}),
@@ -355,6 +359,7 @@ async function join () {
355
359
 
356
360
  const { unsupportedRelay } = await import('../src/settings.js')
357
361
  let conn
362
+ let fresh = false // a session this command starts: a quilt from the start when the API says so (src/quilt-default.js)
358
363
  if (positionals[0]) {
359
364
  try { conn = decodeInvite(positionals[0]) } catch (err) { fail(err.message) }
360
365
  } else if (values.room) {
@@ -366,6 +371,7 @@ async function join () {
366
371
  } else {
367
372
  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
373
  conn = newConn()
374
+ fresh = true
369
375
  console.log('starting a new session')
370
376
  }
371
377
 
@@ -407,6 +413,7 @@ async function join () {
407
413
  apiAuth: auth.apiAuth,
408
414
  identity: auth.identity,
409
415
  inviteServer: saved.room === conn.room ? saved.inviteServer : undefined,
416
+ newSession: fresh,
410
417
  onLog: (m) => console.log(`[${stamp()}] ${m}`),
411
418
  onDebug: process.env.QUILT_DEBUG ? (m) => console.log(`[${stamp()}] debug: ${m}`) : undefined,
412
419
  onFatal: (err) => fail(whyStopped(err))
@@ -582,7 +589,7 @@ async function doSetup () {
582
589
  console.log('\nAn AI tool that was already open picks up the "quilt" MCP server when it restarts.')
583
590
  }
584
591
 
585
- // `quilt session migrate [--dry-run]` (plans/quilt-source-control/16): what making this folder's
592
+ // `quilt session migrate [--dry-run]` (Seam's plans/quilt-source-control/16): what making this folder's
586
593
  // session a quilt (Seam) would do, read only, or, without --dry-run, the move itself. The relay
587
594
  // does both, behind QUILT_SOURCE_URL there, and only for the session's owner. --switch then moves
588
595
  // the session's files into its squares (layout 3); --back returns it to its branches. `follow <folder>`
@@ -592,11 +599,45 @@ async function sessionCmd () {
592
599
  if (!argv[1]) fail('Usage: quilt session follow <folder>')
593
600
  return simple('/quilt-follow', { dir: path.resolve(argv[1]) }, (r) => r.text)
594
601
  }
595
- if (argv[0] !== 'migrate') fail('Usage: quilt session migrate [--dry-run | --switch | --back] | quilt session follow <folder>')
602
+ 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
603
  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)
604
+ if (argv.includes('--shadow')) {
605
+ const q = argv[argv.indexOf('--shadow') + 1]
606
+ if (!q || q.startsWith('-')) fail('Usage: quilt session migrate --shadow <quilt> (the quilt an import of this session\'s git remote fills)')
607
+ return simple('/quilt-shadow', { quilt: q }, (r) => r.text)
608
+ }
609
+ if (argv.includes('--unshadow')) return simple('/quilt-shadow', { quilt: null }, (r) => r.text)
610
+ if (argv.includes('--switch')) return sessionMove('/quilt-switch', {}, 'into its squares')
611
+ if (argv.includes('--back')) {
612
+ // --carried <id,id>: open squares whose work was carried over by hand (seam diff <square> | git apply).
613
+ const i = argv.indexOf('--carried')
614
+ const carried = i >= 0 && argv[i + 1] ? argv[i + 1].split(',').map((x) => x.trim().replace(/^sq:/, '')).filter(Boolean) : []
615
+ return sessionMove('/quilt-unswitch', { carried }, 'back to its branches')
616
+ }
617
+ // "Make this a quilt": made a quilt and moved into its squares in one go (16 flag 9); --link-only stops after the first.
618
+ if (argv.includes('--link-only')) return sessionMove('/quilt-migrate', {}, 'into Seam')
619
+ return sessionMove('/quilt-make', {}, 'into Seam')
620
+ }
621
+
622
+ /**
623
+ * A move of the session (into Seam, or back), through this folder's app. The relay does it; on a large
624
+ * session that takes minutes, so it says so first. An app that can't follow a square (older than
625
+ * 0.3.127) is sent away by the relay at the end of the move, and this command's answer is lost with it:
626
+ * refused here before anything moves. If the app stops answering meanwhile, the relay still finishes
627
+ * (or undoes) the move on its own: said, with how to see which.
628
+ */
629
+ async function sessionMove (route, body, where) {
630
+ const { d, call } = await daemonOrFail()
631
+ let info = null
632
+ try { info = await call(d, 'GET', '/info') } catch (err) { fail(err.message) }
633
+ const { moveRefusal } = await import('../src/control.js')
634
+ const refused = moveRefusal(info, (await import('../src/releases.js')).currentVersion())
635
+ if (refused) fail(refused)
636
+ console.error(`Moving the session ${where}. The relay does it; a large session takes a few minutes. Keep Quilt open until this answers.`)
637
+ try { console.log((await call(d, 'POST', route, body)).text) } catch (err) {
638
+ 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.`)
639
+ fail(err.message)
640
+ }
600
641
  }
601
642
 
602
643
  async function daemonOrFail () {
@@ -614,12 +655,16 @@ async function status () {
614
655
  async function history () {
615
656
  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
657
  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 }))
658
+ 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
659
  }
619
660
 
620
661
  async function simple (route, body, format, method = 'POST') {
621
662
  const { d, call } = await daemonOrFail()
622
- try { console.log(format(await call(d, method, route, body))) } catch (err) { fail(err.message) }
663
+ try { console.log(format(await call(d, method, route, body))) } catch (err) {
664
+ // The app doesn't know the route: it runs an older Quilt than this command.
665
+ 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.`)
666
+ fail(err.message)
667
+ }
623
668
  }
624
669
 
625
670
  /** Splits "@bob rest of message" into { to: 'bob', rest: [...] }. */
@@ -982,4 +1027,5 @@ function fail (msg) {
982
1027
  process.exit(1)
983
1028
  }
984
1029
 
985
- main().catch((err) => fail(err.stack || err.message))
1030
+ // An app that can't be reached is said in words (control.js unreachable), not as a stack.
1031
+ 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.138",
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",
@@ -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,7 +390,15 @@ 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 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 },
393
402
  async githubLink (userId) { const g = githubLinks.get(userId); return g ? { ...g } : null },
394
403
  async setGithubLink (userId, { githubId, login }) { githubLinks.set(userId, { githubId, login, linkedAt: now() }) },
395
404
  async deleteGithubLink (userId) { githubLinks.delete(userId) },
@@ -421,9 +430,8 @@ export function createMemoryStore ({ now = Date.now } = {}) {
421
430
  },
422
431
  // The API's own settings (app_settings): a value it keeps across starts.
423
432
  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}`
433
+ // Relay leases (relay_leases; mirrors the SQL functions). `key`: the relay's name for what it holds.
434
+ async takeLease ({ key: k, room, holder = '', ttlMs }) {
427
435
  const cur = leases.get(k)
428
436
  const t = now()
429
437
  if (!cur) { leases.set(k, { room, holder, generation: 1, heartbeatAt: t }); return { ok: true, generation: 1, room } }
@@ -433,15 +441,14 @@ export function createMemoryStore ({ now = Date.now } = {}) {
433
441
  leases.set(k, { room, holder, generation: cur.generation + 1, heartbeatAt: t })
434
442
  return { ok: true, generation: cur.generation + 1, room }
435
443
  },
436
- async heartbeatLease ({ repo, branch, room, generation, ttlMs }) {
437
- const cur = leases.get(`${repo}\0${branch}`)
444
+ async heartbeatLease ({ key: k, room, generation, ttlMs }) {
445
+ const cur = leases.get(k)
438
446
  if (!cur || cur.room !== room || cur.generation !== generation) return { ok: false, lost: true, room: cur ? cur.room : '' }
439
447
  if (cur.heartbeatAt < now() - ttlMs) return { ok: false, expired: true }
440
448
  cur.heartbeatAt = now()
441
449
  return { ok: true, generation }
442
450
  },
443
- async releaseLease ({ repo, branch, room, generation }) {
444
- const k = `${repo}\0${branch}`
451
+ async releaseLease ({ key: k, room, generation }) {
445
452
  const cur = leases.get(k)
446
453
  if (cur && cur.room === room && cur.generation === generation) leases.delete(k)
447
454
  return { ok: true }
@@ -0,0 +1,134 @@
1
+ // Email notices from the website dashboard's Settings › Notifications (plans/dashboard ticket 11). Each
2
+ // person picks what they want (kept on their account: user_metadata.notify), and the API's leader looks
3
+ // every few minutes: messages to them nobody saw for a while (a mention or a direct message not answered
4
+ // after MENTION_DELAY_MS), an agent asking to join a session they own, a daily digest of tasks for them,
5
+ // and a weekly summary of their time in sessions. What's live comes from the relays (nowAsker in
6
+ // routes/sessions.js), the week from the sessions the relays reported. What was already sent is kept on
7
+ // the account too (user_metadata.notify_sent), so nothing is sent twice, across restarts as well.
8
+
9
+ export const NOTIFY_KINDS = ['mentions', 'joins', 'tasks', 'weekly']
10
+ export const NOTIFY_EVERY_MS = 10 * 60 * 1000
11
+ export const MENTION_DELAY_MS = 15 * 60 * 1000
12
+ const DAY_MS = 24 * 60 * 60 * 1000
13
+ export const DIGEST_MS = DAY_MS
14
+ export const WEEKLY_MS = 7 * DAY_MS
15
+
16
+ /** A person's choices: each kind on or off (off unless it was turned on). */
17
+ export function cleanNotify (v) {
18
+ const o = v && typeof v === 'object' ? v : {}
19
+ return Object.fromEntries(NOTIFY_KINDS.map((k) => [k, o[k] === true]))
20
+ }
21
+
22
+ /** What was sent already: the newest mention and join request mailed, and when the digest and summary went. */
23
+ export function cleanSent (v) {
24
+ const o = v && typeof v === 'object' ? v : {}
25
+ const n = (x) => (Number.isFinite(x) && x > 0 ? x : 0)
26
+ return { mentionsAt: n(o.mentionsAt), joinsAt: n(o.joinsAt), tasksAt: n(o.tasksAt), weeklyAt: n(o.weeklyAt) }
27
+ }
28
+
29
+ const hours = (ms) => {
30
+ const m = Math.round(ms / 60000)
31
+ if (m < 60) return `${m}m`
32
+ return `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ''}`
33
+ }
34
+ const line = (n, s) => `- ${s.name || 'Untitled session'}: ${n.by} · "${n.text}"`
35
+ const footer = (site) => ['', `Open your dashboard: ${site}/dashboard`, `Change which emails you get: ${site}/settings/notifications`]
36
+
37
+ /**
38
+ * The emails due for one person at `t`, and what to remember as sent. `now` is their summary of now
39
+ * (nowAsker), `overview` their week (overviewOf) or null; `site` the website's address.
40
+ */
41
+ export function planNotices ({ notify, sent, now, overview = null, t, site }) {
42
+ const want = cleanNotify(notify)
43
+ const was = cleanSent(sent)
44
+ const next = { ...was }
45
+ const mails = []
46
+ const needs = []
47
+ for (const s of (now && now.sessions) || []) for (const n of s.needs || []) needs.push({ n, s })
48
+
49
+ if (want.mentions) {
50
+ const due = needs.filter(({ n }) => (n.kind === 'mention' || n.kind === 'dm') && n.at > was.mentionsAt && n.at <= t - MENTION_DELAY_MS)
51
+ if (due.length) {
52
+ next.mentionsAt = Math.max(...due.map(({ n }) => n.at))
53
+ mails.push({
54
+ subject: due.length === 1 ? `${due[0].n.by} is waiting for you in ${due[0].s.name || 'Quilt'}` : `${due.length} messages are waiting for you in Quilt`,
55
+ text: ['Messages to you nobody has answered yet:', '', ...due.map(({ n, s }) => line(n, s)), '', 'Answer them in the Quilt app.', ...footer(site)].join('\n')
56
+ })
57
+ }
58
+ }
59
+ if (want.joins) {
60
+ const due = needs.filter(({ n }) => n.kind === 'join' && n.at > was.joinsAt)
61
+ if (due.length) {
62
+ next.joinsAt = Math.max(...due.map(({ n }) => n.at))
63
+ mails.push({
64
+ subject: due.length === 1 ? `${due[0].n.by} asks to join ${due[0].s.name || 'your session'}` : `${due.length} are asking to join your sessions`,
65
+ text: ['Waiting for you to let them in:', '', ...due.map(({ n, s }) => `- ${n.by}${n.agent ? ' (an agent)' : ''} in ${s.name || 'Untitled session'}`), '', 'Let them in, or not, from the session in the Quilt app.', ...footer(site)].join('\n')
66
+ })
67
+ }
68
+ }
69
+ if (want.tasks && t - was.tasksAt >= DIGEST_MS) {
70
+ const due = needs.filter(({ n }) => n.kind === 'task')
71
+ if (due.length) {
72
+ next.tasksAt = t
73
+ mails.push({
74
+ subject: `${due.length} ${due.length === 1 ? 'task is' : 'tasks are'} waiting for you in Quilt`,
75
+ text: ['Tasks for you, still to do:', '', ...due.map(({ n, s }) => `- ${n.text} (${s.name || 'Untitled session'}, from ${n.by})`), ...footer(site)].join('\n')
76
+ })
77
+ }
78
+ }
79
+ if (want.weekly && t - was.weeklyAt >= WEEKLY_MS && overview) {
80
+ next.weeklyAt = t
81
+ const week = overview.sessions.filter((s) => s.lastActiveAt >= t - WEEKLY_MS)
82
+ const together = overview.totals?.collaboratingThisWeek || 0
83
+ if (week.length) {
84
+ const top = (overview.totals?.topCollaborators || []).slice(0, 3).map((c) => c.name)
85
+ mails.push({
86
+ subject: `Your week in Quilt: ${week.length} ${week.length === 1 ? 'session' : 'sessions'}${together ? `, ${hours(together)} together` : ''}`,
87
+ text: [
88
+ `This week you worked in ${week.length} ${week.length === 1 ? 'session' : 'sessions'}${together ? ` and spent ${hours(together)} working with others` : ''}.`,
89
+ ...(top.length ? [`You worked most with ${top.join(', ')}.`] : []),
90
+ '',
91
+ ...week.slice(0, 10).map((s) => `- ${s.name || 'Untitled session'}: ${hours(s.myTotalMs)} for you`),
92
+ ...footer(site)
93
+ ].join('\n')
94
+ })
95
+ }
96
+ }
97
+ return { mails, sent: next }
98
+ }
99
+
100
+ /**
101
+ * The job: every `everyMs` on the leading machine, each person who turned a notice on gets what's due.
102
+ * store.notifyUsers() lists them ({ userId, email, notify, sent }); store.setNotifySent keeps what went.
103
+ */
104
+ export function notifier ({ store, mailer, ask, overview, now = Date.now, log = () => {}, site, leads = () => true, everyMs = NOTIFY_EVERY_MS }) {
105
+ let timer = null
106
+ let running = false
107
+ async function tick () {
108
+ if (running || !leads() || !store.notifyUsers) return 0
109
+ running = true
110
+ let sentCount = 0
111
+ try {
112
+ const users = await store.notifyUsers()
113
+ for (const u of users) {
114
+ const want = cleanNotify(u.notify)
115
+ if (!u.email || !NOTIFY_KINDS.some((k) => want[k])) continue
116
+ const t = now()
117
+ const account = `person:${u.userId}`
118
+ try {
119
+ const live = want.mentions || want.joins || want.tasks ? await ask(account, t) : null
120
+ const week = want.weekly && t - cleanSent(u.sent).weeklyAt >= WEEKLY_MS ? await overview(account, t) : null
121
+ const plan = planNotices({ notify: want, sent: u.sent, now: live, overview: week, t, site })
122
+ for (const m of plan.mails) { await mailer.send({ to: u.email, ...m }); sentCount++ }
123
+ if (JSON.stringify(plan.sent) !== JSON.stringify(cleanSent(u.sent))) await store.setNotifySent(u.userId, plan.sent)
124
+ } catch (err) { log(`notify ${u.userId}: ${err.message}`) }
125
+ }
126
+ } catch (err) { log(`notify: ${err.message}`) } finally { running = false }
127
+ return sentCount
128
+ }
129
+ return {
130
+ tick,
131
+ start () { if (!timer) { timer = setInterval(tick, everyMs); timer.unref?.() } },
132
+ stop () { clearInterval(timer); timer = null }
133
+ }
134
+ }
@@ -0,0 +1,29 @@
1
+ // Signing out one browser at a time (plans/dashboard ticket 12, the website's Settings › Security):
2
+ // your browser sign-ins (Supabase Auth's sessions), and ending one. Computers are unlinked on their own
3
+ // (the devices table). Needs the 20261026235000_my_browser_sessions migration; without it, 503.
4
+ import { HttpError, UUID } from '../http.js'
5
+
6
+ const NOT_SET_UP = 'Signing out one browser at a time is not set up on this server yet.'
7
+
8
+ export function browserRoutes ({ store, person, log = () => {} }) {
9
+ const guard = async (fn) => {
10
+ try { return await fn() } catch (err) {
11
+ if (err instanceof HttpError) throw err
12
+ log(`browsers: ${err.message}`)
13
+ throw new HttpError(503, NOT_SET_UP)
14
+ }
15
+ }
16
+ return [
17
+ ['GET', /^\/v1\/me\/browsers$/, async (req) => {
18
+ const { userId } = await person(req)
19
+ return guard(async () => ({ browsers: await store.authSessions(userId) }))
20
+ }],
21
+ ['DELETE', /^\/v1\/me\/browsers\/([^/]+)$/, async (req, body, [id]) => {
22
+ const { userId } = await person(req)
23
+ if (!UUID.test(id)) throw new HttpError(404, 'no such sign-in')
24
+ const ended = await guard(() => store.endAuthSession(userId, id.toLowerCase()))
25
+ if (!ended) throw new HttpError(404, 'no such sign-in')
26
+ return { ok: true }
27
+ }]
28
+ ]
29
+ }
@@ -10,7 +10,6 @@ export { ensureWebhookSecret, redeliverFailed } from '../github-app.js'
10
10
 
11
11
  const PART = /^[A-Za-z0-9_.-]{1,100}$/
12
12
  /** A lease lapses this long after its last heartbeat: two missed heartbeats (plan §8). */
13
- export const LEASE_TTL_MS = 2 * 60 * 1000
14
13
  const ACCOUNT = /^person:([A-Za-z0-9_-]{1,64})$/
15
14
  const ACTOR = /^(person|agent):([A-Za-z0-9_-]{1,64})$/
16
15
  const ROOM = /^[A-Za-z0-9_-]{1,64}$/
@@ -322,18 +321,6 @@ export function githubRoutes ({ store, person, bearer, relaySecret, github: cfg,
322
321
  // Stable: the first of the strictest, and rules are in order broadest first.
323
322
  return rules.reduce((best, r) => rank[r.agentCommits] < rank[best.agentCommits] ? r : best)
324
323
  }],
325
- // The relay: one live session per repository and branch (plan §8). Leases lapse LEASE_TTL_MS after the last heartbeat.
326
- ['POST', /^\/v1\/relay\/leases\/(take|heartbeat|release)$/, async (req, b) => {
327
- fromRelay(req)
328
- const what = new URL(req.url, 'http://x').pathname.split('/').pop()
329
- const repo = String(b.repo || ''); const branch = String(b.branch || ''); const room = String(b.room || '')
330
- if (!/^[A-Za-z0-9.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(repo) || !branch || branch.length > 255 || !/^[A-Za-z0-9_-]{1,64}$/.test(room)) throw new HttpError(400, 'give repo (host/owner/name), branch and room')
331
- const ttlMs = Math.max(1000, Math.min(10 * 60_000, Number(b.ttlMs) || LEASE_TTL_MS))
332
- const generation = Number.isInteger(b.generation) ? b.generation : 0
333
- if (what === 'take') return store.takeLease({ repo, branch, room, holder: String(b.holder || '').slice(0, 100), ttlMs })
334
- if (what === 'heartbeat') return store.heartbeatLease({ repo, branch, room, generation, ttlMs })
335
- return store.releaseLease({ repo, branch, room, generation })
336
- }],
337
324
  // The relay: which GitHub accounts the session's people linked (login and id), for commit authorship
338
325
  // (relay-outbound.js: a person's commits carry their GitHub noreply address). Never a token.
339
326
  ['POST', /^\/v1\/relay\/github-logins$/, async (req, b) => {
@@ -15,6 +15,18 @@ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
15
15
  const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
16
16
 
17
17
  const VIA = ['app', 'hosted']
18
+ // Relay leases (src/relay-lease.js), moved here from routes/github.js in 16's R2 so they outlive git sync.
19
+ export const LEASE_TTL_MS = 2 * 60 * 1000
20
+ const LEASE_KEY = /^[\x21-\x7e][\x20-\x7e]{0,599}$/
21
+ const LEASE_REPO = /^[A-Za-z0-9.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
22
+
23
+ /** A lease's key: `key` as the relay names it, else (a relay from before R2) "<repo> <branch>", the same key either way; null when neither is one. */
24
+ export function leaseKey (b) {
25
+ if (typeof b.key === 'string' && LEASE_KEY.test(b.key)) return b.key
26
+ const repo = String(b.repo || ''); const branch = String(b.branch || '')
27
+ if (!LEASE_REPO.test(repo) || !branch || branch.length > 255 || /\s/.test(branch)) return null
28
+ return `${repo} ${branch}`
29
+ }
18
30
  /** A short label: printable, at most `n` characters. */
19
31
  const label = (x, n) => stripInvisible(x).slice(0, n).join('').replace(/[\u0000-\u001f\u007f]/g, '').trim()
20
32
 
@@ -51,9 +63,9 @@ export function cleanEvent (e, now) {
51
63
  export function relayRoutes ({ store, now, log, bearer, relaySecret }) {
52
64
  let prunedAt = 0
53
65
  const fromRelay = (req) => {
54
- if (!relaySecret) throw new HttpError(503, 'presence is not set up on this server')
66
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
55
67
  // Hashed first so the comparison takes the same time whatever was sent.
56
- if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may report presence')
68
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may ask for this')
57
69
  }
58
70
 
59
71
  return [
@@ -71,6 +83,17 @@ export function relayRoutes ({ store, now, log, bearer, relaySecret }) {
71
83
  .catch((err) => log(`could not prune session activity: ${err?.message || err}`))
72
84
  }
73
85
  return { ok: true, applied, skipped: body.events.length - events.length }
74
- }, { maxBody: PRESENCE_BODY }]
86
+ }, { maxBody: PRESENCE_BODY }],
87
+ // One holder at a time per key, across relay machines (relay-lease.js). Leases lapse LEASE_TTL_MS after the last heartbeat.
88
+ ['POST', /^\/v1\/relay\/leases\/(take|heartbeat|release)$/, async (req, b, [what]) => {
89
+ fromRelay(req)
90
+ const key = leaseKey(b); const room = String(b.room || '')
91
+ if (!key || !/^[A-Za-z0-9_-]{1,64}$/.test(room)) throw new HttpError(400, 'give key (or repo as host/owner/name and branch) and room')
92
+ const ttlMs = Math.max(1000, Math.min(10 * 60_000, Number(b.ttlMs) || LEASE_TTL_MS))
93
+ const generation = Number.isInteger(b.generation) ? b.generation : 0
94
+ if (what === 'take') return store.takeLease({ key, room, holder: String(b.holder || '').slice(0, 100), ttlMs })
95
+ if (what === 'heartbeat') return store.heartbeatLease({ key, room, generation, ttlMs })
96
+ return store.releaseLease({ key, room, generation })
97
+ }]
75
98
  ]
76
99
  }
@@ -1,6 +1,7 @@
1
- // Making a session a quilt (plans/quilt-source-control/16, "How a session becomes a quilt"):
1
+ // Making a session a quilt (Seam's plans/quilt-source-control/16, "How a session becomes a quilt"):
2
2
  // POST /v1/sessions/:room/migrate { dry_run, token, step }. The session's owner, signed in, asks here
3
- // (step 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back);
3
+ // (step 'make' is "Make this a quilt": made and moved into its squares in one go; 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back;
4
+ // 'shadow' links a session with a git remote to the quilt its import fills, 16 step 1);
4
5
  // the relay machine holding the room does the work (POST /quilt/migrate, with the relay secret),
5
6
  // so this is the same operation as the MCP tool and `quilt session migrate`. Seam is called as the
6
7
  // owner: with `token` (their Seam sign-in) when given, else the sign-in they used here. The relay
@@ -23,8 +24,10 @@ export function sessionMigrateRoutes ({ store, person, bearer, relaySecret, clus
23
24
  const dry = body && body.dry_run === true
24
25
  const token = body && typeof body.token === 'string' && body.token ? body.token : bearer(req)
25
26
  const where = await store.relayRoom(room).catch(() => null)
26
- const step = body && (body.step === 'switch' || body.step === 'unswitch') ? body.step : null
27
- const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}) }
27
+ const step = body && (body.step === 'make' || body.step === 'switch' || body.step === 'unswitch' || body.step === 'shadow') ? body.step : null
28
+ // step 'shadow' (16 step 1): `quilt` names the quilt an import of the session's git remote fills; null unlinks.
29
+ const quilt = step === 'shadow' && typeof body.quilt === 'string' && body.quilt ? body.quilt.slice(0, 200) : null
30
+ const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}), ...(step === 'shadow' && !dry ? { quilt } : {}), ...(step === 'unswitch' && !dry && Array.isArray(body.carried) ? { carried: body.carried.filter((x) => typeof x === 'string').slice(0, 200) } : {}) }
28
31
  try {
29
32
  return await cluster.callRelay((where && where.machineId) || '', 'POST', '/quilt/migrate', ask, { timeoutMs: MIGRATE_TIMEOUT_MS })
30
33
  } catch (err) {