heyquilt 0.3.122 → 0.3.140
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 +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- 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 +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- 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-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -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 +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- 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/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- 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 +377 -489
- package/src/ui/tree.js +2 -8
- 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/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- 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/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- 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-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- 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 -501
- package/src/quilts/oauth.js +0 -202
- 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 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- 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 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- package/src/upstream.js +0 -192
package/src/relay-mcp.js
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
// The older link-based server (handleAgentMcp) is kept for relays without sign-in.
|
|
11
11
|
import { authorOf } from './ui/chat.js'
|
|
12
12
|
import { projectRules, renderRules } from './project-rules.js'
|
|
13
|
-
import { cleanGit, branchBoard, branchesMarkdown } from './branches.js'
|
|
14
13
|
import crypto from 'node:crypto'
|
|
15
14
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
16
15
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
@@ -27,28 +26,25 @@ import { applyTextDiff } from './textdiff.js'
|
|
|
27
26
|
import { parseInvite } from './ui/invite.js'
|
|
28
27
|
import { scanInbox, renderInbox } from './inbox.js'
|
|
29
28
|
import { withContext, queryConversation, renderConversation, CONVERSATION_DESCRIPTION } from './conversation.js'
|
|
30
|
-
import { cleanFiles, describeCommitRequests, REQUEST_COMMIT_DESCRIPTION, MAX_REQUEST_FILES } from './commit.js'
|
|
31
|
-
import { commitRequestMessage } from './commit-request.js'
|
|
32
|
-
import { GIT_COMMIT_DESCRIPTION, gitCommitSchema, agentPrefix, slug } from './github-commit.js'
|
|
33
|
-
import { liveSyncOn, agentCommitRefusal, ownFiles } from './relay-outbound.js'
|
|
34
|
-
import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
|
|
35
|
-
import { clashAction, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
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
29
|
import { UpdateCheck } from './update-check.js'
|
|
38
30
|
import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
|
|
39
31
|
import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff, legacyTail } from './history.js'
|
|
32
|
+
import { linkedHistory } from './quilt-history.js'
|
|
40
33
|
import { changeRefusal, TALK_REFUSED } from './session-access.js'
|
|
41
34
|
import { MIGRATE_SESSION_DESCRIPTION, describeQuiltPlan } from './session-quilt.js'
|
|
42
35
|
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'
|
|
43
36
|
import { describeSubscription, describeWebhookTest, COALESCE_HELP, WEBHOOK_EVENTS } from './webhooks.js'
|
|
44
37
|
import { registerWorkspaceTools, bytesFetcher, WORKSPACE_GUIDE_HOSTED } from './workspace-tools.js'
|
|
45
38
|
import { DEFAULT_KEY, validBranchKey } from './branchdocs.js'
|
|
46
|
-
import { LOAD_FILES } from './relay-upstream.js'
|
|
47
39
|
import { isDiff, hasEntry, listPaths, readPath, readPaths, textOf, notePresent, removePath, baseOf, gitForSync } from './branch-view.js'
|
|
48
40
|
import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
|
|
49
41
|
import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
|
|
50
42
|
import { describeSecretNames, SECRET_NAMES_DESCRIPTION } from './secret-names.js'
|
|
51
|
-
import { quiltOf, seamRefusal, seamNote } from './session-seam.js'
|
|
43
|
+
import { quiltOf, seamRefusal, seamNote, seamStatus, seamFileRefusal, seamToolSpec, runSessionSeamTool, seamOnly, seamWorkflow, SEAM_SESSION_TOOLS, SEAM_GUIDE_HOSTED, GIT_TOOLS } from './session-seam.js'
|
|
44
|
+
import { seamAccess } from './seam-access.js'
|
|
45
|
+
import { releaseInSeam } from './seam-claims.js'
|
|
46
|
+
import { releaseWords } from './release-words.js'
|
|
47
|
+
import { LAYOUT_QUILT } from './session-layout3.js'
|
|
52
48
|
import { TRANSCRIPT_DESCRIPTION, renderLines, readLines, inVoice, transcribeSetting, voiceStatusLines } from './voice.js'
|
|
53
49
|
|
|
54
50
|
const FEED_CAP = 300
|
|
@@ -95,8 +91,6 @@ export const HOSTED_INSTRUCTIONS =
|
|
|
95
91
|
'Do not edit files someone else has claimed: a refused write tells you who holds the file; ask for it in its file queue with quilt_request_file (a title, and up to 300 characters on what you plan) and carry on with other work: you are woken when it is handed to you. ' +
|
|
96
92
|
'When someone waits in the queue for a file you hold, every answer says so: finish your change, then hand it off with quilt_handoff and your context (what you changed, what is left). You cannot finish a task or release the file before. A claim whose holder does nothing for 20 minutes goes to the next in its queue. ' +
|
|
97
93
|
'Read what people said about a file before you change it: quilt_read_messages, and each write tells you what was said about that file. ' +
|
|
98
|
-
'On a branch that follows a GitHub repository, what you write stays in a box until you commit it (quilt_git_commit): everyone sees the change, but it is in nobody else\'s folder, so nobody else\'s git can commit it. Committing opens the box by itself; releasing everything does not. ' +
|
|
99
|
-
'quilt_unpack shares a box early when someone needs it: decide yourself, and ask your person first for a large, risky or critical change or when the project\'s rules say so. A file boxed by someone else (quilt_status lists Boxed work, and quilt_read_file says so) is their unfinished work: do not build on it; ask for it with quilt_request_file. ' +
|
|
100
94
|
'This rule is enforced for agents: while someone who messaged or mentioned you waits for an answer, writes, claims and task changes are refused until you answer with quilt_message. ' +
|
|
101
95
|
PERSON_CHAT + ' ' +
|
|
102
96
|
'Everyone sees your changes on their own disk within moments. ' +
|
|
@@ -106,11 +100,7 @@ export const HOSTED_INSTRUCTIONS =
|
|
|
106
100
|
'You can be in several sessions at once (quilt_join_session each): every tool then takes session: <id> (quilt_session_info lists them; a webhook event or inbox entry says which session it is from), and a call that names none is refused, so an answer never lands in the wrong session. ' +
|
|
107
101
|
'You are connected over HTTP, so you show as online for 30 minutes after each tool call; while idle, people reach you in the way you chose with quilt_wake. ' +
|
|
108
102
|
'HTTP is only for an agent with no computer. If you can run shell commands anywhere (a computer, a cloud machine, a sandbox), you must use the Quilt CLI there instead (`quilt agent join`, then `quilt join <invite> --agent <your name>`): files sync to disk and you show as live. ' +
|
|
109
|
-
'A session has a branch for each
|
|
110
|
-
'Commits pushed or merged on GitHub come into your branch by themselves: through a member\'s folder on it when one is online and can fetch, otherwise the relay brings them in from GitHub about every 10 minutes. quilt_sync_branch asks for that now (at most once a minute per branch), for example after a PR merged. ' +
|
|
111
|
-
'When those commits and the session\'s uncommitted work changed the same files, Quilt merges them on its own (where both changed the same lines, the newer edit stays, and whoever wrote the other lines is told): there is never a merge for you to do. ' +
|
|
112
|
-
'A private repository needs a read-only GitHub token on the relay: when quilt_status says the relay can\'t read it, ask the session owner (quilt_github_token sets it, owner only). You have no folder here, so git is the quilt_git_* tools: taking a task (quilt_move_task to doing) puts you on a branch of your own, quilt/<you>/<task>; quilt_git_commit commits your changed files there and opens the pull request tied to the task; quilt_git_status says where you stand, quilt_git_diff what differs from GitHub, quilt_git_log what was committed, quilt_git_revert puts GitHub\'s version of a file back. The session\'s branch itself is open to agents only when the owner lets agents push to any branch. Work only in the session has not shipped: commit before you move a task to QA. Done merges the task\'s pull request once its checks pass: a person moves it to Done, or you when the owner lets agents commit to any branch. quilt_git_pull_requests lists open pull requests with their checks; quilt_git_merge merges one (agents: when the owner lets agents commit to any branch). When agents may not commit, ask a person with quilt_request_commit. Pushes and merges on GitHub reach the session within seconds. ' +
|
|
113
|
-
'When a branch syncs with GitHub (quilt_git_status and quilt_branches say so), pushes and merges on GitHub come into the session by themselves, and nothing goes to GitHub until someone commits: quilt_git_commit commits your own changes (with_others: true takes others\' changes in the same files too, as co-authors). A file GitHub and the session both changed is held, and its merger proposes the merge with quilt_clash (the reviewer accepts). Releasing a file only lets go of your claim: it doesn\'t publish anything by itself. ' +
|
|
103
|
+
'A session has a branch for each branch its members work on, each with its own files: your first call on files puts you on the busiest one. A session in Seam keeps its work in squares: the seam_* tools commit, merge and read history. ' +
|
|
114
104
|
MAIL_GUIDE + ' ' +
|
|
115
105
|
TASK_WORKFLOW
|
|
116
106
|
|
|
@@ -127,21 +117,6 @@ const REMOVED = 'You are no longer in that session. Ask for a new invite and cal
|
|
|
127
117
|
const NEEDS_ROOM_PASS = 'Reconnecting you to the session. Call the same tool again.'
|
|
128
118
|
|
|
129
119
|
const id = () => crypto.randomBytes(8).toString('hex')
|
|
130
|
-
const BRING_IN_NOTE = 'Commits pushed or merged elsewhere come into the session by themselves: the folder of a member on that branch fetches about once a minute and brings them in. While no folder on a branch can (none online, or only ones that can\'t fetch, are held or diverged, or are a viewer\'s), the relay brings them in from GitHub itself, about every 10 minutes; quilt_sync_branch asks it to look now.'
|
|
131
|
-
|
|
132
|
-
/** For a hosted agent: what the relay does about new commits on its branch, and when it last looked; or ''. */
|
|
133
|
-
function relayStatus (room, key) {
|
|
134
|
-
const rec = room.meta && room.meta.upstreams ? room.meta.upstreams[room.resolveKey ? room.resolveKey(key) : key] : null
|
|
135
|
-
if (!rec || !rec.repo) return ''
|
|
136
|
-
const up = rec.name || 'its upstream'
|
|
137
|
-
if (!rec.checkedAt) return `The relay brings commits from ${up} into \`${key}\` while no folder on it is online; it hasn't looked yet.`
|
|
138
|
-
const bits = [`The relay last checked ${up} for new commits ${ago(rec.checkedAt)}.`]
|
|
139
|
-
if (rec.brought && rec.brought.at) bits.push(`It brought in ${rec.brought.count} commit${rec.brought.count === 1 ? '' : 's'} (${rec.brought.files} file${rec.brought.files === 1 ? '' : 's'}) ${ago(rec.brought.at)}.`)
|
|
140
|
-
if (rec.clash) bits.push(`Its clash with ${up} is task ${rec.clash.task} on the board.`)
|
|
141
|
-
if (rec.problem) bits.push(`Note: ${rec.problem}.`)
|
|
142
|
-
return bits.join(' ')
|
|
143
|
-
}
|
|
144
|
-
|
|
145
120
|
/** The chat `me` can see: public messages, and direct ones to or from them. */
|
|
146
121
|
const chatFor = (doc, me) => doc.getArray('chat').toArray().filter((m) => m && m.id && (!m.to || m.to === me || m.by === me))
|
|
147
122
|
|
|
@@ -154,10 +129,17 @@ function takeStock (doc, me) {
|
|
|
154
129
|
const linkInboxes = new WeakMap()
|
|
155
130
|
const text = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
156
131
|
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true })
|
|
132
|
+
// A session that is a quilt, by its quilt's name (its slug; its id before the relay knows one), or null.
|
|
133
|
+
const quiltName = (room) => { const q = quiltOf(room.meta); return q ? ((room.meta.quilt && room.meta.quilt.slug) || q) : null }
|
|
157
134
|
// quilt_status in a session that is a quilt starts with how work goes there (session-seam.js).
|
|
135
|
+
// Any other answer, the first in a session that is a quilt for this agent: its instructions may be from before the move.
|
|
136
|
+
const withSeamLead = (r, quilt) => {
|
|
137
|
+
if (r && typeof r.then === 'function') return r.then((x) => withSeamLead(x, quilt))
|
|
138
|
+
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
|
|
139
|
+
}
|
|
158
140
|
const withSeamNote = (r, quilt) => {
|
|
159
141
|
if (r && typeof r.then === 'function') return r.then((x) => withSeamNote(x, quilt))
|
|
160
|
-
return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: seamNote(quilt) }, ...r.content] } : r
|
|
142
|
+
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
|
|
161
143
|
}
|
|
162
144
|
|
|
163
145
|
function ago (ts) {
|
|
@@ -193,29 +175,11 @@ function sessionTools (server, ctx) {
|
|
|
193
175
|
}
|
|
194
176
|
// Files, claims and history are a branch's: the agent's own (hosted agents can choose one), or the session's active branch.
|
|
195
177
|
const branchOf = (room) => ctx.branch ? ctx.branch(room) : room.activeBranch ? room.activeBranch() : null
|
|
196
|
-
/**
|
|
197
|
-
* An agent without a folder taking a task goes onto a branch of its own for it (quilt/<agent>/<task>,
|
|
198
|
-
* a copy of the default branch's files), as a folder agent's checkout does. Stays on a branch of its own
|
|
199
|
-
* that still has an open task; nothing when agents may not commit, or this relay has no branch documents.
|
|
200
|
-
*/
|
|
201
|
-
const branchForHostedTask = (room, task) => {
|
|
202
|
-
if (ctx.kind !== 'agent' || !ctx.setBranch || !task || !task.title || room.meta.layout !== 2) return null
|
|
203
|
-
if (!agentsCanCommit(room, room.defaultKey)) return null
|
|
204
|
-
const prefix = agentPrefix(me)
|
|
205
|
-
const here = branchOf(room) || room.defaultKey
|
|
206
|
-
if (here.startsWith(prefix) && readTasks(room.doc.getMap('tasks')).some((t) => t && !t.archived && t.column !== 'done' && t.pr && t.pr.branch === here)) return null
|
|
207
|
-
let name = `${prefix}${slug(task.title)}`
|
|
208
|
-
for (let n = 2; room.meta.branches[room.resolveKey(name)] && n < 50; n++) name = `${prefix}${slug(task.title)}-${n}`
|
|
209
|
-
try {
|
|
210
|
-
if (here !== room.defaultKey) ctx.setBranch(room, room.defaultKey, {})
|
|
211
|
-
ctx.setBranch(room, name, { create: true, by: me, editor: true })
|
|
212
|
-
return name
|
|
213
|
-
} catch { return null }
|
|
214
|
-
}
|
|
215
178
|
// The branch document the agent works on (its store entry), or null for a room without branch documents.
|
|
216
179
|
// The session's default branch may always start; any other new branch only for someone who may change files.
|
|
217
180
|
const branchEntry = (room) => {
|
|
218
|
-
|
|
181
|
+
// At layout 3 there are no branch documents to work on: every tool but the file tools works on the room.
|
|
182
|
+
if (!room.branchDoc || room.meta.layout === LAYOUT_QUILT) return null
|
|
219
183
|
const key = branchOf(room)
|
|
220
184
|
const editor = room.resolveKey(key) === room.defaultKey || ctx.access(room)?.role !== 'viewer'
|
|
221
185
|
return room.branchDoc(key, { by: me, editor })
|
|
@@ -224,13 +188,16 @@ function sessionTools (server, ctx) {
|
|
|
224
188
|
// Hosted agents are in several sessions at once: `session` says which this call is about.
|
|
225
189
|
const { session, ...args } = given || {}
|
|
226
190
|
return stale(await ctx.withSession((room) => {
|
|
227
|
-
// A hosted agent with no branch of its own is pinned to the active branch by its first call
|
|
228
|
-
// on files, so a read and the write after it never land on two different branches.
|
|
229
|
-
if (ctx.pin && FILE_TOOLS.has(name)) ctx.pin(room)
|
|
230
191
|
// A session that is a quilt has no GitHub path for agents: each GitHub tool names its Seam tool.
|
|
231
|
-
|
|
232
|
-
const
|
|
192
|
+
// 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).
|
|
193
|
+
const quilt = quiltName(room)
|
|
194
|
+
// quilt_release lets go of the agent's claims in Seam then (seam-claims.js), when it reaches Seam.
|
|
195
|
+
const seamRelease = name === 'quilt_release' && !!(ctx.seam && ctx.seam())
|
|
196
|
+
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)
|
|
233
197
|
if (refused) return fail(refused)
|
|
198
|
+
// A hosted agent with no branch of its own is pinned to the active branch by its first call
|
|
199
|
+
// on files, so a read and the write after it never land on two different branches.
|
|
200
|
+
if (ctx.pin && FILE_TOOLS.has(name) && room.meta.layout !== LAYOUT_QUILT) ctx.pin(room)
|
|
234
201
|
// The audit trail: which tool, on what (a path, a pattern or a task id; never contents).
|
|
235
202
|
const caller = ctx.who(room)
|
|
236
203
|
if (caller && caller.id && room.audit) {
|
|
@@ -247,7 +214,9 @@ function sessionTools (server, ctx) {
|
|
|
247
214
|
room.auditAs = caller && caller.id
|
|
248
215
|
try {
|
|
249
216
|
const r = queueNote(room, fn(args || {}, parts))
|
|
250
|
-
|
|
217
|
+
// The first answer in a session that is a quilt says how work lands there (quilt_status always does).
|
|
218
|
+
const once = quilt && ctx.seamOnce ? ctx.seamOnce(room) : false
|
|
219
|
+
return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : once ? withSeamLead(r, quilt) : r
|
|
251
220
|
} finally { room.auditAs = prev }
|
|
252
221
|
}, session))
|
|
253
222
|
})
|
|
@@ -274,13 +243,6 @@ function sessionTools (server, ctx) {
|
|
|
274
243
|
for (const h of room.hostedOnline ? room.hostedOnline() : []) if (h.name !== me && !out.some((p) => p.name === h.name)) out.push({ name: h.name, kind: h.kind, tool: 'hosted', hosted: true })
|
|
275
244
|
return out
|
|
276
245
|
}
|
|
277
|
-
// Each member's folder tells the room its git (branch, upstream, the repository's branches): one list.
|
|
278
|
-
const branchesOf = (room) => {
|
|
279
|
-
const members = []
|
|
280
|
-
for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
|
|
281
|
-
for (const p of peers(room)) if (p.persona) members.push({ name: p.name, git: null, persona: true })
|
|
282
|
-
return branchBoard(members, room.branchList ? room.branchList() : [])
|
|
283
|
-
}
|
|
284
246
|
// Claims as people see them: an older app's AI session ("Dana · login", of: Dana) is its person.
|
|
285
247
|
const claimsOf = (room) => (room.claimList ? room.claimList(branchOf(room)) : []).map((c) => (c.of || (c.queue || []).some((r) => r && r.of) ? { ...c, by: c.of || c.by, queue: (c.queue || []).map((r) => (r && r.of ? { ...r, by: r.of } : r)) } : c))
|
|
286
248
|
// Claims are per branch: who the agent is, on the branch it works on.
|
|
@@ -312,7 +274,8 @@ function sessionTools (server, ctx) {
|
|
|
312
274
|
// Claims follow this agent's writes: a file it changes that nobody holds is claimed for it, and let
|
|
313
275
|
// go when it hasn't written the file for a while (it has no end of turn Quilt can see).
|
|
314
276
|
const autoHeld = new Map() // `${roomId}\0${rel}` -> timer
|
|
315
|
-
|
|
277
|
+
// Boxes opened when a commit to the branch's repository landed: no session commits to git now.
|
|
278
|
+
const boxable = () => false
|
|
316
279
|
const roomKey = (room) => `${room.id || room.name || ''}\0${branchOf(room)}` // claims held per branch
|
|
317
280
|
const autoClaim = (room, rel) => {
|
|
318
281
|
const key = `${roomKey(room)}\0${rel}`
|
|
@@ -356,11 +319,18 @@ function sessionTools (server, ctx) {
|
|
|
356
319
|
with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
|
|
357
320
|
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
358
321
|
}
|
|
359
|
-
}, ({ path: p, by, since, task, with_diff, limit }, { room }) => {
|
|
322
|
+
}, async ({ path: p, by, since, task, with_diff, limit }, { room }) => {
|
|
360
323
|
const from = parseSince(since)
|
|
361
324
|
if (from === undefined) return fail('since: use a duration like 2h or 3d, "today", "yesterday", or a date.')
|
|
362
325
|
const q = process.env.QUILT_SOURCE_URL && room.meta && room.meta.quilt_id ? room.meta.quilt : null
|
|
363
|
-
const
|
|
326
|
+
const own = legacyTail(historyOf(room).entries(), { at: q && q.at, history: !!(q && q.history) })
|
|
327
|
+
// Linked to a quilt (16: the session became one, or shadows one in step 1): Seam's log plus this record.
|
|
328
|
+
const link = room.quiltHistoryLink ? room.quiltHistoryLink() : null
|
|
329
|
+
if (link) {
|
|
330
|
+
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 } })
|
|
331
|
+
return text(`${formatHistory(r.list, { withDiff: !!with_diff })}${r.note ? `\n\n${r.note}` : ''}`)
|
|
332
|
+
}
|
|
333
|
+
const list = queryHistory(own, { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
|
|
364
334
|
return text(formatHistory(list, { withDiff: !!with_diff }))
|
|
365
335
|
})
|
|
366
336
|
|
|
@@ -393,42 +363,19 @@ function sessionTools (server, ctx) {
|
|
|
393
363
|
if (away.length) lines.push('', '## Away (agents that work in turns, between turns: they read what you write at their next turn)', ...away.map((m) => `- ${m.name} (agent) · last here ${ago(m.lastSeen)}`))
|
|
394
364
|
const all = claimsOf(room)
|
|
395
365
|
const bxs = all.filter((c) => c.box)
|
|
396
|
-
if (bxs.length) lines.push('', '## Boxed work', ...bxs.map((c) => `- ${c.pattern}: ${c.by === me ? 'your unfinished work, in nobody else\'s folder until you commit it
|
|
366
|
+
if (bxs.length) lines.push('', '## Boxed work', ...bxs.map((c) => `- ${c.pattern}: ${c.by === me ? 'your unfinished work, in nobody else\'s folder until you commit it or quilt_unpack it' : `${c.by}'s unfinished work: not in anyone else's folder until they commit or unpack it`}`))
|
|
397
367
|
const cl = all.filter((c) => !c.box)
|
|
398
368
|
lines.push('', '## Claimed files', ...(cl.length ? cl.map((c) => `- ${c.pattern} by ${c.by}${c.note ? ` (${c.note})` : ''}${c.queue.length ? ` · waiting: ${c.queue.map((r) => `${r.by} ("${r.title}")`).join(', ')}` : ''}`) : ['- None.']))
|
|
399
369
|
const acts = activity.toArray().filter(Boolean).slice(-12).reverse()
|
|
400
370
|
lines.push('', '## Recent file changes', ...(acts.length ? acts.map((x) => x.kind === 'pulled' ? `- ${x.by} pulled ${x.detail || 'commits'} (${ago(x.ts)})` : x.kind === 'brought' ? `- ${x.by} brought in ${x.detail || 'commits'}${x.branch ? ` on ${x.branch}` : ''} (${ago(x.ts)})` : x.kind === 'switched' ? `- ${x.by} switched to ${x.branch} (${ago(x.ts)})` : `- ${x.by} ${x.kind} ${x.path} (${ago(x.ts)})`) : ['- None yet.']))
|
|
401
371
|
const msgs = chat.toArray().filter(visible).slice(-8)
|
|
402
372
|
lines.push('', '## Recent messages', ...(msgs.length ? msgs.map(fmtMsg) : ['- None.']))
|
|
403
|
-
const asked = [...doc.getMap('commitRequests').values()].filter((r) => r && r.id && r.state === 'open').sort((x, y) => x.ts - y.ts)
|
|
404
|
-
if (asked.length) lines.push('', '## Commit requests', ...asked.map((r) => `- [${r.id}] ${r.by === me ? 'you' : r.by} (${ago(r.ts)}): ${r.message}${Array.isArray(r.files) ? ` (${r.files.length} files)` : ''}${r.error ? `. Last try: ${r.error}` : ''}`))
|
|
405
373
|
lines.push('', '## Tasks', taskMarkdown(readTasks(doc.getMap('tasks')), me, { tool: ctx.tool(), asAi: false, mentionYours: true }))
|
|
406
|
-
const br = branchesOf(room)
|
|
407
|
-
if (br.length) {
|
|
408
|
-
lines.push('', '## Branches', branchesMarkdown(br, { limit: 6, mine: branchOf(room) }))
|
|
409
|
-
if (ctx.branch) lines.push('Work on another branch with quilt_git_branch.')
|
|
410
|
-
// Hosted agents learn when the relay last looked for new commits on their branch (relay-upstream.js).
|
|
411
|
-
const said = relayStatus(room, branchOf(room))
|
|
412
|
-
if (said) lines.push(said)
|
|
413
|
-
}
|
|
414
374
|
if (ctx.webhook) { const w = ctx.webhook.get(); lines.push('', w ? (w.email ? 'Quilt wakes you by email (quilt_wake changes it).' : `Webhook: Quilt POSTs to ${w.url} on ${w.events.join(', ')}.`) : 'No webhook or email wake: choose how Quilt wakes you with quilt_wake.') }
|
|
415
375
|
return text(lines.join('\n') + ctx.warn(room))
|
|
416
376
|
})
|
|
417
377
|
|
|
418
|
-
|
|
419
|
-
description: SYNC_STATUS_DESCRIPTION,
|
|
420
|
-
inputSchema: {}
|
|
421
|
-
}, (_, { room, branch }) => text(syncStatusText(room.branchList ? room.branchList() : [], { mine: branch ? branch.key : '' })))
|
|
422
|
-
|
|
423
|
-
tool('quilt_branches', {
|
|
424
|
-
description: 'The session\'s git branches: which branch each member\'s folder is on, which AI sessions and worktrees work on which branch, how each stands against its upstream (behind, ahead, diverged), and who committed last.',
|
|
425
|
-
inputSchema: {}
|
|
426
|
-
}, (_, { room }) => {
|
|
427
|
-
const br = branchesOf(room)
|
|
428
|
-
return text(`${branchesMarkdown(br, { limit: 40 })}\n\n${BRING_IN_NOTE}`)
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
// Seam (plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
|
|
378
|
+
// Seam (Seam's plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
|
|
432
379
|
// quilt would do. Only the dry run: the move itself is the owner's, from the app.
|
|
433
380
|
if (process.env.QUILT_SOURCE_URL) {
|
|
434
381
|
tool('quilt_migrate_session', {
|
|
@@ -490,6 +437,8 @@ function sessionTools (server, ctx) {
|
|
|
490
437
|
: pickChecklist(files.get('AGENTS.md')?.toString(), files.get('CLAUDE.md')?.toString())
|
|
491
438
|
/** The project's rules (project-rules.js) on the agent's branch, a diff branch's from git where the session left them. */
|
|
492
439
|
const rulesFor = async (room, branch, files) => {
|
|
440
|
+
// 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).
|
|
441
|
+
if (room.meta.layout === LAYOUT_QUILT && quiltOf(room.meta)) return rulesOnSeam(room)
|
|
493
442
|
if (!branch || !isDiff(branch)) return projectRules({ list: [...files.keys()], read: (p) => files.get(p)?.toString() ?? null })
|
|
494
443
|
const list = [...(await listPaths(room, branch.key, branch)).keys()]
|
|
495
444
|
const got = new Map()
|
|
@@ -502,6 +451,20 @@ function sessionTools (server, ctx) {
|
|
|
502
451
|
}
|
|
503
452
|
return projectRules({ list, read: (p) => got.get(p) ?? null })
|
|
504
453
|
}
|
|
454
|
+
const rulesOnSeam = async (room) => {
|
|
455
|
+
const seam = room.quiltReadClient ? room.quiltReadClient() : null
|
|
456
|
+
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.`)
|
|
457
|
+
const q = room.meta.quilt_id
|
|
458
|
+
const list = ((await seam.tree(q, 'main')).entries || []).filter((e) => e && e.path && e.mode !== 'dir').map((e) => e.path)
|
|
459
|
+
const got = new Map()
|
|
460
|
+
for (let round = 0; round < 4; round++) {
|
|
461
|
+
const asked = new Set()
|
|
462
|
+
const r = projectRules({ list, read: (p) => { if (got.has(p)) return got.get(p); asked.add(p); return null } })
|
|
463
|
+
if (!asked.size) return r
|
|
464
|
+
for (const p of asked) got.set(p, await seam.cat(q, 'main', p).then((x) => Buffer.from(x.content || '', 'base64').toString('utf8'), () => null))
|
|
465
|
+
}
|
|
466
|
+
return projectRules({ list, read: (p) => got.get(p) ?? null })
|
|
467
|
+
}
|
|
505
468
|
tool('quilt_move_task', {
|
|
506
469
|
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). ' +
|
|
507
470
|
'"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
|
|
@@ -514,11 +477,6 @@ function sessionTools (server, ctx) {
|
|
|
514
477
|
}
|
|
515
478
|
}, async ({ id, column, qaNotes, verified }, { room, doc, files, branch }) => {
|
|
516
479
|
{ const w = waitRefusal(doc, 'quilt_move_task', room); if (w) return fail(w) }
|
|
517
|
-
// An agent's finished work goes to git before QA, when it can commit itself (relay-commit.js); Done merges the task's pull request.
|
|
518
|
-
if (column === 'qa' && ctx.kind === 'agent' && !Object.values(room.meta && room.meta.upstreams || {}).some((rec) => rec && rec.clash && rec.clash.task === id)) {
|
|
519
|
-
const u = await unpublishedFor(room, { key: branch ? branch.key : DEFAULT_KEY, who: me, task: id, column }, { fetch: room.githubFetch })
|
|
520
|
-
if (u.unpublished.length) return fail(unpublishedRefusal(readTasks(taskMap(doc)).find((t) => t.id === id), u.unpublished, column, []))
|
|
521
|
-
}
|
|
522
480
|
const err = writable(room)
|
|
523
481
|
if (err) return fail(err)
|
|
524
482
|
try {
|
|
@@ -527,11 +485,6 @@ function sessionTools (server, ctx) {
|
|
|
527
485
|
if (column === 'qa' && !qaNotesEnough(qaNotes)) return fail(qaRefusal({ task: cur, checklist: await checklistOf(files, room, branch) }))
|
|
528
486
|
if (column === 'done' && !verifiedEnough(verified)) return fail(doneRefusal({ task: cur, checklist: await checklistOf(files, room, branch) }))
|
|
529
487
|
{ const q = (column === 'done' || column === 'qa') && renderQueued(queuedFor(claimsOf(room), me), 'move the task again'); if (q) return fail(q) }
|
|
530
|
-
let merged = null
|
|
531
|
-
if (column === 'done' && cur.column !== 'done') {
|
|
532
|
-
const f = await finishTaskFor(room, { who: { name: me, kind: ctx.kind || 'human', access: ctx.access(room) }, key: branch ? branch.key : DEFAULT_KEY, task: id }, { fetch: room.githubFetch })
|
|
533
|
-
merged = f.merged
|
|
534
|
-
}
|
|
535
488
|
const patch = { id, column }
|
|
536
489
|
if (column === 'qa') patch.qaNotes = qaNotes
|
|
537
490
|
if (column === 'done') patch.verified = verified
|
|
@@ -541,11 +494,11 @@ function sessionTools (server, ctx) {
|
|
|
541
494
|
const all = historyOf(room).entries()
|
|
542
495
|
const history = (tf.length ? all.filter((e) => tf.includes(e.path)) : all).slice(-8)
|
|
543
496
|
const claims = claimsOf(room).filter((c) => !tf.length || tf.some((f) => globMatcher(c.pattern)(f)))
|
|
544
|
-
|
|
545
|
-
return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, branchEntry(room)), me })
|
|
497
|
+
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 }) }))
|
|
498
|
+
return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, branchEntry(room)), me }))
|
|
546
499
|
}
|
|
547
500
|
if (column === 'qa') return text(`Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}`)
|
|
548
|
-
if (column === 'done') return text(`Moved "${task.title}" to Done
|
|
501
|
+
if (column === 'done') return text(`Moved "${task.title}" to Done. Verified: ${verifiedLine(task)}`)
|
|
549
502
|
return text(`Moved "${task.title}" to ${columnName(task.column)}.`)
|
|
550
503
|
} catch (e) { return fail(e.message) }
|
|
551
504
|
})
|
|
@@ -797,247 +750,6 @@ function sessionTools (server, ctx) {
|
|
|
797
750
|
return text(msgs.length ? msgs.map(fmtMsg).join('\n') : 'No messages yet.')
|
|
798
751
|
})
|
|
799
752
|
|
|
800
|
-
// ------------------------------------------------------ commit timing --
|
|
801
|
-
// The same requests as a folder's quilt_request_commit (Session.requestCommit): a message to
|
|
802
|
-
// whoever commits, so viewers and members who may not post are refused.
|
|
803
|
-
const commitMap = (doc) => doc.getMap('commitRequests')
|
|
804
|
-
const commitState = (room, doc) => {
|
|
805
|
-
const busy = []
|
|
806
|
-
for (const st of room.awareness.getStates().values()) {
|
|
807
|
-
if (!st || !st.name || st.name === me) continue
|
|
808
|
-
const a = st.agent
|
|
809
|
-
if (a && a.sharing !== false && a.status === 'working') busy.push(`${st.name}'s ${a.tool || 'AI'} is working`)
|
|
810
|
-
else if (st.work && st.work.state === 'working') busy.push(`${st.name} is working${st.work.note ? `: ${String(st.work.note).slice(0, 200)}` : ''}`)
|
|
811
|
-
}
|
|
812
|
-
const requests = [...commitMap(doc).values()].filter((r) => r && r.id).sort((a, b) => a.ts - b.ts)
|
|
813
|
-
return { busy, open: requests.filter((r) => r.state === 'open'), recent: requests.filter((r) => r.state === 'done').slice(-5) }
|
|
814
|
-
}
|
|
815
|
-
const describeCommits = (c) => [
|
|
816
|
-
c.busy.length ? `⏳ Still working: ${c.busy.join('; ')}` : '✅ Everyone else\'s AI is idle: a good moment to commit.',
|
|
817
|
-
describeCommitRequests(c)
|
|
818
|
-
].join('\n')
|
|
819
|
-
|
|
820
|
-
tool('quilt_request_commit', {
|
|
821
|
-
description: REQUEST_COMMIT_DESCRIPTION,
|
|
822
|
-
inputSchema: {
|
|
823
|
-
message: z.string().max(500).optional().describe('The commit message: what the change does (a task\'s title by default)'),
|
|
824
|
-
files: z.array(z.string().max(1024)).max(500).optional().describe('The files you changed, relative to the project\'s top folder (deleted ones too)'),
|
|
825
|
-
task: z.string().max(40).optional().describe('The task this work was for: its title is the message and its changes the files, unless you give them')
|
|
826
|
-
}
|
|
827
|
-
}, ({ message, files, task }, { room, doc, fdoc }) => {
|
|
828
|
-
{ const w = waitRefusal(doc, 'quilt_request_commit', room); if (w) return fail(w) }
|
|
829
|
-
const err = writable(room)
|
|
830
|
-
if (err) return fail(err)
|
|
831
|
-
const a = ctx.access(room)
|
|
832
|
-
if (a && a.role === 'viewer') return fail('Viewers can\'t ask for commits.')
|
|
833
|
-
if (ctx.kind === 'agent' && agentsCanCommit(room, branchOf(room) || DEFAULT_KEY)) return fail('Commit it yourself: quilt_git_commit (the files you changed and a message) commits them to the branch you are on, with nobody else needed; on a branch of your own the pull request opens by itself. Asking a person is for when agents may not commit here.')
|
|
834
|
-
if (a && a.talk === false) return fail(TALK_REFUSED)
|
|
835
|
-
const t = task ? readTasks(taskMap(doc)).find((x) => x.id === String(task)) : null
|
|
836
|
-
if (task && !t) return fail(`No task ${task} on the board.`)
|
|
837
|
-
const msg = String(message || (t ? t.title : '')).trim().slice(0, 500)
|
|
838
|
-
if (!msg) return fail('Say what the commit is for.')
|
|
839
|
-
let list = cleanFiles(files || [])
|
|
840
|
-
if (files && files.length && !list.length) return fail('None of those are files in the project (give paths relative to its top folder).')
|
|
841
|
-
if (!list.length) {
|
|
842
|
-
// What was changed for the task, or by this agent, on record in the branch's history.
|
|
843
|
-
for (const e of fdoc.getArray('history').toArray()) {
|
|
844
|
-
if (!e || e.pulled || typeof e.path !== 'string') continue
|
|
845
|
-
if (t ? e.task && e.task.id === t.id : e.by === me) { if (!list.includes(e.path)) list.push(e.path) }
|
|
846
|
-
}
|
|
847
|
-
if (t) for (const f of t.files || []) if (!list.includes(f)) list.push(f)
|
|
848
|
-
list = cleanFiles(list.slice(-MAX_REQUEST_FILES))
|
|
849
|
-
}
|
|
850
|
-
if (!list.length) return fail(t ? `Task ${t.id} has no changed files on record: give them in files.` : 'Quilt has no changes of yours on record: give the files in files.')
|
|
851
|
-
const key = branchOf(room)
|
|
852
|
-
const r = { id: crypto.randomBytes(6).toString('hex'), by: me, message: msg, ...(key ? { branch: key } : {}), ts: Date.now(), state: 'open', files: list, ...(t ? { task: { id: t.id, title: t.title } } : {}) }
|
|
853
|
-
// The session owner is told with a direct message, as from a folder (commit-request.js).
|
|
854
|
-
const note = commitRequestMessage(r, { owner: room.meta && room.meta.ownerName, id: id(), defaultBranch: DEFAULT_KEY })
|
|
855
|
-
doc.transact(() => {
|
|
856
|
-
const map = commitMap(doc)
|
|
857
|
-
map.set(r.id, r)
|
|
858
|
-
if (note) {
|
|
859
|
-
const chat = doc.getArray('chat')
|
|
860
|
-
chat.push([note])
|
|
861
|
-
if (chat.length > 500) chat.delete(0, chat.length - 500)
|
|
862
|
-
}
|
|
863
|
-
const done = [...map.values()].filter((x) => x && x.state === 'done').sort((x, y) => x.ts - y.ts)
|
|
864
|
-
for (const x of done.slice(0, Math.max(0, done.length - 100))) map.delete(x.id)
|
|
865
|
-
}, AGENT)
|
|
866
|
-
return text(`Asked for a commit [${r.id}] of ${list.length} file${list.length === 1 ? '' : 's'}: ${list.slice(0, 12).join(', ')}${list.length > 12 ? ', …' : ''}. ` +
|
|
867
|
-
'A person with git on this branch commits it from their Quilt; you are told the commit when it is made.')
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
if (ctx.kind) {
|
|
871
|
-
// Git, for an agent with no folder: the quilt_git_* tools work on the branch the agent is on (its branch document).
|
|
872
|
-
const whoAmI = (room) => ({ name: me, kind: ctx.kind, access: ctx.access(room) })
|
|
873
|
-
const keyOf = (branch) => branch ? branch.key : DEFAULT_KEY
|
|
874
|
-
|
|
875
|
-
tool('quilt_git_commit', {
|
|
876
|
-
description: GIT_COMMIT_DESCRIPTION,
|
|
877
|
-
inputSchema: gitCommitSchema(z)
|
|
878
|
-
}, async (args, { room, doc, branch }) => {
|
|
879
|
-
{ const w = waitRefusal(doc, 'quilt_git_commit', room); if (w) return fail(w) }
|
|
880
|
-
// A branch that syncs with GitHub through the relay's mirror: your own changes (or the files named, or the task's)
|
|
881
|
-
// become one commit now, pushed forward only. Others' changes in those files go in only with with_others.
|
|
882
|
-
// An agent the owner's policy keeps off this branch commits to a branch of its own instead (below, with its pull request).
|
|
883
|
-
if (room.outbound && liveSyncOn(room, keyOf(branch)) && !(ctx.kind === 'agent' && agentCommitRefusal(room, keyOf(branch), me))) {
|
|
884
|
-
const key = keyOf(branch)
|
|
885
|
-
const own = ownFiles(room, key, { who: me, files: args.files, task: args.task })
|
|
886
|
-
if (own.refused) return fail(own.refused)
|
|
887
|
-
const t = args.task ? room.doc.getMap('tasks').get(String(args.task)) : null
|
|
888
|
-
const message = String(args.message || (t && t.title) || '').trim().slice(0, 2000)
|
|
889
|
-
if (!message) return fail('Say what the commit does (message).')
|
|
890
|
-
const r = await room.outbound.flush(key, { message, by: me, only: own.list, own: { who: me, withOthers: args.with_others === true } })
|
|
891
|
-
if (r.state === 'others') return fail(`These files also hold changes by others that are not in git yet: ${[...r.others].map(([rel, names]) => `${rel} (${names.join(', ')})`).join('; ')}. Leave them out (files), ask those people to commit first, or commit them together with with_others: true (they are named as co-authors).`)
|
|
892
|
-
const rec = (room.meta.upstreams || {})[key] || {}
|
|
893
|
-
if (r.state === 'pushed') return text(`Pushed ${r.sha.slice(0, 7)} to ${rec.repo || 'GitHub'} ${rec.ref || key}: "${r.subject}" (${r.changed.length} file${r.changed.length === 1 ? '' : 's'}: ${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''})${r.live ? `, through pull request work on ${r.live}` : ''}.`)
|
|
894
|
-
if (r.state === 'nothing') return text(`Nothing to commit: ${own.list.length === 1 ? own.list[0] : 'those files'} on ${key} ${own.list.length === 1 ? 'is' : 'are'} already on GitHub as the session has ${own.list.length === 1 ? 'it' : 'them'}${r.skipped && r.skipped.claimed.length ? ` (held by someone: ${r.skipped.claimed.join(', ')})` : ''}.`)
|
|
895
|
-
if (r.state === 'paused') return fail(`GitHub can't take the commit right now (${rec.problem || r.why}): Quilt keeps it and makes it when GitHub answers.`)
|
|
896
|
-
if (r.state === 'protected') return fail(`${rec.ref || key} is protected on GitHub: ${r.why}`)
|
|
897
|
-
if (r.state === 'refused') return fail(`Not committed: ${r.why}.`)
|
|
898
|
-
return fail(`Could not push ${key} right now (${r.state}${r.why ? `: ${r.why}` : ''}): try quilt_git_commit again in a moment.`)
|
|
899
|
-
}
|
|
900
|
-
if (!room.commitFor) return fail('This relay can\'t commit yet.')
|
|
901
|
-
try {
|
|
902
|
-
const r = await gitCommitFor(room, { who: whoAmI(room), key: keyOf(branch), message: args.message, files: args.files, task: args.task, withOthers: args.with_others === true }, { fetch: room.githubFetch, log: room.log || (() => {}) })
|
|
903
|
-
return text(describeGitCommit(r, { policy: agentCommitsOf(room) }))
|
|
904
|
-
} catch (err) { return fail(err.message) }
|
|
905
|
-
})
|
|
906
|
-
|
|
907
|
-
tool('quilt_clash', {
|
|
908
|
-
description: CLASH_DESCRIPTION,
|
|
909
|
-
inputSchema: clashSchema(z)
|
|
910
|
-
}, async (args, { room, branch }) => {
|
|
911
|
-
try {
|
|
912
|
-
const r = await clashAction(room, { who: { name: me, kind: ctx.kind === 'agent' ? 'agent' : 'human', access: ctx.access(room) }, key: keyOf(branch), action: args.action, path: args.path || '', content: args.content ?? null, checks: args.checks || null, force: args.force === true })
|
|
913
|
-
return r.ok ? text(r.text) : fail(r.text)
|
|
914
|
-
} catch (err) { return fail(err.message) }
|
|
915
|
-
})
|
|
916
|
-
|
|
917
|
-
tool('quilt_git_status', {
|
|
918
|
-
description: GIT_STATUS_DESCRIPTION,
|
|
919
|
-
inputSchema: {}
|
|
920
|
-
}, async (_, { room, branch }) => {
|
|
921
|
-
try { return text(describeGitStatus(await gitStatusFor(room, { who: whoAmI(room), key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
922
|
-
})
|
|
923
|
-
|
|
924
|
-
tool('quilt_git_log', {
|
|
925
|
-
description: GIT_LOG_DESCRIPTION,
|
|
926
|
-
inputSchema: { n: z.number().int().min(1).max(50).optional().describe('How many commits (default 10)') }
|
|
927
|
-
}, async ({ n }, { room, branch }) => {
|
|
928
|
-
try { return text(describeGitLog(await gitLogFor(room, { key: keyOf(branch), n: n || 10 }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
929
|
-
})
|
|
930
|
-
|
|
931
|
-
tool('quilt_git_diff', {
|
|
932
|
-
description: GIT_DIFF_DESCRIPTION,
|
|
933
|
-
inputSchema: { files: z.array(z.string().min(1).max(500)).max(20).optional().describe('The files to compare; default: your uncommitted changes') }
|
|
934
|
-
}, async ({ files: list }, { room, branch }) => {
|
|
935
|
-
try { return text(describeGitDiff(await gitDiffFor(room, { who: whoAmI(room), key: keyOf(branch), files: list }, { fetch: room.githubFetch, lineDiff }))) } catch (err) { return fail(err.message) }
|
|
936
|
-
})
|
|
937
|
-
|
|
938
|
-
tool('quilt_git_revert', {
|
|
939
|
-
description: GIT_REVERT_DESCRIPTION,
|
|
940
|
-
inputSchema: { files: z.array(z.string().min(1).max(500)).min(1).max(20).describe('The files to put back as GitHub has them') }
|
|
941
|
-
}, async ({ files: list }, { room, doc, fdoc, branch, files, blobs, activity }) => {
|
|
942
|
-
{ const w = waitRefusal(doc, 'quilt_git_revert', room); if (w) return fail(w) }
|
|
943
|
-
const err = writable(room) || branchWritable(room, branch)
|
|
944
|
-
if (err) return fail(err)
|
|
945
|
-
const a = ctx.access(room)
|
|
946
|
-
if (a && a.role === 'viewer') return fail('You can only view this session; file changes are refused.')
|
|
947
|
-
try {
|
|
948
|
-
const d = await gitDiffFor(room, { who: whoAmI(room), key: keyOf(branch), files: list }, { fetch: room.githubFetch, lineDiff })
|
|
949
|
-
const { githubRepoOf, credentialsFor } = await import('./relay-commit.js')
|
|
950
|
-
const { githubWriter } = await import('./github-commit.js')
|
|
951
|
-
const { repo } = githubRepoOf(room, keyOf(branch))
|
|
952
|
-
const gh = githubWriter({ fetch: room.githubFetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
|
|
953
|
-
const done = []; const skipped = []
|
|
954
|
-
for (const f of d.files) {
|
|
955
|
-
const rel = cleanPath(f.path)
|
|
956
|
-
if (f.note === 'same') { skipped.push(`${rel} (already as on GitHub)`); continue }
|
|
957
|
-
if (f.note) { skipped.push(`${rel} (${f.note})`); continue }
|
|
958
|
-
const refusal = a && changeRefusal(a, rel)
|
|
959
|
-
if (refusal) { skipped.push(`${rel} (${refusal})`); continue }
|
|
960
|
-
const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
|
|
961
|
-
if (claim) { skipped.push(`${rel} (held by ${claim.by})`); continue }
|
|
962
|
-
const was = d.head ? await gh.content(d.head, rel) : null
|
|
963
|
-
const diff = branch && isDiff(branch)
|
|
964
|
-
let fromGit = null
|
|
965
|
-
if (diff && !hasEntry(branch, rel) && !branch.gone.has(rel)) { const r = await readPath(room, branch.key, branch, rel); fromGit = r.kind === 'text' ? r.text : null }
|
|
966
|
-
fdoc.transact(() => doc.transact(() => {
|
|
967
|
-
const existed = files.has(rel) || blobs.has(rel) || fromGit !== null
|
|
968
|
-
let before = fromGit || ''
|
|
969
|
-
if (was === null || was === undefined) {
|
|
970
|
-
const y = files.get(rel); if (y) before = y.toString()
|
|
971
|
-
if (diff) removePath(branch, rel, { by: me, base: baseOf(room, branch.key, branch) })
|
|
972
|
-
else { files.delete(rel); blobs.delete(rel) }
|
|
973
|
-
} else {
|
|
974
|
-
blobs.delete(rel)
|
|
975
|
-
let ytext = files.get(rel)
|
|
976
|
-
if (!ytext) { ytext = new Y.Text(); if (fromGit) ytext.insert(0, fromGit); files.set(rel, ytext) }
|
|
977
|
-
if (diff) notePresent(branch, rel, { base: baseOf(room, branch.key, branch) })
|
|
978
|
-
before = ytext.toString()
|
|
979
|
-
applyTextDiff(ytext, was.toString('utf8'))
|
|
980
|
-
}
|
|
981
|
-
const kind = was == null ? 'deleted' : existed ? 'edited' : 'created'
|
|
982
|
-
activity.push([{ by: me, path: rel, kind, detail: `reverted to GitHub (${d.against})`, ...(branch ? { branch: branch.key } : {}), ts: Date.now() }])
|
|
983
|
-
if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
|
|
984
|
-
historyOf(room).record({ by: me, path: rel, kind, before, after: was == null ? '' : was.toString('utf8'), task: currentTask(readTasks(taskMap(doc)), me) })
|
|
985
|
-
}, AGENT), AGENT)
|
|
986
|
-
done.push(rel)
|
|
987
|
-
}
|
|
988
|
-
return text(`${done.length ? `Put back as GitHub's ${d.against} has ${done.length === 1 ? 'it' : 'them'}: ${done.join(', ')}.` : 'Nothing reverted.'}${skipped.length ? ` Left alone: ${skipped.join('; ')}.` : ''}`)
|
|
989
|
-
} catch (err) { return fail(err.message) }
|
|
990
|
-
})
|
|
991
|
-
|
|
992
|
-
tool('quilt_git_pull_requests', {
|
|
993
|
-
description: PULLS_DESCRIPTION,
|
|
994
|
-
inputSchema: {}
|
|
995
|
-
}, async (_, { room, branch }) => {
|
|
996
|
-
try { return text(describePulls(await pullRequestsFor(room, { key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
997
|
-
})
|
|
998
|
-
|
|
999
|
-
tool('quilt_git_merge', {
|
|
1000
|
-
description: MERGE_DESCRIPTION,
|
|
1001
|
-
inputSchema: mergeSchema(z)
|
|
1002
|
-
}, async (args, { room, doc, branch }) => {
|
|
1003
|
-
{ const w = waitRefusal(doc, 'quilt_git_merge', room); if (w) return fail(w) }
|
|
1004
|
-
try {
|
|
1005
|
-
const r = await mergeForMember(room, { who: whoAmI(room), key: keyOf(branch), number: args.number, branch: args.branch, method: args.method, deleteBranch: args.delete_branch, force: args.force === true }, { fetch: room.githubFetch, log: () => {} })
|
|
1006
|
-
return text(describeMerge(r))
|
|
1007
|
-
} catch (err) { return fail(err.message) }
|
|
1008
|
-
})
|
|
1009
|
-
|
|
1010
|
-
tool('quilt_agent_commits', {
|
|
1011
|
-
description: 'Session owner only: git for this session, for everyone in it (people and agents push with git through one GitHub connection, nothing for them to set up). "off": off for everyone (nobody pushes through Quilt; agents ask a person with quilt_request_commit, who commits from the app); "branches" (the default): on, agents push to branches of their own, quilt/<agent>/…, with pull requests for people to merge; "any": on, agents push to any branch, the session\'s own included. People push any branch while it is on. A rule its org, team or workspace sets is a ceiling: you may be stricter, never looser. For a hard limit on main, protect the branch on GitHub.',
|
|
1012
|
-
inputSchema: { mode: z.enum(['off', 'branches', 'any']) }
|
|
1013
|
-
}, ({ mode }, { room }) => {
|
|
1014
|
-
if (!ctx.access(room)?.owner) return fail('Only the session owner decides whether git is on here.')
|
|
1015
|
-
try { return text(`Now ${POLICY_WORDS[room.setAgentCommits ? room.setAgentCommits(mode) : mode]}.`) } catch (err) { return fail(err.message) }
|
|
1016
|
-
})
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
tool('quilt_commit_status', {
|
|
1020
|
-
description: 'Is it a good moment to commit? Lists open commit requests and whose AI is still working (not counting yours).',
|
|
1021
|
-
inputSchema: {}
|
|
1022
|
-
}, (_, { room, doc }) => text(describeCommits(commitState(room, doc))))
|
|
1023
|
-
|
|
1024
|
-
tool('quilt_commit_request_done', {
|
|
1025
|
-
description: 'Close commit requests whose work was committed with git by hand (or isn\'t needed any more). Without an id, every open request is closed.',
|
|
1026
|
-
inputSchema: {
|
|
1027
|
-
id: z.string().max(40).optional().describe('One request id; omit for all open ones'),
|
|
1028
|
-
hash: z.string().max(64).optional().describe('The commit that did it, if you know it')
|
|
1029
|
-
}
|
|
1030
|
-
}, ({ id: one, hash }, { room, doc }) => {
|
|
1031
|
-
{ const w = waitRefusal(doc, 'quilt_commit_request_done', room); if (w) return fail(w) }
|
|
1032
|
-
const err = writable(room)
|
|
1033
|
-
if (err) return fail(err)
|
|
1034
|
-
const open = [...commitMap(doc).values()].filter((r) => r && r.state === 'open' && (!one || r.id === one))
|
|
1035
|
-
if (!open.length) return text(one ? `No open commit request ${one}.` : 'No open commit requests.')
|
|
1036
|
-
const h = typeof hash === 'string' && /^[0-9a-f]{7,64}$/.test(hash) ? hash : ''
|
|
1037
|
-
doc.transact(() => { for (const r of open) commitMap(doc).set(r.id, { ...r, state: 'done', doneBy: me, hash: h, doneAt: Date.now() }) }, AGENT)
|
|
1038
|
-
return text(`Marked ${open.length} commit request${open.length === 1 ? '' : 's'} done.`)
|
|
1039
|
-
})
|
|
1040
|
-
|
|
1041
753
|
tool('quilt_list_files', {
|
|
1042
754
|
description: 'List the shared project files with who is working on them (claims).',
|
|
1043
755
|
inputSchema: { under: z.string().optional().describe('Only files under this folder') }
|
|
@@ -1166,10 +878,24 @@ function sessionTools (server, ctx) {
|
|
|
1166
878
|
return text(`Claimed ${pattern.trim()}. Release it with quilt_release when done.`)
|
|
1167
879
|
})
|
|
1168
880
|
|
|
881
|
+
const releaseHostedInSeam = async (room, pattern) => {
|
|
882
|
+
const quilt = quiltName(room) && room.meta.quilt_id
|
|
883
|
+
const access = ctx.seam && ctx.seam()
|
|
884
|
+
if (!quilt || !access) return fail(seamFileRefusal(quiltName(room)))
|
|
885
|
+
try {
|
|
886
|
+
const asked = room.claimRequest(who(room), { op: 'quilt-claims' })
|
|
887
|
+
const { gone, ...r } = await releaseInSeam({ access, api: process.env.QUILT_SOURCE_URL, quilt, asked, pattern: pattern ? pattern.trim() : '*' })
|
|
888
|
+
if (gone.length) room.claimRequest(who(room), { op: 'quilt-released', ids: gone })
|
|
889
|
+
return text(releaseWords(r, { cli: 'quilt_release' }))
|
|
890
|
+
} catch (err) { return fail(err.message) }
|
|
891
|
+
}
|
|
892
|
+
|
|
1169
893
|
tool('quilt_release', {
|
|
1170
894
|
description: 'Release a claim (or all of your claims when no pattern is given).',
|
|
1171
895
|
inputSchema: { pattern: z.string().optional() }
|
|
1172
896
|
}, ({ pattern }, { room }) => {
|
|
897
|
+
// At layout 3 the claims are Seam's: the agent lets go of its own there, as itself.
|
|
898
|
+
if (room.meta.layout === LAYOUT_QUILT) return releaseHostedInSeam(room, pattern)
|
|
1173
899
|
// Releasing everything keeps boxes: a box opens when it's committed (quilt_git_commit) or unpacked (quilt_unpack).
|
|
1174
900
|
const mine = claimsOf(room).filter((c) => c.by === me && (pattern ? c.pattern === pattern.trim() : !c.box))
|
|
1175
901
|
// A file someone waits for is handed off, not released.
|
|
@@ -1430,6 +1156,13 @@ export function hostedWorkspaceAccess ({ apiUrl, pass, fetch: fetchImpl = global
|
|
|
1430
1156
|
return { call, fetchBytes: bytesFetcher(fetchImpl, { timeoutMs: WORKSPACE_TRANSFER_TIMEOUT_MS }), put, saveDir: null, readLocal: null }
|
|
1431
1157
|
}
|
|
1432
1158
|
|
|
1159
|
+
/** A request's JSON body, read here (the transport takes it parsed). */
|
|
1160
|
+
async function readJsonBody (req) {
|
|
1161
|
+
const chunks = []
|
|
1162
|
+
for await (const c of req) chunks.push(c)
|
|
1163
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1433
1166
|
async function serve (mcp, req, res, body) {
|
|
1434
1167
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
1435
1168
|
res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
|
|
@@ -1446,11 +1179,30 @@ async function serve (mcp, req, res, body) {
|
|
|
1446
1179
|
*/
|
|
1447
1180
|
export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, secrets = null, body = undefined }) {
|
|
1448
1181
|
const image = String(req.headers['x-quilt-image'] || '').trim()
|
|
1449
|
-
// The library's guide is part of the instructions only when its tools are offered.
|
|
1450
|
-
const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${HOSTED_INSTRUCTIONS}\n\n${WORKSPACE_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS) })
|
|
1451
1182
|
const me = pass.name
|
|
1452
1183
|
const account = `${pass.kind}:${pass.sub}`
|
|
1453
1184
|
const touched = new Set()
|
|
1185
|
+
// Whether the agent's sessions on this machine are quilts (some, all): an agent whose sessions all
|
|
1186
|
+
// are reads only Seam's way, with no git in its instructions or tool list (session-seam.js).
|
|
1187
|
+
const quilts = (() => {
|
|
1188
|
+
let any = false; let all = true; let seen = 0
|
|
1189
|
+
for (const name of hostedRooms(relay.hosted.get(account))) {
|
|
1190
|
+
if (relay.roomEnded(name)) continue
|
|
1191
|
+
const room = relay.getRoom(name)
|
|
1192
|
+
if (!room || !room.exists) continue
|
|
1193
|
+
touched.add(room)
|
|
1194
|
+
seen++
|
|
1195
|
+
if (quiltOf(room.meta)) any = true; else all = false
|
|
1196
|
+
}
|
|
1197
|
+
return { any, all: any && all && seen > 0 }
|
|
1198
|
+
})()
|
|
1199
|
+
// The library's guide is part of the instructions only when its tools are offered.
|
|
1200
|
+
const base = quilts.all ? `${seamOnly(HOSTED_INSTRUCTIONS)} In a session that is a quilt: ${SEAM_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS
|
|
1201
|
+
const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${base}\n\n${WORKSPACE_GUIDE_HOSTED}` : base) })
|
|
1202
|
+
// Every tool it registers, so the git-era ones can be left out for an agent whose sessions are all quilts.
|
|
1203
|
+
const registered = new Map()
|
|
1204
|
+
const register = mcp.registerTool.bind(mcp)
|
|
1205
|
+
mcp.registerTool = (name, spec, handler) => { const t = register(name, spec, handler); registered.set(name, t); return t }
|
|
1454
1206
|
const done = () => { for (const room of touched) if (room && !room.conns.size && room.onEmpty) room.onEmpty() }
|
|
1455
1207
|
res.on('close', done)
|
|
1456
1208
|
// Which sessions the agent is in, for the accounts API (x-quilt-rooms), and which one this
|
|
@@ -1548,6 +1300,8 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1548
1300
|
multi: true, // every tool takes `session`
|
|
1549
1301
|
who: () => ({ name: me, id: account }),
|
|
1550
1302
|
kind: pass.kind === 'agent' ? 'agent' : 'human',
|
|
1303
|
+
// This agent to Seam (its pass, traded for an agent token), for what it does there as itself.
|
|
1304
|
+
seam: () => process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && req.headers['x-quilt-pass'] ? hostedSeam(account, String(req.headers['x-quilt-pass'])) : null,
|
|
1551
1305
|
access: (room) => room.hostedAccess(pass),
|
|
1552
1306
|
branch: (room) => room.hostedBranch(account),
|
|
1553
1307
|
setBranch: (room, key, opts) => room.setHostedBranch(account, key, opts),
|
|
@@ -1588,6 +1342,14 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1588
1342
|
} : null,
|
|
1589
1343
|
updates: relay.updates || null,
|
|
1590
1344
|
image: () => image,
|
|
1345
|
+
// Whether this agent is yet to be told the Seam way in `room` (once per quilt, kept with its record there).
|
|
1346
|
+
seamOnce: (room) => {
|
|
1347
|
+
const r = hostedIn(relay.hosted.get(account), room.name)
|
|
1348
|
+
if (!r || !room.meta.quilt_id || r.seamTold === room.meta.quilt_id) return false
|
|
1349
|
+
r.seamTold = room.meta.quilt_id
|
|
1350
|
+
relay.saveHosted()
|
|
1351
|
+
return true
|
|
1352
|
+
},
|
|
1591
1353
|
withSession: (fn, session) => {
|
|
1592
1354
|
const c = current(session)
|
|
1593
1355
|
return c.error ? fail(c.error) : fn(c.room)
|
|
@@ -1678,88 +1440,6 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1678
1440
|
return text(`Left room ${name}.${left.length ? ` You are still in ${left.join(', ')}.` : ''}`)
|
|
1679
1441
|
})
|
|
1680
1442
|
|
|
1681
|
-
mcp.registerTool('quilt_git_branch', {
|
|
1682
|
-
description: 'Work on another branch of the session: your file tools, claims and history then read and write that branch. quilt_status lists the branches and who is on each. create: true adds a branch that isn\'t in the session yet: one that is on GitHub is loaded at its latest commit (and the relay brings later commits on it in); any other starts from a copy of the files on the one you are on.',
|
|
1683
|
-
inputSchema: {
|
|
1684
|
-
branch: z.string().min(1).max(200).optional().describe('The branch to work on, e.g. quilt/<you>/<topic> or feature/login; without it, the session\'s branches are listed'),
|
|
1685
|
-
create: z.boolean().optional().describe('Add it to the session: from GitHub when it is there, otherwise a copy of your current branch\'s files'),
|
|
1686
|
-
session: SESSION_ARG
|
|
1687
|
-
}
|
|
1688
|
-
}, async ({ branch, create, session }) => {
|
|
1689
|
-
const c = current(session)
|
|
1690
|
-
if (c.error) return fail(c.error)
|
|
1691
|
-
{ const no = seamRefusal('quilt_git_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
|
|
1692
|
-
const room = c.room
|
|
1693
|
-
if (!branch) {
|
|
1694
|
-
const members = []
|
|
1695
|
-
for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
|
|
1696
|
-
const br = branchBoard(members, room.branchList ? room.branchList() : [])
|
|
1697
|
-
return text(br.length ? `${branchesMarkdown(br, { limit: 20, mine: room.hostedBranch(account) })}\nSwitch with quilt_git_branch and a branch name; create: true adds one.` : 'This session has one branch, and you are on it.')
|
|
1698
|
-
}
|
|
1699
|
-
const key = String(branch).trim()
|
|
1700
|
-
const editor = ctx.access(room)?.role !== 'viewer'
|
|
1701
|
-
let gh = null
|
|
1702
|
-
try {
|
|
1703
|
-
if (create && validBranchKey(key) && key !== DEFAULT_KEY && !room.meta.branches[room.resolveKey(key)]) {
|
|
1704
|
-
room.assertMigrated()
|
|
1705
|
-
room.assertBranchRoom(editor, 0) // refused before asking GitHub anything
|
|
1706
|
-
gh = await room.branchFromGitHub(account, key)
|
|
1707
|
-
if (!(gh && gh.state === 'loaded') && room.readyToCut) await room.readyToCut(account)
|
|
1708
|
-
}
|
|
1709
|
-
const r = room.setHostedBranch(account, key, { create: !!create, by: me, editor, github: gh && gh.state === 'loaded' ? gh : null })
|
|
1710
|
-
if (r.created && gh && gh.state === 'loaded' && gh.diff) return text(`Started ${r.branch} from GitHub (${gh.repo} at ${gh.head.slice(0, 7)}). Your file tools, claims and history use ${r.branch} now: files you haven't changed are read from git. People work on it with \`git checkout ${r.branch}\`, and their folder follows. The relay brings new commits on ${gh.upstream.name} in while no folder on it can; quilt_sync_branch asks for them now.`)
|
|
1711
|
-
if (r.created && gh && gh.state === 'loaded') {
|
|
1712
|
-
const n = gh.texts.size + gh.blobs.size
|
|
1713
|
-
const left = gh.skipped.length ? ` ${gh.skipped.length} file${gh.skipped.length === 1 ? ' was' : 's were'} too large for the relay and left out (${gh.skipped.slice(0, 5).join(', ')}${gh.skipped.length > 5 ? ', …' : ''}): a member's folder on it brings ${gh.skipped.length === 1 ? 'it' : 'them'} in.` : ''
|
|
1714
|
-
return text(`Loaded ${r.branch} from GitHub (${gh.repo} at ${gh.head.slice(0, 7)}, ${n} file${n === 1 ? '' : 's'}).${left} Your file tools, claims and history use ${r.branch} now. People work on it with \`git checkout ${r.branch}\`, and their folder follows. The relay brings new commits on ${gh.upstream.name} in while no folder on it can; quilt_sync_branch asks for them now.`)
|
|
1715
|
-
}
|
|
1716
|
-
if (r.created) {
|
|
1717
|
-
const why = !gh ? '' : {
|
|
1718
|
-
'not-github': `The session's repository (${gh.repo}) isn't on GitHub, so the relay can't load ${r.branch} from it: `,
|
|
1719
|
-
'no-branch': `${r.branch} isn't a branch on GitHub (${gh.repo}), so `,
|
|
1720
|
-
private: `The relay can't read ${gh.repo} on GitHub (a private repository needs a read-only token: ask the session owner to add one), so `,
|
|
1721
|
-
'token-refused': `The relay's GitHub token can't read ${gh.repo} (ask the session owner to give it read access), so `,
|
|
1722
|
-
'too-many': `${r.branch} on GitHub has more files than the relay loads at once (${LOAD_FILES}), so `,
|
|
1723
|
-
backoff: 'GitHub asked the relay to slow down, so ',
|
|
1724
|
-
error: `The relay couldn't read ${r.branch} from GitHub (${gh.message || 'no answer'}), so `
|
|
1725
|
-
}[gh.state] || ''
|
|
1726
|
-
const said = `Started ${r.branch} from ${r.from}, with a copy of its files. Your file tools, claims and history use ${r.branch} now. It isn't in git yet: people work on it with \`git checkout -b ${r.branch}\` (or \`git checkout ${r.branch}\` once it is pushed), and their folder follows.`
|
|
1727
|
-
return text(why ? `${why}${said[0].toLowerCase()}${said.slice(1)}` : said)
|
|
1728
|
-
}
|
|
1729
|
-
return text(`You are on ${r.branch} now: your file tools, claims and history use it.`)
|
|
1730
|
-
} catch (e) { return fail(e.message) }
|
|
1731
|
-
})
|
|
1732
|
-
|
|
1733
|
-
mcp.registerTool('quilt_sync_branch', {
|
|
1734
|
-
description: 'Bring commits made outside the session (a PR merged on GitHub, a push) into your branch now. A member\'s folder on the branch that can fetch does this about once a minute; otherwise the relay asks GitHub itself, about every 10 minutes, and this asks it now (at most once a minute per branch). It never merges git history: files that clash with the session\'s work become one task on the board for one agent to merge.',
|
|
1735
|
-
inputSchema: { session: SESSION_ARG }
|
|
1736
|
-
}, async ({ session }) => {
|
|
1737
|
-
const c = current(session)
|
|
1738
|
-
if (c.error) return fail(c.error)
|
|
1739
|
-
{ const no = seamRefusal('quilt_sync_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
|
|
1740
|
-
const room = c.room
|
|
1741
|
-
if (room.meta.layout !== 2 || !room.syncUpstreamNow) return fail('This session has no branches on the relay.')
|
|
1742
|
-
try {
|
|
1743
|
-
const key = room.hostedBranch(account)
|
|
1744
|
-
const r = await room.syncUpstreamNow(key)
|
|
1745
|
-
return text(r.said)
|
|
1746
|
-
} catch (e) { return fail(e.message) }
|
|
1747
|
-
})
|
|
1748
|
-
|
|
1749
|
-
mcp.registerTool('quilt_github_token', {
|
|
1750
|
-
description: 'Session owner only: set (or clear, with an empty token) the GitHub token the relay uses to bring commits in from a private repository while no folder can, to load branches from it, and, when it can write, to commit members\' work (quilt_commit). A fine-grained token for the repository with "Contents: Read" brings commits in; "Contents: Read and write" and "Pull requests: Read and write" let agents commit and open pull requests, within what quilt_agent_commits allows. It stays on the relay: it is never shown again, in any answer, status or log.',
|
|
1751
|
-
inputSchema: { token: z.string().max(300).describe('The token (github_pat_… or ghp_…), or "" to remove it'), session: SESSION_ARG }
|
|
1752
|
-
}, ({ token, session }) => {
|
|
1753
|
-
const c = current(session)
|
|
1754
|
-
if (c.error) return fail(c.error)
|
|
1755
|
-
{ const no = seamRefusal('quilt_github_token', quiltOf(c.room.meta)); if (no) return fail(no) }
|
|
1756
|
-
if (!c.access || !c.access.owner) return fail('Only the session owner can set the GitHub token: ask them.')
|
|
1757
|
-
try {
|
|
1758
|
-
const r = c.room.setGithubToken(token)
|
|
1759
|
-
return text(r.githubToken ? 'Saved the GitHub token on the relay. It looks at the session\'s branches again now.' : 'Removed the GitHub token.')
|
|
1760
|
-
} catch (e) { return fail(e.message) }
|
|
1761
|
-
})
|
|
1762
|
-
|
|
1763
1443
|
// Secrets: names only. A hosted agent runs no commands, so it uses none (the answer says so).
|
|
1764
1444
|
mcp.registerTool('quilt_secret_names', {
|
|
1765
1445
|
description: `${SECRET_NAMES_DESCRIPTION} You are a hosted agent: you run no commands, so you can't use secrets yourself; this tells you which exist.`,
|
|
@@ -1808,11 +1488,53 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
|
|
|
1808
1488
|
}
|
|
1809
1489
|
|
|
1810
1490
|
sessionTools(mcp, ctx)
|
|
1491
|
+
// Seam's tools for an agent in a session that is a quilt (session-seam.js), as itself: its
|
|
1492
|
+
// pass, traded once for an agent token of its own (seam-access.js). Only while one of its
|
|
1493
|
+
// sessions here is a quilt, so every other agent's tool list stays as it was.
|
|
1494
|
+
const rawPass = String(req.headers['x-quilt-pass'] || '')
|
|
1495
|
+
// Hidden from the list, but a call still answers with the Seam way: an agent's client keeps the
|
|
1496
|
+
// list it read before its session moved into Seam, and a hidden tool would answer only "disabled".
|
|
1497
|
+
if (quilts.all) {
|
|
1498
|
+
if (body === undefined && req.method === 'POST') {
|
|
1499
|
+
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 }
|
|
1500
|
+
}
|
|
1501
|
+
const calls = [].concat(body || []).some((m) => m && m.method === 'tools/call')
|
|
1502
|
+
if (!calls) for (const [name, t] of registered) if (GIT_TOOLS.has(name)) t.disable()
|
|
1503
|
+
}
|
|
1504
|
+
if (process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && rawPass && quilts.any) {
|
|
1505
|
+
const seam = hostedSeam(account, rawPass)
|
|
1506
|
+
for (const name of SEAM_SESSION_TOOLS) {
|
|
1507
|
+
const spec = seamToolSpec(name, { hosted: true })
|
|
1508
|
+
mcp.registerTool(name, { ...spec, inputSchema: spec.inputSchema.extend({ session: SESSION_ARG }) }, async ({ session, ...args } = {}) => {
|
|
1509
|
+
const c = current(session)
|
|
1510
|
+
if (c.error) return fail(c.error)
|
|
1511
|
+
const quilt = quiltOf(c.room.meta)
|
|
1512
|
+
if (!quilt) return fail('This session is not a quilt: its source control is git, as quilt_status says.')
|
|
1513
|
+
let client
|
|
1514
|
+
try { client = (await seam.client(quilt)).client } catch (e) { return fail(e.message) }
|
|
1515
|
+
return runSessionSeamTool(name, args, { client, quilt, hosted: true })
|
|
1516
|
+
})
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1811
1519
|
// The library works whether or not the agent is in a session (it never touches relay.hosted).
|
|
1812
1520
|
if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
|
|
1813
1521
|
await serve(mcp, req, res, body)
|
|
1814
1522
|
}
|
|
1815
1523
|
|
|
1524
|
+
// Each hosted agent's way into Seam, kept between its requests (each is a new MCP server): the
|
|
1525
|
+
// agent token it traded its pass for lasts hours, while passes last minutes.
|
|
1526
|
+
const hostedSeams = new Map() // account -> { access, pass }
|
|
1527
|
+
function hostedSeam (account, pass) {
|
|
1528
|
+
let e = hostedSeams.get(account)
|
|
1529
|
+
if (!e) {
|
|
1530
|
+
e = { pass }
|
|
1531
|
+
e.access = seamAccess({ api: () => process.env.QUILT_SOURCE_URL, credential: async () => `QuiltPass ${e.pass}` })
|
|
1532
|
+
hostedSeams.set(account, e)
|
|
1533
|
+
}
|
|
1534
|
+
e.pass = pass
|
|
1535
|
+
return e.access
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1816
1538
|
/**
|
|
1817
1539
|
* Handles one MCP request (stateless: a fresh server per request) for the older
|
|
1818
1540
|
* link-based flow: a browser's token tied to a room by /agent/link.
|