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/api/server.js CHANGED
@@ -27,6 +27,7 @@ import { relayCluster } from './relay-cluster.js'
27
27
  import crypto from 'node:crypto'
28
28
  import { githubRoutes } from './routes/github.js'
29
29
  import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
30
+ import { releaseSource } from './releases.js'
30
31
  import { sessionRoutes } from './routes/sessions.js'
31
32
  import { accessTypeRoutes } from './routes/access-types.js'
32
33
  import { grantRoutes } from './routes/grants.js'
@@ -43,6 +44,7 @@ import { aiVmRoutes, cloudAiOf, WAIT_MS as AI_VM_WAIT_MS } from './routes/ai-vm.
43
44
  import { personaName } from '../persona.js'
44
45
  import { CLOUD_LABEL } from '../cloud-ai.js'
45
46
  import { routeName, cleanEvent } from './issues.js'
47
+ import { secretRoutes } from './routes/secrets.js'
46
48
 
47
49
  const API_VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version
48
50
 
@@ -64,7 +66,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
64
66
  // a change to its grant reaches it as soon as it reaches a connected app.
65
67
  const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
66
68
 
67
- export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, relayFetch = null, relayFly = null, heavy = null, clusterTiming = {} }) {
69
+ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs }) {
68
70
  // PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
69
71
  if (passKey) passPublicKey(passKey)
70
72
  const site = String(siteUrl || '').replace(/\/+$/, '')
@@ -213,7 +215,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
213
215
  ['GET', /^\/healthz$/, async () => ({ ok: true })],
214
216
 
215
217
  // Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
216
- ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces })],
218
+ ['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret })],
217
219
 
218
220
  ['POST', /^\/v1\/device\/start$/, async (req, body) => {
219
221
  limitStarts(req)
@@ -346,7 +348,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
346
348
  const deliveries = new Set()
347
349
  const trackDelivery = (p) => { deliveries.add(p); p.finally(() => deliveries.delete(p)).catch(() => {}); return p }
348
350
  // The relay on many machines: where each room lives, and heavy rooms' own machines (relay-cluster.js).
349
- const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...clusterTiming })
351
+ const cluster = relayCluster({ store, relayUrl: relay, relaySecret, fetch: relayFetch || githubFetch, now, log, fly: relayFly, ...(heavy ? { limits: heavy } : {}), ...(relayTargets ? { targets: relayTargets } : {}), ...clusterTiming })
350
352
  const ctx = { store, user, person, device, bearer, now, site, apiUrl: api, mailer, log, limit: limitInvites, limitSend: limitInviteSend, limitTokens, limitStarts, spendResume, resumeWindowMs: RESUME_WINDOW_MS, limitJoin, agentAuth, reportKey, limitReports, relaySecret, files, maxFileBytes, workspaceQuotaBytes, maxWorkspaceFiles }
351
353
  // GitHub's webhook secret: set on the app by this API at start (github-app.js), kept in the store when the environment gives none.
352
354
  const hookSecret = { value: (github && github.webhookSecret) || '' }
@@ -357,20 +359,23 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
357
359
  await redeliverFailed({ app, log })
358
360
  })().catch((err) => log(`github at start: ${err.message}`))
359
361
  // A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up.
360
- const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs })
362
+ const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs })
361
363
  routes.push(...aiVm.routes)
362
364
  // A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
363
- const policyChanged = () => {
365
+ // With a room (a session put in or taken out of a workspace), only that session asks.
366
+ const policyChanged = (room = '') => {
364
367
  if (!relay || !relaySecret) return
365
- cluster.tellRelays('/github/policy', {}).catch((err) => log(`git rules to relay: ${err.message}`))
368
+ cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
366
369
  }
367
- routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes(ctx), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
370
+ routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
368
371
  // Always routed: with the flag off each answers a plain 404 of its own, so the app's
369
372
  // check at every launch isn't filed as a missing route.
370
373
  // Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
371
- routes.push(...workspaceRoutes({ ...ctx, workspaces, passKey }), ...workspaceInviteRoutes({ ...ctx, workspaces, passKey }), ...workspaceAgentRoutes({ ...ctx, workspaces, passKey, relayUrl, webhookFetch, webhookLookup, allowLocalWebhooks, trackDelivery, limitAnnounce }))
374
+ routes.push(...workspaceRoutes({ ...ctx, workspaces, passKey, policyChanged }), ...workspaceInviteRoutes({ ...ctx, workspaces, passKey }), ...workspaceAgentRoutes({ ...ctx, workspaces, passKey, relayUrl, webhookFetch, webhookLookup, allowLocalWebhooks, trackDelivery, limitAnnounce }))
372
375
  const wsFiles = workspaceFileRoutes({ ...ctx, workspaces, passKey })
373
376
  routes.push(...wsFiles.routes)
377
+ // Secrets (routes/secrets.js): off until the vault is set up (QUILT_VAULT_SECRET).
378
+ routes.push(...secretRoutes({ ...ctx, passKey, vaultSecret, vaultUrl, allowLocalUpstreams, ...(secretWaitMs ? { waitMs: secretWaitMs } : {}) }))
374
379
 
375
380
  async function openLink (code) {
376
381
  const userCode = normalizeUserCode(code)
@@ -427,6 +432,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
427
432
  try {
428
433
  if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
429
434
  if (pathname === '/mcp') return await proxyMcp(req, res, send)
435
+ if (req.method === 'GET' && (pathname === '/v1/releases/latest' || pathname.startsWith('/v1/download/'))) return await serveRelease(req, res, send, pathname)
430
436
  const url = new URL(req.url, 'http://x')
431
437
  const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
432
438
  if (!route) throw Object.assign(new HttpError(404, 'not found'), { noRoute: true })
@@ -452,6 +458,29 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
452
458
  }
453
459
  })
454
460
 
461
+ // Downloads and the update check (releases.js): public, from the private repository's latest
462
+ // release, read as Quilt's GitHub App. A download is a redirect to GitHub's own short-lived link.
463
+ const releases = releaseSource({ github, fetch: githubFetch, now, apiUrl: api })
464
+ async function serveRelease (req, res, send, pathname) {
465
+ if (!releases) throw new HttpError(503, 'downloads are not set up on this server')
466
+ try {
467
+ if (pathname === '/v1/releases/latest') {
468
+ const body = JSON.stringify(await releases.latestPublic())
469
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=60', ...cors(req) })
470
+ return res.end(body)
471
+ }
472
+ let rest = ''
473
+ try { rest = decodeURIComponent(pathname.slice('/v1/download/'.length)) } catch {}
474
+ const [first, second] = rest.split('/')
475
+ const to = second !== undefined ? await releases.downloadUrl(second, first) : await releases.downloadUrl(first)
476
+ res.writeHead(302, { location: to, 'cache-control': 'no-store', ...cors(req) })
477
+ return res.end()
478
+ } catch (err) {
479
+ if (err && err.status && !(err instanceof HttpError)) throw new HttpError(err.status, err.message)
480
+ throw err
481
+ }
482
+ }
483
+
455
484
  // Hosted agents' MCP: the agent's access key signs it in here; the relay gets the same
456
485
  // request with a pass for the agent, and its answer comes straight back. Stateless on
457
486
  // both sides, so each request stands alone. The pass is for the room the agent is joining
@@ -573,6 +602,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
573
602
  // Workspace files: uploads that never landed, and deleted files past their 30 days
574
603
  // (only with the flag on: before then the tables may not exist yet).
575
604
  if (workspaces) try { await wsFiles.sweep() } catch (err) { log(`file sweep failed: ${err?.message || err}`) }
605
+ // Secrets' write tickets a day past their end (only with secrets on: the tables exist then).
606
+ if (vaultSecret) try { await store.pruneSecretTickets(now() - 24 * 60 * 60 * 1000) } catch (err) { log(`secret ticket prune failed: ${err?.message || err}`) }
576
607
  }
577
608
  const prune = setInterval(() => { pruneNow() }, pruneEveryMs)
578
609
  prune.unref()
@@ -587,7 +618,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
587
618
 
588
619
  return new Promise((resolve) => server.listen(port, host, () => {
589
620
  const p = server.address().port
590
- resolve({ port: p, url: `http://${host}:${p}`, cluster, close: () => { clearInterval(prune); clearTimeout(firstPrune); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
621
+ resolve({ port: p, url: `http://${host}:${p}`, cluster, fillSpares: aiVm.fillSpares, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
591
622
  }))
592
623
  }
593
624
 
@@ -23,7 +23,7 @@ const TEAM_MEMBER = 'team_id, member_id, access, scopes, added_at'
23
23
  const INVITE = 'id, org_id, email, role_id, token_hash, invited_by, expires_at, accepted_at, cancelled_at, created_at'
24
24
  const REQUEST = 'id, org_id, user_id, email, status, decided_by, decided_at, created_at'
25
25
  const AGENT_INVITE = 'id, token_hash, owner_user_id, org_id, created_by, role_id, teams, expires_at, used_at, used_by_agent_id, rejoined, cancelled_at, created_at, workspace_id, workspace_access, workspace_sessions, global'
26
- const AGENT_APP_KEY = 'id, agent_id, name, key_hash, created_at, last_used_at, revoked_at'
26
+ const AGENT_APP_KEY = 'id, agent_id, name, key_hash, created_at, last_used_at, revoked_at, kind, hint, room, session_link, session_name, slot'
27
27
  const AGENT_KEY = 'id, agent_id, family_id, access_hash, refresh_hash, access_expires_at, refresh_expires_at, refreshed_at, revoked_at, created_at'
28
28
  const RELAY_SESSION = 'room, name, owner_account, created_at, last_active_at, renamed_at, workspace_id, workspace_linked_by'
29
29
  const ACCESS_TYPE = 'id, owner_account, name, files, folders, talk, created_at, updated_at'
@@ -38,10 +38,18 @@ const AGENT_PLACEMENT = 'agent_id, reach, workspace_ids, sessions, access, scope
38
38
  const WORKSPACE_AGENT_OVERRIDE = 'workspace_id, agent_id, sessions, excluded'
39
39
  const SESSION_AGENT_EXCLUSION = 'room, agent_id, excluded_by, created_at'
40
40
  const AGENT_WEBHOOK = 'agent_id, url, secret, created_at, updated_at'
41
- const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, last_report_at, created_at, updated_at'
42
- const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hello_at, reported_at, created_at'
43
- const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at'
41
+ const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, sessions, last_report_at, created_at, updated_at'
42
+ const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hot_at, over_at, empty_at, hello_at, reported_at, created_at'
43
+ const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at, moved_at'
44
44
  const RELAY_JOB = 'key, state, detail, started_at, finished_at'
45
+ // Secrets: named columns, so a list never carries a sealed value (secretSealed reads that, for the vault).
46
+ const SECRET = 'id, scope_type, scope_id, name, key_version, digest, upstream, format, who, created_by, updated_by, created_at, updated_at, last_used_at'
47
+ const SECRET_DATA_KEY = 'scope_type, scope_id, version, provider, sealed_key, created_at'
48
+ const SECRET_SETTINGS = 'scope_type, scope_id, inherit_off, inherit_off_by, inherit_off_at, agents_on, agents, updated_at'
49
+ const SECRET_TICKET = 'id, token_hash, scope_type, scope_id, entries, created_by, created_at, expires_at, redeemed_at, stored_at'
50
+ const SECRET_RUN = 'id, token_hash, room, account, kind, label, created_at, expires_at, revoked_at'
51
+ const SECRET_ASK = 'run_id, secret_id, name, state, asked_at, answered_at'
52
+ const SECRET_USE = 'id, secret_id, run_id, room, account, upstream, at'
45
53
  // PostgREST hands back at most 1000 rows per request: longer lists are read a page at a time.
46
54
  const PAGE = 1000
47
55
 
@@ -168,8 +176,26 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
168
176
  },
169
177
 
170
178
  // App keys (qk_): hashes only, and they don't run out.
171
- async createAgentAppKey ({ agentId, name, keyHash }) {
172
- return rowFrom(await one(db.from('agent_app_keys').insert({ agent_id: agentId, name, key_hash: keyHash }).select(AGENT_APP_KEY).single()))
179
+ // A sandbox key (kind 'sandbox') also has its session, slot and hint; a plain app key has none.
180
+ async createAgentAppKey ({ agentId, name, keyHash, kind, hint, room, sessionLink, sessionName, slot }) {
181
+ const insert = { agent_id: agentId, name, key_hash: keyHash, ...toSnake({ kind, hint, room, sessionLink, sessionName, slot }) }
182
+ return rowFrom(await one(db.from('agent_app_keys').insert(insert).select(AGENT_APP_KEY).single()))
183
+ },
184
+ async agentAppKeyById (id) { return rowFrom(await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('id', id).maybeSingle())) },
185
+ // These agents' sandbox keys, revoked ones too (until removed), newest first.
186
+ async listSandboxKeys (agentIds) {
187
+ if (!agentIds.length) return []
188
+ return (await one(db.from('agent_app_keys').select(AGENT_APP_KEY).in('agent_id', agentIds).eq('kind', 'sandbox').order('created_at', { ascending: false }))).map(rowFrom)
189
+ },
190
+ // Only a revoked key, and only one of this agent's.
191
+ async removeAgentAppKey (agentId, id) {
192
+ const rows = await one(db.from('agent_app_keys').delete().eq('id', id).eq('agent_id', agentId).not('revoked_at', 'is', null).select('id'))
193
+ return rows.length > 0
194
+ },
195
+ async countAppKeyUse (keyId, day) { await one(db.rpc('count_app_key_use', { p_key: keyId, p_day: day })) },
196
+ async appKeyUses (keyIds, sinceDay) {
197
+ if (!keyIds.length) return []
198
+ return (await one(db.from('agent_app_key_uses').select('key_id, day, count').in('key_id', keyIds).gte('day', sinceDay))).map((r) => ({ keyId: r.key_id, day: r.day, count: r.count }))
173
199
  },
174
200
  async agentAppKeyByHash (h) { return rowFrom(await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('key_hash', h).maybeSingle())) },
175
201
  async listAgentAppKeys (agentId) {
@@ -279,11 +305,11 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
279
305
  return r ? r.value : String(value)
280
306
  },
281
307
 
282
- // Relay machines and where each room lives (20261017000000_relay_cluster.sql; src/api/relay-cluster.js).
308
+ // Relay machines and where each room lives (20261017100000_relay_cluster.sql, 20261018100000_relay_autoscale.sql; src/api/relay-cluster.js).
283
309
  async relayMachine (id) { return rowFrom(await one(db.from('relay_machines').select(RELAY_MACHINE).eq('id', id).maybeSingle())) },
284
310
  async relayMachines () { return (await one(db.from('relay_machines').select(RELAY_MACHINE).order('created_at').order('id'))).map(rowFrom) },
285
- async putRelayMachine ({ id, helloAt, reportedAt, ...fields }) {
286
- const row = toSnake({ id, ...fields, hello_at: ts(helloAt), reported_at: ts(reportedAt) })
311
+ async putRelayMachine ({ id, helloAt, reportedAt, hotAt, overAt, emptyAt, ...fields }) {
312
+ const row = toSnake({ id, ...fields, hello_at: ts(helloAt), reported_at: ts(reportedAt), hot_at: ts(hotAt), over_at: ts(overAt), empty_at: ts(emptyAt) })
287
313
  return rowFrom(await one(db.from('relay_machines').upsert(row, { onConflict: 'id' }).select(RELAY_MACHINE).single()))
288
314
  },
289
315
  async relayRoom (room) { return rowFrom(await one(db.from('relay_rooms').select(RELAY_ROOM).eq('room', room).maybeSingle())) },
@@ -294,8 +320,8 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
294
320
  return out
295
321
  },
296
322
  async placeRelayRoom (room, machineId) { return rowFrom((await one(db.rpc('place_relay_room', { p_room: room, p_machine: machineId })))[0]) },
297
- async setRelayRoom (room, { reportedAt, ...patch }) {
298
- return rowFrom(await one(db.from('relay_rooms').update(toSnake({ ...patch, reported_at: ts(reportedAt) })).eq('room', room).select(RELAY_ROOM).maybeSingle()))
323
+ async setRelayRoom (room, { reportedAt, movedAt, ...patch }) {
324
+ return rowFrom(await one(db.from('relay_rooms').update(toSnake({ ...patch, reported_at: ts(reportedAt), moved_at: ts(movedAt) })).eq('room', room).select(RELAY_ROOM).maybeSingle()))
299
325
  },
300
326
  async forgetRelayRoom (room, machineId) {
301
327
  let q = db.from('relay_rooms').delete().eq('room', room)
@@ -760,6 +786,90 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
760
786
  async pruneIssues (before) {
761
787
  await one(db.from('issues').delete().lt('last_seen_at', ts(before)))
762
788
  return 0
789
+ },
790
+
791
+ // Secrets (see 20261018000000_secrets.sql).
792
+ async listSecrets (scopeType, scopeId) { return (await one(db.from('secrets').select(SECRET).eq('scope_type', scopeType).eq('scope_id', scopeId).order('name'))).map(rowFrom) },
793
+ async secretByName (scopeType, scopeId, name) { return rowFrom(await one(db.from('secrets').select(SECRET).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('name', name).maybeSingle())) },
794
+ async secretById (id) { return rowFrom(await one(db.from('secrets').select(SECRET).eq('id', id).maybeSingle())) },
795
+ async secretSealed (id) {
796
+ const r = await one(db.from('secrets').select('sealed_value, key_version').eq('id', id).maybeSingle())
797
+ return r ? { sealedValue: r.sealed_value, keyVersion: r.key_version } : null
798
+ },
799
+ async putSecret ({ scopeType, scopeId, name, sealedValue, keyVersion, digest, upstream, format, who, by }) {
800
+ const set = { sealed_value: sealedValue, key_version: keyVersion, digest, upstream, format, who, updated_by: by, updated_at: new Date().toISOString() }
801
+ const had = await one(db.from('secrets').update(set).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('name', name).select(SECRET))
802
+ if (had.length) return rowFrom(had[0])
803
+ return rowFrom(await one(db.from('secrets').insert({ scope_type: scopeType, scope_id: scopeId, name, ...set, created_by: by }).select(SECRET).single()))
804
+ },
805
+ async updateSecret (id, { who, updatedBy }) {
806
+ return rowFrom(await one(db.from('secrets').update({ who, updated_by: updatedBy, updated_at: new Date().toISOString() }).eq('id', id).select(SECRET).maybeSingle()))
807
+ },
808
+ async deleteSecret (id) { return (await one(db.from('secrets').delete().eq('id', id).select('id'))).length > 0 },
809
+ async deleteScopeSecrets (scopeType, scopeId) {
810
+ await one(db.from('secrets').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
811
+ await one(db.from('secret_data_keys').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
812
+ await one(db.from('secret_scope_settings').delete().eq('scope_type', scopeType).eq('scope_id', scopeId))
813
+ },
814
+ async secretDataKey (scopeType, scopeId) {
815
+ return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).order('version', { ascending: false }).limit(1).maybeSingle()))
816
+ },
817
+ async secretDataKeyVersion (scopeType, scopeId, version) {
818
+ return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).maybeSingle()))
819
+ },
820
+ async putSecretDataKey ({ scopeType, scopeId, version, sealedKey, provider }) {
821
+ await one(db.from('secret_data_keys').upsert({ scope_type: scopeType, scope_id: scopeId, version, sealed_key: sealedKey, provider }, { onConflict: 'scope_type,scope_id,version', ignoreDuplicates: true }))
822
+ return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).single()))
823
+ },
824
+ async secretSettings (scopeType, scopeId) {
825
+ const r = rowFrom(await one(db.from('secret_scope_settings').select(SECRET_SETTINGS).eq('scope_type', scopeType).eq('scope_id', scopeId).maybeSingle()))
826
+ return r || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
827
+ },
828
+ async putSecretSettings (scopeType, scopeId, patch) {
829
+ const set = toSnake({ ...patch, inheritOffAt: ts(patch.inheritOffAt) })
830
+ return rowFrom(await one(db.from('secret_scope_settings').upsert({ scope_type: scopeType, scope_id: scopeId, ...set, updated_at: new Date().toISOString() }, { onConflict: 'scope_type,scope_id' }).select(SECRET_SETTINGS).single()))
831
+ },
832
+ async createSecretTicket ({ tokenHash, scopeType, scopeId, entries, createdBy, expiresAt }) {
833
+ return rowFrom(await one(db.from('secret_tickets').insert({ token_hash: tokenHash, scope_type: scopeType, scope_id: scopeId, entries, created_by: createdBy, expires_at: ts(expiresAt) }).select(SECRET_TICKET).single()))
834
+ },
835
+ async redeemSecretTicket (tokenHash, at) {
836
+ const rows = await one(db.rpc('redeem_secret_ticket', { p_token_hash: tokenHash, p_at: ts(at) }))
837
+ return rowFrom((rows || [])[0] || null)
838
+ },
839
+ async secretTicketById (id) { return rowFrom(await one(db.from('secret_tickets').select(SECRET_TICKET).eq('id', id).maybeSingle())) },
840
+ async markSecretTicketStored (id, at) {
841
+ return (await one(db.from('secret_tickets').update({ stored_at: ts(at) }).eq('id', id).not('redeemed_at', 'is', null).is('stored_at', null).select('id'))).length > 0
842
+ },
843
+ async pruneSecretTickets (before) { await one(db.from('secret_tickets').delete().lt('expires_at', ts(before))) },
844
+ async createSecretRun ({ tokenHash, room, account, kind, label = '', expiresAt }) {
845
+ return rowFrom(await one(db.from('secret_runs').insert({ token_hash: tokenHash, room, account, kind, label, expires_at: ts(expiresAt) }).select(SECRET_RUN).single()))
846
+ },
847
+ async secretRunByToken (tokenHash) { return rowFrom(await one(db.from('secret_runs').select(SECRET_RUN).eq('token_hash', tokenHash).maybeSingle())) },
848
+ async secretRunById (id) { return rowFrom(await one(db.from('secret_runs').select(SECRET_RUN).eq('id', id).maybeSingle())) },
849
+ async revokeSecretRun (id, at) {
850
+ return (await one(db.from('secret_runs').update({ revoked_at: ts(at) }).eq('id', id).is('revoked_at', null).select('id'))).length > 0
851
+ },
852
+ async putSecretRunAsks (runId, asks) {
853
+ if (!asks.length) return
854
+ await one(db.from('secret_run_asks').insert(asks.map((a) => ({ run_id: runId, secret_id: a.secretId, name: a.name, state: a.state || 'asked' }))))
855
+ },
856
+ async listSecretRunAsks (runId) { return (await one(db.from('secret_run_asks').select(SECRET_ASK).eq('run_id', runId))).map(rowFrom) },
857
+ async answerSecretRunAsks (runId, state, at) {
858
+ return (await one(db.from('secret_run_asks').update({ state, answered_at: ts(at) }).eq('run_id', runId).eq('state', 'asked').select('run_id'))).length
859
+ },
860
+ async pendingSecretAsks (account, at) {
861
+ const runs = (await one(db.from('secret_runs').select(SECRET_RUN).eq('account', account).is('revoked_at', null).gt('expires_at', ts(at)).order('created_at'))).map(rowFrom)
862
+ if (!runs.length) return []
863
+ const asks = (await one(db.from('secret_run_asks').select(SECRET_ASK).in('run_id', runs.map((r) => r.id)).eq('state', 'asked'))).map(rowFrom)
864
+ return runs.map((run) => ({ run, asks: asks.filter((a) => a.runId === run.id) })).filter((x) => x.asks.length)
865
+ },
866
+ async recordSecretUse ({ secretId, runId, room, account, upstream, at }) {
867
+ await one(db.from('secret_uses').insert({ secret_id: secretId, run_id: runId, room, account, upstream, at: ts(at) }))
868
+ await one(db.from('secrets').update({ last_used_at: ts(at) }).eq('id', secretId))
869
+ },
870
+ async listSecretUses (secretIds, { limit = 100 } = {}) {
871
+ if (!secretIds.length) return []
872
+ return (await one(db.from('secret_uses').select(SECRET_USE).in('secret_id', secretIds).order('at', { ascending: false }).limit(limit))).map((r) => ({ ...rowFrom(r), at: ms(r.at) }))
763
873
  }
764
874
  }
765
875
  }
package/src/autowork.js CHANGED
@@ -1,4 +1,4 @@
1
- // "Work on its own": when work comes to "<person>'s AI" (a task given to it, a merge sent to
1
+ // "Work on its own": when work comes to "<person>'s AI" (a task given to it, a message to
2
2
  // it) and none of the person's AI sessions is live, Quilt can start the person's own AI tool
3
3
  // in its non-interactive mode, on their computer, in the session's folder — only when they
4
4
  // turned it on in Settings. The run talks through Quilt chat: it asks with quilt_message and
@@ -71,8 +71,6 @@ export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
71
71
  if (job.kind === 'task') {
72
72
  lines.push(`${job.by || 'Someone'} gave ${person}'s AI a task on the board:`, `Task ${job.id}: ${job.title}`, '',
73
73
  'Read it in full with quilt_task, move it to "doing" with quilt_move_task when you start (that briefs you), and to "qa" with qaNotes when it is done and checked.')
74
- } else if (job.kind === 'merge') {
75
- lines.push(job.prompt.trim())
76
74
  } else if (job.kind === 'message') {
77
75
  lines.push(`${job.by || 'Someone'} wrote to ${person}'s AI in Quilt chat:`, '', String(job.text || '').trim(), '',
78
76
  `Answer ${job.by || 'them'} with quilt_message. If they ask for work, do it as ${person}'s AI would, and say in chat what you did.`)
@@ -211,14 +209,6 @@ export class AutoWork {
211
209
  }
212
210
  }
213
211
 
214
- /** A merge sent to the person's AI. Resolves once it is queued; the run follows. */
215
- sendMerge (session, { id, path: file, prompt }) {
216
- const c = this.config()
217
- if (!c.preset) throw new Error(`${c.tool || 'Your AI tool'} can't work on its own: open the merge in it instead`)
218
- this.enqueue({ kind: 'merge', session, id, path: file, title: `merge ${file}`, prompt, by: session.name })
219
- return { queued: true, tool: c.tool }
220
- }
221
-
222
212
  enqueue (job) {
223
213
  this.queue.push(job)
224
214
  this.idle = this.idle.then(() => this.next()).catch(() => {})
@@ -262,7 +252,7 @@ export class AutoWork {
262
252
  const args = resumes
263
253
  ? p.resume({ ...o, prompt: replyPrompt({ run, reply: job.reply, resumes: true }) })
264
254
  : p.start({ ...o, prompt: job.kind === 'reply' ? replyPrompt({ run, reply: job.reply, resumes: false }) : run.prompt })
265
- const label = job.kind === 'merge' ? job.title : (run.title || 'work')
255
+ const label = run.title || 'work'
266
256
  const started = this.now()
267
257
  let persona = null
268
258
  const r = await this.spawnRun(exe, args, {
@@ -296,15 +286,13 @@ export class AutoWork {
296
286
  }
297
287
 
298
288
  /**
299
- * One plain line in chat for the person; the work stays where it was (a merge lets go of its
300
- * file again). A tool that isn't signed in is said so plainly, never with one tool's fix.
289
+ * One plain line in chat for the person; the work stays where it was. A tool that isn't signed in is said so plainly, never with one tool's fix.
301
290
  */
302
291
  fail (session, run, why) {
303
292
  const where = session.aiOf ? 'your cloud AI (Settings → Your AI)' : 'this computer'
304
293
  const text = SIGNED_OUT.test(String(why)) ? `${run.tool || 'your AI tool'} isn't signed in on ${where}` : String(why).replace(/[.\s]+$/, '').slice(0, 300)
305
294
  this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI in your chat.`, run)
306
295
  session.dropAutoPersona(run.via)
307
- if (run.kind === 'merge' && run.path) session.releaseMergeClaim(run.path).catch(() => {})
308
296
  }
309
297
 
310
298
  notice (session, text, run = null) {
package/src/catchup.js CHANGED
@@ -104,7 +104,7 @@ export function catchUpMarkdown (c, { ago = () => '' } = {}) {
104
104
  const m = c.mine || {}
105
105
  if (m.shared) out.push(`- Your ${plural(m.shared, 'change')} from while you were away went into the session.`)
106
106
  if (m.merged?.length) out.push(`- Combined with the session's changes: ${m.merged.map((p) => `\`${p}\``).join(', ')}.`)
107
- if (m.conflicts?.length) out.push(`- Clashed with the session's changes (the session's version is in the file, yours is in the merge; see quilt_merges): ${m.conflicts.map((p) => `\`${p}\``).join(', ')}.`)
107
+ if (m.conflicts?.length) out.push(`- Held by someone else, so waiting: Quilt merges your changes in when they let go of ${m.conflicts.map((p) => `\`${p}\``).join(', ')}.`)
108
108
  if (c.backups?.length) out.push(`- Your copies of ${plural(c.backups.length, 'file')} differed from the session's and were replaced; they are kept: ${c.backups.map((b) => `\`${b.path}\` → \`${b.copy}\``).join(', ')}.`)
109
109
  out.push('Read quilt_history for the diffs before you change these files.')
110
110
  return out
package/src/clash.js CHANGED
@@ -1,7 +1,8 @@
1
- // A clash between commits from outside and the session's uncommitted work is
2
- // handed to ONE AI, as a task on the board, instead of every folder behind on
3
- // that branch telling its AI to pull and resolve (several AIs starting the same
4
- // merge). Each folder that reports the clash works out the same owner from
1
+ // Commits from outside that a folder can't bring in (git couldn't read a file
2
+ // they change: too large, or behind a filter such as Git LFS) are handed to ONE
3
+ // member as a task on the board, instead of every folder behind on that branch
4
+ // telling its AI. Files both sides changed never come here: Quilt combines them
5
+ // itself (upstream.js combine). Each folder that reports the clash works out the same owner from
5
6
  // presence; only the owner's Quilt writes the task, under an id derived from
6
7
  // the branch and the upstream commit, so two that race write the same task.
7
8
  // Pure: Session (reviewClash) reads presence and the board and applies these.
@@ -106,35 +107,22 @@ export function clashFiles (conflicts = []) {
106
107
  return [...new Set(conflicts.map((c) => c.path))].slice(0, MAX_FILES)
107
108
  }
108
109
 
109
- /** The task's description, as its first comment: each file and why, and what to do. */
110
+ /** The task's description, as its first comment: each file and why. Nobody is asked to pull or merge. */
110
111
  export function clashBrief ({ branch, upstream, sha, behind, conflicts = [] }) {
111
- const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)}) clash with the session's uncommitted work, so Quilt brought nothing in:`]
112
+ const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)}) change files Quilt can't read here, so it brought nothing in yet:`]
112
113
  for (const c of conflicts.slice(0, 30)) lines.push(`- ${c.path}: ${c.why}`)
113
114
  if (conflicts.length > 30) lines.push(`- and ${conflicts.length - 30} more`)
114
- lines.push('', `In your folder on \`${branch}\`: run git pull, resolve those files and git add them. Quilt takes in the result and every other folder on \`${branch}\` follows yours, without merging again. Quilt closes this task by itself once the merge lands (a folder at ${sha.slice(0, 7)} or later with nothing clashing): no need to move it.`)
115
+ lines.push('', `Files both sides changed are combined by Quilt on its own; nothing here needs merging by hand. Once a folder on \`${branch}\` that can read these files is at ${sha.slice(0, 7)} or later, every other folder follows it and Quilt closes this task by itself: no need to move it.`)
115
116
  return lines.join('\n')
116
117
  }
117
118
 
118
119
  /** The notice for a member whose folder is behind but who is not merging: leave it to the owner. */
119
120
  export function leaveItNotice ({ label, upstream, task, facts }) {
120
- return `${label} is merging the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
121
+ return `${label} is bringing in the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
121
122
  }
122
123
 
123
124
  /** What a folder that reports the clash knows about it, without the advice to pull. */
124
125
  export function clashFacts ({ branch, upstream, behind, conflicts = [] }) {
125
126
  const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
126
- return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and the session's uncommitted work clashes with them in ${list}.`
127
- }
128
-
129
- /**
130
- * The description of a clash the relay hands to a hosted agent (relay-upstream.js): no folder on
131
- * the branch is online, so the agent merges in the session itself, file by file. The change from
132
- * upstream for each file follows in the next comments.
133
- */
134
- export function relayClashBrief ({ branch, upstream, sha, base, behind, conflicts = [], compare = '' }) {
135
- const lines = [`${n(behind, 'commit')} on ${upstream} (${base.slice(0, 7)} → ${sha.slice(0, 7)}) clash with the session's uncommitted work on \`${branch}\`, and no folder on \`${branch}\` that can bring commits in is online, so the relay brought nothing in:`]
136
- for (const c of conflicts.slice(0, 20)) lines.push(`- ${c.path}: ${c.why}`)
137
- if (conflicts.length > 20) lines.push(`- and ${conflicts.length - 20} more`)
138
- lines.push('', `For each file: read it with quilt_read_file, fold in what ${upstream} changed (the next comments${compare ? `, or ${compare}` : ''}) while keeping the session's work, and write the merged file (quilt_write_file, or in your folder), without conflict markers. Each write of one of these files makes the relay look again at once: a file counts as merged when the lines that clashed were rewritten. If you merged a file differently (kept one side whole, say), move this task to QA when every file is done: that tells the relay the files are merged as they are. The rest of the commits then come in, and Quilt closes this task by itself. quilt_sync_branch asks the relay to look again too.`)
139
- return lines.join('\n').slice(0, 2000)
127
+ return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to bring them in.`
140
128
  }
package/src/control.js CHANGED
@@ -6,9 +6,15 @@ import path from 'node:path'
6
6
  import crypto from 'node:crypto'
7
7
  import { renderStatus } from './status.js'
8
8
  import { migrateDir } from './legacy.js'
9
+ import { createRun, waitRun, endRun, secretNames } from './secrets-client.js'
9
10
 
10
11
  export async function startControl (session, extras = {}) {
11
12
  const token = crypto.randomBytes(16).toString('hex')
13
+ // { api, token } for the accounts API as this session's identity (runner.js `apiAuth`).
14
+ const secretsApi = async () => {
15
+ if (!extras.apiAuth) throw new Error('This session is not signed in to Quilt, so it has no secrets. Sign in (quilt login) and join again.')
16
+ return extras.apiAuth()
17
+ }
12
18
  const routes = {
13
19
  'GET /status': (b) => {
14
20
  // To one of the AI sessions working through this app, "you" is that session (persona.js).
@@ -143,22 +149,17 @@ export async function startControl (session, extras = {}) {
143
149
  return r
144
150
  },
145
151
  'POST /tasks/delete': (b) => { session.deleteTask(b.id); return { tasks: session.taskList() } },
146
- 'GET /merges': () => {
147
- const merges = session.mergeList()
148
- // Agents are told the other version is under .quilt/merges/<id>/, so put
149
- // it there on this machine too (base and ours are written once). A local
150
- // record's versions are only on the opener's machine; skip any that fail.
151
- for (const m of merges) {
152
- if (m.state !== 'open' || m.local) continue
153
- try { session.prepareMergeSend(m.id) } catch {}
154
- }
155
- return { merges }
156
- },
157
- 'POST /merges/resolve': (b) => session.settleMerge(String(b.id || ''), { how: b.how }),
152
+ // Merges Quilt made on its own (session.js autoSettle), and files waiting for their holder to let go.
153
+ 'GET /merges': () => ({ merges: session.mergeList() }),
158
154
  // quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js).
159
155
  'POST /clash': (b) => session.clash({ action: String(b.action || ''), path: String(b.path || ''), content: typeof b.content === 'string' ? b.content : null, checks: b.checks && typeof b.checks === 'object' ? b.checks : null, force: b.force === true }),
160
- 'POST /merges/send': (b) => session.sendMerge(String(b.id || '')),
161
156
  'GET /rules': () => ({ text: session.projectRulesText() }),
157
+ // Secrets (`quilt run`, quilt_secret_names): asked of the API as this session's own identity
158
+ // (its person, or the agent it runs as). Run tokens and names only: never a value.
159
+ 'POST /secrets/run': async (b) => createRun(await secretsApi(), { room: session.room, label: String(b.label || '').slice(0, 200) }),
160
+ 'POST /secrets/run/wait': async (b) => waitRun(await secretsApi(), String(b.id || '')),
161
+ 'POST /secrets/run/end': async (b) => endRun(await secretsApi(), String(b.id || '')),
162
+ 'GET /secrets/names': async () => secretNames(await secretsApi(), session.room),
162
163
  'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid })
163
164
  }
164
165
  const server = http.createServer(async (req, res) => {
@@ -233,11 +234,13 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
233
234
  }
234
235
  }
235
236
 
236
- export async function call (daemon, method, route, body) {
237
+ /** `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt). */
238
+ export async function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
237
239
  const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
238
240
  method,
239
241
  headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
240
- body: body ? JSON.stringify(body) : undefined
242
+ body: body ? JSON.stringify(body) : undefined,
243
+ signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
241
244
  })
242
245
  const json = await res.json()
243
246
  if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
package/src/fsutil.js CHANGED
@@ -4,12 +4,22 @@ import crypto from 'node:crypto'
4
4
  import { makeIgnore, isIgnored, scopeIgnore, IGNORE_FILES } from './pathrules.js'
5
5
 
6
6
  export { IGNORE_FILES } from './pathrules.js'
7
- export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
7
+ export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
8
8
 
9
9
  export function toPosix (p) {
10
10
  return p.split(path.sep).join('/')
11
11
  }
12
12
 
13
+ /**
14
+ * Closes a chokidar watcher for good. chokidar calls its own add() when the last file it watches
15
+ * goes (to watch for its return), and add() reopens a closed watcher: a scan still under way at
16
+ * close (an index.lock that came and went) left a watch open that kept the process alive.
17
+ */
18
+ export function closeWatcher (w) {
19
+ w.add = () => w
20
+ return w.close()
21
+ }
22
+
13
23
  /**
14
24
  * Builds the ignore matcher from built-ins and every .gitignore / .quiltignore
15
25
  * in the project, each applying to its own folder. Folders already ignored
package/src/gitstate.js CHANGED
@@ -11,7 +11,7 @@ import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import { execFile } from 'node:child_process'
13
13
  import { watch } from 'chokidar'
14
- import { looksBinary, sha1, MAX_STORED_BINARY_BYTES } from './fsutil.js'
14
+ import { looksBinary, sha1, MAX_STORED_BINARY_BYTES, closeWatcher } from './fsutil.js'
15
15
 
16
16
  export const GIT_TIMEOUT_MS = 5000
17
17
  export const SETTLE_MS = 2000
@@ -343,6 +343,23 @@ export async function changesBetween (root, shaA, shaB) {
343
343
  return changes
344
344
  }
345
345
 
346
+ /**
347
+ * When each path last changed in `from`..`to`, by commit time (ms): Map path -> ms, in one git log.
348
+ * What "newer" means for the lines both a catch-up's commits and the session changed (upstream.js combine).
349
+ */
350
+ export async function commitTimes (root, from, to) {
351
+ const out = await run(root, ['log', '--format=%x1e%ct', '--name-only', '--no-renames', `${from}..${to}`])
352
+ const times = new Map()
353
+ if (out === null) return times
354
+ for (const chunk of out.split('\x1e')) {
355
+ const [ct, ...names] = chunk.split('\n')
356
+ const ms = Number(ct) * 1000
357
+ if (!Number.isFinite(ms) || !ms) continue
358
+ for (const rel of names) if (rel && !times.has(rel)) times.set(rel, ms) // newest first
359
+ }
360
+ return times
361
+ }
362
+
346
363
  /** Paths that differ between two commits. */
347
364
  export async function changedBetween (root, shaA, shaB) {
348
365
  const changes = await changesBetween(root, shaA, shaB)
@@ -466,7 +483,7 @@ export function watchGit (root, onEvent) {
466
483
  }
467
484
  }
468
485
  watcher.on('add', onAny).on('change', onAny).on('unlink', onAny).on('addDir', onAny).on('unlinkDir', onAny)
469
- return { close: () => Promise.all([watcher.close(), refs.close()]) }
486
+ return { close: () => Promise.all([closeWatcher(watcher), closeWatcher(refs)]) }
470
487
  }
471
488
 
472
489
  /** The repository's shared git directory (a worktree's .git/worktrees/<name> points at it), or null. */
@@ -561,6 +578,13 @@ export const shellQuote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
561
578
 
562
579
  const HOOK_MARK = '# Quilt pre-push hook'
563
580
 
581
+ /**
582
+ * The Quilt CLI as git runs it (through sh: a helper starting with !, and the hook). In the desktop
583
+ * app `node` is the app itself, which without ELECTRON_RUN_AS_NODE opens as the app and never
584
+ * answers, so git waited for ever; plain node ignores it.
585
+ */
586
+ const cliRun = (node, script) => `ELECTRON_RUN_AS_NODE=1 ${shellQuote(node)} ${shellQuote(script)}`
587
+
564
588
  /**
565
589
  * Lets an agent's folder use git like anyone: git asks Quilt for credentials for `host`
566
590
  * (`quilt git-credential`, which asks the running session, which asks the relay for a short-lived
@@ -573,7 +597,7 @@ const HOOK_MARK = '# Quilt pre-push hook'
573
597
  export async function installGitHelpers (root, { node, script, host = 'github.com', origin = '', identity = null }) {
574
598
  const dir = gitDir(root)
575
599
  if (!dir || !node || !script) return { helper: false, hook: false }
576
- const cmd = (sub) => `!${shellQuote(node)} ${shellQuote(script)} ${sub} --dir ${shellQuote(root)}`
600
+ const cmd = (sub) => `!${cliRun(node, script)} ${sub} --dir ${shellQuote(root)}`
577
601
  let helper = false
578
602
  if (host) {
579
603
  // `origin`: the remote's scheme, host and port (the relay's git, http:// in tests); else https://<host>.
@@ -609,7 +633,7 @@ async function writeQuiltHook (root, { node, script }) {
609
633
  let now = ''
610
634
  try { now = fs.readFileSync(file, 'utf8') } catch {}
611
635
  if (now && !now.includes(HOOK_MARK)) return false
612
- const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\nexec ${shellQuote(node)} ${shellQuote(script)} git-pre-push --dir ${shellQuote(root)} "$@"\n`
636
+ const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\n${cliRun(node, script).replace(/ /, ' exec ')} git-pre-push --dir ${shellQuote(root)} "$@"\n`
613
637
  if (now !== want) {
614
638
  fs.mkdirSync(path.dirname(file), { recursive: true })
615
639
  fs.writeFileSync(file, want, { mode: 0o755 })
@@ -646,7 +670,7 @@ export async function setPersonGit (root, { node, script, url = '', helper = fal
646
670
  for (const k of await quiltHelperKeys(root)) if (k !== key) await call(root, ['config', '--local', '--unset-all', k])
647
671
  let helperOn = false
648
672
  if (key) {
649
- const want = `!${shellQuote(node)} ${shellQuote(script)} git-credential --dir ${shellQuote(root)} --fallback`
673
+ const want = `!${cliRun(node, script)} git-credential --dir ${shellQuote(root)} --fallback`
650
674
  const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
651
675
  if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
652
676
  await call(root, ['config', '--local', '--replace-all', key, ''])
package/src/hooks.js CHANGED
@@ -187,7 +187,7 @@ function mergesContext (merges) {
187
187
  }
188
188
  const shown = open.slice(0, 5).map(describe).join(', ')
189
189
  const more = open.length > 5 ? `, and ${open.length - 5} more` : ''
190
- return `${open.length} file(s) need merging: ${shown}${more}. Run quilt_merges before editing those files; settle one with quilt_resolve_merge.`
190
+ return `${open.length} file(s) wait for whoever holds them: ${shown}${more}. Quilt merges those changes in by itself when they let go; leave those files alone until then.`
191
191
  }
192
192
 
193
193
  async function preEdit (event, d, api, state, say, name) {