heyquilt 0.3.121 → 0.3.122

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 (48) hide show
  1. package/RELEASES.md +19 -0
  2. package/bin/quilt.js +23 -2
  3. package/package.json +2 -1
  4. package/src/api/memory-store.js +5 -1
  5. package/src/api/routes/seam-connect.js +103 -0
  6. package/src/api/routes/session-migrate.js +36 -0
  7. package/src/api/server.js +37 -9
  8. package/src/api/supabase-store.js +10 -0
  9. package/src/connection.js +4 -1
  10. package/src/control.js +12 -0
  11. package/src/history.js +15 -1
  12. package/src/mcp.js +12 -1
  13. package/src/quilts/accounts.js +136 -0
  14. package/src/quilts/affected.js +132 -0
  15. package/src/quilts/checks.js +308 -0
  16. package/src/quilts/cli.js +97 -10
  17. package/src/quilts/client.js +9 -0
  18. package/src/quilts/describe.js +31 -1
  19. package/src/quilts/events.js +13 -2
  20. package/src/quilts/export-remote.js +156 -0
  21. package/src/quilts/import-follow.js +236 -0
  22. package/src/quilts/import-git.js +6 -3
  23. package/src/quilts/import.js +67 -5
  24. package/src/quilts/index.js +28 -6
  25. package/src/quilts/keys.js +186 -0
  26. package/src/quilts/merge.js +16 -1
  27. package/src/quilts/oauth.js +202 -0
  28. package/src/quilts/reviews.js +18 -5
  29. package/src/quilts/routes.js +61 -1
  30. package/src/quilts/runner.js +95 -0
  31. package/src/quilts/server.js +144 -0
  32. package/src/quilts/service.js +41 -1
  33. package/src/quilts/setup.js +9 -2
  34. package/src/quilts/store-memory.js +164 -3
  35. package/src/quilts/store-rpc.js +12 -1
  36. package/src/quilts/tools.js +39 -11
  37. package/src/quilts/window.js +2 -0
  38. package/src/relay-mcp.js +33 -3
  39. package/src/relay-prune.js +4 -1
  40. package/src/server.js +317 -14
  41. package/src/session-follow.js +81 -0
  42. package/src/session-layout3.js +60 -0
  43. package/src/session-migrate.js +119 -0
  44. package/src/session-quilt.js +145 -0
  45. package/src/session-seam.js +71 -0
  46. package/src/session.js +74 -2
  47. package/src/source-live/live-block.js +4 -3
  48. package/src/source-live/live-square.js +1 -1
package/RELEASES.md CHANGED
@@ -11,6 +11,25 @@ Format: `## <version> — <YYYY-MM-DD>`, an optional one-line summary, then bull
11
11
  Lead each bullet with a short bold phrase. Inline `code` and **bold** are rendered;
12
12
  nothing else is.
13
13
 
14
+ ## 0.3.122 — 2026-10-26
15
+
16
+ - **Seam follows a live git repository**: `seam import git https://… --into <slug> --follow` imports a git remote on the server and keeps taking its new commits on `main` and `release/*` every 10 minutes, or at once when a git hook or cron job pokes it (`POST /v1/imports/:id/poke` with the import's secret; `seam import poke`). Those lines stay frozen while git leads, so nothing merges in Seam until cut-over; a rewritten branch stops the follow. `seam import status` shows where it is. Also as the `seam_import_remote`, `seam_import_status` and `seam_import_poke` tools.
17
+ - **Seam exports to any git remote**: `seam export remote <url> --quilt <q> [--line main,release/*] [--releases] [--credential <name>]` pushes each merge on those lines to GitHub, GitLab or any git remote as it lands, fast-forward only, with release tags when asked. A remote someone else pushed to is refused, never forced, and `seam export status` says which line and commit; failures to reach the remote retry by themselves. Also as `seam_export_remote` and `seam_export_status`, and `PUT`/`GET /v1/quilts/:q/export`.
18
+ - **Seam cut-over**: `seam import cutover <id>` ends a followed import for good: one last pass, a check that the git remote has nothing the import lacks, then the lines take merges in Seam and `main` exports back to the same remote after each merge, continuing git's history. It refuses and changes nothing if git moved. The day's steps are in `docs/seam-cutover.md`.
19
+ - **Squares close themselves while git leads**: during a followed import, a square whose diff someone applied in git and pushed is closed as merged by the next follow pass (`merged.how = matched`), with the git commit it became. A square whose diff was edited on the way is closed by an admin with `seam square merged <square> --as <sha>` (also `seam_square_merged` and `POST /v1/quilts/:q/squares/:s/merged`).
20
+ - **Busy lines no longer drop merges**: a merge waiting behind many others on the same line for more than 10 seconds was ended as if its server had died; it now keeps itself alive while it waits.
21
+ - **A Settings tab for each repo on seam.heyquilt.com**: admins set the export to a git remote there (address, lines, releases, the credential's name) or turn it off; everyone who can read the repo sees whether it is up to date, what was pushed last and the last error.
22
+ - **Seam asks your test runners to run checks.** A line's policy can name who runs each check (`seam line require main --checks ci --runners ci=agent:ci`). Seam then sends them `check.requested` when a square gets a new commit, when it asks to merge, and after each merge on the line; runners can also list what's waiting with `seam checks --state queued`. Only a named runner's result counts; anyone else's is kept as advisory.
23
+ - **Seam says which tests a change touches.** A line's policy can list where its tests live (`seam line require main --tests "test/**"`), and each check request then names the tests the change reaches through its imports (JavaScript, TypeScript, Python), or says "run everything" when a lockfile, build file or CI config changed. Checks can also be set to run only before or only after a merge (`--tiers e2e=post_merge`); after a merge everything always runs.
24
+ - **Seam cancels test runs nobody needs and can run a check again.** When a square gets a newer commit, the run on the old one is cancelled and its runner told to stop (after merges, the oldest run still finishes). `seam check rerun ci --square <square>` (or `--line main`) asks the runners to run a check again.
25
+ - **`seam runner` runs your checks on your own machine.** `seam runner --check ci --run "npm test"` picks up the checks Seam asks its key to run, runs each on its commit in a fresh folder, and reports pass or fail with a link to the log; a run Seam cancels is stopped. docs/seam-runner.md says how to keep one running on a small Fly machine.
26
+ - **Seam has its own accounts.** Seam can sign people in with an account of its own, apart from heyquilt.com, and checks those sign-ins itself; `GET /v1/me` says who you are to Seam. Accounts with heyquilt.com keep working as they do.
27
+ - **`seam login` signs in to Seam itself, and agents get Seam keys.** `seam login` (seam 0.26.0) links your computer to your Seam account on seam.heyquilt.com; `seam agent new <name>` (or Your agents on the site) makes an agent with its own key, which `seam agent key` replaces and `seam agent revoke` ends. If you signed in with an older seam, run `seam logout` and `seam login` once.
28
+ - **Apps can connect to Seam with your permission.** Seam is now an OAuth 2 provider: an app (Quilt is one) sends you to seam.heyquilt.com, you see what it asks for (one quilt or all of them, read or change) and Allow or Deny, and it gets a token that reaches only that. `seam oauth client new <name> --redirect <uri>` registers an app (seam 0.26.0); revoking it ends every token it holds.
29
+ - **Your Seam history stays yours.** Sign in to Seam with the email of your heyquilt.com account and you keep the same member, with every patch you made; `seam agent relink` brings your old agents over with theirs (seam 0.25.0).
30
+ - **Seam runs as its own service.** Seam has its own server at `api.seam.heyquilt.com`, apart from Quilt's; the `seam` command (0.22.0) and seam.heyquilt.com talk to it, and the old address keeps answering by passing Seam's requests on, so nothing you made stops working. `seam serve` runs a Seam server of your own from Seam's source.
31
+ - **Seam's Your repos page says what importing does**, and how to install `seam` first.
32
+
14
33
  ## 0.3.121 — 2026-10-26
15
34
 
16
35
  - **Shown with the AI tool you really use.** When you haven't picked a tool in Settings, Quilt guessed it from which tool's folder changed last, and Quilt itself changes Cursor's and Windsurf's folders when it connects them, so a Claude Code user could show up as Windsurf. It now goes by what each tool writes as you use it (chats, sessions, editor state), and as soon as your AI connects to a session you're shown with that one. Saving your colour no longer pins the guess as your pick.
package/bin/quilt.js CHANGED
@@ -144,6 +144,7 @@ async function main () {
144
144
  if (!token.trim()) fail('no token given')
145
145
  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
146
  }
147
+ case 'session': return sessionCmd()
147
148
  case 'invite': return invite()
148
149
  case 'workspace': case 'workspaces': return workspaceCmd()
149
150
  case 'stop': return stopAll()
@@ -241,8 +242,11 @@ async function apiCmd () {
241
242
  // 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
243
  apiFly: (await import('../src/api/api-machines.js')).apiFlyFromEnv(env),
243
244
  ...(env.QUILT_API_MAX_MACHINES ? { maxMachines: Number(env.QUILT_API_MAX_MACHINES) } : {}),
244
- // Quilt source control (src/quilts/): off unless QUILT_SOURCE is set (src/quilts/setup.js).
245
- quilts: await (await import('../src/quilts/setup.js')).quiltsFromEnv(env, { memory: !!values.memory, log: console.log }),
245
+ // Seam is its own service (docs/seam-service.md): its old routes here go on to it.
246
+ ...(env.QUILT_SEAM_URL !== undefined ? { seamUrl: env.QUILT_SEAM_URL } : {}),
247
+ ...(env.QUILT_SEAM_CLIENT_ID ? { seamClientId: env.QUILT_SEAM_CLIENT_ID } : {}),
248
+ ...(env.QUILT_SEAM_SITE_URL ? { seamSiteUrl: env.QUILT_SEAM_SITE_URL } : {}),
249
+ ...(env.QUILT_SEAM_CONNECT_SECRET ? { seamConnectSecret: env.QUILT_SEAM_CONNECT_SECRET } : {}),
246
250
  // Voice: Cloudflare TURN for calls on networks that block a direct line, Workers AI for transcripts; each off when not set.
247
251
  voice: {
248
252
  turnKeyId: env.CLOUDFLARE_TURN_KEY_ID || '',
@@ -578,6 +582,23 @@ async function doSetup () {
578
582
  console.log('\nAn AI tool that was already open picks up the "quilt" MCP server when it restarts.')
579
583
  }
580
584
 
585
+ // `quilt session migrate [--dry-run]` (plans/quilt-source-control/16): what making this folder's
586
+ // session a quilt (Seam) would do, read only, or, without --dry-run, the move itself. The relay
587
+ // does both, behind QUILT_SOURCE_URL there, and only for the session's owner. --switch then moves
588
+ // the session's files into its squares (layout 3); --back returns it to its branches. `follow <folder>`
589
+ // then makes that folder follow this branch's square live (session-follow.js).
590
+ async function sessionCmd () {
591
+ if (argv[0] === 'follow') {
592
+ if (!argv[1]) fail('Usage: quilt session follow <folder>')
593
+ return simple('/quilt-follow', { dir: path.resolve(argv[1]) }, (r) => r.text)
594
+ }
595
+ if (argv[0] !== 'migrate') fail('Usage: quilt session migrate [--dry-run | --switch | --back] | quilt session follow <folder>')
596
+ if (argv.includes('--dry-run')) return simple('/quilt-plan', {}, (r) => r.text)
597
+ if (argv.includes('--switch')) return simple('/quilt-switch', {}, (r) => r.text)
598
+ if (argv.includes('--back')) return simple('/quilt-unswitch', {}, (r) => r.text)
599
+ return simple('/quilt-migrate', {}, (r) => r.text)
600
+ }
601
+
581
602
  async function daemonOrFail () {
582
603
  const { findDaemon, call } = await import('../src/control.js')
583
604
  const d = findDaemon()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.121",
3
+ "version": "0.3.122",
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.17.0",
40
41
  "@modelcontextprotocol/sdk": "^1.30.1",
41
42
  "@noble/hashes": "^2.4.0",
42
43
  "@supabase/supabase-js": "^2.117.2",
@@ -55,7 +55,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
55
55
  const hostedRooms = new Map() // "account\nroom" -> { account, room, addedAt }
56
56
  const hostedWebhooks = new Map(); const resumeSignatures = new Map()
57
57
  // Secrets (20261018000000_secrets.sql): only what the vault sealed.
58
- const secretRows = new Map(); const secretDataKeys = new Map(); const secretSettingsRows = new Map()
58
+ const seamLinks = new Map(); const secretRows = new Map(); const secretDataKeys = new Map(); const secretSettingsRows = new Map()
59
59
  const secretTickets = new Map(); const secretRuns = new Map(); const secretAsks = new Map(); const secretUses = []
60
60
  const scopeKey = (scopeType, scopeId) => `${scopeType}\n${scopeId}`
61
61
  // A secret as the API's routes see it: never its sealed value (secretSealed reads that, for the vault).
@@ -1227,6 +1227,10 @@ export function createMemoryStore ({ now = Date.now } = {}) {
1227
1227
  if (!secretDataKeys.has(k)) secretDataKeys.set(k, { scopeType, scopeId, version, sealedKey, provider, createdAt: now() })
1228
1228
  return copy(secretDataKeys.get(k))
1229
1229
  },
1230
+ // "Connect Seam" (routes/seam-connect.js): the person's Seam tokens, one row per Quilt user.
1231
+ async seamLink (userId) { return copy(seamLinks.get(userId)) || null },
1232
+ 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) },
1233
+ async deleteSeamLink (userId) { seamLinks.delete(userId) },
1230
1234
  async secretSettings (scopeType, scopeId) {
1231
1235
  return copy(secretSettingsRows.get(scopeKey(scopeType, scopeId))) || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
1232
1236
  },
@@ -0,0 +1,103 @@
1
+ // "Connect Seam": a Quilt person links their own Seam account to Quilt, the way any app connects
2
+ // to Seam (Seam's OAuth 2, authorization code with PKCE). Quilt never shares accounts with Seam:
3
+ // it keeps the person's Seam token and calls Seam's HTTP API with it. Off unless Quilt is
4
+ // registered as a Seam app (seamClientId, env QUILT_SEAM_CLIENT_ID).
5
+ //
6
+ // The PKCE verifier is never stored: `state` carries who asked and until when, signed with
7
+ // Quilt's secret, and the verifier is an HMAC of the state, so any API machine can finish it.
8
+ import crypto from 'node:crypto'
9
+ import { HttpError, Raw } from '../http.js'
10
+
11
+ export const CONNECT_MS = 10 * 60_000
12
+ const REFRESH_EARLY_MS = 60_000
13
+
14
+ const b64 = (b) => Buffer.from(b).toString('base64url')
15
+ const mac = (secret, text) => crypto.createHmac('sha256', secret).update(text).digest('base64url')
16
+ const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
17
+ const page = (status, title, text) => new Raw(status, `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:4em auto;max-width:32em"><h1>${title}</h1><p>${text}</p></body>`, 'text/html; charset=utf-8')
18
+
19
+ export function seamConnectRoutes ({ store, person, now = Date.now, apiUrl, seamUrl, seamSiteUrl = 'https://seam.heyquilt.com', clientId, secret, fetch = globalThis.fetch, log = () => {} }) {
20
+ if (!clientId || !secret || !seamUrl || !store.seamLink) return []
21
+ const seam = String(seamUrl).replace(/\/+$/, '')
22
+ const site = String(seamSiteUrl).replace(/\/+$/, '')
23
+ const redirectUri = `${String(apiUrl).replace(/\/+$/, '')}/v1/seam/connect/callback`
24
+ const verifierOf = (state) => mac(secret, `seam-pkce\0${state}`)
25
+ const userOf = (p) => p.userId || p.id
26
+
27
+ function makeState (userId) {
28
+ const body = b64(JSON.stringify({ u: userId, e: now() + CONNECT_MS, n: crypto.randomBytes(12).toString('base64url') }))
29
+ return `${body}.${mac(secret, `seam-state\0${body}`)}`
30
+ }
31
+ function readState (state) {
32
+ const [body, sig] = String(state || '').split('.')
33
+ if (!body || !sig || !same(sig, mac(secret, `seam-state\0${body}`))) return null
34
+ try {
35
+ const s = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
36
+ return s && s.e > now() ? s : null
37
+ } catch { return null }
38
+ }
39
+
40
+ async function tokenCall (form) {
41
+ let res
42
+ try { res = await fetch(`${seam}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams({ client_id: clientId, ...form }).toString(), signal: AbortSignal.timeout(10_000) }) } catch { throw new HttpError(503, 'Seam could not be reached; try again') }
43
+ const data = await res.json().catch(() => null)
44
+ if (!res.ok || !data?.access_token) return { error: data?.error || `seam_${res.status}`, detail: data?.error_description || '' }
45
+ return { access: data.access_token, refresh: data.refresh_token, scope: data.scope || '', expiresAt: now() + Number(data.expires_in || 3600) * 1000 }
46
+ }
47
+
48
+ /** The person's Seam access token, refreshed when it is about to end; null when not connected. */
49
+ async function freshToken (userId) {
50
+ const link = await store.seamLink(userId)
51
+ if (!link) return null
52
+ if (link.expiresAt - REFRESH_EARLY_MS > now()) return link
53
+ const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: link.refresh })
54
+ if (t.error) {
55
+ // Seam ended the grant (revoked on seam.heyquilt.com, or the token was reused): connect again.
56
+ if (t.error === 'invalid_grant' || t.error === 'invalid_client') { await store.deleteSeamLink(userId); return null }
57
+ throw new HttpError(503, 'Seam could not refresh your connection; try again')
58
+ }
59
+ return store.putSeamLink(userId, { ...t, connectedAt: link.connectedAt })
60
+ }
61
+
62
+ return [
63
+ // { scope? } → { url }: the page on Seam's site where the person allows Quilt.
64
+ ['POST', /^\/v1\/seam\/connect$/, async (req, body) => {
65
+ const p = await person(req)
66
+ const scope = String(body?.scope || 'quilts:write').trim()
67
+ const state = makeState(userOf(p))
68
+ const challenge = crypto.createHash('sha256').update(verifierOf(state)).digest('base64url')
69
+ const q = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, state, code_challenge: challenge, code_challenge_method: 'S256' })
70
+ return { url: `${site}/oauth/authorize?${q}` }
71
+ }],
72
+ // Seam sends the browser back here with a code (or error=access_denied).
73
+ ['GET', /^\/v1\/seam\/connect\/callback$/, async (req) => {
74
+ const q = new URL(req.url, 'http://x').searchParams
75
+ const s = readState(q.get('state'))
76
+ if (!s) return page(400, 'Seam was not connected', 'This link expired or was not made by Quilt. Start again from Quilt.')
77
+ if (q.get('error')) return page(200, 'Seam was not connected', 'You chose not to connect Seam. You can connect it from Quilt any time.')
78
+ const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: redirectUri, code_verifier: verifierOf(q.get('state')) })
79
+ if (t.error) { log(`seam connect: ${t.error} ${t.detail}`); return page(400, 'Seam was not connected', 'Seam did not accept this sign-in. Start again from Quilt.') }
80
+ await store.putSeamLink(s.u, { ...t, connectedAt: now() })
81
+ return page(200, 'Seam is connected', 'Quilt can now use Seam as you. You can close this tab.')
82
+ }],
83
+ ['GET', /^\/v1\/seam\/connection$/, async (req) => {
84
+ const link = await store.seamLink(userOf(await person(req)))
85
+ return link ? { connected: true, scope: link.scope, connectedAt: link.connectedAt } : { connected: false }
86
+ }],
87
+ // The person's own Seam token, for their app to call Seam's API with.
88
+ ['GET', /^\/v1\/seam\/token$/, async (req) => {
89
+ const link = await freshToken(userOf(await person(req)))
90
+ if (!link) throw new HttpError(404, 'Seam is not connected: connect it first')
91
+ return { access_token: link.access, token_type: 'Bearer', expires_at: link.expiresAt, scope: link.scope, seam_url: seam }
92
+ }],
93
+ ['DELETE', /^\/v1\/seam\/connection$/, async (req) => {
94
+ const userId = userOf(await person(req))
95
+ const link = await store.seamLink(userId)
96
+ if (link) {
97
+ await fetch(`${seam}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: link.refresh }).toString(), signal: AbortSignal.timeout(10_000) }).catch(() => {})
98
+ await store.deleteSeamLink(userId)
99
+ }
100
+ return { connected: false }
101
+ }]
102
+ ]
103
+ }
@@ -0,0 +1,36 @@
1
+ // Making a session a quilt (plans/quilt-source-control/16, "How a session becomes a quilt"):
2
+ // POST /v1/sessions/:room/migrate { dry_run, token, step }. The session's owner, signed in, asks here
3
+ // (step 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back);
4
+ // the relay machine holding the room does the work (POST /quilt/migrate, with the relay secret),
5
+ // so this is the same operation as the MCP tool and `quilt session migrate`. Seam is called as the
6
+ // owner: with `token` (their Seam sign-in) when given, else the sign-in they used here. The relay
7
+ // refuses while Seam is off for it (QUILT_SOURCE_URL), so nothing changes until that flag is set.
8
+ import { HttpError } from '../http.js'
9
+
10
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
11
+ const NO_SESSION = 'no such session'
12
+ const MIGRATE_TIMEOUT_MS = 5 * 60 * 1000 // a large session's patch 1 and squares take a while
13
+
14
+ export function sessionMigrateRoutes ({ store, person, bearer, relaySecret, cluster }) {
15
+ return [
16
+ ['POST', /^\/v1\/sessions\/([^/]+)\/migrate$/, async (req, body, [room]) => {
17
+ const account = `person:${(await person(req)).userId}`
18
+ if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
19
+ const session = await store.sessionByRoom(room).catch(() => null)
20
+ if (!session) throw new HttpError(404, NO_SESSION)
21
+ if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can make it a quilt.')
22
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
23
+ const dry = body && body.dry_run === true
24
+ const token = body && typeof body.token === 'string' && body.token ? body.token : bearer(req)
25
+ const where = await store.relayRoom(room).catch(() => null)
26
+ const step = body && (body.step === 'switch' || body.step === 'unswitch') ? body.step : null
27
+ const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}) }
28
+ try {
29
+ return await cluster.callRelay((where && where.machineId) || '', 'POST', '/quilt/migrate', ask, { timeoutMs: MIGRATE_TIMEOUT_MS })
30
+ } catch (err) {
31
+ const said = String(err.message).replace(/^relay .*? answered \d+: /, '')
32
+ throw new HttpError(err.status && err.status < 500 ? err.status : 502, said)
33
+ }
34
+ }]
35
+ ]
36
+ }
package/src/api/server.js CHANGED
@@ -29,6 +29,7 @@ import { githubRoutes } from './routes/github.js'
29
29
  import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
30
30
  import { releaseSource } from './releases.js'
31
31
  import { sessionRoutes } from './routes/sessions.js'
32
+ import { sessionMigrateRoutes } from './routes/session-migrate.js'
32
33
  import { accessTypeRoutes } from './routes/access-types.js'
33
34
  import { grantRoutes } from './routes/grants.js'
34
35
  import { sessionInviteRoutes } from './routes/session-invites.js'
@@ -37,6 +38,7 @@ import { workspaceFileRoutes } from './routes/workspace-files.js'
37
38
  import { workspaceAgentRoutes } from './routes/workspace-agents.js'
38
39
  import { workspaceInviteRoutes } from './routes/workspace-invites.js'
39
40
  import { HOSTED_RELAY } from '../settings.js'
41
+ import { seamConnectRoutes } from './routes/seam-connect.js'
40
42
  import { roomAccessFrom } from './access.js'
41
43
  import { joiningRoom, sessionArg } from '../mcp-room.js'
42
44
  import { issueRoutes } from './routes/issues.js'
@@ -47,7 +49,6 @@ import { secretRoutes } from './routes/secrets.js'
47
49
  import { voiceRoutes } from './routes/voice.js'
48
50
  import { mailRoutes } from './routes/mail.js'
49
51
  import { agentWakeRoutes } from './routes/agent-wake.js'
50
- import { quiltRoutes } from '../quilts/routes.js'
51
52
  import { workspaceReach } from './workspace-reach.js'
52
53
  import { leader, LEADER_ONLY } from './leader.js'
53
54
  import { autoscale } from './autoscale.js'
@@ -73,7 +74,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
73
74
  // a change to its grant reaches it as soon as it reaches a connected app.
74
75
  const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
75
76
 
76
- 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, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines, quilts = null }) {
77
+ 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, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines, seamUrl = 'https://api.seam.heyquilt.com', seamFetch = globalThis.fetch, seamClientId = '', seamSiteUrl = 'https://seam.heyquilt.com', seamConnectSecret = '' }) {
77
78
  // PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
78
79
  if (passKey) passPublicKey(passKey)
79
80
  const site = String(siteUrl || '').replace(/\/+$/, '')
@@ -394,7 +395,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
394
395
  if (!relay || !relaySecret) return
395
396
  cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
396
397
  }
397
- 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))
398
+ 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), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
398
399
  // Always routed: with the flag off each answers a plain 404 of its own, so the app's
399
400
  // check at every launch isn't filed as a missing route.
400
401
  // Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
@@ -416,16 +417,19 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
416
417
  routes.push(...mailRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter, tellRoom }))
417
418
  // How Quilt wakes each agent (routes/agent-wake.js): step 2 of joining.
418
419
  routes.push(...agentWakeRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter }))
419
- // Quilt source control (src/quilts/, plans/quilt-source-control/): off unless startApi gets `quilts`.
420
- // Seam takes who is calling from us, as a quilt member: { member_id, kind, name, tool?, on_behalf_of? }.
421
- const quiltCaller = quilts ? workspaceReach({ ...ctx, passKey }) : null
422
- const identifyQuiltMember = async (req) => {
423
- const c = await quiltCaller.caller(req)
420
+ // Seam (src/quilts/, plans/quilt-source-control/) is its own service at seamUrl. This API is its
421
+ // identity service for Quilt accounts: GET /v1/seam/me says who is calling, as a quilt member
422
+ // { member_id, kind, name, tool?, on_behalf_of? }, for a person's sign-in, an agent key or a pass.
423
+ const seamCaller = workspaceReach({ ...ctx, passKey })
424
+ const identifySeamMember = async (req) => {
425
+ const c = await seamCaller.caller(req)
424
426
  if (c.agent) return { member_id: c.account, kind: 'agent', name: c.agent.name || 'agent', ...(c.agent.provider ? { tool: String(c.agent.provider).toLowerCase() } : {}), ...(c.agent.ownerUserId ? { on_behalf_of: `person:${c.agent.ownerUserId}` } : {}) }
425
427
  const p = c.userId && store.profile ? await store.profile(c.userId).catch(() => null) : null
426
428
  return { member_id: c.account, kind: 'person', name: (p && p.name) || 'person' }
427
429
  }
428
- routes.push(...quiltRoutes({ quilts, identify: identifyQuiltMember }))
430
+ routes.push(['GET', /^\/v1\/seam\/me$/, (req) => identifySeamMember(req)])
431
+ // "Connect Seam": a person links their own Seam account (Seam's OAuth); off without seamClientId.
432
+ routes.push(...seamConnectRoutes({ store, person, now, apiUrl: api, seamUrl, seamSiteUrl, clientId: seamClientId, secret: seamConnectSecret || passKey, fetch: seamFetch, log }))
429
433
 
430
434
  async function openLink (code) {
431
435
  const userCode = normalizeUserCode(code)
@@ -488,6 +492,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
488
492
  try {
489
493
  if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
490
494
  if (pathname === '/mcp') return await proxyMcp(req, res, send)
495
+ if (SEAM_PATH.test(pathname)) return await forwardSeam(req, res)
491
496
  if (req.method === 'GET' && (pathname === '/v1/releases/latest' || pathname.startsWith('/v1/download/'))) return await serveRelease(req, res, send, pathname)
492
497
  const url = new URL(req.url, 'http://x')
493
498
  const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
@@ -514,6 +519,25 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
514
519
  }
515
520
  })
516
521
 
522
+ // Seam's routes moved to Seam's own server (docs/seam-service.md): old seam commands and sites
523
+ // still call them here, so they go on to seamUrl as sent, and Seam's answer comes straight back.
524
+ async function forwardSeam (req, res) {
525
+ if (!seamUrl) throw new HttpError(404, 'not found')
526
+ const headers = {}
527
+ for (const h of ['authorization', 'content-type', 'accept']) if (req.headers[h]) headers[h] = req.headers[h]
528
+ const hasBody = !['GET', 'HEAD', 'DELETE'].includes(req.method)
529
+ let up
530
+ try {
531
+ up = await seamFetch(seamUrl.replace(/\/+$/, '') + req.url, { method: req.method, headers, ...(hasBody ? { body: await readRaw(req, SEAM_MAX_BODY), duplex: 'half' } : {}), redirect: 'manual' })
532
+ } catch (err) {
533
+ if (err instanceof HttpError) throw err
534
+ throw new HttpError(502, 'Seam did not answer; try again')
535
+ }
536
+ const body = Buffer.from(await up.arrayBuffer())
537
+ res.writeHead(up.status, { 'content-type': up.headers.get('content-type') || 'application/json', 'cache-control': 'no-store', ...cors(req) })
538
+ res.end(body)
539
+ }
540
+
517
541
  // Downloads and the update check (releases.js): public, from the private repository's latest
518
542
  // release, read as Quilt's GitHub App. A download is a redirect to GitHub's own short-lived link.
519
543
  const releases = releaseSource({ github, fetch: githubFetch, now, apiUrl: api })
@@ -681,6 +705,10 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
681
705
  // Which room a hosted agent's MCP request is about: shared with the relay (src/mcp-room.js).
682
706
  export { joiningRoom, sessionArg } from '../mcp-room.js'
683
707
 
708
+ // Seam's routes, answered by Seam's own server now (forwardSeam). Its biggest body is a pack.
709
+ const SEAM_PATH = /^\/v1\/(quilts|imports|search|signing-keys)(\/|$)/
710
+ const SEAM_MAX_BODY = 96 * 1024 * 1024
711
+
684
712
  function decodePart (s) {
685
713
  try { return decodeURIComponent(s) } catch { throw new HttpError(400, 'bad path') }
686
714
  }
@@ -885,6 +885,16 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
885
885
  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 }))
886
886
  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()))
887
887
  },
888
+ // "Connect Seam" (routes/seam-connect.js): the person's Seam tokens, one row per Quilt user.
889
+ async seamLink (userId) {
890
+ const r = await one(db.from('seam_links').select('user_id,access_token,refresh_token,scope,expires_at,connected_at').eq('user_id', userId).maybeSingle())
891
+ return r ? { userId: r.user_id, access: r.access_token, refresh: r.refresh_token, scope: r.scope, expiresAt: Date.parse(r.expires_at), connectedAt: Date.parse(r.connected_at) } : null
892
+ },
893
+ async putSeamLink (userId, link) {
894
+ await one(db.from('seam_links').upsert({ user_id: userId, access_token: link.access, refresh_token: link.refresh, scope: link.scope || '', expires_at: ts(link.expiresAt), connected_at: ts(link.connectedAt ?? Date.now()), updated_at: new Date().toISOString() }, { onConflict: 'user_id' }))
895
+ return { userId, access: link.access, refresh: link.refresh, scope: link.scope || '', expiresAt: link.expiresAt, connectedAt: link.connectedAt ?? Date.now() }
896
+ },
897
+ async deleteSeamLink (userId) { await one(db.from('seam_links').delete().eq('user_id', userId)) },
888
898
  async secretSettings (scopeType, scopeId) {
889
899
  const r = rowFrom(await one(db.from('secret_scope_settings').select(SECRET_SETTINGS).eq('scope_type', scopeType).eq('scope_id', scopeId).maybeSingle()))
890
900
  return r || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
package/src/connection.js CHANGED
@@ -382,7 +382,10 @@ export class Connection extends EventEmitter {
382
382
  this.noteSynced()
383
383
  }
384
384
  } else if (type === MSG_BRANCHES) {
385
- const { branches, reply, pushed } = JSON.parse(decoding.readVarString(dec))
385
+ const { branches, reply, pushed, quilt } = JSON.parse(decoding.readVarString(dec))
386
+ // The session is a quilt (Seam): its link, or null while it is not.
387
+ const link = quilt && typeof quilt.id === 'string' ? quilt : null
388
+ if (JSON.stringify(link) !== JSON.stringify(this.quilt || null)) { this.quilt = link; this.emit('quilt', link) }
386
389
  // GitHub moved a branch the session follows (the relay heard it from GitHub): look now.
387
390
  if (pushed && typeof pushed.branch === 'string') this.emit('pushed', pushed)
388
391
  if (reply && this.joining && reply.id === this.joining.id) {
package/src/control.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Local control API (127.0.0.1 only) so the CLI and the MCP server can talk to
2
2
  // a running session. Discovery info is written to .quilt/daemon.json.
3
+ import { readAccount } from './account.js'
3
4
  import http from 'node:http'
4
5
  import fs from 'node:fs'
5
6
  import path from 'node:path'
@@ -106,6 +107,17 @@ export async function startControl (session, extras = {}) {
106
107
  // The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
107
108
  'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
108
109
  'POST /branches/sync': () => session.syncBranchNow(),
110
+ // `quilt session migrate --dry-run`: what making the session a quilt would do (the relay's dry run).
111
+ 'POST /quilt-plan': () => session.quiltPlan(),
112
+ // The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
113
+ 'GET /quilt': () => ({ quilt: session.quilt || null }),
114
+ // `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
115
+ 'POST /quilt-migrate': () => session.quiltMigrate(process.env.SEAM_TOKEN || (readAccount() || {}).token),
116
+ // `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
117
+ 'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
118
+ 'POST /quilt-unswitch': () => session.quiltUnswitch(),
119
+ // `quilt session follow <folder>`: at layout 3, that folder follows this branch's square, live.
120
+ 'POST /quilt-follow': (b) => session.quiltFollow(process.env.SEAM_TOKEN || (readAccount() || {}).token, b.dir),
109
121
  'POST /commit-request': (b) => session.requestCommit(b.message, { files: b.files == null ? null : b.files, description: b.description || '', task: b.task || null, via: b.via ? String(b.via) : null }),
110
122
  // quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
111
123
  'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
package/src/history.js CHANGED
@@ -252,6 +252,20 @@ export function queryHistory (entries, { path, by, since, task, limit = 50 } = {
252
252
  return list
253
253
  }
254
254
 
255
+ export const LEGACY_DAYS = 90
256
+
257
+ /**
258
+ * The chronology once the session became a quilt at `at` (an ISO date, meta.quilt.at): entries from
259
+ * before then are its legacy tail (plans/quilt-source-control/16), marked `legacy: true` for
260
+ * LEGACY_DAYS and then left out. Not a quilt (no `at`): the entries as they are.
261
+ */
262
+ export function legacyTail (entries, { at, now = Date.now() } = {}) {
263
+ const t = at ? Date.parse(at) : NaN
264
+ if (!Number.isFinite(t)) return entries
265
+ if (now - t > LEGACY_DAYS * 24 * 60 * 60 * 1000) return entries.filter((e) => !(e && e.ts < t))
266
+ return entries.map((e) => e && e.ts < t ? { ...e, legacy: true } : e)
267
+ }
268
+
255
269
  function ago (ts, now) {
256
270
  const s = Math.max(0, Math.round((now - ts) / 1000))
257
271
  if (s < 60) return `${s}s ago`
@@ -268,7 +282,7 @@ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}
268
282
  const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
269
283
  const what = e.detail ? ` (${e.detail})` : ''
270
284
  const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''
271
- out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}`)
285
+ out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}${e.legacy ? ' (before the session became a quilt)' : ''}`)
272
286
  if (withDiff && e.diff) out.push(e.diff, '')
273
287
  }
274
288
  return out.join('\n').replace(/\n+$/, '')
package/src/mcp.js CHANGED
@@ -46,6 +46,7 @@ import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
46
46
  import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
47
47
  import { appWorkspaces } from './app-workspaces.js'
48
48
  import { TRANSCRIPT_DESCRIPTION, renderLines } from './voice.js'
49
+ import { seamGate, seamNote } from './session-seam.js'
49
50
 
50
51
  // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
51
52
  const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
@@ -221,12 +222,22 @@ export async function runMcp () {
221
222
  return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
222
223
  }
223
224
 
225
+ // Seam (plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
226
+ // refuse with the Seam tool to use instead, the same for every AI tool (session-seam.js).
227
+ const quiltNow = async () => {
228
+ const d = findDaemon(joined ? joined.dir : undefined)
229
+ if (!d) return null
230
+ try { return (await call(d, 'GET', '/quilt')).quilt || null } catch { return null }
231
+ }
232
+ server.registerTool = seamGate(server.registerTool.bind(server), quiltNow)
233
+
224
234
  server.registerTool('quilt_status', {
225
235
  description: 'See who else is in the live session, what they are working on, which files they recently edited or claimed, recent messages, and which open tasks are assigned to you. Call this before starting work.',
226
236
  inputSchema: {}
227
237
  }, () => withDaemon(async (d) => {
228
238
  const st = await call(d, 'GET', '/status')
229
- return renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
239
+ const q = await quiltNow()
240
+ return (q ? seamNote(q.slug || q.id) + '\n\n' : '') + renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
230
241
  }))
231
242
 
232
243
  server.registerTool('quilt_tasks', {