heyquilt 0.3.121 → 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 +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- 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 +21 -10
- 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/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- 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 +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -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 +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- 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/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/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- 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-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- 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 -486
- 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 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- 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 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- 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()
|
|
@@ -144,6 +146,7 @@ async function main () {
|
|
|
144
146
|
if (!token.trim()) fail('no token given')
|
|
145
147
|
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
148
|
}
|
|
149
|
+
case 'session': return sessionCmd()
|
|
147
150
|
case 'invite': return invite()
|
|
148
151
|
case 'workspace': case 'workspaces': return workspaceCmd()
|
|
149
152
|
case 'stop': return stopAll()
|
|
@@ -233,6 +236,8 @@ async function apiCmd () {
|
|
|
233
236
|
siteUrl: env.QUILT_SITE_URL || 'http://localhost:3000',
|
|
234
237
|
trustProxy: /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''), log: console.log,
|
|
235
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',
|
|
236
241
|
// Secrets: the vault (quilt-vault) and the secret it calls this API with; off when not set.
|
|
237
242
|
vaultSecret: env.QUILT_VAULT_SECRET || '',
|
|
238
243
|
vaultUrl: env.QUILT_VAULT_URL || 'https://vault.heyquilt.com',
|
|
@@ -241,8 +246,11 @@ async function apiCmd () {
|
|
|
241
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).
|
|
242
247
|
apiFly: (await import('../src/api/api-machines.js')).apiFlyFromEnv(env),
|
|
243
248
|
...(env.QUILT_API_MAX_MACHINES ? { maxMachines: Number(env.QUILT_API_MAX_MACHINES) } : {}),
|
|
244
|
-
//
|
|
245
|
-
|
|
249
|
+
// Seam is its own service (Seam's docs/seam-service.md): its old routes here go on to it.
|
|
250
|
+
...(env.QUILT_SEAM_URL !== undefined ? { seamUrl: env.QUILT_SEAM_URL } : {}),
|
|
251
|
+
...(env.QUILT_SEAM_CLIENT_ID ? { seamClientId: env.QUILT_SEAM_CLIENT_ID } : {}),
|
|
252
|
+
...(env.QUILT_SEAM_SITE_URL ? { seamSiteUrl: env.QUILT_SEAM_SITE_URL } : {}),
|
|
253
|
+
...(env.QUILT_SEAM_CONNECT_SECRET ? { seamConnectSecret: env.QUILT_SEAM_CONNECT_SECRET } : {}),
|
|
246
254
|
// Voice: Cloudflare TURN for calls on networks that block a direct line, Workers AI for transcripts; each off when not set.
|
|
247
255
|
voice: {
|
|
248
256
|
turnKeyId: env.CLOUDFLARE_TURN_KEY_ID || '',
|
|
@@ -351,6 +359,7 @@ async function join () {
|
|
|
351
359
|
|
|
352
360
|
const { unsupportedRelay } = await import('../src/settings.js')
|
|
353
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)
|
|
354
363
|
if (positionals[0]) {
|
|
355
364
|
try { conn = decodeInvite(positionals[0]) } catch (err) { fail(err.message) }
|
|
356
365
|
} else if (values.room) {
|
|
@@ -362,6 +371,7 @@ async function join () {
|
|
|
362
371
|
} else {
|
|
363
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.")
|
|
364
373
|
conn = newConn()
|
|
374
|
+
fresh = true
|
|
365
375
|
console.log('starting a new session')
|
|
366
376
|
}
|
|
367
377
|
|
|
@@ -403,6 +413,7 @@ async function join () {
|
|
|
403
413
|
apiAuth: auth.apiAuth,
|
|
404
414
|
identity: auth.identity,
|
|
405
415
|
inviteServer: saved.room === conn.room ? saved.inviteServer : undefined,
|
|
416
|
+
newSession: fresh,
|
|
406
417
|
onLog: (m) => console.log(`[${stamp()}] ${m}`),
|
|
407
418
|
onDebug: process.env.QUILT_DEBUG ? (m) => console.log(`[${stamp()}] debug: ${m}`) : undefined,
|
|
408
419
|
onFatal: (err) => fail(whyStopped(err))
|
|
@@ -578,6 +589,57 @@ async function doSetup () {
|
|
|
578
589
|
console.log('\nAn AI tool that was already open picks up the "quilt" MCP server when it restarts.')
|
|
579
590
|
}
|
|
580
591
|
|
|
592
|
+
// `quilt session migrate [--dry-run]` (Seam's plans/quilt-source-control/16): what making this folder's
|
|
593
|
+
// session a quilt (Seam) would do, read only, or, without --dry-run, the move itself. The relay
|
|
594
|
+
// does both, behind QUILT_SOURCE_URL there, and only for the session's owner. --switch then moves
|
|
595
|
+
// the session's files into its squares (layout 3); --back returns it to its branches. `follow <folder>`
|
|
596
|
+
// then makes that folder follow this branch's square live (session-follow.js).
|
|
597
|
+
async function sessionCmd () {
|
|
598
|
+
if (argv[0] === 'follow') {
|
|
599
|
+
if (!argv[1]) fail('Usage: quilt session follow <folder>')
|
|
600
|
+
return simple('/quilt-follow', { dir: path.resolve(argv[1]) }, (r) => r.text)
|
|
601
|
+
}
|
|
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>')
|
|
603
|
+
if (argv.includes('--dry-run')) return simple('/quilt-plan', {}, (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
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
581
643
|
async function daemonOrFail () {
|
|
582
644
|
const { findDaemon, call } = await import('../src/control.js')
|
|
583
645
|
const d = findDaemon()
|
|
@@ -593,12 +655,16 @@ async function status () {
|
|
|
593
655
|
async function history () {
|
|
594
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' } } })
|
|
595
657
|
const { formatHistory } = await import('../src/history.js')
|
|
596
|
-
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}` : ''}`)
|
|
597
659
|
}
|
|
598
660
|
|
|
599
661
|
async function simple (route, body, format, method = 'POST') {
|
|
600
662
|
const { d, call } = await daemonOrFail()
|
|
601
|
-
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
|
+
}
|
|
602
668
|
}
|
|
603
669
|
|
|
604
670
|
/** Splits "@bob rest of message" into { to: 'bob', rest: [...] }. */
|
|
@@ -961,4 +1027,5 @@ function fail (msg) {
|
|
|
961
1027
|
process.exit(1)
|
|
962
1028
|
}
|
|
963
1029
|
|
|
964
|
-
|
|
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,6 +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.237.313",
|
|
40
41
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
41
42
|
"@noble/hashes": "^2.4.0",
|
|
42
43
|
"@supabase/supabase-js": "^2.117.2",
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
"desktop/preload.cjs",
|
|
84
85
|
"desktop/updater.js",
|
|
85
86
|
"desktop/media-permission.js",
|
|
87
|
+
"desktop/screen-share.js",
|
|
86
88
|
"desktop/icons/*.png",
|
|
87
89
|
"desktop/icons/*.ico",
|
|
88
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()
|
|
@@ -55,7 +56,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
|
|
|
55
56
|
const hostedRooms = new Map() // "account\nroom" -> { account, room, addedAt }
|
|
56
57
|
const hostedWebhooks = new Map(); const resumeSignatures = new Map()
|
|
57
58
|
// Secrets (20261018000000_secrets.sql): only what the vault sealed.
|
|
58
|
-
const secretRows = new Map(); const secretDataKeys = new Map(); const secretSettingsRows = new Map()
|
|
59
|
+
const seamLinks = new Map(); const secretRows = new Map(); const secretDataKeys = new Map(); const secretSettingsRows = new Map()
|
|
59
60
|
const secretTickets = new Map(); const secretRuns = new Map(); const secretAsks = new Map(); const secretUses = []
|
|
60
61
|
const scopeKey = (scopeType, scopeId) => `${scopeType}\n${scopeId}`
|
|
61
62
|
// A secret as the API's routes see it: never its sealed value (secretSealed reads that, for the vault).
|
|
@@ -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 }
|
|
@@ -1227,6 +1234,10 @@ export function createMemoryStore ({ now = Date.now } = {}) {
|
|
|
1227
1234
|
if (!secretDataKeys.has(k)) secretDataKeys.set(k, { scopeType, scopeId, version, sealedKey, provider, createdAt: now() })
|
|
1228
1235
|
return copy(secretDataKeys.get(k))
|
|
1229
1236
|
},
|
|
1237
|
+
// "Connect Seam" (routes/seam-connect.js): the person's Seam tokens, one row per Quilt user.
|
|
1238
|
+
async seamLink (userId) { return copy(seamLinks.get(userId)) || null },
|
|
1239
|
+
async putSeamLink (userId, link) { const row = { userId, access: link.access, refresh: link.refresh, scope: link.scope || '', expiresAt: link.expiresAt, connectedAt: link.connectedAt ?? now() }; seamLinks.set(userId, copy(row)); return copy(row) },
|
|
1240
|
+
async deleteSeamLink (userId) { seamLinks.delete(userId) },
|
|
1230
1241
|
async secretSettings (scopeType, scopeId) {
|
|
1231
1242
|
return copy(secretSettingsRows.get(scopeKey(scopeType, scopeId))) || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
|
|
1232
1243
|
},
|
|
@@ -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
|
}
|