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.
- package/RELEASES.md +19 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +74 -2
- package/src/source-live/live-block.js +4 -3
- 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
|
-
//
|
|
245
|
-
|
|
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.
|
|
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",
|
package/src/api/memory-store.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
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(
|
|
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
|
-
|
|
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', {
|