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.
- package/RELEASES.md +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -9
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +151 -17
- package/src/relay-outbound.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1517 -86
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +848 -67
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- 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('--
|
|
598
|
-
|
|
599
|
-
|
|
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) {
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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('',
|
|
127
|
+
lines.push('', workflow, '', checklistBlock(checklist))
|
|
128
128
|
return lines.join('\n')
|
|
129
129
|
}
|
|
130
130
|
|
package/src/agents/common.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/api/activity.js
CHANGED
|
@@ -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
|
})
|
package/src/api/memory-store.js
CHANGED
|
@@ -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() //
|
|
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 ? {
|
|
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
|
-
//
|
|
425
|
-
async takeLease ({
|
|
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 ({
|
|
437
|
-
const cur = leases.get(
|
|
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 ({
|
|
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
|
+
}
|
package/src/api/routes/github.js
CHANGED
|
@@ -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) => {
|
package/src/api/routes/relay.js
CHANGED
|
@@ -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, '
|
|
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
|
|
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
|
-
|
|
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) {
|