heyquilt 0.3.90 → 0.3.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
package/src/server.js CHANGED
@@ -60,12 +60,12 @@ import { leaseKeeper, LEASE_HEARTBEAT_MS } from './relay-lease.js'
60
60
  import { bringInBranch, BACKSTOP_MS, ACTIVE_BACKSTOP_MS, syncStatus, markFolderBrought } from './relay-sync.js'
61
61
  import { noteCiResult } from './relay-ci.js'
62
62
  import { outboundFor, replayBundle, liveSyncOn, adoptBranch, receiveAgentPush } from './relay-outbound.js'
63
- import { ensureView, syncViewRefs, serveGit, parseUpdates, viewRepoOf, viewDir } from './relay-githttp.js'
63
+ import { ensureView, syncViewRefs, serveGit, parseUpdates, viewRepoOf, viewDir, gitHttpBackendWorks } from './relay-githttp.js'
64
64
  import { relayFileKey } from './relay-filekey.js'
65
- import { RoomMeter, roomDiskBytes } from './relay-metrics.js'
65
+ import { RoomMeter, roomDiskBytes, MachineMeter, machineLoad } from './relay-metrics.js'
66
66
  import { clusterConfig, clusterProblems, Cluster, NOT_ROOMS, roomOfPath, replaySource, canReplay, displaceRoom, packRoom, unpackRoom, placeStaged, removeLocalRoom, MOVING, ELSEWHERE } from './relay-cluster.js'
67
67
  import { joiningRoom, sessionArg } from './mcp-room.js'
68
- import { clashAction, heldForClash, reviewClashes, noteMergeChecks, openOfflineClash } from './relay-clash.js'
68
+ import { clashAction, noteMergeChecks } from './relay-clash.js'
69
69
  import { AGENT_COMMITS } from './github-commit.js'
70
70
  import { UPSTREAM_CHECK_MS, SYNC_EVERY_MS, SYNC_EVERY_GIT_MS, upstreamsOf, noteFolders, relayEntry, relayLooks, dueBranches, checkBranch, folderOn, connectedOn, hostedOn, validToken, folderAble, catcherAble, noteHolds, connectedAs, describeRelayCheck, loadBranch, parseRepo, configureGithubBudget, CLASH_IDLE_MS, noteConnection } from './relay-upstream.js'
71
71
 
@@ -142,6 +142,9 @@ export function relayConfig (opts = {}) {
142
142
  // GitHub as a node (relay-sync.js): commits come in through the relay's git mirror, on by default; QUILT_GIT_SYNC=0 keeps the REST path.
143
143
  gitSync: opts.gitSync ?? env.QUILT_GIT_SYNC !== '0',
144
144
  gitRemote: typeof opts.gitRemote === 'function' ? opts.gitRemote : null, // tests: a repository slug -> where its git is
145
+ // Agents' folders use git through the relay (relay-githttp.js); off when this machine's git has no http-backend.
146
+ gitHttp: opts.gitHttp !== false,
147
+ gitHttpCommand: typeof opts.gitHttpCommand === 'string' ? opts.gitHttpCommand : 'git', // tests: a git without http-backend
145
148
  syncAuto: opts.syncAuto === true, // the relay's own commit timer: off; commits happen when someone asks for one (quilt_git_commit, a git push)
146
149
  crashAt: typeof opts.crashAt === 'string' ? opts.crashAt : null, // tests: where to inject a crash (relay-sync.js crashPoint)
147
150
  leaseTtlMs: num(opts.leaseTtlMs, undefined),
@@ -719,8 +722,7 @@ class Room {
719
722
  mayBox (ws, rel) {
720
723
  const a = this.access.get(ws)
721
724
  rel = String(rel ?? '').trim().replace(/^\.\//, '')
722
- if (a && !this.mayWrite(a, rel)) return false
723
- return !(a && a.kind === 'agent' && this.cfg.gitSync && ws.branch && heldForClash(this, this.resolveKey(ws.branch), rel))
725
+ return !(a && !this.mayWrite(a, rel))
724
726
  }
725
727
 
726
728
  /**
@@ -778,8 +780,6 @@ class Room {
778
780
  }
779
781
  }
780
782
  for (const [rel, types] of touched) {
781
- // A file held for a clash with GitHub (relay-clash.js): agents propose the merge with quilt_clash; only people edit it in place.
782
- if (d.key && a && a.kind === 'agent' && this.cfg.gitSync && heldForClash(this, d.key, rel)) { refused.push(`${rel} (held for a clash: propose with quilt_clash)`); for (const t of types) undo.add(t); continue }
783
783
  if (this.mayWrite(a, rel)) continue
784
784
  refused.push(rel)
785
785
  for (const t of types) undo.add(t)
@@ -1603,20 +1603,6 @@ class Room {
1603
1603
  * field and find it on their next look), and the relay checks GitHub itself for a branch no
1604
1604
  * folder can bring in. Returns whether any branch follows it.
1605
1605
  */
1606
- /** The open clashes on every branch (relay-clash.js): timeouts and hand-offs, about once a minute, one branch at a time in its queue. */
1607
- async reviewClashTick () {
1608
- if (!this.cfg.gitSync || !this.mirrors || !this.meta.sync) return []
1609
- const out = []
1610
- for (const [key, st] of Object.entries(this.meta.sync)) {
1611
- if (!st || !Array.isArray(st.held) || !st.held.some((h) => h.why === 'clash')) continue
1612
- const rec = upstreamsOf(this)[key]
1613
- const repo = rec && parseRepo(`https://${rec.repo}`)
1614
- if (!repo) continue
1615
- out.push(...await this.mirrors.queue(repo.slug, rec.ref, () => reviewClashes(this, key)))
1616
- }
1617
- return out
1618
- }
1619
-
1620
1606
  /** Whether session `other` has this session's owner in it (its owner or a member): then the app may offer to open it ("Open the live session", plan §8). */
1621
1607
  sameOwner (other) {
1622
1608
  if (!other || !this.meta.ownerSub) return false
@@ -1667,7 +1653,7 @@ class Room {
1667
1653
  const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
1668
1654
  const follows = Object.values(upstreamsOf(this)).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
1669
1655
  if (!follows) return false
1670
- // Checks on a commit the relay pushed with merged files in it (relay-clash.js): failing pulls both parties in; passing lets held files go.
1656
+ // Checks on a commit the relay pushed with merged files in it (relay-clash.js): failing tells both parties; nothing is held.
1671
1657
  if (this.cfg.gitSync && this.mirrors && /^[0-9a-f]{40}$/.test(sha)) {
1672
1658
  for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1673
1659
  if (!rec || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want || (branch && rec.ref !== branch && !(rec.ref && branch === `quilt/live/${rec.ref}`))) continue
@@ -2868,16 +2854,11 @@ class Room {
2868
2854
  .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2869
2855
  return
2870
2856
  }
2871
- // A member's offline edits clash with the session's (plan §7.1): held and merged as a clash (relay-clash.js).
2857
+ // Sent by apps before 0.3.99 for offline edits that clashed with the session's: Quilt merges those on its own now
2858
+ // (session.js autoSettle), so there is nothing to hold. Answered ok so an older app doesn't retry.
2872
2859
  if (req.op === 'offlineClash') {
2873
- const id = req.id
2874
- const a = this.access.get(ws)
2875
- if (!a || (a.state && a.state !== 'approved') || !ws.branch || a.role === 'viewer') throw new Error('not in this session')
2876
- if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub.')
2877
- const text = (v) => typeof v === 'string' && v.length <= 1024 * 1024 ? v : null
2878
- openOfflineClash(this, ws.branch, { who: { name: a.name }, path: String(req.path || ''), mergeId: typeof req.mergeId === 'string' ? req.mergeId.slice(0, 40) : null, base: text(req.base), ours: text(req.ours), oursDeleted: req.oursDeleted === true, binary: req.binary === true })
2879
- .then((h) => ({ id, ok: true, offlineClash: { task: h.task, merger: h.merger, reviewer: h.reviewer } }), (err) => ({ id, ok: false, error: err.message }))
2880
- .then((r) => { this.broadcastBranches(); if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2860
+ const r = { id: req.id, ok: true, offlineClash: { task: null, merger: null, reviewer: null } }
2861
+ if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r }))
2881
2862
  return
2882
2863
  }
2883
2864
  // A person adopts an agent's branch into theirs (addendum 16.7; relay-outbound.js adoptBranch).
@@ -2896,7 +2877,6 @@ class Room {
2896
2877
  const id = req.id
2897
2878
  const a = this.access.get(ws)
2898
2879
  if (!a || (a.state && a.state !== 'approved') || !ws.branch) throw new Error('not in this session')
2899
- if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub, so there are no held clashes here.')
2900
2880
  clashAction(this, { who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }, key: ws.branch, action: String(req.action || ''), path: String(req.path || ''), content: typeof req.content === 'string' ? req.content : null, checks: req.checks && typeof req.checks === 'object' ? req.checks : null, force: req.force === true })
2901
2881
  .then((out) => out.ok ? { id, ok: true, clash: out, text: out.text } : { id, ok: false, error: out.text }, (err) => ({ id, ok: false, error: err.message }))
2902
2882
  .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
@@ -3042,6 +3022,11 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3042
3022
  // Without sign-in the limit is per address, and the message says so (as before).
3043
3023
  const TOO_MANY = passKey ? 'too many new sessions; try again later' : 'too many new sessions from this address; try again later'
3044
3024
  if (dataDir) fs.mkdirSync(dataDir, { recursive: true })
3025
+ // Without git http-backend every agent's git request would fail with a 500: agents' folders use GitHub's git instead.
3026
+ if (cfg.gitSync && cfg.gitHttp && passKey && !gitHttpBackendWorks(cfg.gitHttpCommand)) {
3027
+ cfg.gitHttp = false
3028
+ log('⚠️ this machine\'s git has no http-backend (on Alpine it comes with git-daemon): agents\' folders can\'t use git through the relay, so they use GitHub\'s')
3029
+ }
3045
3030
  // The relay's git mirrors (relay-git.js): one bare repository per GitHub repository, one writer per branch.
3046
3031
  const mirrors = mirrorManager({ dataDir, log, ...(cfg.pushGapMs !== undefined ? { pushGapMs: cfg.pushGapMs } : {}), ...(cfg.pushesPerHour !== undefined ? { pushesPerHour: cfg.pushesPerHour } : {}) })
3047
3032
  // One live session per repository and branch (relay-lease.js): in the accounts API when this relay has one, else in this process.
@@ -3337,14 +3322,30 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3337
3322
  return n
3338
3323
  }
3339
3324
  /** How busy and how big a loaded room is (relay-metrics.js): for /stats and the accounts API's placement. */
3340
- const roomStats = (room) => ({ room: room.name, conns: room.conns.size, hosted: room.hostedSeen.size, ...room.meter.lastMinute(), docBytes: room.totalBytes(), diskBytes: diskOf(room.name) })
3341
- const machineStats = () => {
3325
+ // Git pushes into a room's view (/git/<room>.git) being received now.
3326
+ const gitPushes = new Map() // room -> count
3327
+ /** Git work running or queued for a room now: pushes being received, and commits, pushes and bring-ins on its branches (relay-git.js). */
3328
+ const gitBusy = (room) => {
3329
+ let n = gitPushes.get(room.name) || 0
3330
+ for (const rec of Object.values((room.meta && room.meta.upstreams) || {})) {
3331
+ const repo = rec && rec.repo && rec.ref ? parseRepo(`https://${rec.repo}`) : null
3332
+ if (repo) n += mirrors.queue.pending(repo.slug, rec.ref)
3333
+ }
3334
+ return n
3335
+ }
3336
+ const roomStats = (room) => ({ room: room.name, conns: room.conns.size, hosted: room.hostedSeen.size, ...room.meter.lastMinute(), docBytes: room.totalBytes(), diskBytes: diskOf(room.name), git: gitBusy(room) })
3337
+ // The machine's own load (memory, event-loop lag, CPU), a minute at a time: for scaling the cluster on load.
3338
+ const machineMeter = new MachineMeter()
3339
+ /** The machine: rooms loaded, connections, memory, lag, CPU and every room's traffic (`list`: the rooms' stats, when already made). */
3340
+ const machineStats = (list = null) => {
3342
3341
  const mem = process.memoryUsage()
3343
- return { rooms: rooms.size, conns: wss.clients.size, rss: mem.rss, heapUsed: mem.heapUsed, load: os.loadavg()[0], uptime: Math.round(process.uptime()) }
3342
+ const traffic = list || [...rooms.values()].filter((r) => !r.ended).map((r) => r.meter.lastMinute())
3343
+ return { rooms: rooms.size, conns: wss.clients.size, rss: mem.rss, heapUsed: mem.heapUsed, load: os.loadavg()[0], uptime: Math.round(process.uptime()), ...machineLoad({ meter: machineMeter, rooms: traffic, mem }) }
3344
3344
  }
3345
3345
  const stats = () => {
3346
3346
  for (const name of diskBytes.keys()) if (!rooms.has(name)) diskBytes.delete(name)
3347
- return { machine: machineStats(), rooms: [...rooms.values()].filter((r) => !r.ended).map(roomStats) }
3347
+ const list = [...rooms.values()].filter((r) => !r.ended).map(roomStats)
3348
+ return { machine: machineStats(list), rooms: list }
3348
3349
  }
3349
3350
  /** Closes a loaded room on this machine without ending it: its people reconnect (to wherever it is now). */
3350
3351
  const unloadForMove = (name) => {
@@ -3519,13 +3520,22 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3519
3520
  })
3520
3521
  }
3521
3522
  // An org or workspace changed what agents may commit (the accounts API): every open session asks again now.
3523
+ // With { room }, only that session (one just put in or taken out of a workspace), if this machine holds it.
3522
3524
  if (url.pathname === '/github/policy' && req.method === 'POST') {
3523
3525
  const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3524
3526
  const want = cfg.relayApiSecret || ''
3525
3527
  const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3526
3528
  if (!same) return json(401, { error: 'only the accounts API may say this' })
3527
- for (const room of rooms.values()) room.refreshPolicy()
3528
- return json(200, { ok: true, rooms: rooms.size })
3529
+ return readJson(req, 4 * 1024, (err, b) => {
3530
+ const one = !err && b && typeof b.room === 'string' && b.room ? b.room : ''
3531
+ if (one) {
3532
+ const room = ROOM_RE.test(one) ? rooms.get(one) : null
3533
+ if (room) room.refreshPolicy()
3534
+ return json(200, { ok: true, rooms: room ? 1 : 0 })
3535
+ }
3536
+ for (const room of rooms.values()) room.refreshPolicy()
3537
+ json(200, { ok: true, rooms: rooms.size })
3538
+ })
3529
3539
  }
3530
3540
  // Someone connected GitHub (the API's callback): every session they are in checks its repository again now.
3531
3541
  if (url.pathname === '/github/connected' && req.method === 'POST') {
@@ -3559,7 +3569,14 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3559
3569
  if (req.method !== 'POST') return text(405, 'method not allowed')
3560
3570
  return readJson(req, 4096, (err, b) => {
3561
3571
  if (err) return json(400, { error: err.message })
3562
- if (action === 'release') { letRoomGo(name); return json(200, { ok: true }) }
3572
+ if (action === 'release') {
3573
+ // Never mid git push or commit: the API tries again later (or another room).
3574
+ const loaded = rooms.get(name)
3575
+ const busy = loaded ? gitBusy(loaded) : gitPushes.get(name) || 0
3576
+ if (busy) return json(409, { error: `${name} has git work running (${busy}); try again later` })
3577
+ letRoomGo(name)
3578
+ return json(200, { ok: true })
3579
+ }
3563
3580
  if (action === 'restore') { cluster.restore(name); log(`cluster: ${name} stays on this machine`); return json(200, { ok: true }) }
3564
3581
  if (action === 'forget') { forgetRoomHere(name, String((b && b.to) || '')); return json(200, { ok: true }) }
3565
3582
  // fetch
@@ -3618,7 +3635,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3618
3635
  // branches at /git/<room>.git, signed in with the member's Quilt pass (git's password; quilt git-credential).
3619
3636
  const gm = url.pathname.match(/^\/git\/([A-Za-z0-9_-]{1,64})\.git(\/info\/refs|\/git-upload-pack|\/git-receive-pack)$/)
3620
3637
  if (gm) {
3621
- if (!cfg.gitSync || !passKey || !dataDir) return text(404, 'this relay serves no git')
3638
+ if (!cfg.gitSync || !cfg.gitHttp || !passKey || !dataDir) return text(404, 'this relay serves no git')
3622
3639
  const name = gm[1]
3623
3640
  const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(String(req.headers.authorization || ''))
3624
3641
  const decoded = basic ? Buffer.from(basic[1], 'base64').toString('utf8') : ''
@@ -3629,6 +3646,11 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3629
3646
  if (!room.letIn(pass)) return text(403, NOT_LET_IN)
3630
3647
  const service = gm[2] === '/info/refs' ? url.searchParams.get('service') : gm[2].slice(1)
3631
3648
  if (service === 'git-receive-pack') { const a = room.httpAccess(pass); if (a && a.role === 'viewer') return text(403, 'you can only view this session, so you can\'t push') }
3649
+ if (gm[2] === '/git-receive-pack') {
3650
+ // A push being received: the room is not moved to another machine until it is done.
3651
+ gitPushes.set(name, (gitPushes.get(name) || 0) + 1)
3652
+ res.once('close', () => { const n = (gitPushes.get(name) || 1) - 1; if (n > 0) gitPushes.set(name, n); else gitPushes.delete(name) })
3653
+ }
3632
3654
  const def = viewRepoOf(room)
3633
3655
  if (!def) { log(`[${name}] git over http: no GitHub repository (default ${room.defaultKey}: ${JSON.stringify(upstreamsOf(room)[room.defaultKey] || null)})`); return text(404, 'this session follows no GitHub repository') }
3634
3656
  const repo = parseRepo(`https://${def.repo}`)
@@ -3637,7 +3659,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3637
3659
  if (!(await runGit(m.dir, ['rev-parse', '--verify', '-q', `refs/remotes/origin/${def.ref}`])).out.trim()) await room.mirrors.fetch(m.dir, def.ref, { token: await room.gitToken(repo, { access: 'read' }) }).catch(() => {})
3638
3660
  const dir = await ensureView(dataDir, name, m.dir)
3639
3661
  await syncViewRefs(room, dir, def.repo)
3640
- serveGit(req, res, { root: path.dirname(dir), pathInfo: `/${name}.git${gm[2]}`, query: url.search.slice(1), env: { QUILT_HOOK_URL: `http://127.0.0.1:${httpServer.address().port}/git-hook`, QUILT_HOOK_TOKEN: hookToken, QUILT_ROOM: name, QUILT_WHO: `${pass.kind}:${pass.sub}`, QUILT_WHO_NAME: String(pass.name || '') } })
3662
+ serveGit(req, res, { root: path.dirname(dir), pathInfo: `/${name}.git${gm[2]}`, query: url.search.slice(1), env: { QUILT_HOOK_URL: `http://127.0.0.1:${httpServer.address().port}/git-hook`, QUILT_HOOK_TOKEN: hookToken, QUILT_ROOM: name, QUILT_WHO: `${pass.kind}:${pass.sub}`, QUILT_WHO_NAME: String(pass.name || '') }, git: cfg.gitHttpCommand, onError: (why) => log(`[${name}] git over http (${req.method} ${gm[2]}): ${why}`) })
3641
3663
  })().catch((err) => { log(`[${name}] git over http: ${err.message}`); if (!res.headersSent) text(500, 'the relay could not serve git right now') })
3642
3664
  return
3643
3665
  }
@@ -3683,7 +3705,9 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3683
3705
  return { index: { rooms: [...idx.rooms] }, noteHosted: (o) => cluster.hostedRoom(account, o), shareWebhook: (w) => cluster.hostedWebhook(account, w || null) }
3684
3706
  })
3685
3707
  : Promise.resolve({})
3686
- return shared.then((more) => handleHostedMcp({ req, res, pass, workspaces, body: parsed, relay: { getRoom, roomEnded, refused, hosted, saveHosted, webhooks, log, endedMessage: ENDED_MESSAGE, updates, visitStart: hostedVisitStart, visitEnd: hostedVisitEnd, ...more } })
3708
+ // Secrets' names, through the accounts API as the agent (its pass).
3709
+ const secrets = cfg.apiUrl && pass.kind === 'agent' ? { apiUrl: cfg.apiUrl, pass: String(req.headers['x-quilt-pass']), fetch: apiFetch } : null
3710
+ return shared.then((more) => handleHostedMcp({ req, res, pass, workspaces, secrets, body: parsed, relay: { getRoom, roomEnded, refused, hosted, saveHosted, webhooks, log, endedMessage: ENDED_MESSAGE, updates, visitStart: hostedVisitStart, visitEnd: hostedVisitEnd, ...more } })
3687
3711
  // Its first session on this machine: the webhook it set elsewhere applies here too.
3688
3712
  .then(() => { if (more.index && cluster.hosted.get(account)) applySharedWebhook(account, cluster.hosted.get(account).webhook) }))
3689
3713
  .catch((err) => { log(`mcp error: ${err.message}`); if (!res.headersSent) text(500, 'mcp error') })
@@ -3915,7 +3939,9 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3915
3939
  }
3916
3940
  })
3917
3941
  reportTicker = setInterval(() => {
3918
- cluster.report(machineStats(), [...rooms.values()].filter((r) => !r.ended).map(roomStats)).catch((err) => log(`cluster: could not report this machine's minute: ${err.message}`))
3942
+ machineMeter.roll()
3943
+ const list = [...rooms.values()].filter((r) => !r.ended).map(roomStats)
3944
+ cluster.report(machineStats(list), list).catch((err) => log(`cluster: could not report this machine's minute: ${err.message}`))
3919
3945
  // Hosted agents in loaded rooms: a webhook set or removed on another machine reaches this one within a minute.
3920
3946
  let n = 0
3921
3947
  for (const [id, h] of hosted) if (n < 200 && h.rooms && Object.keys(h.rooms).some((r) => rooms.has(r))) { n++; syncHosted(id) }
@@ -3965,7 +3991,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
3965
3991
  sweeper.unref()
3966
3992
  // Branches only hosted agents work on, with no folder online: the relay looks for new commits on GitHub itself.
3967
3993
  const upstreamTicker = cfg.upstreamAuto && cfg.upstreamCheckMs > 0
3968
- ? setInterval(() => { for (const room of rooms.values()) if (!room.ended) { room.checkUpstreams().catch((err) => log(`[${room.name}] could not look for new commits: ${err.message}`)); room.reviewClashTick().catch((err) => log(`[${room.name}] could not look at the clashes: ${err.message}`)) } }, Math.min(cfg.gitSync ? 20 * 1000 : 60 * 1000, cfg.upstreamCheckMs)) // with the mirror, often enough that each branch's minute look isn't a tick late
3994
+ ? setInterval(() => { for (const room of rooms.values()) if (!room.ended) { room.checkUpstreams().catch((err) => log(`[${room.name}] could not look for new commits: ${err.message}`)) } }, Math.min(cfg.gitSync ? 20 * 1000 : 60 * 1000, cfg.upstreamCheckMs)) // with the mirror, often enough that each branch's minute look isn't a tick late
3969
3995
  : null
3970
3996
  upstreamTicker?.unref()
3971
3997
  // Live-sync leases renewed, and let go of for sessions that ended or sat idle for a day (relay-lease.js).
@@ -4009,6 +4035,7 @@ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, lo
4009
4035
  cluster, // exposed for tests
4010
4036
  close: async () => {
4011
4037
  clearInterval(reportTicker)
4038
+ machineMeter.stop()
4012
4039
  if (cluster) cluster.stop()
4013
4040
  clearInterval(leaseTicker)
4014
4041
  clearInterval(heartbeat)