heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/relay-mcp.js
ADDED
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
// MCP servers hosted by the relay, working directly on a room's shared document.
|
|
2
|
+
//
|
|
3
|
+
// Hosted agents (handleHostedMcp): an AI with no computer of its own, or one that
|
|
4
|
+
// can't install Quilt, connects to https://<relay>/mcp with a pass from the accounts
|
|
5
|
+
// API (the API's /mcp hands it over with the agent's access key). It joins a session
|
|
6
|
+
// from an invite link, waits for the owner like anyone else, and then reads and
|
|
7
|
+
// writes the shared files, shares what it's doing, messages and claims files. Its
|
|
8
|
+
// file changes are CRDT edits, so they merge with everyone else's.
|
|
9
|
+
//
|
|
10
|
+
// The older link-based server (handleAgentMcp) is kept for relays without sign-in.
|
|
11
|
+
import { projectRules, renderRules } from './project-rules.js'
|
|
12
|
+
import { cleanGit, branchBoard, branchesMarkdown } from './branches.js'
|
|
13
|
+
import crypto from 'node:crypto'
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
15
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import * as Y from 'yjs'
|
|
18
|
+
import { capText, toolLabel } from './agents/common.js'
|
|
19
|
+
import { globMatcher, isSafeRelPath } from './pathrules.js'
|
|
20
|
+
import { readTasks, addTask, updateTask, deleteTask, taskMarkdown, formatTasks, columnName, assigneeLabel, assignmentFields, COLUMNS } from './tasks.js'
|
|
21
|
+
import { readComments, addComment, withComments, formatTaskDetails, MAX_COMMENT } from './task-comments.js'
|
|
22
|
+
import { applyTextDiff } from './textdiff.js'
|
|
23
|
+
import { parseInvite } from './ui/invite.js'
|
|
24
|
+
import { scanInbox, renderInbox } from './inbox.js'
|
|
25
|
+
import { withContext, queryConversation, renderConversation, CONVERSATION_DESCRIPTION } from './conversation.js'
|
|
26
|
+
import { cleanFiles, describeCommitRequests, REQUEST_COMMIT_DESCRIPTION, MAX_REQUEST_FILES } from './commit.js'
|
|
27
|
+
import { commitRequestMessage } from './commit-request.js'
|
|
28
|
+
import { GIT_COMMIT_DESCRIPTION, gitCommitSchema, agentPrefix, slug } from './github-commit.js'
|
|
29
|
+
import { liveSyncOn, agentCommitRefusal, ownFiles } from './relay-outbound.js'
|
|
30
|
+
import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
|
|
31
|
+
import { clashAction, heldForClash, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
32
|
+
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'
|
|
33
|
+
import { UpdateCheck } from './update-check.js'
|
|
34
|
+
import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
|
|
35
|
+
import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff } from './history.js'
|
|
36
|
+
import { changeRefusal, TALK_REFUSED } from './session-access.js'
|
|
37
|
+
import { aiName } from './persona.js'
|
|
38
|
+
import { chatAbout, renderChatAbout, waitingOn, needsNoReply, renderUnanswered, heldRefusal, queuedFor, renderQueueNotice, renderQueued, answered, unaddressed, CHAT_RULES, RE_HELP, replyFor, renderReplied } from './duties.js'
|
|
39
|
+
import { describeSubscription, WEBHOOK_EVENTS } from './webhooks.js'
|
|
40
|
+
import { registerWorkspaceTools, bytesFetcher, WORKSPACE_GUIDE_HOSTED } from './workspace-tools.js'
|
|
41
|
+
import { DEFAULT_KEY, validBranchKey } from './branchdocs.js'
|
|
42
|
+
import { LOAD_FILES } from './relay-upstream.js'
|
|
43
|
+
import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
|
|
44
|
+
import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
|
|
45
|
+
|
|
46
|
+
const FEED_CAP = 300
|
|
47
|
+
const ACTIVITY_CAP = 300
|
|
48
|
+
const TAB_STALE_MS = 3 * 60 * 1000
|
|
49
|
+
const MAX_WRITE_BYTES = 1024 * 1024
|
|
50
|
+
const HOSTED_AUTO_CLAIM_QUIET_MS = 10 * 60 * 1000 // a file a hosted agent stopped writing this long ago is let go of
|
|
51
|
+
const MAX_READ_CHARS = 200 * 1024
|
|
52
|
+
// Tools that read or change a branch's files, claims or history: a hosted agent's first one pins its branch (Room.pinHostedBranch).
|
|
53
|
+
const FILE_TOOLS = new Set(['quilt_sync', 'quilt_history', 'quilt_list_files', 'quilt_read_file', 'quilt_project_rules', 'quilt_write_file', 'quilt_claim', 'quilt_release', 'quilt_unpack', 'quilt_request_file', 'quilt_handoff', 'quilt_withdraw_request'])
|
|
54
|
+
const AGENT = 'agent-mcp' // transaction origin
|
|
55
|
+
// The board's columns, from tasks.js: hosted agents move tasks to every one of them (QA included), like the local MCP.
|
|
56
|
+
const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
|
|
57
|
+
|
|
58
|
+
export const INSTRUCTIONS =
|
|
59
|
+
'You are in a live quilt session: other people, each with their own AI, are editing this same project right now, ' +
|
|
60
|
+
'and their changes appear in your files as they happen. ' +
|
|
61
|
+
'When your user asks for something, call quilt_share with their request and a short plan before you start, and call it ' +
|
|
62
|
+
'again with a short summary when you finish, so collaborators can follow along. ' +
|
|
63
|
+
'Before starting a task, call quilt_status to see who is working on what, and quilt_tasks for the shared board ' +
|
|
64
|
+
'(open tasks assigned to you are listed first). Assign work with quilt_assign_task; quilt_task reads one task in full with its comments, and quilt_comment_task leaves a note or handoff on it. ' +
|
|
65
|
+
'quilt_history tells you who changed which file, when, with the diff: read it for the files you are about to touch. ' +
|
|
66
|
+
'Claims follow your edits: a file you change that nobody holds is claimed for you until you finish. ' +
|
|
67
|
+
'Do not edit files someone else has claimed: ask for the file in its file queue with quilt_request_file (a title and up to 300 characters on what you plan), and you are handed it with context when they are done. ' +
|
|
68
|
+
'When someone waits in the queue for a file you hold, finish your change and hand it off with quilt_handoff and your context; you cannot finish or release it before. Claim ahead only for a larger change across several files. Always re-read a file right before editing it. ' +
|
|
69
|
+
TASK_WORKFLOW
|
|
70
|
+
|
|
71
|
+
export const HOSTED_INSTRUCTIONS =
|
|
72
|
+
'You are an AI agent in Quilt, where people and agents build one project together in real time. ' +
|
|
73
|
+
'Join a session with quilt_join_session and the invite link you were given; the session owner may have to let you in first ' +
|
|
74
|
+
'(quilt_session_info tells you). Then: quilt_status to see who is doing what, quilt_list_files and quilt_read_file to look ' +
|
|
75
|
+
'around, quilt_write_file to change a file (always read it right before; the file is claimed for you while you work on it: release it with quilt_release when done, unless it is boxed, below), quilt_claim ahead of a larger change across several files, quilt_share to ' +
|
|
76
|
+
'tell everyone what you are doing, and quilt_message to talk. The shared task board is quilt_tasks, quilt_add_task, quilt_assign_task and quilt_move_task; quilt_task reads one in full with its comments, and quilt_comment_task leaves a work note or handoff on it. ' +
|
|
77
|
+
'quilt_history tells you who changed which file, when, with the diff: read it for the files you are about to touch. ' +
|
|
78
|
+
'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. ' +
|
|
79
|
+
'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. ' +
|
|
80
|
+
'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. ' +
|
|
81
|
+
'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. ' +
|
|
82
|
+
'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. ' +
|
|
83
|
+
'This rule is enforced: while someone who messaged or mentioned you waits for an answer, writes, claims and task changes are refused until you answer with quilt_message. ' +
|
|
84
|
+
'Everyone sees your changes on their own disk within moments. ' +
|
|
85
|
+
'Chat: ' + CHAT_RULES.replace(/^Send a chat message\. /, '') + ' A message that needs nothing back is settled with quilt_inbox (no_reply: [its id]), not answered. ' +
|
|
86
|
+
'Mentions of you (@yourname), direct messages and tasks handed to you wait in quilt_inbox, each with the conversation before it with its sender; when a message refers to something earlier you do not have, read back with quilt_conversation before you answer, never guess. To be woken instead of polling, ' +
|
|
87
|
+
'call quilt_webhook_subscribe with a URL of yours: Quilt POSTs each one there as it happens, from every session you are in (and, for a workspace agent, session.started from your workspaces). ' +
|
|
88
|
+
'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. ' +
|
|
89
|
+
'You are connected over HTTP, so you show as online for 30 minutes after each tool call: while idle, call quilt_inbox at least every 30 minutes so people can see you are still there. ' +
|
|
90
|
+
'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. ' +
|
|
91
|
+
'A session has a branch for each git branch its members work on, each with its own files: quilt_status and quilt_branches list them and who is on each, and your first call on files puts you on the busiest one, where you stay until you pick another with quilt_git_branch (create: true loads a branch that is on GitHub but not yet in the session, at its latest commit; any other new branch starts from a copy of the files you have). ' +
|
|
92
|
+
'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. ' +
|
|
93
|
+
'When those commits clash with the session\'s uncommitted work, nothing is brought in and the relay gives one agent a task ("Bring N commits from origin/main into the session"), with each file and what changed upstream in its comments. If it is yours: for each file, quilt_read_file, fold in the upstream change while keeping the session\'s work, and quilt_write_file the merged file without conflict markers; each such write makes the relay look again at once, and the rest of the commits come in when every file merges. If you kept one side whole, move the task to QA once all files are done: the relay then takes the files as merged. The task closes itself. ' +
|
|
94
|
+
'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. ' +
|
|
95
|
+
'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. ' +
|
|
96
|
+
TASK_WORKFLOW
|
|
97
|
+
|
|
98
|
+
const NOT_LINKED = 'Your user is not in a quilt session in their browser right now. Ask them to open quilt in their ' +
|
|
99
|
+
'browser and share a folder or join one from an invite link, then try again. (This link is theirs and works for every session.)'
|
|
100
|
+
const NOT_JOINED = 'You are not in a session. Call quilt_join_session with an invite link (https://join.heyquilt.com/<room>#<secret>).'
|
|
101
|
+
// Every hosted tool takes `session`: which of the agent's sessions it is about (an agent can be in several at once).
|
|
102
|
+
const SESSION_ARG = z.string().min(1).max(64).optional().describe('Which of your sessions this is about (the session id from quilt_session_info, or the `session` in a webhook event or inbox entry). Needed when you are in more than one.')
|
|
103
|
+
const WAITING = 'The session owner has not let you in yet. They see you on their list; call quilt_session_info to check again.'
|
|
104
|
+
const DENIED = 'The session owner did not let you in. Ask them for a new invite and call quilt_join_session again.'
|
|
105
|
+
const REMOVED = 'You are no longer in that session. Ask for a new invite and call quilt_join_session again.'
|
|
106
|
+
// Let in by a grant, but the pass named no room: the accounts API retries with one for the
|
|
107
|
+
// room in x-quilt-room. A client that reaches the relay some other way just calls again.
|
|
108
|
+
const NEEDS_ROOM_PASS = 'Reconnecting you to the session. Call the same tool again.'
|
|
109
|
+
|
|
110
|
+
const id = () => crypto.randomBytes(8).toString('hex')
|
|
111
|
+
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.'
|
|
112
|
+
|
|
113
|
+
/** For a hosted agent: what the relay does about new commits on its branch, and when it last looked; or ''. */
|
|
114
|
+
function relayStatus (room, key) {
|
|
115
|
+
const rec = room.meta && room.meta.upstreams ? room.meta.upstreams[room.resolveKey ? room.resolveKey(key) : key] : null
|
|
116
|
+
if (!rec || !rec.repo) return ''
|
|
117
|
+
const up = rec.name || 'its upstream'
|
|
118
|
+
if (!rec.checkedAt) return `The relay brings commits from ${up} into \`${key}\` while no folder on it is online; it hasn't looked yet.`
|
|
119
|
+
const bits = [`The relay last checked ${up} for new commits ${ago(rec.checkedAt)}.`]
|
|
120
|
+
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)}.`)
|
|
121
|
+
if (rec.clash) bits.push(`Its clash with ${up} is task ${rec.clash.task} on the board.`)
|
|
122
|
+
if (rec.problem) bits.push(`Note: ${rec.problem}.`)
|
|
123
|
+
return bits.join(' ')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The chat `me` can see: public messages, and direct ones to or from them. */
|
|
127
|
+
const chatFor = (doc, me) => doc.getArray('chat').toArray().filter((m) => m && m.id && (!m.to || m.to === me || m.by === me))
|
|
128
|
+
|
|
129
|
+
/** Takes stock of a room for `me`'s inbox: what is already there wakes nobody. */
|
|
130
|
+
function takeStock (doc, me) {
|
|
131
|
+
return { state: scanInbox({ messages: chatFor(doc, me), tasks: readTasks(doc.getMap('tasks')), reader: { name: me, asAi: false, agent: true } }).state }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The link-based server is made anew for every request; a link's inbox lives here between them.
|
|
135
|
+
const linkInboxes = new WeakMap()
|
|
136
|
+
const text = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
137
|
+
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true })
|
|
138
|
+
|
|
139
|
+
function ago (ts) {
|
|
140
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
|
|
141
|
+
if (s < 60) return `${s}s ago`
|
|
142
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
|
143
|
+
return `${Math.round(s / 3600)}h ago`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cleanPath = (p) => String(p || '').trim().replace(/^\.\//, '').replace(/^\/+/, '')
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The tools every relay-hosted MCP offers, on whatever room `ctx.room()` gives now.
|
|
150
|
+
* ctx: { room(), me, who(), access(), tool(), warn() }: `who` is what claims belong to
|
|
151
|
+
* ({ name } or { name, id }), `access` the caller's role and folders, `warn` extra text
|
|
152
|
+
* for status, `withSession(fn)` runs a tool with the current room or explains why not.
|
|
153
|
+
*/
|
|
154
|
+
function sessionTools (server, ctx) {
|
|
155
|
+
// The agent's image (its Quilt version) came with the request; an old one is told to update in every answer.
|
|
156
|
+
const stale = (r) => {
|
|
157
|
+
const n = ctx.updates && ctx.image ? ctx.updates.notice(ctx.image()) : ''
|
|
158
|
+
if (!n || !r || !Array.isArray(r.content)) return r
|
|
159
|
+
return { ...r, content: [...r.content, { type: 'text', text: `⚠️ ${n}` }] }
|
|
160
|
+
}
|
|
161
|
+
// Someone waiting in the file queue for a file this agent holds is told to it with every answer, until it hands the file off.
|
|
162
|
+
const queueNote = (room, r) => {
|
|
163
|
+
const n = renderQueueNotice(queuedFor(claimsOf(room), me))
|
|
164
|
+
if (!n || !r || !Array.isArray(r.content)) return r
|
|
165
|
+
return { ...r, content: [...r.content, { type: 'text', text: `📥 ${n}` }] }
|
|
166
|
+
}
|
|
167
|
+
// Files, claims and history are a branch's: the agent's own (hosted agents can choose one), or the session's active branch.
|
|
168
|
+
const branchOf = (room) => ctx.branch ? ctx.branch(room) : room.activeBranch ? room.activeBranch() : null
|
|
169
|
+
/**
|
|
170
|
+
* An agent without a folder taking a task goes onto a branch of its own for it (quilt/<agent>/<task>,
|
|
171
|
+
* a copy of the default branch's files), as a folder agent's checkout does. Stays on a branch of its own
|
|
172
|
+
* that still has an open task; nothing when agents may not commit, or this relay has no branch documents.
|
|
173
|
+
*/
|
|
174
|
+
const branchForHostedTask = (room, task) => {
|
|
175
|
+
if (ctx.kind !== 'agent' || !ctx.setBranch || !task || !task.title || room.meta.layout !== 2) return null
|
|
176
|
+
if (!agentsCanCommit(room, room.defaultKey)) return null
|
|
177
|
+
const prefix = agentPrefix(me)
|
|
178
|
+
const here = branchOf(room) || room.defaultKey
|
|
179
|
+
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
|
|
180
|
+
let name = `${prefix}${slug(task.title)}`
|
|
181
|
+
for (let n = 2; room.meta.branches[room.resolveKey(name)] && n < 50; n++) name = `${prefix}${slug(task.title)}-${n}`
|
|
182
|
+
try {
|
|
183
|
+
if (here !== room.defaultKey) ctx.setBranch(room, room.defaultKey, {})
|
|
184
|
+
ctx.setBranch(room, name, { create: true, by: me, editor: true })
|
|
185
|
+
return name
|
|
186
|
+
} catch { return null }
|
|
187
|
+
}
|
|
188
|
+
// The branch document the agent works on (its store entry), or null for a room without branch documents.
|
|
189
|
+
// The session's default branch may always start; any other new branch only for someone who may change files.
|
|
190
|
+
const branchEntry = (room) => {
|
|
191
|
+
if (!room.branchDoc) return null
|
|
192
|
+
const key = branchOf(room)
|
|
193
|
+
const editor = room.resolveKey(key) === room.defaultKey || ctx.access(room)?.role !== 'viewer'
|
|
194
|
+
return room.branchDoc(key, { by: me, editor })
|
|
195
|
+
}
|
|
196
|
+
const tool = (name, def, fn) => server.registerTool(name, ctx.multi ? { ...def, inputSchema: { ...(def.inputSchema || {}), session: SESSION_ARG } } : def, async (given) => {
|
|
197
|
+
// Hosted agents are in several sessions at once: `session` says which this call is about.
|
|
198
|
+
const { session, ...args } = given || {}
|
|
199
|
+
return stale(await ctx.withSession((room) => {
|
|
200
|
+
// A hosted agent with no branch of its own is pinned to the active branch by its first call
|
|
201
|
+
// on files, so a read and the write after it never land on two different branches.
|
|
202
|
+
if (ctx.pin && FILE_TOOLS.has(name)) ctx.pin(room)
|
|
203
|
+
// The audit trail: which tool, on what (a path, a pattern or a task id; never contents).
|
|
204
|
+
const caller = ctx.who(room)
|
|
205
|
+
if (caller && caller.id && room.audit) {
|
|
206
|
+
const a = args || {}
|
|
207
|
+
const on = [a.path, a.pattern, a.id, a.under].find((x) => typeof x === 'string' && x)
|
|
208
|
+
room.audit(caller.id, 'tool', on ? `${name} ${on}` : name)
|
|
209
|
+
}
|
|
210
|
+
const doc = room.doc
|
|
211
|
+
const branch = branchEntry(room)
|
|
212
|
+
const fdoc = branch ? branch.doc : doc
|
|
213
|
+
const parts = { room, doc, fdoc, branch, feed: doc.getArray('agentFeed'), chat: doc.getArray('chat'), activity: doc.getArray('activity'), files: fdoc.getMap('files'), blobs: fdoc.getMap('blobs') }
|
|
214
|
+
// Changes the tool makes to the session (files, chat, the board) go in this agent's audit trail.
|
|
215
|
+
const prev = room.auditAs
|
|
216
|
+
room.auditAs = caller && caller.id
|
|
217
|
+
try { return queueNote(room, fn(args || {}, parts)) } finally { room.auditAs = prev }
|
|
218
|
+
}, session))
|
|
219
|
+
})
|
|
220
|
+
if (ctx.updates) {
|
|
221
|
+
server.registerTool('quilt_check_update', {
|
|
222
|
+
description: 'Whether the Quilt you run (your image) is current. Give the version you run (or send it as the x-quilt-image header with every request). An out-of-date image is told to update the app.',
|
|
223
|
+
inputSchema: { image: z.string().max(40).optional().describe('The Quilt version you run, such as 0.3.4') }
|
|
224
|
+
}, ({ image }) => text(ctx.updates.describe(image || (ctx.image ? ctx.image() : undefined))))
|
|
225
|
+
}
|
|
226
|
+
const me = ctx.me
|
|
227
|
+
const writable = (room) => room.full ? 'This session is over its size limit, so nothing new can be saved.' : null
|
|
228
|
+
// A branch over its own size limit takes no new files; the rest of the session carries on.
|
|
229
|
+
const branchWritable = (room, branch) => branch && room.branchFull && room.branchFull(branch.key) ? `${branch.key} is over the session's size limit for one branch, so nothing new can be saved on it. Other branches still take changes. Start a fresh copy with \`git checkout -b <new name>\`: your changes since are kept in your folder.` : null
|
|
230
|
+
// Which branch a file answer is about, when the session has more than one.
|
|
231
|
+
const onBranchNote = (room, branch) => branch && room.meta && Object.keys(room.meta.branches || {}).length > 1 ? ` on ${branch.key}` : ''
|
|
232
|
+
const visible = (m) => m && m.id && (!m.to || m.to === me || m.by === me)
|
|
233
|
+
// The chat this agent may read, kept past the room's newest 500 (chat-archive.js), oldest first.
|
|
234
|
+
const keptChat = (room, chat) => (room && room.chatArchive ? room.chatArchive.with(chat.toArray()) : chat.toArray()).filter(visible)
|
|
235
|
+
const fmtMsg = (m) => `- ${m.by}${m.to ? ` → ${m.to} (direct, id ${m.id}${m.re ? `, answers ${m.re}` : ''})` : ''} (${ago(m.ts)}): ${m.text}${m.file ? ` [file: ${m.file.name}]` : ''}`
|
|
236
|
+
const peers = (room) => {
|
|
237
|
+
const out = []
|
|
238
|
+
for (const s of room.awareness.getStates().values()) if (s && s.name && s.name !== me) out.push(s)
|
|
239
|
+
// AI sessions working through someone's app (persona.js) are members of their own.
|
|
240
|
+
for (const s of room.awareness.getStates().values()) {
|
|
241
|
+
for (const x of (s && Array.isArray(s.personas) ? s.personas : [])) {
|
|
242
|
+
if (x && typeof x.name === 'string' && x.name && x.name !== me && !out.some((p) => p.name === x.name)) out.push({ name: x.name.slice(0, 80), kind: 'agent', tool: typeof x.tool === 'string' ? x.tool.slice(0, 40) : '', focus: typeof x.focus === 'string' ? x.focus.slice(0, 200) : '', persona: true, of: s.name })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
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 })
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
// Each member's folder tells the room its git (branch, upstream, the repository's branches): one list.
|
|
249
|
+
const branchesOf = (room) => {
|
|
250
|
+
const members = []
|
|
251
|
+
for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
|
|
252
|
+
for (const p of peers(room)) if (p.persona) members.push({ name: p.name, git: null, persona: true })
|
|
253
|
+
return branchBoard(members, room.branchList ? room.branchList() : [])
|
|
254
|
+
}
|
|
255
|
+
const claimsOf = (room) => room.claimList ? room.claimList(branchOf(room)) : []
|
|
256
|
+
// Claims are per branch: who the agent is, on the branch it works on.
|
|
257
|
+
const who = (room) => ({ ...ctx.who(room), branch: branchOf(room) })
|
|
258
|
+
// The rules every agent is held to (duties.js), enforced here because hosted agents work through these tools.
|
|
259
|
+
const seen = (doc) => doc.getArray('chat').toArray().filter(visible)
|
|
260
|
+
// Messages this agent settled as needing no reply (quilt_inbox no_reply), kept with its inbox.
|
|
261
|
+
const settledSet = (room) => {
|
|
262
|
+
const box = ctx.inbox ? ctx.inbox(room) : {}
|
|
263
|
+
if (!Array.isArray(box.settled)) box.settled = []
|
|
264
|
+
return new Set(box.settled)
|
|
265
|
+
}
|
|
266
|
+
const waitRefusal = (doc, name, room) => renderUnanswered(waitingOn(seen(doc), me, { agent: true, settled: settledSet(room) }), `call ${name} again`)
|
|
267
|
+
// Everyone this agent could address: who is here and who has been in the chat.
|
|
268
|
+
const memberNames = (room, doc) => {
|
|
269
|
+
const names = new Set(peers(room).map((p) => p.name))
|
|
270
|
+
for (const p of peers(room)) if (p.persona && p.of) names.add(aiName(p.of)) // "Daniel's AI"
|
|
271
|
+
for (const m of seen(doc)) { if (m.by) names.add(m.by); if (m.to) names.add(m.to) }
|
|
272
|
+
names.delete(me)
|
|
273
|
+
return [...names].filter((n) => typeof n === 'string' && n)
|
|
274
|
+
}
|
|
275
|
+
// Chat about a file this agent was already shown, by message id: kept with its inbox, since each
|
|
276
|
+
// request to the hosted MCP gets fresh tools.
|
|
277
|
+
const toldAbout = (room) => {
|
|
278
|
+
const box = ctx.inbox ? ctx.inbox(room) : {}
|
|
279
|
+
if (!Array.isArray(box.told)) box.told = []
|
|
280
|
+
return box
|
|
281
|
+
}
|
|
282
|
+
// Claims follow this agent's writes: a file it changes that nobody holds is claimed for it, and let
|
|
283
|
+
// go when it hasn't written the file for a while (it has no end of turn Quilt can see).
|
|
284
|
+
const autoHeld = new Map() // `${roomId}\0${rel}` -> timer
|
|
285
|
+
const boxable = (room, branch) => !!(room.meta && repoRecordFor(room, branch || room.defaultKey))
|
|
286
|
+
const roomKey = (room) => `${room.id || room.name || ''}\0${branchOf(room)}` // claims held per branch
|
|
287
|
+
const autoClaim = (room, rel) => {
|
|
288
|
+
const key = `${roomKey(room)}\0${rel}`
|
|
289
|
+
const branch = branchOf(room) // released on this branch, even if the agent has moved on since
|
|
290
|
+
clearTimeout(autoHeld.get(key))
|
|
291
|
+
// A box (docs/superpowers/specs/2026-10-16-git-boxes-design.md) where a commit can open it: a branch that follows
|
|
292
|
+
// a repository. Our own claim with no box (claimed ahead, or unpacked) boxes again with the next write.
|
|
293
|
+
const box = boxable(room, branch)
|
|
294
|
+
const own = claimsOf(room).find((c) => c.pattern === rel && c.by === me)
|
|
295
|
+
if (!autoHeld.has(key) || (box && own && !own.box && !own.clash)) {
|
|
296
|
+
try { room.claimRequest({ ...who(room), talk: ctx.access(room)?.talk !== false }, { op: 'claim', pattern: rel, note: 'editing', ...(box ? { box: true } : {}) }) } catch { return false }
|
|
297
|
+
room.broadcastClaims()
|
|
298
|
+
}
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
autoHeld.delete(key)
|
|
301
|
+
// Held for a clash's merge (relay-upstream.js) is not a claim that followed a write: it stays until the merge is done.
|
|
302
|
+
// A box isn't let go of after a quiet while either: it opens when it's committed or unpacked.
|
|
303
|
+
if (!(room.claimList ? room.claimList(branch) : []).some((c) => c.pattern === rel && c.by === me && !c.clash && !c.box)) return
|
|
304
|
+
try { room.claimRequest({ ...ctx.who(room), branch }, { op: 'release', pattern: rel }); room.broadcastClaims() } catch {}
|
|
305
|
+
}, HOSTED_AUTO_CLAIM_QUIET_MS)
|
|
306
|
+
if (timer.unref) timer.unref()
|
|
307
|
+
autoHeld.set(key, timer)
|
|
308
|
+
return true
|
|
309
|
+
}
|
|
310
|
+
// One chronology writer per branch document, shared by every hosted agent's connection.
|
|
311
|
+
const historyOf = (room) => {
|
|
312
|
+
const b = branchEntry(room) || room
|
|
313
|
+
const doc = b.doc
|
|
314
|
+
if (!b.historyLog) b.historyLog = new HistoryLog(doc, doc.getArray('history'), { origin: AGENT })
|
|
315
|
+
return b.historyLog
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
tool('quilt_history', {
|
|
319
|
+
description: 'The chronology of the project: who changed which file, when, what changed (diff) and for which task. ' +
|
|
320
|
+
'Filter by path or glob, person, task or time. Use it to understand recent changes before building on them, or to find what broke something.',
|
|
321
|
+
inputSchema: {
|
|
322
|
+
path: z.string().max(500).optional().describe('A file, a folder ending in "/", or a glob like src/ui/**'),
|
|
323
|
+
by: z.string().max(80).optional().describe('Only changes by this person or agent'),
|
|
324
|
+
since: z.string().max(40).optional().describe('"2h", "3d", "today", "yesterday" or a date'),
|
|
325
|
+
task: z.string().max(40).optional().describe('Only changes made for this task id'),
|
|
326
|
+
with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
|
|
327
|
+
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
328
|
+
}
|
|
329
|
+
}, ({ path: p, by, since, task, with_diff, limit }, { room }) => {
|
|
330
|
+
const from = parseSince(since)
|
|
331
|
+
if (from === undefined) return fail('since: use a duration like 2h or 3d, "today", "yesterday", or a date.')
|
|
332
|
+
const list = queryHistory(historyOf(room).entries(), { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
|
|
333
|
+
return text(formatHistory(list, { withDiff: !!with_diff }))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
tool('quilt_status', {
|
|
337
|
+
description: 'See who else is in the live session, what they and their AIs are doing, recent file changes, claimed files and recent messages. Call this before starting a task.',
|
|
338
|
+
inputSchema: {}
|
|
339
|
+
}, (_, { room, doc, chat, activity }) => {
|
|
340
|
+
const a = ctx.access(room)
|
|
341
|
+
const lines = [`You are ${me} in a live quilt session (room ${room.name}).`]
|
|
342
|
+
if (a && a.role === 'viewer') lines.push('You may only view this session: reading, chat and claims work, file changes are refused.')
|
|
343
|
+
else if (a && a.scopes && a.scopes.length) lines.push(`You may change files only in: ${a.scopes.join(', ')}.`)
|
|
344
|
+
if (a && a.scopesExcept && a.scopesExcept.length && a.role !== 'viewer') lines.push(`You may not change files in: ${a.scopesExcept.join(', ')}.`)
|
|
345
|
+
if (a && a.talk === false) lines.push("You may not post in this session: quilt_message and quilt_share are refused.")
|
|
346
|
+
lines.push('', '## People online')
|
|
347
|
+
const ps = peers(room)
|
|
348
|
+
if (!ps.length) lines.push('- Nobody else right now.')
|
|
349
|
+
for (const p of ps) {
|
|
350
|
+
const ag = p.agent || {}
|
|
351
|
+
const ai = ag.sharing === false ? 'AI sharing paused' : ag.status === 'working' ? `${ag.tool || 'AI'} working` : ag.tool ? `${ag.tool} idle` : ''
|
|
352
|
+
const editing = Object.keys(p.editing || {}).slice(0, 5)
|
|
353
|
+
lines.push(`- ${p.name} (${p.tool || 'unknown tool'}${p.persona ? `, an AI session of ${p.of}` : p.kind === 'agent' ? ', agent' : ''})${ai ? ` · ${ai}` : ''}${p.focus ? ` · focus: ${p.focus}` : ''}${editing.length ? ` · editing ${editing.join(', ')}` : ''}`)
|
|
354
|
+
}
|
|
355
|
+
const away = room.memberList ? room.memberList().filter((m) => m.away && m.name !== me && !ps.some((p) => p.name === m.name)) : []
|
|
356
|
+
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)}`))
|
|
357
|
+
const all = claimsOf(room)
|
|
358
|
+
const bxs = all.filter((c) => c.box)
|
|
359
|
+
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 (quilt_git_commit) or quilt_unpack it' : `${c.by}'s unfinished work: not in anyone else's folder until they commit or unpack it`}`))
|
|
360
|
+
const cl = all.filter((c) => !c.box)
|
|
361
|
+
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.']))
|
|
362
|
+
const acts = activity.toArray().filter(Boolean).slice(-12).reverse()
|
|
363
|
+
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.']))
|
|
364
|
+
const msgs = chat.toArray().filter(visible).slice(-8)
|
|
365
|
+
lines.push('', '## Recent messages', ...(msgs.length ? msgs.map(fmtMsg) : ['- None.']))
|
|
366
|
+
const asked = [...doc.getMap('commitRequests').values()].filter((r) => r && r.id && r.state === 'open').sort((x, y) => x.ts - y.ts)
|
|
367
|
+
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}` : ''}`))
|
|
368
|
+
lines.push('', '## Tasks', taskMarkdown(readTasks(doc.getMap('tasks')), me, { tool: ctx.tool(), asAi: false, mentionYours: true }))
|
|
369
|
+
const br = branchesOf(room)
|
|
370
|
+
if (br.length) {
|
|
371
|
+
lines.push('', '## Branches', branchesMarkdown(br, { limit: 6, mine: branchOf(room) }))
|
|
372
|
+
if (ctx.branch) lines.push('Work on another branch with quilt_git_branch.')
|
|
373
|
+
// Hosted agents learn when the relay last looked for new commits on their branch (relay-upstream.js).
|
|
374
|
+
const said = relayStatus(room, branchOf(room))
|
|
375
|
+
if (said) lines.push(said)
|
|
376
|
+
}
|
|
377
|
+
if (ctx.webhook) { const w = ctx.webhook.get(); lines.push('', w ? `Webhook: Quilt POSTs to ${w.url} on ${w.events.join(', ')}.` : 'No webhook: subscribe with quilt_webhook_subscribe to be told of mentions, direct messages and tasks as they happen.') }
|
|
378
|
+
return text(lines.join('\n') + ctx.warn(room))
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
tool('quilt_sync_status', {
|
|
382
|
+
description: SYNC_STATUS_DESCRIPTION,
|
|
383
|
+
inputSchema: {}
|
|
384
|
+
}, (_, { room, branch }) => text(syncStatusText(room.branchList ? room.branchList() : [], { mine: branch ? branch.key : '' })))
|
|
385
|
+
|
|
386
|
+
tool('quilt_branches', {
|
|
387
|
+
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.',
|
|
388
|
+
inputSchema: {}
|
|
389
|
+
}, (_, { room }) => {
|
|
390
|
+
const br = branchesOf(room)
|
|
391
|
+
return text(`${branchesMarkdown(br, { limit: 40 })}\n\n${BRING_IN_NOTE}`)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
const taskMap = (doc) => doc.getMap('tasks')
|
|
395
|
+
|
|
396
|
+
const reader = () => ({ name: me, tool: ctx.tool(), asAi: false, agent: true })
|
|
397
|
+
const taskFields = ({ assignee, to_ai, files }, room) => {
|
|
398
|
+
const spec = { me, peers: peers(room) }
|
|
399
|
+
if (assignee != null) spec.assignee = assignee
|
|
400
|
+
else if (to_ai) spec.assignee = 'me'
|
|
401
|
+
if (to_ai) spec.to_ai = true
|
|
402
|
+
if (files !== undefined) spec.files = files
|
|
403
|
+
const name = spec.assignee === 'me' ? me : spec.assignee
|
|
404
|
+
if (to_ai && (name == null || name === me)) spec.tool = ctx.tool()
|
|
405
|
+
return assignmentFields(spec)
|
|
406
|
+
}
|
|
407
|
+
const assignedLine = (task) => {
|
|
408
|
+
const label = assigneeLabel(task, me)
|
|
409
|
+
const files = task.files?.length ? `\nFiles: ${task.files.join(', ')}` : ''
|
|
410
|
+
return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
tool('quilt_tasks', {
|
|
414
|
+
description: 'List the shared task board (To do, In progress, QA, Done), with an id on each task. Open tasks assigned to you are listed first.',
|
|
415
|
+
inputSchema: {
|
|
416
|
+
archived: z.boolean().optional().describe('True: list the archived tasks (off the board) instead.')
|
|
417
|
+
}
|
|
418
|
+
}, ({ archived } = {}, { doc }) => text(formatTasks(readTasks(taskMap(doc)), reader(), { archived: !!archived })))
|
|
419
|
+
|
|
420
|
+
tool('quilt_add_task', {
|
|
421
|
+
description: 'Add a task to the shared board, in To do. One short line. Optionally assign it to a person or their AI, and name the files it is about. It is added as you.',
|
|
422
|
+
inputSchema: {
|
|
423
|
+
title: z.string().describe('What needs doing, in a few words'),
|
|
424
|
+
assignee: z.string().optional().describe('Who should do it: a person\'s name, or "me". Omit to leave it unassigned.'),
|
|
425
|
+
to_ai: z.boolean().optional().describe('Assign it to that person\'s AI instead of the person. You are an agent in this session: leave to_ai unset to assign a task to yourself.'),
|
|
426
|
+
files: z.array(z.string()).max(20).optional().describe('Project files this task is about, relative paths such as src/app.js')
|
|
427
|
+
}
|
|
428
|
+
}, ({ title, assignee, to_ai, files }, { room, doc }) => {
|
|
429
|
+
{ const w = waitRefusal(doc, 'quilt_add_task', room); if (w) return fail(w) }
|
|
430
|
+
const err = writable(room)
|
|
431
|
+
if (err) return fail(err)
|
|
432
|
+
try {
|
|
433
|
+
const task = addTask(doc, taskMap(doc), { title, by: me, ...taskFields({ assignee, to_ai, files }, room) }, AGENT)
|
|
434
|
+
return text(`Added to To do: ${task.title}\n${task.id}\n${assignedLine(task)}`)
|
|
435
|
+
} catch (e) { return fail(e.message) }
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const checklistOf = (files) => pickChecklist(files.get('AGENTS.md')?.toString(), files.get('CLAUDE.md')?.toString())
|
|
439
|
+
tool('quilt_move_task', {
|
|
440
|
+
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). ' +
|
|
441
|
+
'"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
|
|
442
|
+
'"done" after QA: requires `verified`, what you ran and what you saw; without it the move is refused.',
|
|
443
|
+
inputSchema: {
|
|
444
|
+
id: z.string().describe('Task id from quilt_tasks'),
|
|
445
|
+
column: z.enum(COLUMN_IDS_LIST).describe(COLUMN_IDS_LIST.join(', ')),
|
|
446
|
+
qaNotes: z.string().max(MAX_VERIFIED).optional().describe('For "qa": describe the changes you made and how you self-validated them, briefly (a few short sentences, no logs).'),
|
|
447
|
+
verified: z.string().max(MAX_VERIFIED).optional().describe('For "done": what you ran and what you saw, concretely and briefly (commands, results, what you exercised in the app; no full logs).')
|
|
448
|
+
}
|
|
449
|
+
}, async ({ id, column, qaNotes, verified }, { room, doc, files, branch }) => {
|
|
450
|
+
{ const w = waitRefusal(doc, 'quilt_move_task', room); if (w) return fail(w) }
|
|
451
|
+
// 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.
|
|
452
|
+
if (column === 'qa' && ctx.kind === 'agent' && !Object.values(room.meta && room.meta.upstreams || {}).some((rec) => rec && rec.clash && rec.clash.task === id)) {
|
|
453
|
+
const u = await unpublishedFor(room, { key: branch ? branch.key : DEFAULT_KEY, who: me, task: id, column }, { fetch: room.githubFetch })
|
|
454
|
+
if (u.unpublished.length) return fail(unpublishedRefusal(readTasks(taskMap(doc)).find((t) => t.id === id), u.unpublished, column, []))
|
|
455
|
+
}
|
|
456
|
+
const err = writable(room)
|
|
457
|
+
if (err) return fail(err)
|
|
458
|
+
try {
|
|
459
|
+
const cur = readTasks(taskMap(doc)).find((t) => t.id === id)
|
|
460
|
+
if (!cur) return fail('no such task')
|
|
461
|
+
if (column === 'qa' && !qaNotesEnough(qaNotes)) return fail(qaRefusal({ task: cur, checklist: checklistOf(files) }))
|
|
462
|
+
if (column === 'done' && !verifiedEnough(verified)) return fail(doneRefusal({ task: cur, checklist: checklistOf(files) }))
|
|
463
|
+
{ const q = (column === 'done' || column === 'qa') && renderQueued(queuedFor(claimsOf(room), me), 'move the task again'); if (q) return fail(q) }
|
|
464
|
+
let merged = null
|
|
465
|
+
if (column === 'done' && cur.column !== 'done') {
|
|
466
|
+
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 })
|
|
467
|
+
merged = f.merged
|
|
468
|
+
}
|
|
469
|
+
const patch = { id, column }
|
|
470
|
+
if (column === 'qa') patch.qaNotes = qaNotes
|
|
471
|
+
if (column === 'done') patch.verified = verified
|
|
472
|
+
const task = updateTask(doc, taskMap(doc), patch, AGENT)
|
|
473
|
+
if (column === 'doing') {
|
|
474
|
+
const tf = task.files || []
|
|
475
|
+
const all = historyOf(room).entries()
|
|
476
|
+
const history = (tf.length ? all.filter((e) => tf.includes(e.path)) : all).slice(-8)
|
|
477
|
+
const claims = claimsOf(room).filter((c) => !tf.length || tf.some((f) => globMatcher(c.pattern)(f)))
|
|
478
|
+
const onto = branchForHostedTask(room, task)
|
|
479
|
+
return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: checklistOf(files), 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.` : ''))
|
|
480
|
+
}
|
|
481
|
+
if (column === 'qa') return text(`Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}`)
|
|
482
|
+
if (column === 'done') return text(`Moved "${task.title}" to Done.${merged ? ` Merged pull request #${merged.number} (${merged.head} → ${merged.base}) as ${String(merged.sha).slice(0, 7)}.` : ''} Verified: ${verifiedLine(task)}`)
|
|
483
|
+
return text(`Moved "${task.title}" to ${columnName(task.column)}.`)
|
|
484
|
+
} catch (e) { return fail(e.message) }
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
tool('quilt_assign_task', {
|
|
488
|
+
description: 'Assign a shared task to a person or to their AI, and optionally set the files it is about. assignee "" clears it. You are an agent here: assignee "me" without to_ai assigns it to you.',
|
|
489
|
+
inputSchema: {
|
|
490
|
+
id: z.string().describe('Task id from quilt_tasks'),
|
|
491
|
+
assignee: z.string().describe('A person\'s name, "me", or "" to unassign'),
|
|
492
|
+
to_ai: z.boolean().optional().describe('True: that person\'s AI. Omit or false: the person.'),
|
|
493
|
+
files: z.array(z.string()).max(20).optional().describe('Replace the file list. Omit to leave the files unchanged.')
|
|
494
|
+
}
|
|
495
|
+
}, ({ id, assignee, to_ai, files }, { room, doc }) => {
|
|
496
|
+
{ const w = waitRefusal(doc, 'quilt_assign_task', room); if (w) return fail(w) }
|
|
497
|
+
const err = writable(room)
|
|
498
|
+
if (err) return fail(err)
|
|
499
|
+
try {
|
|
500
|
+
const task = updateTask(doc, taskMap(doc), { id, ...taskFields({ assignee, to_ai, files }, room) }, AGENT)
|
|
501
|
+
return text(assignedLine(task))
|
|
502
|
+
} catch (e) { return fail(e.message) }
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
tool('quilt_task', {
|
|
506
|
+
description: 'One task in full: its column, assignee, files, QA and Done notes, and its comments (work notes and handoffs people left on it).',
|
|
507
|
+
inputSchema: { id: z.string().describe('Task id from quilt_tasks') }
|
|
508
|
+
}, ({ id }, { doc }) => {
|
|
509
|
+
const task = withComments(readTasks(taskMap(doc)), doc.getMap('taskComments')).find((t) => t.id === id)
|
|
510
|
+
return task ? text(formatTaskDetails(task)) : fail('No such task: read the board with quilt_tasks.')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
tool('quilt_comment_task', {
|
|
514
|
+
description: 'Add a comment to a task: a work note, a handoff, or why it went to whom. Put reasoning about a task here instead of in the chat. Keep it brief: a few short sentences, no logs, diffs or play-by-play.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
id: z.string().describe('Task id from quilt_tasks'),
|
|
517
|
+
text: z.string().max(MAX_COMMENT).describe('The comment')
|
|
518
|
+
}
|
|
519
|
+
}, ({ id, text: words }, { room, doc }) => {
|
|
520
|
+
{ const w = waitRefusal(doc, 'quilt_comment_task', room); if (w) return fail(w) }
|
|
521
|
+
if (ctx.access(room)?.talk === false) return fail(TALK_REFUSED)
|
|
522
|
+
const err = writable(room)
|
|
523
|
+
if (err) return fail(err)
|
|
524
|
+
try {
|
|
525
|
+
const tasks = readTasks(taskMap(doc))
|
|
526
|
+
addComment(doc, doc.getMap('taskComments'), tasks, { taskId: id, by: me, text: words }, AGENT)
|
|
527
|
+
const task = tasks.find((t) => t.id === id)
|
|
528
|
+
return text(`Comment added to "${task.title}" (${readComments(doc.getMap('taskComments'), id).length} on it now).`)
|
|
529
|
+
} catch (e) { return fail(e.message) }
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
tool('quilt_delete_task', {
|
|
533
|
+
description: 'Remove a task from the shared board.',
|
|
534
|
+
inputSchema: { id: z.string().describe('Task id from quilt_tasks') }
|
|
535
|
+
}, ({ id }, { room, doc }) => {
|
|
536
|
+
{ const w = waitRefusal(doc, 'quilt_delete_task', room); if (w) return fail(w) }
|
|
537
|
+
const err = writable(room)
|
|
538
|
+
if (err) return fail(err)
|
|
539
|
+
try {
|
|
540
|
+
deleteTask(doc, taskMap(doc), id, AGENT)
|
|
541
|
+
return text('Removed.')
|
|
542
|
+
} catch (e) { return fail(e.message) }
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
tool('quilt_archive_task', {
|
|
546
|
+
description: 'Archive a task: it leaves the board and every task list, but is kept, and can be brought back. archived false brings it back to the column it was in.',
|
|
547
|
+
inputSchema: {
|
|
548
|
+
id: z.string().describe('Task id from quilt_tasks'),
|
|
549
|
+
archived: z.boolean().optional().describe('Omit or true: archive it. False: bring it back.')
|
|
550
|
+
}
|
|
551
|
+
}, ({ id, archived }, { room, doc }) => {
|
|
552
|
+
{ const w = waitRefusal(doc, 'quilt_archive_task', room); if (w) return fail(w) }
|
|
553
|
+
const err = writable(room)
|
|
554
|
+
if (err) return fail(err)
|
|
555
|
+
try {
|
|
556
|
+
const task = updateTask(doc, taskMap(doc), { id, archived: archived !== false }, AGENT)
|
|
557
|
+
return text(task.archived ? `Archived "${task.title}".` : `Brought "${task.title}" back to ${columnName(task.column)}.`)
|
|
558
|
+
} catch (e) { return fail(e.message) }
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
tool('quilt_share', {
|
|
562
|
+
description: 'Share what you are doing with your collaborators; it appears live in their quilt feed. Call it when you start on a request ' +
|
|
563
|
+
'(`request`: what your user asked, in a sentence; `summary`: your plan) and again when you finish (`summary`: what you did; ' +
|
|
564
|
+
'`files`: files you changed). Keep it short and never include secrets, keys or file contents.',
|
|
565
|
+
inputSchema: {
|
|
566
|
+
request: z.string().max(2000).optional().describe('What your user asked for, in a sentence (only when starting a new request)'),
|
|
567
|
+
summary: z.string().max(4000).describe('Your plan, progress or result, in one to three sentences'),
|
|
568
|
+
files: z.array(z.string().max(300)).max(30).optional().describe('Project files you changed, relative paths')
|
|
569
|
+
}
|
|
570
|
+
}, ({ request, summary, files: changed }, { room, doc, feed }) => {
|
|
571
|
+
const err = writable(room)
|
|
572
|
+
if (err) return fail(err)
|
|
573
|
+
if (ctx.access(room)?.talk === false) return fail(TALK_REFUSED)
|
|
574
|
+
const t = ctx.tool()
|
|
575
|
+
const now = Date.now()
|
|
576
|
+
const entries = []
|
|
577
|
+
const base = { by: me, tool: t, conv: `mcp-${t}` }
|
|
578
|
+
if (request && request.trim()) entries.push({ ...base, id: id(), kind: 'prompt', text: capText(request.trim()), ts: now })
|
|
579
|
+
if (summary && summary.trim()) entries.push({ ...base, id: id(), kind: 'reply', text: capText(summary.trim()), ts: now })
|
|
580
|
+
for (const f of changed || []) {
|
|
581
|
+
const p = cleanPath(f)
|
|
582
|
+
if (isSafeRelPath(p)) entries.push({ ...base, id: id(), kind: 'action', text: `Edited ${p}`, ts: now })
|
|
583
|
+
}
|
|
584
|
+
if (!entries.length) return fail('Nothing to share: give a summary.')
|
|
585
|
+
doc.transact(() => {
|
|
586
|
+
feed.push(entries)
|
|
587
|
+
// Keep the newest FEED_CAP entries per person, like the CLI does.
|
|
588
|
+
const mine = []
|
|
589
|
+
feed.forEach((e, i) => { if (e && e.by === me) mine.push(i) })
|
|
590
|
+
for (let k = mine.length - FEED_CAP - 1; k >= 0; k--) feed.delete(mine[k], 1)
|
|
591
|
+
}, AGENT)
|
|
592
|
+
return text(`Shared with the session.${ctx.warn(room)}`)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
tool('quilt_partner_feed', {
|
|
596
|
+
description: 'Read what a collaborator\'s AI has been doing: their prompts, the AI\'s replies and actions. Without `who`, lists whose feeds exist.',
|
|
597
|
+
inputSchema: {
|
|
598
|
+
who: z.string().optional().describe('Collaborator name'),
|
|
599
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
600
|
+
}
|
|
601
|
+
}, ({ who, limit }, { feed }) => {
|
|
602
|
+
const all = feed.toArray().filter((e) => e && e.by !== me)
|
|
603
|
+
if (!who) {
|
|
604
|
+
const names = [...new Set(all.map((e) => e.by))]
|
|
605
|
+
return text(names.length ? `Feeds: ${names.join(', ')}. Call again with "who".` : 'No one has shared AI activity yet.')
|
|
606
|
+
}
|
|
607
|
+
const theirs = all.filter((e) => e.by === who).slice(-(limit || 40))
|
|
608
|
+
if (!theirs.length) return text(`No AI activity from ${who} yet.`)
|
|
609
|
+
return text(theirs.map((e) => {
|
|
610
|
+
if (e.kind === 'prompt') return `[${ago(e.ts)}] ${who} asked: ${e.text}`
|
|
611
|
+
if (e.kind === 'reply') return `[${ago(e.ts)}] ${e.tool || 'AI'}: ${e.text}`
|
|
612
|
+
if (e.kind === 'action') return `[${ago(e.ts)}] · ${e.text}`
|
|
613
|
+
return `[${ago(e.ts)}] (${e.kind})`
|
|
614
|
+
}).join('\n'))
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
tool('quilt_inbox', {
|
|
618
|
+
description: 'What is waiting for you: mentions of you in chat (@yourname), direct messages to you, and tasks handed to you since you last looked. Act on each one: answer with quilt_message, take a task with quilt_move_task.',
|
|
619
|
+
inputSchema: {
|
|
620
|
+
no_reply: z.array(z.string().max(40)).max(50).optional().describe('Ids of messages that need nothing back from you (thanks, a greeting, an FYI, a status report): settled without a reply')
|
|
621
|
+
}
|
|
622
|
+
}, ({ no_reply }, { room, doc, chat }) => {
|
|
623
|
+
const box = ctx.inbox ? ctx.inbox(room) : { state: null }
|
|
624
|
+
const msgs = chat.toArray().filter(visible)
|
|
625
|
+
let note = ''
|
|
626
|
+
if (no_reply && no_reply.length) {
|
|
627
|
+
const known = new Set(msgs.map((m) => m.id))
|
|
628
|
+
const ok = no_reply.map(String).filter((x) => known.has(x))
|
|
629
|
+
box.settled = [...new Set([...(Array.isArray(box.settled) ? box.settled : []), ...ok])].slice(-500)
|
|
630
|
+
note = `Settled as needing no reply: ${ok.length ? ok.join(', ') : 'none (unknown ids)'}.`
|
|
631
|
+
}
|
|
632
|
+
const r = scanInbox({ messages: msgs, tasks: readTasks(taskMap(doc)), reader: reader() }, box.state)
|
|
633
|
+
box.state = r.state
|
|
634
|
+
if (ctx.saveInbox) ctx.saveInbox()
|
|
635
|
+
const settled = new Set(box.settled || [])
|
|
636
|
+
const open = r.events.filter((e) => (e.kind !== 'dm' && e.kind !== 'mention') || e.queue || e.commit || e.nudge || (!settled.has(e.id) && !needsNoReply(e.text) && !answered(msgs, me, e.by, e.ts)))
|
|
637
|
+
const seen = keptChat(room, chat)
|
|
638
|
+
return text([note, renderInbox(withContext(open, seen, { names: [me], agent: true }), { me: [me] })].filter(Boolean).join('\n\n') || 'Nothing new for you.')
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
tool('quilt_conversation', {
|
|
642
|
+
description: CONVERSATION_DESCRIPTION,
|
|
643
|
+
inputSchema: {
|
|
644
|
+
with: z.string().max(200).optional().describe('Only the conversation with this person or agent: direct messages either way, and messages that @mention one of you'),
|
|
645
|
+
q: z.string().max(200).optional().describe('Only messages containing this text (any case)'),
|
|
646
|
+
before: z.string().max(40).optional().describe('Only messages before this message id (to read further back)'),
|
|
647
|
+
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching messages (default 30)')
|
|
648
|
+
}
|
|
649
|
+
}, (args, { room, chat }) => {
|
|
650
|
+
const r = queryConversation(keptChat(room, chat), { me: [me], with: args.with || null, q: args.q || '', before: args.before || null, limit: args.limit, agent: true })
|
|
651
|
+
return text(renderConversation(r, { me: [me], with: args.with, q: args.q }))
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
if (ctx.webhook) {
|
|
655
|
+
// The agent's one webhook, for every session it is in (and its workspaces): no session needed to set it.
|
|
656
|
+
server.registerTool('quilt_webhook_subscribe', {
|
|
657
|
+
description: 'Be told as it happens, by an HTTP POST to a URL of yours, when you are mentioned in chat (@yourname), sent a direct message or handed a task, in any session you are in: ' +
|
|
658
|
+
'no need to poll quilt_inbox. One webhook per agent, for every session (calling again replaces it); for a workspace agent it also gets session.started from its workspaces. Each POST is JSON that names its session and workspace (answer with that session in `session`), signed with the secret ' +
|
|
659
|
+
'(x-quilt-signature: sha256=HMAC-SHA256(secret, "<x-quilt-timestamp>.<body>")); answer 2xx. Give a secret of your own or get one back (shown once).',
|
|
660
|
+
inputSchema: {
|
|
661
|
+
url: z.string().min(1).max(2000).describe('The https URL to POST to (a webhook trigger of your routine, for example)'),
|
|
662
|
+
secret: z.string().max(200).optional().describe('16 to 200 characters for signing; omit to have Quilt make one'),
|
|
663
|
+
events: z.array(z.enum(WEBHOOK_EVENTS)).max(WEBHOOK_EVENTS.length).optional().describe(`Which events to send (default: all): ${WEBHOOK_EVENTS.join(', ')}`),
|
|
664
|
+
bearer: z.string().max(500).optional().describe('A key your receiver wants on every POST, sent as "Authorization: Bearer <key>" (a Grok Bot routine\'s sender key, for example)')
|
|
665
|
+
}
|
|
666
|
+
}, async ({ url, secret, events, bearer }) => {
|
|
667
|
+
try {
|
|
668
|
+
const sub = ctx.webhook.subscribe({ url, secret, events, bearer })
|
|
669
|
+
const api = await ctx.webhook.tellApi(sub)
|
|
670
|
+
return text(describeSubscription(sub, { showSecret: sub.made }) + (api ? `\n${api}` : ''))
|
|
671
|
+
} catch (e) { return fail(e.message) }
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
server.registerTool('quilt_webhook_unsubscribe', {
|
|
675
|
+
description: 'Stop the webhook: Quilt no longer POSTs mentions, direct messages, tasks or session.started to you. quilt_inbox still has them.',
|
|
676
|
+
inputSchema: {}
|
|
677
|
+
}, async () => text(await ctx.webhook.unsubscribe() ? 'Webhook removed. Mentions, direct messages and tasks still wait in quilt_inbox.' : 'You had no webhook.'))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
tool('quilt_message', {
|
|
681
|
+
description: CHAT_RULES + ' Set `to` to message one person directly.',
|
|
682
|
+
inputSchema: {
|
|
683
|
+
text: z.string().min(1).max(4000),
|
|
684
|
+
to: z.string().optional().describe('Name of one person, for a direct message'),
|
|
685
|
+
everyone: z.boolean().optional().describe('Only for a real announcement to the whole session: lets a message that @mentions nobody go out'),
|
|
686
|
+
re: z.string().max(40).optional().describe(RE_HELP),
|
|
687
|
+
also: z.boolean().optional().describe('Only when their message was already answered and yours is about something different they need')
|
|
688
|
+
}
|
|
689
|
+
}, ({ text: t, to, everyone, re, also }, { room, doc, chat }) => {
|
|
690
|
+
const err = writable(room)
|
|
691
|
+
if (err) return fail(err)
|
|
692
|
+
if (ctx.access(room)?.talk === false) return fail(TALK_REFUSED)
|
|
693
|
+
const reply = replyFor(chat.toArray().filter(visible), { names: [me], to: to || null, re: re || '' })
|
|
694
|
+
if (reply.error) return fail(reply.error)
|
|
695
|
+
to = reply.to
|
|
696
|
+
const why = unaddressed(t, { to, everyone: !!everyone, names: memberNames(room, doc) })
|
|
697
|
+
if (why) return fail(why)
|
|
698
|
+
if (reply.repeat && !also) return fail(renderReplied(reply.repeat))
|
|
699
|
+
const msg = { id: id(), by: me, to: to || null, text: t, ts: Date.now() }
|
|
700
|
+
if (to && reply.re) msg.re = reply.re
|
|
701
|
+
doc.transact(() => {
|
|
702
|
+
chat.push([msg])
|
|
703
|
+
if (chat.length > 500) chat.delete(0, chat.length - 500)
|
|
704
|
+
}, AGENT)
|
|
705
|
+
return text(to ? `Sent to ${to}.` : 'Sent to everyone.')
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
tool('quilt_read_messages', {
|
|
709
|
+
description: 'Read recent chat messages in the session (including direct messages to you).',
|
|
710
|
+
inputSchema: { limit: z.number().int().min(1).max(100).optional() }
|
|
711
|
+
}, ({ limit }, { chat }) => {
|
|
712
|
+
const msgs = chat.toArray().filter(visible).slice(-(limit || 20))
|
|
713
|
+
return text(msgs.length ? msgs.map(fmtMsg).join('\n') : 'No messages yet.')
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
// ------------------------------------------------------ commit timing --
|
|
717
|
+
// The same requests as a folder's quilt_request_commit (Session.requestCommit): a message to
|
|
718
|
+
// whoever commits, so viewers and members who may not post are refused.
|
|
719
|
+
const commitMap = (doc) => doc.getMap('commitRequests')
|
|
720
|
+
const commitState = (room, doc) => {
|
|
721
|
+
const busy = []
|
|
722
|
+
for (const st of room.awareness.getStates().values()) {
|
|
723
|
+
if (!st || !st.name || st.name === me) continue
|
|
724
|
+
const a = st.agent
|
|
725
|
+
if (a && a.sharing !== false && a.status === 'working') busy.push(`${st.name}'s ${a.tool || 'AI'} is working`)
|
|
726
|
+
else if (st.work && st.work.state === 'working') busy.push(`${st.name} is working${st.work.note ? `: ${String(st.work.note).slice(0, 200)}` : ''}`)
|
|
727
|
+
}
|
|
728
|
+
const requests = [...commitMap(doc).values()].filter((r) => r && r.id).sort((a, b) => a.ts - b.ts)
|
|
729
|
+
return { busy, open: requests.filter((r) => r.state === 'open'), recent: requests.filter((r) => r.state === 'done').slice(-5) }
|
|
730
|
+
}
|
|
731
|
+
const describeCommits = (c) => [
|
|
732
|
+
c.busy.length ? `⏳ Still working: ${c.busy.join('; ')}` : '✅ Everyone else\'s AI is idle: a good moment to commit.',
|
|
733
|
+
describeCommitRequests(c)
|
|
734
|
+
].join('\n')
|
|
735
|
+
|
|
736
|
+
tool('quilt_request_commit', {
|
|
737
|
+
description: REQUEST_COMMIT_DESCRIPTION,
|
|
738
|
+
inputSchema: {
|
|
739
|
+
message: z.string().max(500).optional().describe('The commit message: what the change does (a task\'s title by default)'),
|
|
740
|
+
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)'),
|
|
741
|
+
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')
|
|
742
|
+
}
|
|
743
|
+
}, ({ message, files, task }, { room, doc, fdoc }) => {
|
|
744
|
+
{ const w = waitRefusal(doc, 'quilt_request_commit', room); if (w) return fail(w) }
|
|
745
|
+
const err = writable(room)
|
|
746
|
+
if (err) return fail(err)
|
|
747
|
+
const a = ctx.access(room)
|
|
748
|
+
if (a && a.role === 'viewer') return fail('Viewers can\'t ask for commits.')
|
|
749
|
+
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.')
|
|
750
|
+
if (a && a.talk === false) return fail(TALK_REFUSED)
|
|
751
|
+
const t = task ? readTasks(taskMap(doc)).find((x) => x.id === String(task)) : null
|
|
752
|
+
if (task && !t) return fail(`No task ${task} on the board.`)
|
|
753
|
+
const msg = String(message || (t ? t.title : '')).trim().slice(0, 500)
|
|
754
|
+
if (!msg) return fail('Say what the commit is for.')
|
|
755
|
+
let list = cleanFiles(files || [])
|
|
756
|
+
if (files && files.length && !list.length) return fail('None of those are files in the project (give paths relative to its top folder).')
|
|
757
|
+
if (!list.length) {
|
|
758
|
+
// What was changed for the task, or by this agent, on record in the branch's history.
|
|
759
|
+
for (const e of fdoc.getArray('history').toArray()) {
|
|
760
|
+
if (!e || e.pulled || typeof e.path !== 'string') continue
|
|
761
|
+
if (t ? e.task && e.task.id === t.id : e.by === me) { if (!list.includes(e.path)) list.push(e.path) }
|
|
762
|
+
}
|
|
763
|
+
if (t) for (const f of t.files || []) if (!list.includes(f)) list.push(f)
|
|
764
|
+
list = cleanFiles(list.slice(-MAX_REQUEST_FILES))
|
|
765
|
+
}
|
|
766
|
+
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.')
|
|
767
|
+
const key = branchOf(room)
|
|
768
|
+
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 } } : {}) }
|
|
769
|
+
// The session owner is told with a direct message, as from a folder (commit-request.js).
|
|
770
|
+
const note = commitRequestMessage(r, { owner: room.meta && room.meta.ownerName, id: id(), defaultBranch: DEFAULT_KEY })
|
|
771
|
+
doc.transact(() => {
|
|
772
|
+
const map = commitMap(doc)
|
|
773
|
+
map.set(r.id, r)
|
|
774
|
+
if (note) {
|
|
775
|
+
const chat = doc.getArray('chat')
|
|
776
|
+
chat.push([note])
|
|
777
|
+
if (chat.length > 500) chat.delete(0, chat.length - 500)
|
|
778
|
+
}
|
|
779
|
+
const done = [...map.values()].filter((x) => x && x.state === 'done').sort((x, y) => x.ts - y.ts)
|
|
780
|
+
for (const x of done.slice(0, Math.max(0, done.length - 100))) map.delete(x.id)
|
|
781
|
+
}, AGENT)
|
|
782
|
+
return text(`Asked for a commit [${r.id}] of ${list.length} file${list.length === 1 ? '' : 's'}: ${list.slice(0, 12).join(', ')}${list.length > 12 ? ', …' : ''}. ` +
|
|
783
|
+
'A person with git on this branch commits it from their Quilt; you are told the commit when it is made.')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
if (ctx.kind) {
|
|
787
|
+
// Git, for an agent with no folder: the quilt_git_* tools work on the branch the agent is on (its branch document).
|
|
788
|
+
const whoAmI = (room) => ({ name: me, kind: ctx.kind, access: ctx.access(room) })
|
|
789
|
+
const keyOf = (branch) => branch ? branch.key : DEFAULT_KEY
|
|
790
|
+
|
|
791
|
+
tool('quilt_git_commit', {
|
|
792
|
+
description: GIT_COMMIT_DESCRIPTION,
|
|
793
|
+
inputSchema: gitCommitSchema(z)
|
|
794
|
+
}, async (args, { room, doc, branch }) => {
|
|
795
|
+
{ const w = waitRefusal(doc, 'quilt_git_commit', room); if (w) return fail(w) }
|
|
796
|
+
// A branch that syncs with GitHub through the relay's mirror: your own changes (or the files named, or the task's)
|
|
797
|
+
// become one commit now, pushed forward only. Others' changes in those files go in only with with_others.
|
|
798
|
+
// An agent the owner's policy keeps off this branch commits to a branch of its own instead (below, with its pull request).
|
|
799
|
+
if (room.outbound && liveSyncOn(room, keyOf(branch)) && !(ctx.kind === 'agent' && agentCommitRefusal(room, keyOf(branch), me))) {
|
|
800
|
+
const key = keyOf(branch)
|
|
801
|
+
const own = ownFiles(room, key, { who: me, files: args.files, task: args.task })
|
|
802
|
+
if (own.refused) return fail(own.refused)
|
|
803
|
+
const t = args.task ? room.doc.getMap('tasks').get(String(args.task)) : null
|
|
804
|
+
const message = String(args.message || (t && t.title) || '').trim().slice(0, 2000)
|
|
805
|
+
if (!message) return fail('Say what the commit does (message).')
|
|
806
|
+
const r = await room.outbound.flush(key, { message, by: me, only: own.list, own: { who: me, withOthers: args.with_others === true } })
|
|
807
|
+
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).`)
|
|
808
|
+
const rec = (room.meta.upstreams || {})[key] || {}
|
|
809
|
+
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}` : ''}.`)
|
|
810
|
+
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(', ')})` : ''}.`)
|
|
811
|
+
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.`)
|
|
812
|
+
if (r.state === 'protected') return fail(`${rec.ref || key} is protected on GitHub: ${r.why}`)
|
|
813
|
+
if (r.state === 'refused') return fail(`Not committed: ${r.why}.`)
|
|
814
|
+
return fail(`Could not push ${key} right now (${r.state}${r.why ? `: ${r.why}` : ''}): try quilt_git_commit again in a moment.`)
|
|
815
|
+
}
|
|
816
|
+
if (!room.commitFor) return fail('This relay can\'t commit yet.')
|
|
817
|
+
try {
|
|
818
|
+
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 || (() => {}) })
|
|
819
|
+
return text(describeGitCommit(r, { policy: agentCommitsOf(room) }))
|
|
820
|
+
} catch (err) { return fail(err.message) }
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
tool('quilt_clash', {
|
|
824
|
+
description: CLASH_DESCRIPTION,
|
|
825
|
+
inputSchema: clashSchema(z)
|
|
826
|
+
}, async (args, { room, branch }) => {
|
|
827
|
+
if (!room.mirrors || !room.cfg || !room.cfg.gitSync) return fail('This session doesn\'t sync live with GitHub, so there are no held clashes here.')
|
|
828
|
+
try {
|
|
829
|
+
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 })
|
|
830
|
+
return r.ok ? text(r.text) : fail(r.text)
|
|
831
|
+
} catch (err) { return fail(err.message) }
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
tool('quilt_git_status', {
|
|
835
|
+
description: GIT_STATUS_DESCRIPTION,
|
|
836
|
+
inputSchema: {}
|
|
837
|
+
}, async (_, { room, branch }) => {
|
|
838
|
+
try { return text(describeGitStatus(await gitStatusFor(room, { who: whoAmI(room), key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
tool('quilt_git_log', {
|
|
842
|
+
description: GIT_LOG_DESCRIPTION,
|
|
843
|
+
inputSchema: { n: z.number().int().min(1).max(50).optional().describe('How many commits (default 10)') }
|
|
844
|
+
}, async ({ n }, { room, branch }) => {
|
|
845
|
+
try { return text(describeGitLog(await gitLogFor(room, { key: keyOf(branch), n: n || 10 }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
tool('quilt_git_diff', {
|
|
849
|
+
description: GIT_DIFF_DESCRIPTION,
|
|
850
|
+
inputSchema: { files: z.array(z.string().min(1).max(500)).max(20).optional().describe('The files to compare; default: your uncommitted changes') }
|
|
851
|
+
}, async ({ files: list }, { room, branch }) => {
|
|
852
|
+
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) }
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
tool('quilt_git_revert', {
|
|
856
|
+
description: GIT_REVERT_DESCRIPTION,
|
|
857
|
+
inputSchema: { files: z.array(z.string().min(1).max(500)).min(1).max(20).describe('The files to put back as GitHub has them') }
|
|
858
|
+
}, async ({ files: list }, { room, doc, fdoc, branch, files, blobs, activity }) => {
|
|
859
|
+
{ const w = waitRefusal(doc, 'quilt_git_revert', room); if (w) return fail(w) }
|
|
860
|
+
const err = writable(room) || branchWritable(room, branch)
|
|
861
|
+
if (err) return fail(err)
|
|
862
|
+
const a = ctx.access(room)
|
|
863
|
+
if (a && a.role === 'viewer') return fail('You can only view this session; file changes are refused.')
|
|
864
|
+
try {
|
|
865
|
+
const d = await gitDiffFor(room, { who: whoAmI(room), key: keyOf(branch), files: list }, { fetch: room.githubFetch, lineDiff })
|
|
866
|
+
const { githubRepoOf, credentialsFor } = await import('./relay-commit.js')
|
|
867
|
+
const { githubWriter } = await import('./github-commit.js')
|
|
868
|
+
const { repo } = githubRepoOf(room, keyOf(branch))
|
|
869
|
+
const gh = githubWriter({ fetch: room.githubFetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
|
|
870
|
+
const done = []; const skipped = []
|
|
871
|
+
for (const f of d.files) {
|
|
872
|
+
const rel = cleanPath(f.path)
|
|
873
|
+
if (f.note === 'same') { skipped.push(`${rel} (already as on GitHub)`); continue }
|
|
874
|
+
if (f.note) { skipped.push(`${rel} (${f.note})`); continue }
|
|
875
|
+
const refusal = a && changeRefusal(a, rel)
|
|
876
|
+
if (refusal) { skipped.push(`${rel} (${refusal})`); continue }
|
|
877
|
+
const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
|
|
878
|
+
if (claim) { skipped.push(`${rel} (held by ${claim.by})`); continue }
|
|
879
|
+
const was = d.head ? await gh.content(d.head, rel) : null
|
|
880
|
+
fdoc.transact(() => doc.transact(() => {
|
|
881
|
+
const existed = files.has(rel) || blobs.has(rel)
|
|
882
|
+
let before = ''
|
|
883
|
+
if (was === null || was === undefined) {
|
|
884
|
+
const y = files.get(rel); before = y ? y.toString() : ''
|
|
885
|
+
files.delete(rel); blobs.delete(rel)
|
|
886
|
+
} else {
|
|
887
|
+
blobs.delete(rel)
|
|
888
|
+
let ytext = files.get(rel)
|
|
889
|
+
if (!ytext) { ytext = new Y.Text(); files.set(rel, ytext) }
|
|
890
|
+
before = ytext.toString()
|
|
891
|
+
applyTextDiff(ytext, was.toString('utf8'))
|
|
892
|
+
}
|
|
893
|
+
const kind = was == null ? 'deleted' : existed ? 'edited' : 'created'
|
|
894
|
+
activity.push([{ by: me, path: rel, kind, detail: `reverted to GitHub (${d.against})`, ...(branch ? { branch: branch.key } : {}), ts: Date.now() }])
|
|
895
|
+
if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
|
|
896
|
+
historyOf(room).record({ by: me, path: rel, kind, before, after: was == null ? '' : was.toString('utf8'), task: currentTask(readTasks(taskMap(doc)), me) })
|
|
897
|
+
}, AGENT), AGENT)
|
|
898
|
+
done.push(rel)
|
|
899
|
+
}
|
|
900
|
+
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('; ')}.` : ''}`)
|
|
901
|
+
} catch (err) { return fail(err.message) }
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
tool('quilt_git_pull_requests', {
|
|
905
|
+
description: PULLS_DESCRIPTION,
|
|
906
|
+
inputSchema: {}
|
|
907
|
+
}, async (_, { room, branch }) => {
|
|
908
|
+
try { return text(describePulls(await pullRequestsFor(room, { key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
tool('quilt_git_merge', {
|
|
912
|
+
description: MERGE_DESCRIPTION,
|
|
913
|
+
inputSchema: mergeSchema(z)
|
|
914
|
+
}, async (args, { room, doc, branch }) => {
|
|
915
|
+
{ const w = waitRefusal(doc, 'quilt_git_merge', room); if (w) return fail(w) }
|
|
916
|
+
try {
|
|
917
|
+
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: () => {} })
|
|
918
|
+
return text(describeMerge(r))
|
|
919
|
+
} catch (err) { return fail(err.message) }
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
tool('quilt_agent_commits', {
|
|
923
|
+
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.',
|
|
924
|
+
inputSchema: { mode: z.enum(['off', 'branches', 'any']) }
|
|
925
|
+
}, ({ mode }, { room }) => {
|
|
926
|
+
if (!ctx.access(room)?.owner) return fail('Only the session owner decides whether git is on here.')
|
|
927
|
+
try { return text(`Now ${POLICY_WORDS[room.setAgentCommits ? room.setAgentCommits(mode) : mode]}.`) } catch (err) { return fail(err.message) }
|
|
928
|
+
})
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
tool('quilt_commit_status', {
|
|
932
|
+
description: 'Is it a good moment to commit? Lists open commit requests and whose AI is still working (not counting yours).',
|
|
933
|
+
inputSchema: {}
|
|
934
|
+
}, (_, { room, doc }) => text(describeCommits(commitState(room, doc))))
|
|
935
|
+
|
|
936
|
+
tool('quilt_commit_request_done', {
|
|
937
|
+
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.',
|
|
938
|
+
inputSchema: {
|
|
939
|
+
id: z.string().max(40).optional().describe('One request id; omit for all open ones'),
|
|
940
|
+
hash: z.string().max(64).optional().describe('The commit that did it, if you know it')
|
|
941
|
+
}
|
|
942
|
+
}, ({ id: one, hash }, { room, doc }) => {
|
|
943
|
+
{ const w = waitRefusal(doc, 'quilt_commit_request_done', room); if (w) return fail(w) }
|
|
944
|
+
const err = writable(room)
|
|
945
|
+
if (err) return fail(err)
|
|
946
|
+
const open = [...commitMap(doc).values()].filter((r) => r && r.state === 'open' && (!one || r.id === one))
|
|
947
|
+
if (!open.length) return text(one ? `No open commit request ${one}.` : 'No open commit requests.')
|
|
948
|
+
const h = typeof hash === 'string' && /^[0-9a-f]{7,64}$/.test(hash) ? hash : ''
|
|
949
|
+
doc.transact(() => { for (const r of open) commitMap(doc).set(r.id, { ...r, state: 'done', doneBy: me, hash: h, doneAt: Date.now() }) }, AGENT)
|
|
950
|
+
return text(`Marked ${open.length} commit request${open.length === 1 ? '' : 's'} done.`)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
tool('quilt_list_files', {
|
|
954
|
+
description: 'List the shared project files with who is working on them (claims).',
|
|
955
|
+
inputSchema: { under: z.string().optional().describe('Only files under this folder') }
|
|
956
|
+
}, ({ under }, { room, files, blobs }) => {
|
|
957
|
+
const pre = under ? cleanPath(under).replace(/\/+$/, '') + '/' : ''
|
|
958
|
+
const cl = claimsOf(room).map((c) => ({ ...c, m: globMatcher(c.pattern) }))
|
|
959
|
+
const paths = [...new Set([...files.keys(), ...blobs.keys()])].filter((p) => isSafeRelPath(p) && (!pre || p.startsWith(pre))).sort()
|
|
960
|
+
const shown = paths.slice(0, 500).map((p) => {
|
|
961
|
+
const c = cl.find((x) => x.m(p))
|
|
962
|
+
return `- ${p}${c ? ` (${c.box ? `boxed by ${c.by}: their unfinished work` : `claimed by ${c.by}`}${c.queue.length ? `, ${c.queue.length} waiting` : ''})` : ''}`
|
|
963
|
+
})
|
|
964
|
+
return text(paths.length ? shown.join('\n') + (paths.length > 500 ? `\n… and ${paths.length - 500} more` : '') : 'No files.')
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
tool('quilt_project_rules', {
|
|
968
|
+
description: 'The project\'s rules for AI and its design system, the same whatever tool you run in: AGENTS.md (root and folders, else CLAUDE.md), Cursor rules that always apply, and DESIGN.md (or the file AGENTS.md names). Read it before your first change in a session.',
|
|
969
|
+
inputSchema: {}
|
|
970
|
+
}, (args, { files }) => text(renderRules(projectRules({ list: [...files.keys()], read: (p) => files.get(p)?.toString() ?? null }))))
|
|
971
|
+
|
|
972
|
+
tool('quilt_read_file', {
|
|
973
|
+
description: 'Read a shared project file as text, as it is right now.',
|
|
974
|
+
inputSchema: { path: z.string().min(1).max(500).describe('Relative path, e.g. src/app.ts') }
|
|
975
|
+
}, ({ path: p }, { room, files, blobs }) => {
|
|
976
|
+
const rel = cleanPath(p)
|
|
977
|
+
if (!isSafeRelPath(rel)) return fail('That is not a path inside the project.')
|
|
978
|
+
const bx = claimsOf(room).find((c) => c.box && c.by !== me && c.pattern === rel)
|
|
979
|
+
const boxNote = bx ? `(Boxed by ${bx.by}: their unfinished work, not committed yet and in nobody else's folder. Don't build on it as if it were done; ask for the file with quilt_request_file if you need it.)\n\n` : ''
|
|
980
|
+
const t = files.get(rel)
|
|
981
|
+
if (t) {
|
|
982
|
+
const s = t.toString()
|
|
983
|
+
return text(boxNote + (s.length > MAX_READ_CHARS ? s.slice(0, MAX_READ_CHARS) + `\n… (${s.length - MAX_READ_CHARS} more characters not shown)` : s))
|
|
984
|
+
}
|
|
985
|
+
const b = blobs.get(rel)
|
|
986
|
+
if (b && b.stored) return fail(`${rel} is a large file kept in storage (${b.size} bytes); it can't be read here.`)
|
|
987
|
+
if (b) return fail(`${rel} is a binary file (${Buffer.from(b.data || '', 'base64').length} bytes); it can't be read as text.`)
|
|
988
|
+
return fail(`There is no file called ${rel} in the session.`)
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
tool('quilt_write_file', {
|
|
992
|
+
description: 'Write a text file in the shared project (new or existing): everyone gets it on their disk within moments. ' +
|
|
993
|
+
'Give the whole file; it is merged as an edit, so changes others make elsewhere in the file survive. Read the file right before.',
|
|
994
|
+
inputSchema: {
|
|
995
|
+
path: z.string().min(1).max(500).describe('Relative path, e.g. src/app.ts'),
|
|
996
|
+
content: z.string().max(MAX_WRITE_BYTES).describe('The whole new contents of the file')
|
|
997
|
+
}
|
|
998
|
+
}, ({ path: p, content }, { room, doc, fdoc, branch, files, blobs, activity }) => {
|
|
999
|
+
{ const w = waitRefusal(doc, 'quilt_write_file', room); if (w) return fail(w) }
|
|
1000
|
+
const err = writable(room) || branchWritable(room, branch)
|
|
1001
|
+
if (err) return fail(err)
|
|
1002
|
+
const rel = cleanPath(p)
|
|
1003
|
+
if (!isSafeRelPath(rel)) return fail('That is not a path inside the project.')
|
|
1004
|
+
const a = ctx.access(room)
|
|
1005
|
+
if (a && a.role === 'viewer') return fail('You can only view this session; file changes are refused.')
|
|
1006
|
+
const refusal = a && changeRefusal(a, rel)
|
|
1007
|
+
if (refusal) return fail(`${refusal[0].toUpperCase()}${refusal.slice(1)}.`)
|
|
1008
|
+
if (ctx.kind === 'agent' && room.meta.sync && heldForClash(room, branch ? branch.key : DEFAULT_KEY, rel)) return fail(`${rel} is held while a clash with GitHub is merged (quilt_clash status): propose the merged file with quilt_clash propose instead of writing it, so nothing half-merged reaches anyone.`)
|
|
1009
|
+
const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
|
|
1010
|
+
if (claim) return fail(heldRefusal(rel, claim))
|
|
1011
|
+
if (blobs.get(rel)?.stored) return fail(`${rel} is a large file kept in storage; it can't be changed here.`)
|
|
1012
|
+
const held = claimsOf(room).some((c) => c.by === me && globMatcher(c.pattern)(rel))
|
|
1013
|
+
const claimedNow = !held && autoClaim(room, rel)
|
|
1014
|
+
const unboxed = held && boxable(room, branchOf(room)) && claimsOf(room).some((c) => c.by === me && c.pattern === rel && !c.box && !c.clash)
|
|
1015
|
+
if (held && (autoHeld.has(`${roomKey(room)}\0${rel}`) || unboxed)) autoClaim(room, rel) // still writing it: keep holding (in a box)
|
|
1016
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) return fail('That file is too big to write here (1 MB at most).')
|
|
1017
|
+
const boxedNow = claimsOf(room).some((c) => c.by === me && c.pattern === rel && c.box) && (claimedNow || unboxed)
|
|
1018
|
+
let detail = ''
|
|
1019
|
+
let existed = false
|
|
1020
|
+
fdoc.transact(() => doc.transact(() => {
|
|
1021
|
+
existed = files.has(rel) || blobs.has(rel)
|
|
1022
|
+
blobs.delete(rel)
|
|
1023
|
+
let ytext = files.get(rel)
|
|
1024
|
+
if (!ytext) { ytext = new Y.Text(); files.set(rel, ytext) }
|
|
1025
|
+
const before = ytext.toString()
|
|
1026
|
+
detail = applyTextDiff(ytext, content)
|
|
1027
|
+
const kind = existed ? 'edited' : 'created'
|
|
1028
|
+
activity.push([{ by: me, path: rel, kind, detail, ...(branch ? { branch: branch.key } : {}), ts: Date.now() }])
|
|
1029
|
+
if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
|
|
1030
|
+
historyOf(room).record({ by: me, path: rel, kind, before, after: content, task: currentTask(readTasks(taskMap(doc)), me) })
|
|
1031
|
+
}, AGENT), AGENT)
|
|
1032
|
+
// What people said about this file in chat, so the agent works with it in mind (each message once).
|
|
1033
|
+
const box = toldAbout(room)
|
|
1034
|
+
const said = chatAbout([rel], { messages: seen(doc), me }).filter((m) => m.id && !box.told.includes(m.id))
|
|
1035
|
+
if (said.length) {
|
|
1036
|
+
box.told = [...box.told, ...said.map((m) => m.id)].slice(-200)
|
|
1037
|
+
if (ctx.saveInbox) ctx.saveInbox()
|
|
1038
|
+
}
|
|
1039
|
+
const context = renderChatAbout(said)
|
|
1040
|
+
return text(`${existed ? 'Updated' : 'Created'} ${rel}${onBranchNote(room, branch)}${detail ? ` (${detail} lines)` : ' (no change)'}. Everyone in the session has it now.${boxedNow ? ` ${rel} is boxed for you: in nobody else's folder until you commit it (quilt_git_commit), or quilt_unpack it when someone needs it now.` : claimedNow ? ` ${rel} is claimed for you while you work on it; quilt_release it when you are done.` : ''}${context ? `\n\n${context}` : ''}`)
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
tool('quilt_claim', {
|
|
1044
|
+
description: 'Claim files or folders (a path or glob like src/auth/**) before a larger change, so others know not to edit them.',
|
|
1045
|
+
inputSchema: {
|
|
1046
|
+
pattern: z.string().min(1).max(300),
|
|
1047
|
+
note: z.string().max(500).optional().describe('What you are doing')
|
|
1048
|
+
}
|
|
1049
|
+
}, ({ pattern, note }, { room, doc }) => {
|
|
1050
|
+
{ const w = waitRefusal(doc, 'quilt_claim', room); if (w) return fail(w) }
|
|
1051
|
+
const err = writable(room)
|
|
1052
|
+
if (err) return fail(err)
|
|
1053
|
+
try {
|
|
1054
|
+
// Someone who may not post keeps their claim but not its note, which everyone reads.
|
|
1055
|
+
const r = room.claimRequest({ ...who(room), talk: ctx.access(room)?.talk !== false }, { op: 'claim', pattern: pattern.trim(), note: note || '' })
|
|
1056
|
+
if (r.ok === false) return fail(r.error || 'Could not claim that.')
|
|
1057
|
+
const key = `${roomKey(room)}\0${pattern.trim()}`
|
|
1058
|
+
clearTimeout(autoHeld.get(key)); autoHeld.delete(key) // claimed on purpose now: kept until released
|
|
1059
|
+
} catch (e) { return fail(e.message) }
|
|
1060
|
+
room.broadcastClaims()
|
|
1061
|
+
return text(`Claimed ${pattern.trim()}. Release it with quilt_release when done.`)
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
tool('quilt_release', {
|
|
1065
|
+
description: 'Release a claim (or all of your claims when no pattern is given).',
|
|
1066
|
+
inputSchema: { pattern: z.string().optional() }
|
|
1067
|
+
}, ({ pattern }, { room }) => {
|
|
1068
|
+
// Releasing everything keeps boxes: a box opens when it's committed (quilt_git_commit) or unpacked (quilt_unpack).
|
|
1069
|
+
const mine = claimsOf(room).filter((c) => c.by === me && (pattern ? c.pattern === pattern.trim() : !c.box))
|
|
1070
|
+
// A file someone waits for is handed off, not released.
|
|
1071
|
+
const waited = mine.filter((c) => c.queue.length)
|
|
1072
|
+
if (pattern && waited.length) return fail(renderQueued(queuedFor(waited, me), 'release the rest'))
|
|
1073
|
+
const released = []
|
|
1074
|
+
for (const c of mine.filter((x) => !x.queue.length)) {
|
|
1075
|
+
try { room.claimRequest(who(room), { op: 'release', pattern: c.pattern }); released.push(c.pattern) } catch {}
|
|
1076
|
+
const key = `${roomKey(room)}\0${c.pattern}`
|
|
1077
|
+
clearTimeout(autoHeld.get(key)); autoHeld.delete(key)
|
|
1078
|
+
}
|
|
1079
|
+
if (released.length) room.broadcastClaims()
|
|
1080
|
+
const boxes = pattern ? [] : claimsOf(room).filter((c) => c.by === me && c.box).map((c) => c.pattern)
|
|
1081
|
+
return text((released.length ? `Released ${released.join(', ')}.` : 'Nothing to release.') + (waited.length ? ` Kept ${waited.map((c) => c.pattern).join(', ')}: someone is waiting for ${waited.length === 1 ? 'it' : 'them'}; hand off with quilt_handoff.` : '') +
|
|
1082
|
+
(boxes.length ? ` Your boxed work stays boxed (${boxes.slice(0, 10).join(', ')}${boxes.length > 10 ? ', …' : ''}): commit it with quilt_git_commit, or quilt_unpack it.` : ''))
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
tool('quilt_unpack', {
|
|
1086
|
+
description: 'Share boxed work before it is committed. Your writes stay in a box: everyone sees the change, but it is in nobody else\'s folder, so nobody else\'s git can commit it, until you commit it (quilt_git_commit). ' +
|
|
1087
|
+
'Unpack when someone needs your change now: your boxed files reach everyone\'s folder, and you keep them. Decide this yourself; for a large, risky or critical change, or when the project\'s rules say so, ask your person first.',
|
|
1088
|
+
inputSchema: { paths: z.array(z.string().min(1).max(500)).min(1).max(100).describe('Your boxed files, relative paths') }
|
|
1089
|
+
}, ({ paths }, { room, doc }) => {
|
|
1090
|
+
{ const w = waitRefusal(doc, 'quilt_unpack', room); if (w) return fail(w) }
|
|
1091
|
+
const done = []
|
|
1092
|
+
const not = []
|
|
1093
|
+
for (const p of [...new Set(paths.map(cleanPath))]) {
|
|
1094
|
+
const c = claimsOf(room).find((x) => x.pattern === p && x.by === me && x.box)
|
|
1095
|
+
if (!c) { not.push(p); continue }
|
|
1096
|
+
try { if (room.claimRequest(who(room), { op: 'unbox', pattern: p, release: false }).unboxed) done.push(p); else not.push(p) } catch { not.push(p) }
|
|
1097
|
+
}
|
|
1098
|
+
if (done.length) room.broadcastClaims()
|
|
1099
|
+
if (!done.length) return fail(`${not.join(', ')} ${not.length === 1 ? 'is' : 'are'} not boxed by you.`)
|
|
1100
|
+
return text(`Unpacked ${done.join(', ')}: in everyone's folder now. Your next write to one boxes it again.${not.length ? ` Not boxed by you: ${not.join(', ')}.` : ''}`)
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
tool('quilt_request_file', {
|
|
1104
|
+
description: 'Ask for a file someone else holds (claimed), instead of editing it: your request joins its file queue, its holder is told, and when they finish they hand it to you with their context. ' +
|
|
1105
|
+
'You are woken (a direct message, in quilt_inbox and your webhook) when it is yours. Asking again for the same file updates your request.',
|
|
1106
|
+
inputSchema: {
|
|
1107
|
+
path: z.string().min(1).max(500).describe('The file you need, relative path'),
|
|
1108
|
+
title: z.string().min(1).max(120).describe('What you will do, e.g. "Working on retry logic for task t-12"'),
|
|
1109
|
+
description: z.string().max(300).optional().describe('Your plan in summary: what you want to change and why (300 characters at most)'),
|
|
1110
|
+
task: z.string().max(80).optional().describe('The task id this is for, if any')
|
|
1111
|
+
}
|
|
1112
|
+
}, ({ path: p, title, description, task }, { room }) => {
|
|
1113
|
+
const err = writable(room)
|
|
1114
|
+
if (err) return fail(err)
|
|
1115
|
+
try {
|
|
1116
|
+
const r = room.claimRequest({ ...who(room), talk: ctx.access(room)?.talk !== false }, { op: 'request', path: cleanPath(p), title, description: description || '', task })
|
|
1117
|
+
room.broadcastClaims()
|
|
1118
|
+
return text(`Asked for ${cleanPath(p)}: you are number ${r.position} in the queue for ${r.pattern} (held by ${r.holder}). ${r.holder} is told; you will be handed it with their context. Carry on with other work.`)
|
|
1119
|
+
} catch (e) { return fail(e.message) }
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
tool('quilt_handoff', {
|
|
1123
|
+
description: 'Hand a file you hold to someone waiting for it in its file queue (the first, unless you name them), with your context: what you changed, what is left, anything they should know. ' +
|
|
1124
|
+
'The claim becomes theirs and they get your context in a direct message. Required before you finish or release a file someone is waiting for.',
|
|
1125
|
+
inputSchema: {
|
|
1126
|
+
path: z.string().min(1).max(500).describe('The file (or the claimed pattern) to hand off'),
|
|
1127
|
+
context: z.string().min(1).max(2000).describe('What you changed, what is left, gotchas'),
|
|
1128
|
+
to: z.string().max(80).optional().describe('Who to hand it to (a name or request id); omit for the first in the queue')
|
|
1129
|
+
}
|
|
1130
|
+
}, ({ path: p, context, to }, { room }) => {
|
|
1131
|
+
try {
|
|
1132
|
+
const r = room.claimRequest(who(room), { op: 'handoff', pattern: cleanPath(p), context, to: to || '' })
|
|
1133
|
+
const key = `${roomKey(room)}\0${r.pattern}`
|
|
1134
|
+
clearTimeout(autoHeld.get(key)); autoHeld.delete(key)
|
|
1135
|
+
room.broadcastClaims()
|
|
1136
|
+
return text(`Handed ${r.pattern} to ${r.to} with your context.${r.waiting ? ` ${r.waiting} more waiting: ${r.to} hands it on next.` : ''}`)
|
|
1137
|
+
} catch (e) { return fail(e.message) }
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
tool('quilt_withdraw_request', {
|
|
1141
|
+
description: 'Take back your request for a file (from quilt_request_file) when you no longer need it.',
|
|
1142
|
+
inputSchema: { path: z.string().min(1).max(500).describe('The file you asked for') }
|
|
1143
|
+
}, ({ path: p }, { room }) => {
|
|
1144
|
+
const rel = cleanPath(p)
|
|
1145
|
+
const mine = claimsOf(room).flatMap((c) => c.queue).filter((r) => r.by === me && r.path === rel)
|
|
1146
|
+
let n = 0
|
|
1147
|
+
for (const r of mine) n += room.claimRequest(who(room), { op: 'withdraw', request: r.id }).withdrawn || 0
|
|
1148
|
+
if (n) room.broadcastClaims()
|
|
1149
|
+
return text(n ? `Withdrew your request for ${rel}.` : `You had not asked for ${rel}.`)
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
// `quilt sync` (npx heyquilt sync): a sandboxed agent's folder in one shot, its sync state kept
|
|
1153
|
+
// here (relay-turn-sync.js). Hosted only: the CLI reaches it with the agent's QUILT_AGENT_KEY.
|
|
1154
|
+
if (ctx.multi) {
|
|
1155
|
+
tool('quilt_sync', {
|
|
1156
|
+
description: SYNC_DESCRIPTION,
|
|
1157
|
+
inputSchema: {
|
|
1158
|
+
step: z.enum(['plan', 'push', 'pull', 'done']).describe('plan, then push, pull and done'),
|
|
1159
|
+
slot: z.string().min(8).max(64).describe('The slot in your QUILT_AGENT_KEY'),
|
|
1160
|
+
branch: z.string().max(200).optional().describe('plan: the folder\'s git branch; used when the session has it'),
|
|
1161
|
+
files: z.record(z.string(), z.union([z.string(), z.object({ text: z.string().optional(), base64: z.string().optional(), deleted: z.boolean().optional() })])).optional().describe('plan and done: { path: sha1 }; push: { path: { text } | { base64 } | { deleted: true } }'),
|
|
1162
|
+
paths: z.array(z.string().max(1024)).max(10000).optional().describe('pull: the paths to fetch')
|
|
1163
|
+
}
|
|
1164
|
+
}, (args, parts) => {
|
|
1165
|
+
let { room, doc, branch, fdoc, files, blobs, activity } = parts
|
|
1166
|
+
// The folder's git branch, when the session has it: the agent works there from now on (as quilt_git_branch).
|
|
1167
|
+
const want = args.step === 'plan' && typeof args.branch === 'string' ? args.branch.trim() : ''
|
|
1168
|
+
if (want && ctx.setBranch && room.meta.layout === 2 && validBranchKey(want) && want !== DEFAULT_KEY && room.meta.branches[room.resolveKey(want)] && room.resolveKey(want) !== branchOf(room)) {
|
|
1169
|
+
try {
|
|
1170
|
+
ctx.setBranch(room, want, { by: me, editor: ctx.access(room)?.role !== 'viewer' })
|
|
1171
|
+
branch = branchEntry(room)
|
|
1172
|
+
fdoc = branch ? branch.doc : doc
|
|
1173
|
+
files = fdoc.getMap('files')
|
|
1174
|
+
blobs = fdoc.getMap('blobs')
|
|
1175
|
+
} catch {}
|
|
1176
|
+
}
|
|
1177
|
+
const key = branch ? branch.key : DEFAULT_KEY
|
|
1178
|
+
const a = ctx.access(room)
|
|
1179
|
+
try {
|
|
1180
|
+
const answer = turnSyncStep(args, {
|
|
1181
|
+
room,
|
|
1182
|
+
fdoc,
|
|
1183
|
+
files,
|
|
1184
|
+
blobs,
|
|
1185
|
+
branchKey: branch ? branch.key : ROOM_KEY,
|
|
1186
|
+
epoch: branch && room.branchEpoch ? room.branchEpoch(branch.key) : null,
|
|
1187
|
+
account: ctx.who(room).id || me,
|
|
1188
|
+
me,
|
|
1189
|
+
origin: AGENT,
|
|
1190
|
+
globalRefusal: () => waitRefusal(doc, 'quilt_sync', room) || writable(room) || branchWritable(room, branch),
|
|
1191
|
+
pathRefusal: (rel) => {
|
|
1192
|
+
if (a && a.role === 'viewer') return { kind: 'aside', why: 'you can only view this session' }
|
|
1193
|
+
const scope = a && changeRefusal(a, rel)
|
|
1194
|
+
if (scope) return { kind: 'aside', why: scope }
|
|
1195
|
+
if (ctx.kind === 'agent' && room.meta.sync && heldForClash(room, key, rel)) return { kind: 'keep', why: `${rel} is held while a clash with GitHub is merged` }
|
|
1196
|
+
const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
|
|
1197
|
+
return claim ? { kind: 'claimed', why: heldRefusal(rel, claim), by: claim.by } : null
|
|
1198
|
+
},
|
|
1199
|
+
record: ({ rel, kind, before, after, detail }) => {
|
|
1200
|
+
activity.push([{ by: me, path: rel, kind, detail: detail || '', ...(branch ? { branch: branch.key } : {}), ts: Date.now() }])
|
|
1201
|
+
if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
|
|
1202
|
+
historyOf(room).record({ by: me, path: rel, kind, before, after, detail, task: currentTask(readTasks(taskMap(doc)), me) })
|
|
1203
|
+
},
|
|
1204
|
+
lastEditor: (rel) => activity.toArray().reverse().find((e) => e && e.path === rel && (!branch || !e.branch || e.branch === branch.key))?.by || null,
|
|
1205
|
+
// Others' boxes: their unfinished work stays out of this folder (relay-turn-sync.js).
|
|
1206
|
+
ghosts: new Set(claimsOf(room).filter((c) => c.box && c.by !== me).map((c) => c.pattern))
|
|
1207
|
+
})
|
|
1208
|
+
return text(JSON.stringify({ ...answer, branch: key === DEFAULT_KEY ? room.defaultKey : key }))
|
|
1209
|
+
} catch (e) { return fail(e.message) }
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const SYNC_DESCRIPTION = 'For `quilt sync` (npx heyquilt sync), which syncs a sandboxed agent\'s folder with the session in one go: run that command rather than calling this yourself. ' +
|
|
1215
|
+
'Steps: plan (the folder\'s files and their sha1s: what to upload, pull, remove and keep aside), push (their contents), pull (the session\'s versions), done (the folder\'s files after writing: the relay keeps them as this slot\'s checkpoint). Answers are JSON.'
|
|
1216
|
+
|
|
1217
|
+
// The workspace library for hosted agents (see workspace-tools.js), offered only while the
|
|
1218
|
+
// accounts API says workspaces are on.
|
|
1219
|
+
export const FEATURES_EVERY_MS = 10 * 60 * 1000
|
|
1220
|
+
const FEATURES_TIMEOUT_MS = 5000
|
|
1221
|
+
// One API call, and one upload or download (at most 2 MB from here), from the relay: under
|
|
1222
|
+
// the API's own 30 s wait for the relay, so a slow call is reported by the tool, not cut off.
|
|
1223
|
+
const WORKSPACE_CALL_TIMEOUT_MS = 20 * 1000
|
|
1224
|
+
const WORKSPACE_TRANSFER_TIMEOUT_MS = 20 * 1000
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Asks `<apiUrl>/v1/features` now and every `everyMs` whether workspaces are on. on() is
|
|
1228
|
+
* false until the API first says true. A failed probe (unreachable, slow, a server error, an
|
|
1229
|
+
* odd answer) keeps the last answer, so off when there was none; an API without the route
|
|
1230
|
+
* (404) has no workspaces. Never throws; its timer never holds the process open; stop()
|
|
1231
|
+
* ends it and any probe in flight. `ready` is the first probe's answer.
|
|
1232
|
+
*/
|
|
1233
|
+
export function watchFeatures ({ apiUrl, fetch: fetchImpl = globalThis.fetch, everyMs = FEATURES_EVERY_MS, retryMs = 30_000, timeoutMs = FEATURES_TIMEOUT_MS, log = () => {} }) {
|
|
1234
|
+
const url = `${String(apiUrl).replace(/\/+$/, '')}/v1/features`
|
|
1235
|
+
let known = false
|
|
1236
|
+
let inflight = null
|
|
1237
|
+
let current = null // the AbortController of the probe in flight
|
|
1238
|
+
let stopped = false
|
|
1239
|
+
let answered = false // until the API has answered once, ask again sooner
|
|
1240
|
+
const set = (on) => {
|
|
1241
|
+
answered = true
|
|
1242
|
+
if (on !== known) log(`hosted agents: workspace tools ${on ? 'on' : 'off'}`)
|
|
1243
|
+
known = on
|
|
1244
|
+
}
|
|
1245
|
+
async function ask () {
|
|
1246
|
+
const ac = new AbortController()
|
|
1247
|
+
current = ac
|
|
1248
|
+
const timer = setTimeout(() => ac.abort(new Error('the features check took too long')), timeoutMs)
|
|
1249
|
+
timer.unref?.()
|
|
1250
|
+
try {
|
|
1251
|
+
const res = await fetchImpl(url, { signal: ac.signal })
|
|
1252
|
+
if (res.status === 404) { set(false); return }
|
|
1253
|
+
if (!res.ok) return
|
|
1254
|
+
const body = await res.json()
|
|
1255
|
+
if (body && typeof body === 'object') set(body.workspaces === true)
|
|
1256
|
+
} catch {
|
|
1257
|
+
// Unreachable, slow or garbled: keep what we knew.
|
|
1258
|
+
} finally {
|
|
1259
|
+
clearTimeout(timer)
|
|
1260
|
+
ac.abort()
|
|
1261
|
+
current = null
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
function probe () {
|
|
1265
|
+
if (stopped) return Promise.resolve(known)
|
|
1266
|
+
if (!inflight) inflight = ask().catch(() => {}).finally(() => { inflight = null })
|
|
1267
|
+
return inflight.then(() => known)
|
|
1268
|
+
}
|
|
1269
|
+
const ready = probe()
|
|
1270
|
+
const timer = setInterval(() => { probe() }, everyMs)
|
|
1271
|
+
timer.unref?.()
|
|
1272
|
+
// The API may still be starting (both deployed together): until it answers, try every retryMs.
|
|
1273
|
+
const early = setInterval(() => { if (answered) clearInterval(early); else probe() }, retryMs)
|
|
1274
|
+
early.unref?.()
|
|
1275
|
+
return {
|
|
1276
|
+
ready,
|
|
1277
|
+
on: () => !stopped && known,
|
|
1278
|
+
probe,
|
|
1279
|
+
stop () {
|
|
1280
|
+
stopped = true
|
|
1281
|
+
clearInterval(timer)
|
|
1282
|
+
clearInterval(early)
|
|
1283
|
+
current?.abort(new Error('stopped'))
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* How a hosted agent's library tools reach the accounts API: as the agent, with the pass
|
|
1290
|
+
* the relay was handed (`Authorization: QuiltPass`, which only the workspace routes take).
|
|
1291
|
+
* Errors carry the API's message and status. The pass never goes into an error or a log.
|
|
1292
|
+
*/
|
|
1293
|
+
export function hostedWorkspaceAccess ({ apiUrl, pass, fetch: fetchImpl = globalThis.fetch }) {
|
|
1294
|
+
const api = String(apiUrl).replace(/\/+$/, '')
|
|
1295
|
+
const call = async (method, route, body) => {
|
|
1296
|
+
let res
|
|
1297
|
+
try {
|
|
1298
|
+
res = await fetchImpl(api + route, {
|
|
1299
|
+
method,
|
|
1300
|
+
headers: { authorization: `QuiltPass ${pass}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
1301
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1302
|
+
signal: AbortSignal.timeout(WORKSPACE_CALL_TIMEOUT_MS)
|
|
1303
|
+
})
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
throw new Error(`Couldn't reach Quilt (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
1306
|
+
}
|
|
1307
|
+
const data = await res.json().catch(() => null)
|
|
1308
|
+
if (!res.ok) throw Object.assign(new Error(data?.error || `Quilt answered ${res.status}.`), { status: res.status })
|
|
1309
|
+
if (!data || typeof data !== 'object') throw new Error('Quilt sent back something unexpected.')
|
|
1310
|
+
return data
|
|
1311
|
+
}
|
|
1312
|
+
const put = async (url, bytes, headers) => {
|
|
1313
|
+
try {
|
|
1314
|
+
const res = await fetchImpl(url, { method: 'PUT', headers, body: bytes, signal: AbortSignal.timeout(WORKSPACE_TRANSFER_TIMEOUT_MS) })
|
|
1315
|
+
await res.body?.cancel().catch(() => {}) // the status is all we need; let the socket go
|
|
1316
|
+
return res.status
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
throw new Error(`The upload did not go through (${err?.cause?.code || err?.name || 'no answer'}).`)
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return { call, fetchBytes: bytesFetcher(fetchImpl, { timeoutMs: WORKSPACE_TRANSFER_TIMEOUT_MS }), put, saveDir: null, readLocal: null }
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function serve (mcp, req, res, body) {
|
|
1325
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
1326
|
+
res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
|
|
1327
|
+
await mcp.connect(transport)
|
|
1328
|
+
// `body`: read already (the relay on many machines looked at it to find the room's machine).
|
|
1329
|
+
await transport.handleRequest(req, res, body)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Hosted agents: one MCP request (stateless) for the holder of `pass`, who is in whichever
|
|
1334
|
+
* session `relay.hosted` remembers for them. `relay` is { getRoom, roomEnded, refused, hosted,
|
|
1335
|
+
* saveHosted, log, endedMessage }. `workspaces`, given only while the API has them on and
|
|
1336
|
+
* the holder is an agent, is { apiUrl, pass (as sent), fetch }: the library tools are added.
|
|
1337
|
+
*/
|
|
1338
|
+
export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, body = undefined }) {
|
|
1339
|
+
const image = String(req.headers['x-quilt-image'] || '').trim()
|
|
1340
|
+
// The library's guide is part of the instructions only when its tools are offered.
|
|
1341
|
+
const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: workspaces ? `${HOSTED_INSTRUCTIONS}\n\n${WORKSPACE_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS })
|
|
1342
|
+
const me = pass.name
|
|
1343
|
+
const account = `${pass.kind}:${pass.sub}`
|
|
1344
|
+
const touched = new Set()
|
|
1345
|
+
const done = () => { for (const room of touched) if (room && !room.conns.size && room.onEmpty) room.onEmpty() }
|
|
1346
|
+
res.on('close', done)
|
|
1347
|
+
// Which sessions the agent is in, for the accounts API (x-quilt-rooms), and which one this
|
|
1348
|
+
// call acted on (x-quilt-room): its next pass is for that room, and so carries its grant there.
|
|
1349
|
+
// On many relay machines (relay-cluster.js), the agent's sessions on other machines come from the
|
|
1350
|
+
// accounts API's index (`relay.index`, read before this call); this machine holds the records
|
|
1351
|
+
// of its own. Each call is sent to the machine of the session it names.
|
|
1352
|
+
const index = relay.index || null
|
|
1353
|
+
const allRooms = (h) => {
|
|
1354
|
+
const mine = hostedRooms(h)
|
|
1355
|
+
return index ? [...mine, ...index.rooms.filter((r) => !mine.includes(r))] : mine
|
|
1356
|
+
}
|
|
1357
|
+
const elsewhere = (h, name) => !!(index && !hostedIn(h, name) && index.rooms.includes(name))
|
|
1358
|
+
const noteIndex = (o) => {
|
|
1359
|
+
if (!index || !relay.noteHosted) return
|
|
1360
|
+
if (o.add && !index.rooms.includes(o.add)) index.rooms.push(o.add)
|
|
1361
|
+
if (o.remove) index.rooms = index.rooms.filter((r) => r !== o.remove)
|
|
1362
|
+
relay.noteHosted(o)
|
|
1363
|
+
}
|
|
1364
|
+
const tellRoom = (acted = '') => {
|
|
1365
|
+
if (res.headersSent) return
|
|
1366
|
+
const rooms = allRooms(relay.hosted.get(account))
|
|
1367
|
+
if (rooms.length) res.setHeader('x-quilt-rooms', rooms.join(',')); else res.removeHeader('x-quilt-rooms')
|
|
1368
|
+
const one = acted || (rooms.length === 1 ? rooms[0] : '')
|
|
1369
|
+
if (one) res.setHeader('x-quilt-room', one); else res.removeHeader('x-quilt-room')
|
|
1370
|
+
}
|
|
1371
|
+
tellRoom()
|
|
1372
|
+
const listSessions = (rooms) => `You are in ${rooms.length} sessions (${rooms.join(', ')}): say which this is about with session: <id>.`
|
|
1373
|
+
/** The agent's record for the session a call names (or its only one), or why there is none. */
|
|
1374
|
+
const pick = (session) => {
|
|
1375
|
+
const h = relay.hosted.get(account)
|
|
1376
|
+
const rooms = allRooms(h)
|
|
1377
|
+
if (!rooms.length) return { error: NOT_JOINED }
|
|
1378
|
+
const name = session ? String(session).trim() : ''
|
|
1379
|
+
if (name && elsewhere(h, name)) return { error: `Session ${name} is on another relay machine; call again with session: ${name} in a moment.` }
|
|
1380
|
+
if (name && !hostedIn(h, name)) return { error: `You are not in session ${name}. ${rooms.length === 1 ? `You are in ${rooms[0]}.` : listSessions(rooms)}` }
|
|
1381
|
+
if (!name && rooms.length > 1) return { error: listSessions(rooms) }
|
|
1382
|
+
if (!name && elsewhere(h, rooms[0])) return { error: `Session ${rooms[0]} is on another relay machine; call again with session: ${rooms[0]}.` }
|
|
1383
|
+
return { h, name: name || rooms[0] }
|
|
1384
|
+
}
|
|
1385
|
+
/** Forgets one session (ended, removed, denied); the entry, with its webhook, stays. */
|
|
1386
|
+
const drop = (name, reason) => {
|
|
1387
|
+
visitEnd(name, reason)
|
|
1388
|
+
const h = relay.hosted.get(account)
|
|
1389
|
+
if (h && h.rooms) delete h.rooms[name]
|
|
1390
|
+
relay.saveHosted()
|
|
1391
|
+
noteIndex({ remove: name })
|
|
1392
|
+
tellRoom()
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** The session this call is about, with the agent's standing there, or why there is none: { room, access } | { error }. */
|
|
1396
|
+
const current = (session) => {
|
|
1397
|
+
const p = pick(session)
|
|
1398
|
+
if (p.error) return p
|
|
1399
|
+
const { h, name } = p
|
|
1400
|
+
const r = h.rooms[name]
|
|
1401
|
+
if (relay.roomEnded(name)) { drop(name, 'session_ended'); return { error: `${relay.endedMessage}. ${allRooms(h).length ? listSessions(allRooms(h)) : NOT_JOINED}` } }
|
|
1402
|
+
const room = relay.getRoom(name)
|
|
1403
|
+
if (!room) return { error: relay.refused(name)[1] }
|
|
1404
|
+
touched.add(room)
|
|
1405
|
+
if (!room.exists) { drop(name, 'session_ended'); return { error: REMOVED } }
|
|
1406
|
+
if (r.denied) { drop(name, 'removed'); return { error: DENIED } }
|
|
1407
|
+
const access = room.hostedAccess(pass)
|
|
1408
|
+
if (access.needsRoomPass) {
|
|
1409
|
+
if (!res.headersSent) { res.setHeader('x-quilt-retry', 'room-pass'); tellRoom(name) }
|
|
1410
|
+
return { error: NEEDS_ROOM_PASS, room, access }
|
|
1411
|
+
}
|
|
1412
|
+
if (access.state !== 'approved') {
|
|
1413
|
+
// Still waiting, but the relay restarted and forgot: back on the owner's list.
|
|
1414
|
+
if (r.pending && ![...room.pending].some(([k, p]) => k.hosted && p.id === account)) room.hostedRequest(pass, r.invitedAs || 'viewer')
|
|
1415
|
+
if (!r.pending) visitEnd(name, 'removed')
|
|
1416
|
+
return { error: r.pending ? WAITING : REMOVED, room, access }
|
|
1417
|
+
}
|
|
1418
|
+
r.seenAt = h.seenAt = Date.now()
|
|
1419
|
+
relay.saveHosted()
|
|
1420
|
+
tellRoom(name)
|
|
1421
|
+
room.hostedActive(account)
|
|
1422
|
+
visitStart(room, access)
|
|
1423
|
+
return { room, access }
|
|
1424
|
+
}
|
|
1425
|
+
// The audit trail (server.js keeps each visit with the agent's record for that session in relay.hosted).
|
|
1426
|
+
const visitStart = (room, access) => { if (relay.visitStart) relay.visitStart(account, room, { name: me, tool: toolLabel(mcp.server.getClientVersion()?.name) || 'hosted', owner: !!(access && access.owner) }) }
|
|
1427
|
+
const visitEnd = (roomName, reason) => { if (relay.visitEnd) relay.visitEnd(account, roomName, reason) }
|
|
1428
|
+
// The agent's sessions that are loaded on this relay now (for a webhook taking stock).
|
|
1429
|
+
const loadedRooms = () => hostedRooms(relay.hosted.get(account)).map((n) => (relay.roomEnded(n) ? null : relay.getRoom(n))).filter((r) => r && r.exists)
|
|
1430
|
+
const ctx = {
|
|
1431
|
+
me,
|
|
1432
|
+
multi: true, // every tool takes `session`
|
|
1433
|
+
who: () => ({ name: me, id: account }),
|
|
1434
|
+
kind: pass.kind === 'agent' ? 'agent' : 'human',
|
|
1435
|
+
access: (room) => room.hostedAccess(pass),
|
|
1436
|
+
branch: (room) => room.hostedBranch(account),
|
|
1437
|
+
setBranch: (room, key, opts) => room.setHostedBranch(account, key, opts),
|
|
1438
|
+
pin: (room) => room.pinHostedBranch(account),
|
|
1439
|
+
tool: () => toolLabel(mcp.server.getClientVersion()?.name) || 'hosted',
|
|
1440
|
+
warn: () => '',
|
|
1441
|
+
inbox: (room) => {
|
|
1442
|
+
const r = hostedIn(relay.hosted.get(account), room.name)
|
|
1443
|
+
if (!r) return { state: null }
|
|
1444
|
+
if (!r.inbox) r.inbox = { state: null }
|
|
1445
|
+
return r.inbox
|
|
1446
|
+
},
|
|
1447
|
+
saveInbox: () => relay.saveHosted(),
|
|
1448
|
+
webhook: relay.webhooks ? {
|
|
1449
|
+
get: () => relay.hosted.get(account)?.webhook || null,
|
|
1450
|
+
subscribe: (given) => {
|
|
1451
|
+
const sub = relay.webhooks.subscribe(account, { name: me, rooms: loadedRooms() }, given)
|
|
1452
|
+
if (relay.shareWebhook) relay.shareWebhook(relay.hosted.get(account).webhook)
|
|
1453
|
+
return sub
|
|
1454
|
+
},
|
|
1455
|
+
// A workspace agent's one webhook also gets session.started from the accounts API: the same URL and secret go there.
|
|
1456
|
+
tellApi: async (sub) => {
|
|
1457
|
+
if (!workspaces || pass.kind !== 'agent') return ''
|
|
1458
|
+
try {
|
|
1459
|
+
await hostedWorkspaceAccess(workspaces).call('PUT', '/v1/agents/me/webhook', { url: sub.url, secret: sub.secret })
|
|
1460
|
+
return 'Your workspaces send session.started there too (a session starting in a workspace that invites you, with its join link).'
|
|
1461
|
+
} catch (e) { return `Your workspaces could not be told (${e.message}); session.started events will not reach it until quilt_webhook_subscribe succeeds.` }
|
|
1462
|
+
},
|
|
1463
|
+
unsubscribe: async () => {
|
|
1464
|
+
const had = relay.webhooks.unsubscribe(account)
|
|
1465
|
+
if (relay.shareWebhook) relay.shareWebhook(null)
|
|
1466
|
+
if (had && workspaces && pass.kind === 'agent') await hostedWorkspaceAccess(workspaces).call('DELETE', '/v1/agents/me/webhook').catch(() => {})
|
|
1467
|
+
return had
|
|
1468
|
+
}
|
|
1469
|
+
} : null,
|
|
1470
|
+
updates: relay.updates || null,
|
|
1471
|
+
image: () => image,
|
|
1472
|
+
withSession: (fn, session) => {
|
|
1473
|
+
const c = current(session)
|
|
1474
|
+
return c.error ? fail(c.error) : fn(c.room)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
mcp.registerTool('quilt_join_session', {
|
|
1479
|
+
description: 'Join a live quilt session from an invite link (https://join.heyquilt.com/<room>#<secret>). The session owner may have to let you in; call quilt_session_info to see when they have.',
|
|
1480
|
+
inputSchema: { invite: z.string().min(1).max(600).describe('The invite link, or the full "quilt join <link>" command') }
|
|
1481
|
+
}, ({ invite }) => {
|
|
1482
|
+
let inv
|
|
1483
|
+
try { inv = parseInvite(invite, { allowRelay: () => true }) } catch (err) { return fail(err.message) }
|
|
1484
|
+
if (relay.roomEnded(inv.room)) return fail(`${relay.endedMessage}. Ask for a new invite.`)
|
|
1485
|
+
const room = relay.getRoom(inv.room)
|
|
1486
|
+
if (!room) return fail(relay.refused(inv.room)[1])
|
|
1487
|
+
touched.add(room)
|
|
1488
|
+
if (!room.exists) return fail("That session isn't running on the relay (yet). Ask the person who invited you to start it, then try again.")
|
|
1489
|
+
const auth = room.authorize(inv.secret, '')
|
|
1490
|
+
if (auth !== 'editor' && auth !== 'viewer') return fail('Wrong room secret: copy the whole invite link, including the part after #.')
|
|
1491
|
+
const a = room.hostedRequest(pass, auth)
|
|
1492
|
+
// One more session for the agent (it stays in the others); joining again in the same session carries its visit and inbox on.
|
|
1493
|
+
const h = relay.hosted.get(account) || newHosted()
|
|
1494
|
+
const was = hostedIn(h, inv.room)
|
|
1495
|
+
h.rooms[inv.room] = { since: was ? was.since : Date.now(), seenAt: Date.now(), pending: a.state === 'pending', ...(a.state === 'pending' ? { invitedAs: auth, name: me, kind: pass.kind } : {}), inbox: was && was.inbox ? was.inbox : takeStock(room.doc, me), ...(was && was.visit ? { visit: was.visit } : {}) }
|
|
1496
|
+
h.seenAt = Date.now()
|
|
1497
|
+
relay.hosted.set(account, h)
|
|
1498
|
+
// Its webhook (one for every session) takes stock of this one: what is there already is not news.
|
|
1499
|
+
if (relay.webhooks) relay.webhooks.takeStock(account, room)
|
|
1500
|
+
relay.saveHosted()
|
|
1501
|
+
noteIndex({ add: inv.room })
|
|
1502
|
+
tellRoom(inv.room)
|
|
1503
|
+
const all = allRooms(h)
|
|
1504
|
+
const also = all.length > 1 ? ` You are in ${all.length} sessions now (${all.join(', ')}): name this one with session: ${inv.room} in your calls.` : ''
|
|
1505
|
+
if (a.state === 'pending') return text(`Asked to join room ${inv.room} as ${auth === 'viewer' ? 'a viewer' : 'an editor'}. ${WAITING}${also}`)
|
|
1506
|
+
room.hostedActive(account)
|
|
1507
|
+
visitStart(room, a)
|
|
1508
|
+
return text(`Joined room ${inv.room} as ${me} (${a.owner ? 'owner' : a.role}${a.scopes && a.scopes.length ? `, folders ${a.scopes.join(', ')}` : ''}). Call quilt_status to see who is here.${also}`)
|
|
1509
|
+
})
|
|
1510
|
+
|
|
1511
|
+
mcp.registerTool('quilt_session_info', {
|
|
1512
|
+
description: 'The sessions you are in (their ids, for `session` in every other tool) and your access in each: waiting for the owner, or let in as an editor or viewer. With session, just that one.',
|
|
1513
|
+
inputSchema: { session: SESSION_ARG }
|
|
1514
|
+
}, ({ session } = {}) => {
|
|
1515
|
+
const h = relay.hosted.get(account)
|
|
1516
|
+
const rooms = allRooms(h)
|
|
1517
|
+
if (!rooms.length) return text(NOT_JOINED)
|
|
1518
|
+
const lines = []
|
|
1519
|
+
const one = session ? String(session).trim() : ''
|
|
1520
|
+
if (one && !rooms.includes(one)) return fail(`You are not in session ${one}. ${listSessions(rooms)}`)
|
|
1521
|
+
for (const name of one ? [one] : rooms) {
|
|
1522
|
+
// A session on another relay machine is described there: this call names it to ask.
|
|
1523
|
+
if (elsewhere(h, name)) { lines.push(`Room ${name}: call quilt_session_info with session: ${name} for your access there.`); continue }
|
|
1524
|
+
const c = current(name)
|
|
1525
|
+
if (c.error) { lines.push(`Room ${name}: ${c.error}`); continue }
|
|
1526
|
+
const a = c.access
|
|
1527
|
+
const room = c.room
|
|
1528
|
+
lines.push(`Room ${name}${room.meta.name ? ` (${room.meta.name})` : ''}${room.meta.workspace ? `, workspace ${room.meta.workspace}` : ''}: you are ${me}, ${a.owner ? 'the owner' : a.role === 'viewer' ? 'a viewer (no file changes)' : 'an editor'}${a.scopes && a.scopes.length ? `, limited to ${a.scopes.join(', ')}` : ''}${a.scopesExcept && a.scopesExcept.length ? `, not in ${a.scopesExcept.join(', ')}` : ''}${a.talk === false ? ', and you may not post' : ''}.`)
|
|
1529
|
+
}
|
|
1530
|
+
if (rooms.length > 1) lines.push('Name the session in every call (session: <id>); a webhook event or inbox entry says which it is from.')
|
|
1531
|
+
return text(lines.join('\n'))
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
mcp.registerTool('quilt_leave_session', {
|
|
1535
|
+
description: 'Leave a session you are in (the others, and your webhook, stay).',
|
|
1536
|
+
inputSchema: { session: SESSION_ARG }
|
|
1537
|
+
}, ({ session }) => {
|
|
1538
|
+
const p = pick(session)
|
|
1539
|
+
if (p.error) return p.error === NOT_JOINED ? text('You are not in a session.') : fail(p.error)
|
|
1540
|
+
const { h, name } = p
|
|
1541
|
+
visitEnd(name, 'left')
|
|
1542
|
+
delete h.rooms[name]
|
|
1543
|
+
relay.saveHosted()
|
|
1544
|
+
noteIndex({ remove: name })
|
|
1545
|
+
tellRoom()
|
|
1546
|
+
const room = relay.getRoom(name)
|
|
1547
|
+
if (room) {
|
|
1548
|
+
touched.add(room)
|
|
1549
|
+
room.hostedSeen.delete(account)
|
|
1550
|
+
room.forgetHostedBranch(account)
|
|
1551
|
+
for (const [k, p] of room.pending) if (k.hosted && p.id === account) room.pending.delete(k)
|
|
1552
|
+
// Leaving lets go of its claims: nobody else could.
|
|
1553
|
+
room.dropRequests((r) => r.byId === account)
|
|
1554
|
+
room.dropClaims((c) => c.byId === account, (c) => `${c.by} left the session.`)
|
|
1555
|
+
room.broadcastClaims()
|
|
1556
|
+
room.broadcastMembers()
|
|
1557
|
+
}
|
|
1558
|
+
const left = allRooms(h)
|
|
1559
|
+
return text(`Left room ${name}.${left.length ? ` You are still in ${left.join(', ')}.` : ''}`)
|
|
1560
|
+
})
|
|
1561
|
+
|
|
1562
|
+
mcp.registerTool('quilt_git_branch', {
|
|
1563
|
+
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.',
|
|
1564
|
+
inputSchema: {
|
|
1565
|
+
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'),
|
|
1566
|
+
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'),
|
|
1567
|
+
session: SESSION_ARG
|
|
1568
|
+
}
|
|
1569
|
+
}, async ({ branch, create, session }) => {
|
|
1570
|
+
const c = current(session)
|
|
1571
|
+
if (c.error) return fail(c.error)
|
|
1572
|
+
const room = c.room
|
|
1573
|
+
if (!branch) {
|
|
1574
|
+
const members = []
|
|
1575
|
+
for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
|
|
1576
|
+
const br = branchBoard(members, room.branchList ? room.branchList() : [])
|
|
1577
|
+
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.')
|
|
1578
|
+
}
|
|
1579
|
+
const key = String(branch).trim()
|
|
1580
|
+
const editor = ctx.access(room)?.role !== 'viewer'
|
|
1581
|
+
let gh = null
|
|
1582
|
+
try {
|
|
1583
|
+
if (create && validBranchKey(key) && key !== DEFAULT_KEY && !room.meta.branches[room.resolveKey(key)]) {
|
|
1584
|
+
room.assertMigrated()
|
|
1585
|
+
room.assertBranchRoom(editor, 0) // refused before asking GitHub anything
|
|
1586
|
+
gh = await room.branchFromGitHub(account, key)
|
|
1587
|
+
}
|
|
1588
|
+
const r = room.setHostedBranch(account, key, { create: !!create, by: me, editor, github: gh && gh.state === 'loaded' ? gh : null })
|
|
1589
|
+
if (r.created && gh && gh.state === 'loaded') {
|
|
1590
|
+
const n = gh.texts.size + gh.blobs.size
|
|
1591
|
+
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.` : ''
|
|
1592
|
+
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.`)
|
|
1593
|
+
}
|
|
1594
|
+
if (r.created) {
|
|
1595
|
+
const why = !gh ? '' : {
|
|
1596
|
+
'not-github': `The session's repository (${gh.repo}) isn't on GitHub, so the relay can't load ${r.branch} from it: `,
|
|
1597
|
+
'no-branch': `${r.branch} isn't a branch on GitHub (${gh.repo}), so `,
|
|
1598
|
+
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 `,
|
|
1599
|
+
'token-refused': `The relay's GitHub token can't read ${gh.repo} (ask the session owner to give it read access), so `,
|
|
1600
|
+
'too-many': `${r.branch} on GitHub has more files than the relay loads at once (${LOAD_FILES}), so `,
|
|
1601
|
+
backoff: 'GitHub asked the relay to slow down, so ',
|
|
1602
|
+
error: `The relay couldn't read ${r.branch} from GitHub (${gh.message || 'no answer'}), so `
|
|
1603
|
+
}[gh.state] || ''
|
|
1604
|
+
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.`
|
|
1605
|
+
return text(why ? `${why}${said[0].toLowerCase()}${said.slice(1)}` : said)
|
|
1606
|
+
}
|
|
1607
|
+
return text(`You are on ${r.branch} now: your file tools, claims and history use it.`)
|
|
1608
|
+
} catch (e) { return fail(e.message) }
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
mcp.registerTool('quilt_sync_branch', {
|
|
1612
|
+
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.',
|
|
1613
|
+
inputSchema: { session: SESSION_ARG }
|
|
1614
|
+
}, async ({ session }) => {
|
|
1615
|
+
const c = current(session)
|
|
1616
|
+
if (c.error) return fail(c.error)
|
|
1617
|
+
const room = c.room
|
|
1618
|
+
if (room.meta.layout !== 2 || !room.syncUpstreamNow) return fail('This session has no branches on the relay.')
|
|
1619
|
+
try {
|
|
1620
|
+
const key = room.hostedBranch(account)
|
|
1621
|
+
const r = await room.syncUpstreamNow(key)
|
|
1622
|
+
return text(r.said)
|
|
1623
|
+
} catch (e) { return fail(e.message) }
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
mcp.registerTool('quilt_github_token', {
|
|
1627
|
+
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.',
|
|
1628
|
+
inputSchema: { token: z.string().max(300).describe('The token (github_pat_… or ghp_…), or "" to remove it'), session: SESSION_ARG }
|
|
1629
|
+
}, ({ token, session }) => {
|
|
1630
|
+
const c = current(session)
|
|
1631
|
+
if (c.error) return fail(c.error)
|
|
1632
|
+
if (!c.access || !c.access.owner) return fail('Only the session owner can set the GitHub token: ask them.')
|
|
1633
|
+
try {
|
|
1634
|
+
const r = c.room.setGithubToken(token)
|
|
1635
|
+
return text(r.githubToken ? 'Saved the GitHub token on the relay. It looks at the session\'s branches again now.' : 'Removed the GitHub token.')
|
|
1636
|
+
} catch (e) { return fail(e.message) }
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1639
|
+
sessionTools(mcp, ctx)
|
|
1640
|
+
// The library works whether or not the agent is in a session (it never touches relay.hosted).
|
|
1641
|
+
if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
|
|
1642
|
+
await serve(mcp, req, res, body)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Handles one MCP request (stateless: a fresh server per request) for the older
|
|
1647
|
+
* link-based flow: a browser's token tied to a room by /agent/link.
|
|
1648
|
+
* @param {object} o
|
|
1649
|
+
* @param {object} o.room the relay Room (doc, awareness, full)
|
|
1650
|
+
* @param {object} o.link { name, tool, tabSeenAt }
|
|
1651
|
+
* @param {string} [o.toolHint] tool name from the URL (?tool=Cursor)
|
|
1652
|
+
*/
|
|
1653
|
+
export async function handleAgentMcp ({ req, res, room, link, toolHint, updates = null }) {
|
|
1654
|
+
const mcp = new McpServer({ name: 'quilt', version: '0.1.0' }, { instructions: INSTRUCTIONS })
|
|
1655
|
+
const me = link ? link.name : ''
|
|
1656
|
+
const image = String(req.headers['x-quilt-image'] || '').trim()
|
|
1657
|
+
const ctx = {
|
|
1658
|
+
me,
|
|
1659
|
+
updates,
|
|
1660
|
+
image: () => image,
|
|
1661
|
+
who: () => ({ name: me }),
|
|
1662
|
+
access: () => null,
|
|
1663
|
+
tool: () => toolHint || toolLabel(mcp.server.getClientVersion()?.name),
|
|
1664
|
+
warn: () => Date.now() - (link.tabSeenAt || 0) > TAB_STALE_MS
|
|
1665
|
+
? `\n\n⚠️ ${me}'s quilt browser tab doesn't seem to be open, so file changes aren't syncing. Ask your user to reopen quilt in their browser.`
|
|
1666
|
+
: '',
|
|
1667
|
+
withSession: (fn) => (room && link) ? fn(room) : fail(NOT_LINKED),
|
|
1668
|
+
inbox: () => {
|
|
1669
|
+
let box = link ? linkInboxes.get(link) : null
|
|
1670
|
+
if (!box) {
|
|
1671
|
+
box = room && link ? takeStock(room.doc, me) : { state: null }
|
|
1672
|
+
if (link) linkInboxes.set(link, box)
|
|
1673
|
+
}
|
|
1674
|
+
return box
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
sessionTools(mcp, ctx)
|
|
1678
|
+
await serve(mcp, req, res)
|
|
1679
|
+
}
|