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.
Files changed (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. 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
- if (!room.branchDoc) return null
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 { return queueNote(room, fn(args || {}, parts)) } finally { room.auditAs = prev }
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 list = queryHistory(historyOf(room).entries(), { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
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.
@@ -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).
@@ -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
 
@@ -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, toolGuessed = false, 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 = '', workspace = '', apiAuth = null }) {
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, toolGuessed, color, prefer, kind, shareAgent, identity, passes, startName, startAgentCommits, workspace: savedWorkspace, turns })
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
- // Read config fresh: the workspace can change after the session started (the app's
152
- // "move to workspace", or a later quilt_start_session call writing it here).
153
- try { fs.writeFileSync(statusFile, renderStatus({ ...session.status(), workspace: readConfig(dir)?.workspace })) } catch {}
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
+ }
@@ -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
+ }