heyquilt 0.3.121 → 0.3.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/relay-mcp.js
CHANGED
|
@@ -36,8 +36,10 @@ import { clashAction, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
|
36
36
|
import { agentCommitsOf, repoRecordFor, POLICY_WORDS, pullRequestsFor, mergeForMember, describePulls, describeMerge, PULLS_DESCRIPTION, MERGE_DESCRIPTION, mergeSchema, agentsCanCommit, unpublishedFor, unpublishedRefusal, finishTaskFor, gitCommitFor, describeGitCommit, gitStatusFor, describeGitStatus, gitLogFor, describeGitLog, gitDiffFor, describeGitDiff, GIT_STATUS_DESCRIPTION, GIT_LOG_DESCRIPTION, GIT_DIFF_DESCRIPTION, GIT_REVERT_DESCRIPTION } from './relay-commit.js'
|
|
37
37
|
import { UpdateCheck } from './update-check.js'
|
|
38
38
|
import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
|
|
39
|
-
import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff } from './history.js'
|
|
39
|
+
import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff, legacyTail } from './history.js'
|
|
40
|
+
import { linkedHistory } from './quilt-history.js'
|
|
40
41
|
import { changeRefusal, TALK_REFUSED } from './session-access.js'
|
|
42
|
+
import { MIGRATE_SESSION_DESCRIPTION, describeQuiltPlan } from './session-quilt.js'
|
|
41
43
|
import { chatAbout, renderChatAbout, waitingOn, needsNoReply, renderUnanswered, heldRefusal, queuedFor, renderQueueNotice, renderQueued, answered, unaddressed, CHAT_RULES, RE_HELP, replyFor, renderReplied, PERSON_CHAT, PERSON_INBOX } from './duties.js'
|
|
42
44
|
import { describeSubscription, describeWebhookTest, COALESCE_HELP, WEBHOOK_EVENTS } from './webhooks.js'
|
|
43
45
|
import { registerWorkspaceTools, bytesFetcher, WORKSPACE_GUIDE_HOSTED } from './workspace-tools.js'
|
|
@@ -47,6 +49,11 @@ import { isDiff, hasEntry, listPaths, readPath, readPaths, textOf, notePresent,
|
|
|
47
49
|
import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
|
|
48
50
|
import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
|
|
49
51
|
import { describeSecretNames, SECRET_NAMES_DESCRIPTION } from './secret-names.js'
|
|
52
|
+
import { quiltOf, seamRefusal, seamNote, seamStatus, seamFileRefusal, seamToolSpec, runSessionSeamTool, seamOnly, seamWorkflow, SEAM_SESSION_TOOLS, SEAM_GUIDE_HOSTED, GIT_TOOLS } from './session-seam.js'
|
|
53
|
+
import { seamAccess } from './seam-access.js'
|
|
54
|
+
import { releaseInSeam } from './seam-claims.js'
|
|
55
|
+
import { releaseWords } from './release-words.js'
|
|
56
|
+
import { LAYOUT_QUILT } from './session-layout3.js'
|
|
50
57
|
import { TRANSCRIPT_DESCRIPTION, renderLines, readLines, inVoice, transcribeSetting, voiceStatusLines } from './voice.js'
|
|
51
58
|
|
|
52
59
|
const FEED_CAP = 300
|
|
@@ -152,6 +159,18 @@ function takeStock (doc, me) {
|
|
|
152
159
|
const linkInboxes = new WeakMap()
|
|
153
160
|
const text = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
154
161
|
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true })
|
|
162
|
+
// A session that is a quilt, by its quilt's name (its slug; its id before the relay knows one), or null.
|
|
163
|
+
const quiltName = (room) => { const q = quiltOf(room.meta); return q ? ((room.meta.quilt && room.meta.quilt.slug) || q) : null }
|
|
164
|
+
// quilt_status in a session that is a quilt starts with how work goes there (session-seam.js).
|
|
165
|
+
// Any other answer, the first in a session that is a quilt for this agent: its instructions may be from before the move.
|
|
166
|
+
const withSeamLead = (r, quilt) => {
|
|
167
|
+
if (r && typeof r.then === 'function') return r.then((x) => withSeamLead(x, quilt))
|
|
168
|
+
return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: `🧵 ${seamNote(quilt, { hosted: true })} This is how work lands here, whatever your instructions said before.` }, ...r.content] } : r
|
|
169
|
+
}
|
|
170
|
+
const withSeamNote = (r, quilt) => {
|
|
171
|
+
if (r && typeof r.then === 'function') return r.then((x) => withSeamNote(x, quilt))
|
|
172
|
+
return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: seamNote(quilt, { hosted: true }) }, ...r.content.map((c) => c && c.type === 'text' ? { ...c, text: seamStatus(c.text) } : c)] } : r
|
|
173
|
+
}
|
|
155
174
|
|
|
156
175
|
function ago (ts) {
|
|
157
176
|
const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
|
|
@@ -208,7 +227,8 @@ function sessionTools (server, ctx) {
|
|
|
208
227
|
// The branch document the agent works on (its store entry), or null for a room without branch documents.
|
|
209
228
|
// The session's default branch may always start; any other new branch only for someone who may change files.
|
|
210
229
|
const branchEntry = (room) => {
|
|
211
|
-
|
|
230
|
+
// At layout 3 there are no branch documents to work on: every tool but the file tools works on the room.
|
|
231
|
+
if (!room.branchDoc || room.meta.layout === LAYOUT_QUILT) return null
|
|
212
232
|
const key = branchOf(room)
|
|
213
233
|
const editor = room.resolveKey(key) === room.defaultKey || ctx.access(room)?.role !== 'viewer'
|
|
214
234
|
return room.branchDoc(key, { by: me, editor })
|
|
@@ -217,9 +237,16 @@ function sessionTools (server, ctx) {
|
|
|
217
237
|
// Hosted agents are in several sessions at once: `session` says which this call is about.
|
|
218
238
|
const { session, ...args } = given || {}
|
|
219
239
|
return stale(await ctx.withSession((room) => {
|
|
240
|
+
// A session that is a quilt has no GitHub path for agents: each GitHub tool names its Seam tool.
|
|
241
|
+
// At layout 3 its files are in squares, not branches: the file tools name Seam's (quilt_history reads Seam's log, quilt_project_rules main's rules).
|
|
242
|
+
const quilt = quiltName(room)
|
|
243
|
+
// quilt_release lets go of the agent's claims in Seam then (seam-claims.js), when it reaches Seam.
|
|
244
|
+
const seamRelease = name === 'quilt_release' && !!(ctx.seam && ctx.seam())
|
|
245
|
+
const refused = seamRefusal(name, quilt, { hosted: true }) || (quilt && room.meta.layout === LAYOUT_QUILT && FILE_TOOLS.has(name) && name !== 'quilt_history' && name !== 'quilt_project_rules' && !seamRelease ? seamFileRefusal(quilt) : null)
|
|
246
|
+
if (refused) return fail(refused)
|
|
220
247
|
// A hosted agent with no branch of its own is pinned to the active branch by its first call
|
|
221
248
|
// on files, so a read and the write after it never land on two different branches.
|
|
222
|
-
if (ctx.pin && FILE_TOOLS.has(name)) ctx.pin(room)
|
|
249
|
+
if (ctx.pin && FILE_TOOLS.has(name) && room.meta.layout !== LAYOUT_QUILT) ctx.pin(room)
|
|
223
250
|
// The audit trail: which tool, on what (a path, a pattern or a task id; never contents).
|
|
224
251
|
const caller = ctx.who(room)
|
|
225
252
|
if (caller && caller.id && room.audit) {
|
|
@@ -234,7 +261,12 @@ function sessionTools (server, ctx) {
|
|
|
234
261
|
// Changes the tool makes to the session (files, chat, the board) go in this agent's audit trail.
|
|
235
262
|
const prev = room.auditAs
|
|
236
263
|
room.auditAs = caller && caller.id
|
|
237
|
-
try {
|
|
264
|
+
try {
|
|
265
|
+
const r = queueNote(room, fn(args || {}, parts))
|
|
266
|
+
// The first answer in a session that is a quilt says how work lands there (quilt_status always does).
|
|
267
|
+
const once = quilt && ctx.seamOnce ? ctx.seamOnce(room) : false
|
|
268
|
+
return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : once ? withSeamLead(r, quilt) : r
|
|
269
|
+
} finally { room.auditAs = prev }
|
|
238
270
|
}, session))
|
|
239
271
|
})
|
|
240
272
|
if (ctx.updates) {
|
|
@@ -342,10 +374,18 @@ function sessionTools (server, ctx) {
|
|
|
342
374
|
with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
|
|
343
375
|
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
344
376
|
}
|
|
345
|
-
}, ({ path: p, by, since, task, with_diff, limit }, { room }) => {
|
|
377
|
+
}, async ({ path: p, by, since, task, with_diff, limit }, { room }) => {
|
|
346
378
|
const from = parseSince(since)
|
|
347
379
|
if (from === undefined) return fail('since: use a duration like 2h or 3d, "today", "yesterday", or a date.')
|
|
348
|
-
const
|
|
380
|
+
const q = process.env.QUILT_SOURCE_URL && room.meta && room.meta.quilt_id ? room.meta.quilt : null
|
|
381
|
+
const own = legacyTail(historyOf(room).entries(), { at: q && q.at, history: !!(q && q.history) })
|
|
382
|
+
// Linked to a quilt (16: the session became one, or shadows one in step 1): Seam's log plus this record.
|
|
383
|
+
const link = room.quiltHistoryLink ? room.quiltHistoryLink() : null
|
|
384
|
+
if (link) {
|
|
385
|
+
const r = await linkedHistory({ seam: link.seam, quilt: link.quilt, entries: own, query: { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30, withDiff: !!with_diff } })
|
|
386
|
+
return text(`${formatHistory(r.list, { withDiff: !!with_diff })}${r.note ? `\n\n${r.note}` : ''}`)
|
|
387
|
+
}
|
|
388
|
+
const list = queryHistory(own, { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
|
|
349
389
|
return text(formatHistory(list, { withDiff: !!with_diff }))
|
|
350
390
|
})
|
|
351
391
|
|
|
@@ -413,6 +453,18 @@ function sessionTools (server, ctx) {
|
|
|
413
453
|
return text(`${branchesMarkdown(br, { limit: 40 })}\n\n${BRING_IN_NOTE}`)
|
|
414
454
|
})
|
|
415
455
|
|
|
456
|
+
// Seam (Seam's plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
|
|
457
|
+
// quilt would do. Only the dry run: the move itself is the owner's, from the app.
|
|
458
|
+
if (process.env.QUILT_SOURCE_URL) {
|
|
459
|
+
tool('quilt_migrate_session', {
|
|
460
|
+
description: MIGRATE_SESSION_DESCRIPTION,
|
|
461
|
+
inputSchema: { dry_run: z.literal(true).optional().describe('Always true: report what would happen, change nothing.') }
|
|
462
|
+
}, (_, { room }) => {
|
|
463
|
+
if (!room.quiltPlan) return fail('This session cannot become a quilt here.')
|
|
464
|
+
try { return text(describeQuiltPlan(room.quiltPlan(ctx.access(room)))) } catch (e) { return fail(e.message) }
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
416
468
|
const taskMap = (doc) => doc.getMap('tasks')
|
|
417
469
|
|
|
418
470
|
const reader = () => ({ name: me, tool: ctx.tool(), asAi: false, agent: true })
|
|
@@ -463,6 +515,8 @@ function sessionTools (server, ctx) {
|
|
|
463
515
|
: pickChecklist(files.get('AGENTS.md')?.toString(), files.get('CLAUDE.md')?.toString())
|
|
464
516
|
/** The project's rules (project-rules.js) on the agent's branch, a diff branch's from git where the session left them. */
|
|
465
517
|
const rulesFor = async (room, branch, files) => {
|
|
518
|
+
// In squares (layout 3): main's rules on Seam, read with the room's read token (the branch documents are as they were at the move).
|
|
519
|
+
if (room.meta.layout === LAYOUT_QUILT && quiltOf(room.meta)) return rulesOnSeam(room)
|
|
466
520
|
if (!branch || !isDiff(branch)) return projectRules({ list: [...files.keys()], read: (p) => files.get(p)?.toString() ?? null })
|
|
467
521
|
const list = [...(await listPaths(room, branch.key, branch)).keys()]
|
|
468
522
|
const got = new Map()
|
|
@@ -475,6 +529,20 @@ function sessionTools (server, ctx) {
|
|
|
475
529
|
}
|
|
476
530
|
return projectRules({ list, read: (p) => got.get(p) ?? null })
|
|
477
531
|
}
|
|
532
|
+
const rulesOnSeam = async (room) => {
|
|
533
|
+
const seam = room.quiltReadClient ? room.quiltReadClient() : null
|
|
534
|
+
if (!seam) throw new Error(`The session has no read token for ${quiltName(room)}, so its rules can't be read here: its owner links it to Seam again. Meanwhile read AGENTS.md with seam_cat.`)
|
|
535
|
+
const q = room.meta.quilt_id
|
|
536
|
+
const list = ((await seam.tree(q, 'main')).entries || []).filter((e) => e && e.path && e.mode !== 'dir').map((e) => e.path)
|
|
537
|
+
const got = new Map()
|
|
538
|
+
for (let round = 0; round < 4; round++) {
|
|
539
|
+
const asked = new Set()
|
|
540
|
+
const r = projectRules({ list, read: (p) => { if (got.has(p)) return got.get(p); asked.add(p); return null } })
|
|
541
|
+
if (!asked.size) return r
|
|
542
|
+
for (const p of asked) got.set(p, await seam.cat(q, 'main', p).then((x) => Buffer.from(x.content || '', 'base64').toString('utf8'), () => null))
|
|
543
|
+
}
|
|
544
|
+
return projectRules({ list, read: (p) => got.get(p) ?? null })
|
|
545
|
+
}
|
|
478
546
|
tool('quilt_move_task', {
|
|
479
547
|
description: 'Move a task on the shared board. "doing" when you start it: you get a briefing (its files, recent changes to them, claims, the project\'s checks). ' +
|
|
480
548
|
'"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
|
|
@@ -514,6 +582,7 @@ function sessionTools (server, ctx) {
|
|
|
514
582
|
const all = historyOf(room).entries()
|
|
515
583
|
const history = (tf.length ? all.filter((e) => tf.includes(e.path)) : all).slice(-8)
|
|
516
584
|
const claims = claimsOf(room).filter((c) => !tf.length || tf.some((f) => globMatcher(c.pattern)(f)))
|
|
585
|
+
if (quiltOf(room.meta)) return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, null), me, workflow: seamWorkflow(TASK_WORKFLOW, { hosted: true }) }))
|
|
517
586
|
const onto = branchForHostedTask(room, task)
|
|
518
587
|
return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, branchEntry(room)), me }) + (onto ? `\nYou are on ${onto} now (Quilt made it for this task, a copy of ${room.defaultKey}'s files): write your files here and quilt_git_commit them; the pull request opens by itself. Move the task to QA when it is done and tested.` : ''))
|
|
519
588
|
}
|
|
@@ -1139,10 +1208,24 @@ function sessionTools (server, ctx) {
|
|
|
1139
1208
|
return text(`Claimed ${pattern.trim()}. Release it with quilt_release when done.`)
|
|
1140
1209
|
})
|
|
1141
1210
|
|
|
1211
|
+
const releaseHostedInSeam = async (room, pattern) => {
|
|
1212
|
+
const quilt = quiltName(room) && room.meta.quilt_id
|
|
1213
|
+
const access = ctx.seam && ctx.seam()
|
|
1214
|
+
if (!quilt || !access) return fail(seamFileRefusal(quiltName(room)))
|
|
1215
|
+
try {
|
|
1216
|
+
const asked = room.claimRequest(who(room), { op: 'quilt-claims' })
|
|
1217
|
+
const { gone, ...r } = await releaseInSeam({ access, api: process.env.QUILT_SOURCE_URL, quilt, asked, pattern: pattern ? pattern.trim() : '*' })
|
|
1218
|
+
if (gone.length) room.claimRequest(who(room), { op: 'quilt-released', ids: gone })
|
|
1219
|
+
return text(releaseWords(r, { cli: 'quilt_release' }))
|
|
1220
|
+
} catch (err) { return fail(err.message) }
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1142
1223
|
tool('quilt_release', {
|
|
1143
1224
|
description: 'Release a claim (or all of your claims when no pattern is given).',
|
|
1144
1225
|
inputSchema: { pattern: z.string().optional() }
|
|
1145
1226
|
}, ({ pattern }, { room }) => {
|
|
1227
|
+
// At layout 3 the claims are Seam's: the agent lets go of its own there, as itself.
|
|
1228
|
+
if (room.meta.layout === LAYOUT_QUILT) return releaseHostedInSeam(room, pattern)
|
|
1146
1229
|
// Releasing everything keeps boxes: a box opens when it's committed (quilt_git_commit) or unpacked (quilt_unpack).
|
|
1147
1230
|
const mine = claimsOf(room).filter((c) => c.by === me && (pattern ? c.pattern === pattern.trim() : !c.box))
|
|
1148
1231
|
// A file someone waits for is handed off, not released.
|
|
@@ -1403,6 +1486,13 @@ export function hostedWorkspaceAccess ({ apiUrl, pass, fetch: fetchImpl = global
|
|
|
1403
1486
|
return { call, fetchBytes: bytesFetcher(fetchImpl, { timeoutMs: WORKSPACE_TRANSFER_TIMEOUT_MS }), put, saveDir: null, readLocal: null }
|
|
1404
1487
|
}
|
|
1405
1488
|
|
|
1489
|
+
/** A request's JSON body, read here (the transport takes it parsed). */
|
|
1490
|
+
async function readJsonBody (req) {
|
|
1491
|
+
const chunks = []
|
|
1492
|
+
for await (const c of req) chunks.push(c)
|
|
1493
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1406
1496
|
async function serve (mcp, req, res, body) {
|
|
1407
1497
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
1408
1498
|
res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
|
|
@@ -1419,11 +1509,30 @@ async function serve (mcp, req, res, body) {
|
|
|
1419
1509
|
*/
|
|
1420
1510
|
export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, secrets = null, body = undefined }) {
|
|
1421
1511
|
const image = String(req.headers['x-quilt-image'] || '').trim()
|
|
1422
|
-
// The library's guide is part of the instructions only when its tools are offered.
|
|
1423
|
-
const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${HOSTED_INSTRUCTIONS}\n\n${WORKSPACE_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS) })
|
|
1424
1512
|
const me = pass.name
|
|
1425
1513
|
const account = `${pass.kind}:${pass.sub}`
|
|
1426
1514
|
const touched = new Set()
|
|
1515
|
+
// Whether the agent's sessions on this machine are quilts (some, all): an agent whose sessions all
|
|
1516
|
+
// are reads only Seam's way, with no git in its instructions or tool list (session-seam.js).
|
|
1517
|
+
const quilts = (() => {
|
|
1518
|
+
let any = false; let all = true; let seen = 0
|
|
1519
|
+
for (const name of hostedRooms(relay.hosted.get(account))) {
|
|
1520
|
+
if (relay.roomEnded(name)) continue
|
|
1521
|
+
const room = relay.getRoom(name)
|
|
1522
|
+
if (!room || !room.exists) continue
|
|
1523
|
+
touched.add(room)
|
|
1524
|
+
seen++
|
|
1525
|
+
if (quiltOf(room.meta)) any = true; else all = false
|
|
1526
|
+
}
|
|
1527
|
+
return { any, all: any && all && seen > 0 }
|
|
1528
|
+
})()
|
|
1529
|
+
// The library's guide is part of the instructions only when its tools are offered.
|
|
1530
|
+
const base = quilts.all ? `${seamOnly(HOSTED_INSTRUCTIONS)} In a session that is a quilt: ${SEAM_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS
|
|
1531
|
+
const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${base}\n\n${WORKSPACE_GUIDE_HOSTED}` : base) })
|
|
1532
|
+
// Every tool it registers, so the git-era ones can be left out for an agent whose sessions are all quilts.
|
|
1533
|
+
const registered = new Map()
|
|
1534
|
+
const register = mcp.registerTool.bind(mcp)
|
|
1535
|
+
mcp.registerTool = (name, spec, handler) => { const t = register(name, spec, handler); registered.set(name, t); return t }
|
|
1427
1536
|
const done = () => { for (const room of touched) if (room && !room.conns.size && room.onEmpty) room.onEmpty() }
|
|
1428
1537
|
res.on('close', done)
|
|
1429
1538
|
// Which sessions the agent is in, for the accounts API (x-quilt-rooms), and which one this
|
|
@@ -1521,6 +1630,8 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1521
1630
|
multi: true, // every tool takes `session`
|
|
1522
1631
|
who: () => ({ name: me, id: account }),
|
|
1523
1632
|
kind: pass.kind === 'agent' ? 'agent' : 'human',
|
|
1633
|
+
// This agent to Seam (its pass, traded for an agent token), for what it does there as itself.
|
|
1634
|
+
seam: () => process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && req.headers['x-quilt-pass'] ? hostedSeam(account, String(req.headers['x-quilt-pass'])) : null,
|
|
1524
1635
|
access: (room) => room.hostedAccess(pass),
|
|
1525
1636
|
branch: (room) => room.hostedBranch(account),
|
|
1526
1637
|
setBranch: (room, key, opts) => room.setHostedBranch(account, key, opts),
|
|
@@ -1561,6 +1672,14 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1561
1672
|
} : null,
|
|
1562
1673
|
updates: relay.updates || null,
|
|
1563
1674
|
image: () => image,
|
|
1675
|
+
// Whether this agent is yet to be told the Seam way in `room` (once per quilt, kept with its record there).
|
|
1676
|
+
seamOnce: (room) => {
|
|
1677
|
+
const r = hostedIn(relay.hosted.get(account), room.name)
|
|
1678
|
+
if (!r || !room.meta.quilt_id || r.seamTold === room.meta.quilt_id) return false
|
|
1679
|
+
r.seamTold = room.meta.quilt_id
|
|
1680
|
+
relay.saveHosted()
|
|
1681
|
+
return true
|
|
1682
|
+
},
|
|
1564
1683
|
withSession: (fn, session) => {
|
|
1565
1684
|
const c = current(session)
|
|
1566
1685
|
return c.error ? fail(c.error) : fn(c.room)
|
|
@@ -1661,6 +1780,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1661
1780
|
}, async ({ branch, create, session }) => {
|
|
1662
1781
|
const c = current(session)
|
|
1663
1782
|
if (c.error) return fail(c.error)
|
|
1783
|
+
{ const no = seamRefusal('quilt_git_branch', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
|
|
1664
1784
|
const room = c.room
|
|
1665
1785
|
if (!branch) {
|
|
1666
1786
|
const members = []
|
|
@@ -1708,6 +1828,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1708
1828
|
}, async ({ session }) => {
|
|
1709
1829
|
const c = current(session)
|
|
1710
1830
|
if (c.error) return fail(c.error)
|
|
1831
|
+
{ const no = seamRefusal('quilt_sync_branch', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
|
|
1711
1832
|
const room = c.room
|
|
1712
1833
|
if (room.meta.layout !== 2 || !room.syncUpstreamNow) return fail('This session has no branches on the relay.')
|
|
1713
1834
|
try {
|
|
@@ -1723,6 +1844,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1723
1844
|
}, ({ token, session }) => {
|
|
1724
1845
|
const c = current(session)
|
|
1725
1846
|
if (c.error) return fail(c.error)
|
|
1847
|
+
{ const no = seamRefusal('quilt_github_token', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
|
|
1726
1848
|
if (!c.access || !c.access.owner) return fail('Only the session owner can set the GitHub token: ask them.')
|
|
1727
1849
|
try {
|
|
1728
1850
|
const r = c.room.setGithubToken(token)
|
|
@@ -1778,11 +1900,53 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1778
1900
|
}
|
|
1779
1901
|
|
|
1780
1902
|
sessionTools(mcp, ctx)
|
|
1903
|
+
// Seam's tools for an agent in a session that is a quilt (session-seam.js), as itself: its
|
|
1904
|
+
// pass, traded once for an agent token of its own (seam-access.js). Only while one of its
|
|
1905
|
+
// sessions here is a quilt, so every other agent's tool list stays as it was.
|
|
1906
|
+
const rawPass = String(req.headers['x-quilt-pass'] || '')
|
|
1907
|
+
// Hidden from the list, but a call still answers with the Seam way: an agent's client keeps the
|
|
1908
|
+
// list it read before its session moved into Seam, and a hidden tool would answer only "disabled".
|
|
1909
|
+
if (quilts.all) {
|
|
1910
|
+
if (body === undefined && req.method === 'POST') {
|
|
1911
|
+
try { body = await readJsonBody(req) } catch { res.writeHead(400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null })); return }
|
|
1912
|
+
}
|
|
1913
|
+
const calls = [].concat(body || []).some((m) => m && m.method === 'tools/call')
|
|
1914
|
+
if (!calls) for (const [name, t] of registered) if (GIT_TOOLS.has(name)) t.disable()
|
|
1915
|
+
}
|
|
1916
|
+
if (process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && rawPass && quilts.any) {
|
|
1917
|
+
const seam = hostedSeam(account, rawPass)
|
|
1918
|
+
for (const name of SEAM_SESSION_TOOLS) {
|
|
1919
|
+
const spec = seamToolSpec(name, { hosted: true })
|
|
1920
|
+
mcp.registerTool(name, { ...spec, inputSchema: spec.inputSchema.extend({ session: SESSION_ARG }) }, async ({ session, ...args } = {}) => {
|
|
1921
|
+
const c = current(session)
|
|
1922
|
+
if (c.error) return fail(c.error)
|
|
1923
|
+
const quilt = quiltOf(c.room.meta)
|
|
1924
|
+
if (!quilt) return fail('This session is not a quilt: its source control is git, as quilt_status says.')
|
|
1925
|
+
let client
|
|
1926
|
+
try { client = (await seam.client(quilt)).client } catch (e) { return fail(e.message) }
|
|
1927
|
+
return runSessionSeamTool(name, args, { client, quilt, hosted: true })
|
|
1928
|
+
})
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1781
1931
|
// The library works whether or not the agent is in a session (it never touches relay.hosted).
|
|
1782
1932
|
if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
|
|
1783
1933
|
await serve(mcp, req, res, body)
|
|
1784
1934
|
}
|
|
1785
1935
|
|
|
1936
|
+
// Each hosted agent's way into Seam, kept between its requests (each is a new MCP server): the
|
|
1937
|
+
// agent token it traded its pass for lasts hours, while passes last minutes.
|
|
1938
|
+
const hostedSeams = new Map() // account -> { access, pass }
|
|
1939
|
+
function hostedSeam (account, pass) {
|
|
1940
|
+
let e = hostedSeams.get(account)
|
|
1941
|
+
if (!e) {
|
|
1942
|
+
e = { pass }
|
|
1943
|
+
e.access = seamAccess({ api: () => process.env.QUILT_SOURCE_URL, credential: async () => `QuiltPass ${e.pass}` })
|
|
1944
|
+
hostedSeams.set(account, e)
|
|
1945
|
+
}
|
|
1946
|
+
e.pass = pass
|
|
1947
|
+
return e.access
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1786
1950
|
/**
|
|
1787
1951
|
* Handles one MCP request (stateless: a fresh server per request) for the older
|
|
1788
1952
|
* link-based flow: a browser's token tied to a room by /agent/link.
|
package/src/relay-outbound.js
CHANGED
|
@@ -50,7 +50,7 @@ const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
|
50
50
|
|
|
51
51
|
/** Whether branch `key` of the room syncs live to GitHub: the mirror is on, the owner hasn't switched it off, and the relay can write to the repository. */
|
|
52
52
|
export function liveSyncOn (room, key) {
|
|
53
|
-
if (!room.cfg || !room.cfg.gitSync || !room.mirrors || room.meta.githubSync === false) return false
|
|
53
|
+
if (!room.cfg || !room.cfg.gitSync || !room.mirrors || room.meta.githubSync === false || room.meta.syncStopped) return false
|
|
54
54
|
const rec = upstreamsOf(room)[key]
|
|
55
55
|
if (!rec || !rec.repo || !rec.ref) return false
|
|
56
56
|
// Agents' policy "off" (addendum 16.7): an agent's own branch stays in the session; a person adopts its work (adoptBranch).
|
package/src/relay-prune.js
CHANGED
|
@@ -55,8 +55,11 @@ export const lastActive = (b) => Math.max(Number(b && (b.used || b.seen)) || 0,
|
|
|
55
55
|
/** Branches nobody has worked on for INACTIVE_MS: not the default, not main or master, not in use. */
|
|
56
56
|
export function inactiveBranches (room, now = Date.now(), inactiveMs = INACTIVE_MS) {
|
|
57
57
|
const on = inUse(room, now)
|
|
58
|
+
// A quilt's squares' live documents (layout 3) stay: their squares decide when they go. Agents'
|
|
59
|
+
// own branches are named quilt/<agent>/… too, so only the keys the room lists are kept.
|
|
60
|
+
const live = new Set(Object.values(room.meta.quilt?.live || {}))
|
|
58
61
|
return Object.entries(room.meta.branches)
|
|
59
|
-
.filter(([k, b]) => k !== room.defaultKey && !TRUNKS.has(k) && !on.has(k) && now - lastActive(b) >= inactiveMs)
|
|
62
|
+
.filter(([k, b]) => k !== room.defaultKey && !TRUNKS.has(k) && !live.has(k) && !on.has(k) && now - lastActive(b) >= inactiveMs)
|
|
60
63
|
.map(([k]) => k)
|
|
61
64
|
}
|
|
62
65
|
|
package/src/release-words.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const list = (xs) => xs.length === 1 ? xs[0] : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`
|
|
6
6
|
|
|
7
|
-
/** r: { released, held?, branches?, coveredBy?, holder?, yours? }; `cli`: the command to name (quilt release, or quilt_release). */
|
|
7
|
+
/** r: { released, held?, seam? (claims are Seam's: layout 3), branches?, coveredBy?, holder?, yours? }; `cli`: the command to name (quilt release, or quilt_release). */
|
|
8
8
|
export function releaseWords (r, { cli = 'quilt release' } = {}) {
|
|
9
9
|
const n = (r && r.released) || 0
|
|
10
10
|
let s = `Released ${n} claim(s)${r && r.branches && r.branches.length ? ` (on ${list([...new Set(r.branches)])})` : ''}.`
|
|
@@ -13,6 +13,6 @@ export function releaseWords (r, { cli = 'quilt release' } = {}) {
|
|
|
13
13
|
? ` That path is not a claim of its own: your claim on ${r.coveredBy} covers it. Let go of that with ${cli} ${r.coveredBy}.`
|
|
14
14
|
: ` That path is not a claim of its own: ${r.holder || 'someone'}'s claim on ${r.coveredBy} covers it; only they (or the session owner) can let go of it.`
|
|
15
15
|
}
|
|
16
|
-
if (r && r.held && r.held.length) s += ` Kept ${r.held.join(', ')}: someone is waiting for ${r.held.length === 1 ? 'it' : 'them'}; hand off with quilt_handoff.`
|
|
16
|
+
if (r && r.held && r.held.length) s += ` Kept ${r.held.join(', ')}: someone is waiting for ${r.held.length === 1 ? 'it' : 'them'}; ${r.seam ? `hand it on in Seam with a note (seam claim handoff ${r.held[0]} --note "…")` : 'hand off with quilt_handoff'}.`
|
|
17
17
|
return s
|
|
18
18
|
}
|
package/src/runner.js
CHANGED
|
@@ -22,6 +22,8 @@ import { keptInProject } from './agent-join.js'
|
|
|
22
22
|
|
|
23
23
|
export { JOIN_HOST }
|
|
24
24
|
|
|
25
|
+
const STATUS_FILE_MS = 1500 // .quilt/STATUS.md is written at most this often
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* An invite link: https://join.heyquilt.com/<room>#<secret> for sessions on Quilt's relay,
|
|
27
29
|
* or https://<relay>/join/<room>#<secret> for any other relay (development relays).
|
|
@@ -101,7 +103,7 @@ export async function describeOtherSync (dir) {
|
|
|
101
103
|
* optionally overrides the relay address given out in invites (e.g. a public
|
|
102
104
|
* tunnel URL when the relay runs on this machine).
|
|
103
105
|
*/
|
|
104
|
-
export async function runSession ({ dir, conn, name, tool,
|
|
106
|
+
export async function runSession ({ dir, conn, name, tool, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', newSession = false, workspace = '', apiAuth = null }) {
|
|
105
107
|
dir = path.resolve(dir)
|
|
106
108
|
if (!/^wss?:\/\//.test(conn.server)) throw new Error('The relay address must start with ws:// or wss://')
|
|
107
109
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
@@ -139,7 +141,7 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
|
|
|
139
141
|
|
|
140
142
|
// An agent whose keys are kept in its project (quilt agent join --here) is in a sandbox that is cleaned up after every run: it works in turns.
|
|
141
143
|
const turns = kind === 'agent' && keptInProject(name)
|
|
142
|
-
const session = new Session({ dir, ...conn, name, tool,
|
|
144
|
+
const session = new Session({ dir, ...conn, name, tool, color, prefer, kind, shareAgent, identity, passes, startName, startAgentCommits, newSession, workspace: savedWorkspace, turns, apiAuth })
|
|
143
145
|
const summarizer = () => createSummarizer({ onWarn: (msg) => session.log(`✂️ ${msg}`) })
|
|
144
146
|
if (summarize) session.summarizer = summarizer()
|
|
145
147
|
session.taskTitler = createTaskTitler({ onWarn: (msg) => session.log(`📋 ${msg}`) })
|
|
@@ -147,12 +149,24 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
|
|
|
147
149
|
if (onDebug) session.on('debug', onDebug)
|
|
148
150
|
session.on('fatal', (err) => onFatal && onFatal(err))
|
|
149
151
|
const statusFile = path.join(dir, '.quilt', 'STATUS.md')
|
|
152
|
+
// Written at most every STATUS_FILE_MS: each write works out the whole status again.
|
|
153
|
+
let statusWrite = null
|
|
150
154
|
session.on('status-changed', () => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
if (statusWrite) return
|
|
156
|
+
statusWrite = setTimeout(() => {
|
|
157
|
+
statusWrite = null
|
|
158
|
+
if (session.stopped) return
|
|
159
|
+
// Read config fresh: the workspace can change after the session started (the app's
|
|
160
|
+
// "move to workspace", or a later quilt_start_session call writing it here).
|
|
161
|
+
try { fs.writeFileSync(statusFile, renderStatus({ ...session.status(), workspace: readConfig(dir)?.workspace })) } catch {}
|
|
162
|
+
}, STATUS_FILE_MS)
|
|
163
|
+
statusWrite.unref?.()
|
|
154
164
|
})
|
|
155
165
|
|
|
166
|
+
// Work for this person's AI can run on its own here, when they turned that on (autowork.js). It
|
|
167
|
+
// listens before the session starts: what was written to their cloud AI while it was away arrives
|
|
168
|
+
// as the session syncs, before start() returns, and is its work.
|
|
169
|
+
autowork().attach(session)
|
|
156
170
|
try {
|
|
157
171
|
await session.start({ waitTimeoutMs: 15000 })
|
|
158
172
|
} catch (err) {
|
|
@@ -164,8 +178,6 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
|
|
|
164
178
|
session.on('identity', ({ name }) => saveName(name))
|
|
165
179
|
// apiAuth: { api, token } for the accounts API as this session's person or agent (secrets, `quilt run`).
|
|
166
180
|
const control = await startControl(session, { invite, viewInvite, joined, apiAuth })
|
|
167
|
-
// Work for this person's AI can run on its own here, when they turned that on (autowork.js).
|
|
168
|
-
autowork().attach(session)
|
|
169
181
|
remember({ dir, room: conn.room, server: conn.server, name: session.name, tool, kind, workspace: savedWorkspace })
|
|
170
182
|
|
|
171
183
|
// Every AI tool on this computer can reach Quilt (its MCP server, by absolute path), with nothing to set up.
|
|
@@ -198,6 +210,7 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
|
|
|
198
210
|
if (stopped) return
|
|
199
211
|
stopped = true
|
|
200
212
|
if (readers) readers.stop()
|
|
213
|
+
clearTimeout(statusWrite)
|
|
201
214
|
await control.close()
|
|
202
215
|
await session.stop()
|
|
203
216
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Who each AI in a session that is a quilt is to Seam, with nothing set up by hand (Seam's
|
|
2
|
+
// plans/quilt-source-control/11.5). Seam takes Quilt's sign-ins through Quilt's identity service
|
|
3
|
+
// (GET /v1/seam/me on the accounts API): a person's Quilt sign-in is that person
|
|
4
|
+
// (person:<user id>), and a person's AI sessions on their computer are the person; an agent's
|
|
5
|
+
// Quilt key or pass (a cloud AI, a hosted agent) is that agent (agent:<id>), working for its
|
|
6
|
+
// person. SEAM_TOKEN, when set, is used as it is.
|
|
7
|
+
//
|
|
8
|
+
// An agent's own key only reads on Seam: it writes with an agent token. This trades the key for
|
|
9
|
+
// one per quilt, for the squares it works in (merge when its person holds merge, else write),
|
|
10
|
+
// and keeps it until a quarter of an hour before it ends: never once per call, which would
|
|
11
|
+
// reach Seam's 20 live tokens per agent. A token for other squares is revoked when a new one is made.
|
|
12
|
+
import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
|
|
13
|
+
|
|
14
|
+
export const RENEW_MS = 15 * 60_000
|
|
15
|
+
|
|
16
|
+
/** Whether a credential is an agent's own (a Quilt agent key or pass, or a Seam agent's key from `seam agent new`), which trades for an agent token. */
|
|
17
|
+
export const isAgentKey = (token) => /^(?:QuiltPass |qa_|sak_)/.test(String(token || ''))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Seam clients for one member, on Seam at `api` (a URL, or a function giving it at call time).
|
|
21
|
+
* `credential()` resolves to the token they sign in to Seam with
|
|
22
|
+
* (or null). `client(quilt, squares)` is a client acting as them in `quilt`; for an agent, with
|
|
23
|
+
* a token that reaches `squares` (ids; none: the squares it holds); one that only reads (`read`)
|
|
24
|
+
* takes the token it holds there already, whatever its squares. Its `token` is the one it uses.
|
|
25
|
+
*/
|
|
26
|
+
export function seamAccess ({ api, credential, make = quiltClient, now = Date.now, fetch: fetchImpl = globalThis.fetch }) {
|
|
27
|
+
const minted = new Map() // quilt -> { token, id, expires_at, max_level, squares }
|
|
28
|
+
const where = () => typeof api === 'function' ? api() : api
|
|
29
|
+
// A trade refused 404: Seam finds no quilt this agent reaches through the person it works for (an
|
|
30
|
+
// agent needs no member row of its own: Seam takes the person's). So the person is not in the quilt,
|
|
31
|
+
// or the quilt is gone: the agent is told who that person is to Seam, to pass on.
|
|
32
|
+
async function noQuilt (quilt, key) {
|
|
33
|
+
let person = ''
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchImpl(`${String(where()).replace(/\/+$/, '')}/v1/me`, { headers: { authorization: /^QuiltPass /.test(key) ? key : `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) })
|
|
36
|
+
const me = res.ok ? await res.json() : null
|
|
37
|
+
if (me && typeof me.on_behalf_of === 'string') person = me.on_behalf_of
|
|
38
|
+
} catch {}
|
|
39
|
+
const who = person || 'the person you work for'
|
|
40
|
+
return `Seam finds no quilt ${quilt} that the person this agent works for (${who}) is in: they are not a member of it, or it was deleted. Tell them: a quilt admin adds them once (seam quilts add ${person || 'person:<their id>'} --level write --quilt ${quilt}), and this agent then works there for them.`
|
|
41
|
+
}
|
|
42
|
+
async function mint (quilt, key, squares) {
|
|
43
|
+
const own = make({ api: where(), token: key })
|
|
44
|
+
const was = minted.get(quilt)
|
|
45
|
+
const body = { squares: squares && squares.length ? squares : 'own' }
|
|
46
|
+
let t
|
|
47
|
+
try { t = await own.createAgentToken(quilt, { ...body, max_level: 'merge' }) } catch (err) {
|
|
48
|
+
if (err.status === 404) throw Object.assign(new Error(await noQuilt(quilt, key)), { status: 404, code: 'no_quilt' })
|
|
49
|
+
// Its person holds write, not merge: a token that writes, and merges wait for a person.
|
|
50
|
+
if (err.status !== 403) throw err
|
|
51
|
+
t = await own.createAgentToken(quilt, { ...body, max_level: 'write' })
|
|
52
|
+
}
|
|
53
|
+
const kept = { token: t.token, id: t.id, expires_at: Number(t.expires_at) || now() + 8 * 3600e3, max_level: t.max_level || 'write', squares: body.squares }
|
|
54
|
+
minted.set(quilt, kept)
|
|
55
|
+
if (was && was.id && was.id !== kept.id && own.revokeAgentToken) own.revokeAgentToken(quilt, was.id).catch(() => {})
|
|
56
|
+
return kept
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
async client (quilt, squares = [], { read = false, retry } = {}) {
|
|
60
|
+
const at = where()
|
|
61
|
+
if (!at) throw new Error('Seam is not on for this app (QUILT_SOURCE_URL is not set).')
|
|
62
|
+
const key = await credential()
|
|
63
|
+
if (!key) throw new Error('This session has no Quilt sign-in to reach Seam with: sign in to Quilt (quilt login) and join again.')
|
|
64
|
+
// `retry: false`: a read that has somewhere else to go (quilt_history: the relay) is not held up by a Seam that is down.
|
|
65
|
+
const made = (token) => make({ api: at, token, ...(retry === false ? { retry: false } : {}) })
|
|
66
|
+
if (!isAgentKey(key) || !quilt) return { client: made(key), token: key, person: !isAgentKey(key) }
|
|
67
|
+
// An agent's own key does not even read a quilt it is not a member of (it reaches it through
|
|
68
|
+
// its person only with a token), so reading uses whichever token it holds there now.
|
|
69
|
+
const want = squares && squares.length ? squares : 'own'
|
|
70
|
+
let t = minted.get(quilt)
|
|
71
|
+
const fresh = t && t.expires_at - now() >= RENEW_MS
|
|
72
|
+
if (!fresh || (!read && JSON.stringify(t.squares) !== JSON.stringify(want))) t = await mint(quilt, key, squares)
|
|
73
|
+
return { client: made(t.token), token: t.token, level: t.max_level, person: false }
|
|
74
|
+
},
|
|
75
|
+
/** Forgets the tokens it made (a refused one is made again on the next call). */
|
|
76
|
+
forget () { minted.clear() }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The credential a folder's session reaches Seam with: SEAM_TOKEN, else its Quilt sign-in (`apiAuth`: the person's, or its agent's key). */
|
|
81
|
+
export function sessionCredential ({ env = process.env, apiAuth = null, account = () => null } = {}) {
|
|
82
|
+
return async () => {
|
|
83
|
+
if (env.SEAM_TOKEN) return env.SEAM_TOKEN
|
|
84
|
+
if (apiAuth) { try { const a = await apiAuth(); if (a && a.token) return a.token } catch {} }
|
|
85
|
+
const acc = account()
|
|
86
|
+
return (acc && acc.token) || null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Who `token` is to Seam at `api` (GET /v1/me): { member_id, kind, on_behalf_of? }, or null when Seam can't say. */
|
|
91
|
+
export async function seamWhoAmI (api, token, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/me`, { headers: { authorization: /^QuiltPass /.test(token) ? token : `Bearer ${token}` }, signal: AbortSignal.timeout(10_000) })
|
|
94
|
+
const me = res.ok ? await res.json() : null
|
|
95
|
+
return me && typeof me.member_id === 'string' ? me : null
|
|
96
|
+
} catch { return null }
|
|
97
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Letting go of claims in a session that is a quilt (layout 3): its claims are Seam's, and each
|
|
2
|
+
// member lets go of theirs in Seam as themselves (Session.releaseInSeam). The move into Seam made
|
|
3
|
+
// the session's claims as the member who moved it (Seam makes a claim only as its caller), so the
|
|
4
|
+
// relay keeps whose each was (`moved`, server.js quilt-claims): those are their claimant's to let
|
|
5
|
+
// go of, not the mover's, and the session owner may let go of any one named.
|
|
6
|
+
import { seamWhoAmI } from './seam-access.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Which of Seam's `claims` this member lets go of for `pattern` ('*': all of theirs): { release,
|
|
10
|
+
* notYours: [{ pattern, holder }] }. `me`: the member's Seam id; `moved`: the relay's records of the
|
|
11
|
+
* move's claims ({ id, by, mine }); `owner`: the session owner, who lets go of any claim it names.
|
|
12
|
+
*/
|
|
13
|
+
export function claimsToRelease ({ claims = [], me = '', moved = [], pattern = '*', owner = false }) {
|
|
14
|
+
const all = !pattern || pattern === '*'
|
|
15
|
+
const rec = new Map((moved || []).map((m) => [m.id, m]))
|
|
16
|
+
const release = []
|
|
17
|
+
const notYours = []
|
|
18
|
+
for (const c of claims || []) {
|
|
19
|
+
if (!c || c.conflict || (!all && c.pattern !== pattern)) continue
|
|
20
|
+
const m = rec.get(c.id)
|
|
21
|
+
const theirs = m ? !!m.mine : !!me && c.holder === me
|
|
22
|
+
if (theirs || (owner && !all)) release.push(c)
|
|
23
|
+
else if (!all) notYours.push({ pattern: c.pattern, holder: m ? `${m.by || 'someone'} (moved into Seam with the session)` : (c.holder_name || c.holder) })
|
|
24
|
+
}
|
|
25
|
+
return { release, notYours }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lets go of this member's claims for `pattern` in Seam: `access` is the member's seamAccess (a
|
|
30
|
+
* person's sign-in, or an agent's key traded for a token that reaches the claims' squares); `asked`
|
|
31
|
+
* the relay's quilt-claims answer ({ moved, owner }). Resolves to { released, seam: true, held?,
|
|
32
|
+
* gone: [claim ids let go of] }; throws naming the holder when the one named is someone else's.
|
|
33
|
+
*/
|
|
34
|
+
export async function releaseInSeam ({ access, api, quilt, asked = {}, pattern = '*' }) {
|
|
35
|
+
const { client: reader, token } = await access.client(quilt, [], { read: true })
|
|
36
|
+
const me = await seamWhoAmI(api, token)
|
|
37
|
+
const got = await reader.claims(quilt)
|
|
38
|
+
const claims = Array.isArray(got) ? got : (got && got.claims) || []
|
|
39
|
+
const pick = claimsToRelease({ claims, me: (me && me.member_id) || '', moved: asked.moved, owner: !!asked.owner, pattern })
|
|
40
|
+
if (!pick.release.length && pick.notYours.length) throw new Error(`${pattern} is claimed by ${pick.notYours[0].holder}; only they can release it (or the session owner)`)
|
|
41
|
+
if (!pick.release.length) return { released: 0, seam: true, gone: [] }
|
|
42
|
+
const { client } = await access.client(quilt, [...new Set(pick.release.map((c) => c.square_id))])
|
|
43
|
+
const gone = []
|
|
44
|
+
const held = []
|
|
45
|
+
for (const c of pick.release) {
|
|
46
|
+
try { await client.unclaim(quilt, c.square_id, c.id); gone.push(c.id) } catch (err) {
|
|
47
|
+
if (err.code === 'claim_queued') { held.push(c.pattern); continue }
|
|
48
|
+
if (err.status === 404) { gone.push(c.id); continue } // let go of already
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { released: gone.length, seam: true, gone, ...(held.length ? { held } : {}) }
|
|
53
|
+
}
|
package/src/seam-url.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Where the app and the CLI find Seam, Quilt's source control (Seam's plans/quilt-source-control/16).
|
|
2
|
+
// On by default: QUILT_SOURCE_URL names another Seam, and `off` (or an empty value) turns it off.
|
|
3
|
+
// Only the app's side reads this. The relay and the API read QUILT_SOURCE_URL themselves and stay
|
|
4
|
+
// off without it. It changes nothing for a session the relay does not say is linked to a quilt.
|
|
5
|
+
|
|
6
|
+
export const SEAM_URL = 'https://api.seam.heyquilt.com'
|
|
7
|
+
|
|
8
|
+
/** The app's Seam API, or '' when it is turned off. */
|
|
9
|
+
export function appSeamUrl (env = process.env) {
|
|
10
|
+
const v = env ? env.QUILT_SOURCE_URL : undefined
|
|
11
|
+
if (v === undefined || v === null) return SEAM_URL
|
|
12
|
+
const t = String(v).trim()
|
|
13
|
+
if (!t || /^(off|0|false|no|none)$/i.test(t)) return ''
|
|
14
|
+
return t.replace(/\/+$/, '')
|
|
15
|
+
}
|