heyquilt 0.3.122 → 0.3.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +279 -17
- package/bin/quilt.js +62 -152
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/account.js +0 -29
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -38
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +17 -25
- package/src/api/supabase-store.js +43 -56
- package/src/autowork.js +182 -16
- package/src/branch-view.js +6 -131
- package/src/connection.js +36 -9
- package/src/control.js +131 -48
- package/src/duties.js +1 -1
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/github-commit.js +3 -315
- package/src/history.js +13 -3
- package/src/mcp.js +93 -167
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +154 -432
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1516 -1383
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +745 -3438
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +7 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +9 -4
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -4
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -28
- package/src/ui/home.js +3 -223
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +377 -489
- package/src/ui/tree.js +2 -8
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +81 -66
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/api/routes/github.js +0 -393
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/git.js +0 -147
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1033
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -918
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/ui/branches.js +0 -111
- package/src/upstream.js +0 -192
package/src/mcp.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// and their AIs are doing, coordinate, and even join or start a session by
|
|
4
4
|
// itself. It forwards to the local `quilt join` process for the project, or
|
|
5
5
|
// runs the session itself when the agent joins on its own.
|
|
6
|
-
import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
|
|
7
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
8
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
9
8
|
import { z } from 'zod'
|
|
@@ -18,7 +17,6 @@ import os from 'node:os'
|
|
|
18
17
|
import { findDaemon, call as rawCall } from './control.js'
|
|
19
18
|
import { parentPids } from './hooks.js'
|
|
20
19
|
import { renderMessage, renderStatus } from './status.js'
|
|
21
|
-
import { branchesMarkdown, upstreamLine, describeBranchSync } from './branches.js'
|
|
22
20
|
import { DEFAULT_KEY } from './branchdocs.js'
|
|
23
21
|
import { formatTasks, columnName, assigneeLabel, renderNextTask, COLUMNS } from './tasks.js'
|
|
24
22
|
import { formatTaskDetails, MAX_COMMENT } from './task-comments.js'
|
|
@@ -33,20 +31,16 @@ import { quiltHome } from './legacy.js'
|
|
|
33
31
|
import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, MAX_VERIFIED } from './agent-task-workflow.js'
|
|
34
32
|
import { formatHistory } from './history.js'
|
|
35
33
|
import { releaseWords } from './release-words.js'
|
|
36
|
-
import { describeCommitRequests, REQUEST_COMMIT_DESCRIPTION } from './commit.js'
|
|
37
|
-
import { POLICY_WORDS, unpublishedRefusal } from './relay-commit.js'
|
|
38
34
|
import { renderInbox, describeEvent, INBOX_HOW } from './inbox.js'
|
|
39
35
|
import { renderConversation, renderContext, CONVERSATION_DESCRIPTION } from './conversation.js'
|
|
40
36
|
import { renderChatAbout, renderUnanswered, heldRefusal, renderQueueNotice, renderQueued, CHAT_RULES, RE_HELP, PERSON_CHAT } from './duties.js'
|
|
41
37
|
import { describeSubscription, describeWebhookTest, COALESCE_HELP, WEBHOOK_EVENTS } from './webhooks.js'
|
|
42
38
|
import { UpdateCheck } from './update-check.js'
|
|
43
39
|
import { getSettings } from './settings.js'
|
|
44
|
-
import { mergeAction } from './merges.js'
|
|
45
|
-
import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
46
40
|
import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
|
|
47
41
|
import { appWorkspaces } from './app-workspaces.js'
|
|
48
42
|
import { TRANSCRIPT_DESCRIPTION, renderLines } from './voice.js'
|
|
49
|
-
import { seamGate, seamNote } from './session-seam.js'
|
|
43
|
+
import { seamGate, seamNote, seamStatus, seamToolSpec, seamOnly, seamWorkflow, seamRefusal, SEAM_SESSION_TOOLS, GIT_TOOLS } from './session-seam.js'
|
|
50
44
|
|
|
51
45
|
// The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
|
|
52
46
|
const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
|
|
@@ -85,19 +79,17 @@ export const MCP_INSTRUCTIONS =
|
|
|
85
79
|
'(open tasks assigned to you are listed first), add work with quilt_add_task, assign it with quilt_assign_task, ' +
|
|
86
80
|
'and move a task with quilt_move_task when you start or finish it; quilt_task reads one in full with its comments, and quilt_comment_task leaves a note or handoff on it. ' +
|
|
87
81
|
'quilt_history tells you who changed which file, when, with the diff: read it for the files you are about to touch. ' +
|
|
88
|
-
'If git refuses to pull because untracked files would be overwritten, those files came from the session: quilt_status lists them under Pulling (and whether they match); make way and pull with rm <files> && git pull --autostash, and Quilt keeps them for everyone. ' +
|
|
89
82
|
'If the person you work for lets their AI pick up work by itself (their cloud AI, on the cloud machine they connected), finishing (quilt_set_work done, a task to QA or Done) hands you your next task from the board: start it. ' +
|
|
90
83
|
'Before you change files, call quilt_before_edit with their paths: it tells you whether each is yours to edit (claiming free ones for you, so partners are refused instead of overwriting you), ' +
|
|
91
84
|
'and shows what people said about those files in chat, so you know what was asked or planned before you change them. ' +
|
|
92
85
|
'Do not edit a file it refuses: ask for it in its file queue with quilt_request_file (a title like "Working on <what> for <task>" and up to 300 characters on your plan) and carry on with other work; you are woken when it is handed to you, with the holder\'s context. ' +
|
|
93
86
|
'Claims also follow your edits: a file you change that nobody holds is claimed for you until you finish. ' +
|
|
94
87
|
'If an edit of yours was undone because someone else holds the file, your next quilt answer says so: do not retry; ask for it with quilt_request_file and carry on with other work. ' +
|
|
95
|
-
'
|
|
96
|
-
'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) is not in your folder yet: do not build on it; ask for it with quilt_request_file. ' +
|
|
88
|
+
'A boxed file\'s edits are only in its holder\'s folder; quilt_unpack shares a box 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) is not in your folder yet: do not build on it; ask for it with quilt_request_file. ' +
|
|
97
89
|
'When someone waits in the file queue for a file you hold, every quilt answer says so: finish the change you are making, then hand it off with quilt_handoff and your context (what you changed, what is left, gotchas). ' +
|
|
98
90
|
'Until you do, you cannot finish (quilt_set_work done, a task to Done) or release that file. A claim whose holder does nothing in the session for 20 minutes goes to the next one waiting, or is released. ' +
|
|
99
91
|
'For an agent of its own (not a person\'s AI session), every quilt answer starts with anything new for it (direct messages, mentions, tasks handed to it): act on it. ' +
|
|
100
|
-
'These rules are enforced for agents: while someone who messaged or mentioned you is waiting for an answer, the tools that move work on (claims, tasks, focus,
|
|
92
|
+
'These rules are enforced for agents: while someone who messaged or mentioned you is waiting for an answer, the tools that move work on (claims, tasks, focus, quilt_set_work) refuse until you answer them with quilt_message; ' +
|
|
101
93
|
'an edit to a file someone else holds is undone. ' +
|
|
102
94
|
'When you start a piece of work, call quilt_set_work with "working" and a note of what it is (with quilt_set_focus), so the loom shows you stitching; when you finish, call it with "done": it lets go of the files claimed for you. ' +
|
|
103
95
|
'Claim ahead (quilt_claim) only for a larger change across several files. ' +
|
|
@@ -105,25 +97,36 @@ export const MCP_INSTRUCTIONS =
|
|
|
105
97
|
'If quilt_status starts with While you were away, the session changed since this folder last synced: read quilt_history for those files before editing them. ' +
|
|
106
98
|
'For an agent of its own, mentions of it (@yourname) in chat, direct messages to it and tasks handed to it wait in quilt_inbox: read it when you start, and act on each one. ' +
|
|
107
99
|
'Each comes 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. ' +
|
|
108
|
-
'Git works here as usual: commit only the files you changed and push yourself. When the relay serves the session\'s git, your folder\'s origin is the relay: git signs in with your Quilt pass, and the relay checks each push (the session owner\'s policy, never a force-push, no ignored files) and puts it on GitHub, replayed onto GitHub\'s head if it moved (your folder follows). Otherwise your folder signs in to GitHub through Quilt (a short-lived token for this repository). Either way pushes stay within the owner\'s policy: by default branches of your own. Taking a task (quilt_move_task to doing) puts your folder on quilt/<you>/<task>; push there and Quilt opens the pull request for the task; move the task to QA when it is done and tested; Done merges the pull request once its checks pass (a person moves it to Done unless the owner lets agents push to any branch). When agents may not commit, ask a person with quilt_request_commit. ' +
|
|
109
|
-
'When a branch syncs with GitHub (quilt_branches says so), pushes and merges on GitHub come into the session by themselves; nothing goes to GitHub until someone commits and pushes. A file GitHub and the session both changed is merged by Quilt on its own (the newer edit stays where both changed the same lines). Releasing a file only lets go of your claim: it publishes nothing by itself. ' +
|
|
110
|
-
'Work only in the session has not shipped: push before you move a task to QA. Pushes and merges on GitHub reach the session within seconds: never pull by hand. ' +
|
|
111
100
|
'When you are about to answer someone in chat, quilt_typing shows them you are typing; sending clears it. ' +
|
|
112
101
|
'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. ' +
|
|
113
102
|
WAKE_STEP + ' On a computer the ways include a command Quilt runs on each event (`quilt join --on-mention`), `quilt inbox --wait`, the quilt://inbox resource (MCP push), a webhook, email and a schedule. ' +
|
|
114
103
|
'In the session you are your person: your messages, claims and tasks are theirs, and people see you as them. "<person>\'s AI" ("Daniel\'s AI") is only that person\'s cloud AI, when they connected one (Quilt runs their own AI tool for them on their own cloud machine): write to it by that name. Nothing written or handed to "<person>\'s AI" comes to you; when your person asks you to work on tasks, take them from the board (quilt_tasks). ' +
|
|
115
104
|
PERSON_CHAT + ' ' +
|
|
116
|
-
'Share what you are doing with quilt_share: when you start on a request (request and your plan) and when you finish (what you did and the files you changed). Partners see it in their feed, it puts your work on the task board
|
|
105
|
+
'Share what you are doing with quilt_share: when you start on a request (request and your plan) and when you finish (what you did and the files you changed). Partners see it in their feed, and it puts your work on the task board. ' +
|
|
117
106
|
'When Claude Code is started with the quilt channel, they arrive on their own as <channel source="quilt"> events while you work: treat each like a request from that person, answer with quilt_message, and take a task with quilt_move_task. ' +
|
|
118
107
|
RUN_GUIDE + ' ' +
|
|
119
108
|
MAIL_GUIDE + ' ' +
|
|
120
109
|
TASK_WORKFLOW
|
|
121
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The instructions for a folder in a session that is a quilt (session-seam.js): every sentence
|
|
113
|
+
* about git left out, and how work lands on Seam in its place. Nothing there names git or GitHub.
|
|
114
|
+
*/
|
|
115
|
+
export const mcpQuiltInstructions = (quilt) => `${seamOnly(MCP_INSTRUCTIONS)} ${seamNote(quilt)}`
|
|
116
|
+
|
|
117
|
+
/** The session this folder is in, as its app says, when it answers at once: whether it is a quilt picks the instructions. */
|
|
118
|
+
async function quiltAtStart () {
|
|
119
|
+
const d = findDaemon()
|
|
120
|
+
if (!d) return null
|
|
121
|
+
try { return (await rawCall(d, 'GET', '/quilt', undefined, { timeoutMs: 1500 })).quilt || null } catch { return null }
|
|
122
|
+
}
|
|
123
|
+
|
|
122
124
|
export async function runMcp () {
|
|
125
|
+
const startQuilt = await quiltAtStart()
|
|
123
126
|
const server = new McpServer(
|
|
124
127
|
{ name: 'quilt', version: '0.1.0' },
|
|
125
128
|
// The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
|
|
126
|
-
{ instructions: MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
129
|
+
{ instructions: startQuilt && startQuilt.id ? mcpQuiltInstructions(startQuilt.slug || startQuilt.id) : MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
127
130
|
)
|
|
128
131
|
|
|
129
132
|
// This AI session, by an id of its own, so the app can tell it from the others working through
|
|
@@ -200,10 +203,19 @@ export async function runMcp () {
|
|
|
200
203
|
// When this person lets their AI pick up work by itself: the next task, said as it finishes one.
|
|
201
204
|
const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\n➡️ ${n}` : '' } catch { return '' } }
|
|
202
205
|
const gates = (...list) => async (d) => { for (const g of list) { const r = g && await g(d); if (r) return r } return '' }
|
|
206
|
+
// An AI session started before its session moved into Seam (or back to git) was told the other
|
|
207
|
+
// way in its instructions, which never change once given: the next answer says the way now, once.
|
|
208
|
+
let toldQuilt = !!(startQuilt && startQuilt.id)
|
|
209
|
+
const moved = async () => {
|
|
210
|
+
const q = await quiltNow()
|
|
211
|
+
if (q === undefined || !!q === toldQuilt) return ''
|
|
212
|
+
toldQuilt = !!q
|
|
213
|
+
return q ? `🧵 Since you started, ${seamNote(q.slug || q.id).replace(/^This/, 'this')} Leave aside what your instructions said about how work lands.\n\n` : '🧵 Since you started, this session went back to its git branches: quilt_status says how work lands now.\n\n'
|
|
214
|
+
}
|
|
203
215
|
const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
|
|
204
216
|
const d = findDaemon(joined ? joined.dir : undefined)
|
|
205
217
|
if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
|
|
206
|
-
const before = async () => introduce() + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
218
|
+
const before = async () => introduce() + (await moved()) + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
207
219
|
const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\n📥 ${n}` : '' }
|
|
208
220
|
try {
|
|
209
221
|
const refused = gate ? await gate(d) : ''
|
|
@@ -222,14 +234,42 @@ export async function runMcp () {
|
|
|
222
234
|
return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
|
|
223
235
|
}
|
|
224
236
|
|
|
225
|
-
// Seam (plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
|
|
237
|
+
// Seam (Seam's plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
|
|
226
238
|
// refuse with the Seam tool to use instead, the same for every AI tool (session-seam.js).
|
|
239
|
+
// undefined when the app can't say (none, or no answer): unknown, not "not a quilt".
|
|
227
240
|
const quiltNow = async () => {
|
|
228
241
|
const d = findDaemon(joined ? joined.dir : undefined)
|
|
229
|
-
if (!d) return
|
|
230
|
-
|
|
242
|
+
if (!d) return undefined
|
|
243
|
+
let q = null
|
|
244
|
+
try { q = (await call(d, 'GET', '/quilt')).quilt || null } catch { return undefined }
|
|
245
|
+
syncSeamTools(q)
|
|
246
|
+
return q
|
|
247
|
+
}
|
|
248
|
+
// The git-era tools are hidden while the session is a quilt (GIT_TOOLS), and refuse with the Seam tool if called.
|
|
249
|
+
const gitTools = new Map()
|
|
250
|
+
const gated = seamGate(server.registerTool.bind(server), quiltNow)
|
|
251
|
+
server.registerTool = (name, spec, handler) => {
|
|
252
|
+
const t = gated(name, spec, handler)
|
|
253
|
+
if (GIT_TOOLS.has(name)) gitTools.set(name, t)
|
|
254
|
+
return t
|
|
255
|
+
}
|
|
256
|
+
// Seam's everyday tools, served by this server itself while the session is a quilt (none
|
|
257
|
+
// otherwise): the app runs them as this folder's member, on its square (control.js /seam).
|
|
258
|
+
// The client is told when they come and go, as with the workspace library's tools.
|
|
259
|
+
const seamTools = new Map()
|
|
260
|
+
const seamCall = (name, args) => withDaemon(async (d) => {
|
|
261
|
+
await quiltNow() // the session may have stopped being a quilt: the tool list follows
|
|
262
|
+
const r = await call(d, 'POST', '/seam', { tool: name, args: args || {} })
|
|
263
|
+
const text = ((r && r.content) || []).map((c) => c.text || '').join('\n')
|
|
264
|
+
if (r && r.isError) throw new Error(text)
|
|
265
|
+
return text
|
|
266
|
+
}, { gate: name === 'seam_commit' || name === 'seam_merge' ? gateFor(name) : null })
|
|
267
|
+
const syncSeamTools = (q) => {
|
|
268
|
+
const on = !!(q && q.id)
|
|
269
|
+
if (on && !seamTools.size) for (const name of SEAM_SESSION_TOOLS) seamTools.set(name, server.registerTool(name, seamToolSpec(name), (args) => seamCall(name, args)))
|
|
270
|
+
for (const t of seamTools.values()) if (t.enabled !== on) { if (on) t.enable(); else t.disable() }
|
|
271
|
+
for (const t of gitTools.values()) if (t.enabled === on) { if (on) t.disable(); else t.enable() }
|
|
231
272
|
}
|
|
232
|
-
server.registerTool = seamGate(server.registerTool.bind(server), quiltNow)
|
|
233
273
|
|
|
234
274
|
server.registerTool('quilt_status', {
|
|
235
275
|
description: 'See who else is in the live session, what they are working on, which files they recently edited or claimed, recent messages, and which open tasks are assigned to you. Call this before starting work.',
|
|
@@ -237,7 +277,8 @@ export async function runMcp () {
|
|
|
237
277
|
}, () => withDaemon(async (d) => {
|
|
238
278
|
const st = await call(d, 'GET', '/status')
|
|
239
279
|
const q = await quiltNow()
|
|
240
|
-
|
|
280
|
+
const md = renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
|
|
281
|
+
return q ? `${seamNote(q.slug || q.id)}\n\n${seamStatus(md)}` : md
|
|
241
282
|
}))
|
|
242
283
|
|
|
243
284
|
server.registerTool('quilt_tasks', {
|
|
@@ -283,18 +324,15 @@ export async function runMcp () {
|
|
|
283
324
|
const brief = await call(d, 'POST', '/tasks/brief', { id })
|
|
284
325
|
if (column === 'qa' && !qaNotesEnough(qaNotes)) return qaRefusal({ task: brief.task, checklist: brief.checklist })
|
|
285
326
|
if (column === 'done' && !verifiedEnough(verified)) return doneRefusal({ task: brief.task, checklist: brief.checklist })
|
|
286
|
-
// An agent's finished work goes to git before QA, when it can commit itself (relay-commit.js); Done merges its pull request (the daemon asks the relay).
|
|
287
|
-
if (column === 'qa') {
|
|
288
|
-
const u = await call(d, 'POST', '/unpublished', { task: id, column }).catch(() => null)
|
|
289
|
-
if (u && u.unpublished && u.unpublished.length) return unpublishedRefusal(brief.task, u.unpublished, column, [])
|
|
290
|
-
}
|
|
291
327
|
const patch = { id, column }
|
|
292
328
|
if (column === 'qa') patch.qaNotes = qaNotes
|
|
293
329
|
if (column === 'done') patch.verified = verified
|
|
294
|
-
const { task
|
|
295
|
-
|
|
296
|
-
if (column === '
|
|
297
|
-
if (column === '
|
|
330
|
+
const { task } = await call(d, 'POST', '/tasks/update', patch)
|
|
331
|
+
const q = column === 'doing' || column === 'qa' ? await quiltNow() : null
|
|
332
|
+
if (q && column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments }, workflow: seamWorkflow(TASK_WORKFLOW) })
|
|
333
|
+
if (column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments } })
|
|
334
|
+
if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + await nextUp(d)
|
|
335
|
+
if (column === 'done') return `Moved "${task.title}" to Done. Verified: ${verifiedLine(task)}` + await nextUp(d)
|
|
298
336
|
return `Moved "${task.title}" to ${columnName(task.column)}.`
|
|
299
337
|
}, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
|
|
300
338
|
|
|
@@ -448,39 +486,6 @@ export async function runMcp () {
|
|
|
448
486
|
return n ? `Withdrew your request for ${rel}.` : `You had not asked for ${rel}.`
|
|
449
487
|
}))
|
|
450
488
|
|
|
451
|
-
// Quilt merges on its own (session.js autoSettle): these say what it did, and what waits for a holder.
|
|
452
|
-
const mergeLine = (m, me) => {
|
|
453
|
-
const who = m.by === me ? 'you' : m.by
|
|
454
|
-
const what = m.state !== 'done' ? `${who} ${mergeAction(m)}; ${m.claimedBy || 'someone'} has it claimed, and it goes in, merged by Quilt, when they let go`
|
|
455
|
-
: m.reason || 'merged by Quilt'
|
|
456
|
-
return `- \`${m.path}\`: ${what}`
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
server.registerTool('quilt_merges', {
|
|
460
|
-
description: 'What Quilt merged on its own recently: files changed in two places (offline, by a pull or on GitHub, and in the session). Quilt combines them itself; where both changed the same lines the newer edit stays and the other lines are kept in the file\'s history. Nothing here is for you to settle.',
|
|
461
|
-
inputSchema: {}
|
|
462
|
-
}, () => withDaemon(async (d) => {
|
|
463
|
-
const { merges } = await call(d, 'GET', '/merges')
|
|
464
|
-
const me = (await call(d, 'GET', '/info')).name
|
|
465
|
-
if (!merges.length) return 'Quilt has merged nothing lately.'
|
|
466
|
-
return `Quilt merges on its own; nothing to do:\n${merges.map((m) => mergeLine(m, me)).join('\n')}`
|
|
467
|
-
}))
|
|
468
|
-
|
|
469
|
-
// Kept for guides and prompts from before Quilt merged on its own.
|
|
470
|
-
server.registerTool('quilt_resolve_merge', {
|
|
471
|
-
description: 'Not needed any more: Quilt merges on its own. Kept so older instructions get an answer.',
|
|
472
|
-
inputSchema: { id: z.string().optional(), how: z.string().optional() }
|
|
473
|
-
}, () => ({ content: [{ type: 'text', text: 'Quilt merges on its own now: there is nothing to settle.' }] }))
|
|
474
|
-
|
|
475
|
-
server.registerTool('quilt_clash', {
|
|
476
|
-
description: CLASH_DESCRIPTION,
|
|
477
|
-
inputSchema: clashSchema(z)
|
|
478
|
-
}, (args) => withDaemon(async (d) => {
|
|
479
|
-
const r = await call(d, 'POST', '/clash', args)
|
|
480
|
-
if (r && r.ok === false) throw new Error(r.text)
|
|
481
|
-
return r && r.text ? r.text : 'Done.'
|
|
482
|
-
}))
|
|
483
|
-
|
|
484
489
|
server.registerTool('quilt_message', {
|
|
485
490
|
description: CHAT_RULES + ' Set "to" to message one person directly.',
|
|
486
491
|
inputSchema: {
|
|
@@ -730,6 +735,8 @@ export async function runMcp () {
|
|
|
730
735
|
readerOptions: { chatDir: process.cwd() },
|
|
731
736
|
onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
|
|
732
737
|
onFatal: async (err) => { logs.push(`stopped: ${err.message}`); await leave() },
|
|
738
|
+
// A session this agent starts is a quilt from the start when the API says so (src/quilt-default.js).
|
|
739
|
+
newSession: starting && !inviteServer,
|
|
733
740
|
workspace
|
|
734
741
|
})
|
|
735
742
|
joined = { run, dir, invite: run.invite }
|
|
@@ -831,18 +838,8 @@ export async function runMcp () {
|
|
|
831
838
|
return { content: [{ type: 'text', text: left ? 'Left the session. The files stay where they are.' : 'You have not joined a session from here.' }] }
|
|
832
839
|
})
|
|
833
840
|
|
|
834
|
-
// ------------------------------------------------------ commit timing --
|
|
835
|
-
|
|
836
|
-
const describeCommits = (c) => {
|
|
837
|
-
const lines = []
|
|
838
|
-
lines.push(c.ready ? '✅ Everyone else\'s AI is idle: a good moment to commit.' : `⏳ Still working: ${c.busy.map((b) => b.why).join('; ')}`)
|
|
839
|
-
lines.push(describeCommitRequests(c, { canCommit: !!c.canCommit }))
|
|
840
|
-
if (c.open.length) lines.push('A request committed outside Quilt (with git by hand) is closed with quilt_commit_request_done.')
|
|
841
|
-
return lines.join('\n')
|
|
842
|
-
}
|
|
843
|
-
|
|
844
841
|
server.registerTool('quilt_set_work', {
|
|
845
|
-
description: 'Tell everyone whether you (this agent) are working or done, so people know
|
|
842
|
+
description: 'Tell everyone whether you (this agent) are working or done, so people know what is in progress. Set "working" when you start a task and "done" when you finish: ' +
|
|
846
843
|
'"done" lets go of the files claimed for you while you edited. Like every tool that moves work on, it is refused while someone is waiting for an answer from you.',
|
|
847
844
|
inputSchema: {
|
|
848
845
|
state: z.enum(['working', 'done']),
|
|
@@ -926,95 +923,6 @@ export async function runMcp () {
|
|
|
926
923
|
return lines.join('\n') + (said ? `\n\n${said}` : '') + '\n\nRe-read each file right before you edit it.'
|
|
927
924
|
}))
|
|
928
925
|
|
|
929
|
-
server.registerTool('quilt_request_commit', {
|
|
930
|
-
description: REQUEST_COMMIT_DESCRIPTION,
|
|
931
|
-
inputSchema: {
|
|
932
|
-
message: z.string().max(500).optional().describe('The commit message: what the change does (a task\'s title by default)'),
|
|
933
|
-
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)'),
|
|
934
|
-
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')
|
|
935
|
-
}
|
|
936
|
-
}, ({ message, files, task }) => withDaemon(async (d) => {
|
|
937
|
-
const r = await call(d, 'POST', '/commit-request', { message, files, task })
|
|
938
|
-
return `Asked for a commit [${r.id}] of ${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 12).join(', ')}${r.files.length > 12 ? ', …' : ''}.\n${r.committer}`
|
|
939
|
-
}, { gate: gateFor('quilt_request_commit') }))
|
|
940
|
-
|
|
941
|
-
server.registerTool('quilt_agent_commits', {
|
|
942
|
-
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.',
|
|
943
|
-
inputSchema: { mode: z.enum(['off', 'branches', 'any']) }
|
|
944
|
-
}, ({ mode }) => withDaemon(async (d) => {
|
|
945
|
-
const r = await call(d, 'POST', '/agent-commits', { mode })
|
|
946
|
-
return `Now ${POLICY_WORDS[r.agentCommits]}.`
|
|
947
|
-
}))
|
|
948
|
-
|
|
949
|
-
server.registerTool('quilt_github_sync', {
|
|
950
|
-
description: 'Session owner only: whether this session syncs with GitHub through the relay (on by default once a GitHub connection covers the repository): pushes and merges on GitHub come into the session within seconds, and commits made in Quilt (quilt_git_commit) go through the relay. Nothing reaches GitHub without a commit. Off: the session keeps its files to itself, and people commit with git as before.',
|
|
951
|
-
inputSchema: { on: z.boolean() }
|
|
952
|
-
}, ({ on }) => withDaemon(async (d) => {
|
|
953
|
-
const r = await call(d, 'POST', '/github-sync', { on })
|
|
954
|
-
return r.githubSync ? 'This session syncs with GitHub.' : 'Sync with GitHub is off for this session.'
|
|
955
|
-
}))
|
|
956
|
-
|
|
957
|
-
server.registerTool('quilt_adopt_work', {
|
|
958
|
-
description: 'For people (and their AI): take the work on an agent\'s branch (quilt/<agent>/…, everything it changed since it started) into the branch you are on, as your edits, pushed to GitHub at once as one commit. This is how agents\' work reaches GitHub when the owner keeps agents off it ("off"), and a way to take a branch\'s work without a pull request.',
|
|
959
|
-
inputSchema: { from: z.string().min(1).max(200).describe('The agent\'s branch, e.g. quilt/grok/login-page') }
|
|
960
|
-
}, ({ from }) => withDaemon(async (d) => {
|
|
961
|
-
const r = await call(d, 'POST', '/adopt', { from })
|
|
962
|
-
return r.state === 'pushed' ? `Adopted ${from}: pushed as ${String(r.sha).slice(0, 7)} (${(r.changed || []).length} files).` : r.state === 'nothing' ? `Nothing to adopt: ${r.why || 'no changes'}.` : `Adopted into the session; not pushed yet (${r.state}${r.why ? `: ${r.why}` : ''}).`
|
|
963
|
-
}))
|
|
964
|
-
|
|
965
|
-
server.registerTool('quilt_branches', {
|
|
966
|
-
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. Read it before you branch, merge or push.',
|
|
967
|
-
inputSchema: {}
|
|
968
|
-
}, () => withDaemon(async (d) => {
|
|
969
|
-
const { branches, git } = await call(d, 'GET', '/branches')
|
|
970
|
-
const here = git && git.branch ? `This folder is on \`${git.branch}\`${git.upstream ? ` (${upstreamLine(git.upstream)})` : ''}.\n\n` : ''
|
|
971
|
-
return here + branchesMarkdown(branches || [], { limit: 40 })
|
|
972
|
-
}))
|
|
973
|
-
|
|
974
|
-
server.registerTool('quilt_sync_status', {
|
|
975
|
-
description: SYNC_STATUS_DESCRIPTION,
|
|
976
|
-
inputSchema: {}
|
|
977
|
-
}, () => withDaemon(async (d) => { const r = await call(d, 'GET', '/sync-status'); return syncStatusText(r.branches || [], { mine: r.mine || '' }) }))
|
|
978
|
-
|
|
979
|
-
server.registerTool('quilt_sync_branch', {
|
|
980
|
-
description: 'Bring commits made outside the session into it now: fetches this folder\'s upstream (say origin/main after a PR merged, or a push from a worktree) and moves the branch forward, with the session\'s uncommitted work merged in. Quilt also does this by itself about once a minute; call it right after you push or merge elsewhere. It says when the fetch fails (git has no credentials here, say). A folder that is not a git clone, or can\'t fetch, asks the relay to bring the commits in from GitHub instead. Files that both the commits and the session changed are merged by Quilt on its own.',
|
|
981
|
-
inputSchema: {}
|
|
982
|
-
}, () => withDaemon(async (d) => describeBranchSync(await call(d, 'POST', '/branches/sync'))))
|
|
983
|
-
|
|
984
|
-
server.registerTool('quilt_github_token', {
|
|
985
|
-
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 for hosted agents, and, when it can write, to commit the work of agents that have no folder (hosted agents\' quilt_git_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 hosted agents commit and open pull requests, within what quilt_agent_commits allows. It goes to the relay and stays there: never shown again. Agents with a folder don\'t need it: their git signs in through the session\'s GitHub connection.',
|
|
986
|
-
inputSchema: { token: z.string().max(300).describe('The token (github_pat_… or ghp_…), or "" to remove it') }
|
|
987
|
-
}, ({ token }) => withDaemon(async (d) => {
|
|
988
|
-
const r = await call(d, 'POST', '/github-token', { token })
|
|
989
|
-
return r.githubToken ? 'Saved the GitHub token on the relay. It looks at the session\'s branches again now.' : 'Removed the GitHub token.'
|
|
990
|
-
}))
|
|
991
|
-
|
|
992
|
-
server.registerTool('quilt_commit_status', {
|
|
993
|
-
description: 'Is it a good moment to commit? Lists open commit requests and whose AI is still working (not counting yours).',
|
|
994
|
-
inputSchema: {}
|
|
995
|
-
}, () => withDaemon(async (d) => describeCommits(await call(d, 'GET', '/commits'))))
|
|
996
|
-
|
|
997
|
-
server.registerTool('quilt_wait_until_idle', {
|
|
998
|
-
description: 'Wait until every other AI in the session is idle (or the timeout passes), then report. Use it before committing, or when you need everyone else to finish first.',
|
|
999
|
-
inputSchema: { timeout_seconds: z.number().int().min(5).max(1800).optional().describe('How long to wait at most (default 300)') }
|
|
1000
|
-
}, ({ timeout_seconds: timeout = 300 }) => withDaemon(async (d) => {
|
|
1001
|
-
const until = Date.now() + timeout * 1000
|
|
1002
|
-
let c = await call(d, 'GET', '/commits')
|
|
1003
|
-
while (!c.ready && Date.now() < until) {
|
|
1004
|
-
await new Promise((r) => setTimeout(r, 3000))
|
|
1005
|
-
c = await call(d, 'GET', '/commits')
|
|
1006
|
-
}
|
|
1007
|
-
return `${c.ready ? '' : `Stopped waiting after ${timeout}s.\n`}${describeCommits(c)}`
|
|
1008
|
-
}, { gate: gateFor('quilt_wait_until_idle') }))
|
|
1009
|
-
|
|
1010
|
-
server.registerTool('quilt_commit_request_done', {
|
|
1011
|
-
description: 'Close commit requests whose work was committed with git (or isn\'t needed any more). Without an id, every open request is closed.',
|
|
1012
|
-
inputSchema: { id: z.string().optional().describe('One request id; omit for all open ones') }
|
|
1013
|
-
}, ({ id }) => withDaemon(async (d) => {
|
|
1014
|
-
const { done } = await call(d, 'POST', '/commit-request/done', { id })
|
|
1015
|
-
return done ? `Marked ${done} commit request${done === 1 ? '' : 's'} done.` : 'No open commit requests.'
|
|
1016
|
-
}, { gate: gateFor('quilt_commit_request_done') }))
|
|
1017
|
-
|
|
1018
926
|
server.registerTool('quilt_project_rules', {
|
|
1019
927
|
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.',
|
|
1020
928
|
inputSchema: {}
|
|
@@ -1052,8 +960,8 @@ export async function runMcp () {
|
|
|
1052
960
|
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
1053
961
|
}
|
|
1054
962
|
}, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
|
|
1055
|
-
const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
|
|
1056
|
-
return formatHistory(entries, { withDiff: !!with_diff })
|
|
963
|
+
const { entries, note } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30, with_diff: !!with_diff })
|
|
964
|
+
return `${formatHistory(entries, { withDiff: !!with_diff })}${note ? `\n\n${note}` : ''}`
|
|
1057
965
|
}))
|
|
1058
966
|
|
|
1059
967
|
server.registerTool('quilt_partner_feed', {
|
|
@@ -1122,12 +1030,30 @@ export async function runMcp () {
|
|
|
1122
1030
|
if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
|
|
1123
1031
|
}
|
|
1124
1032
|
|
|
1125
|
-
|
|
1033
|
+
const transport = new StdioServerTransport()
|
|
1034
|
+
await server.connect(transport)
|
|
1035
|
+
// A git-era tool hidden while the session is a quilt, called from a tool list read before the
|
|
1036
|
+
// move (a client that missed the list's change): answered with the Seam way, not "disabled".
|
|
1037
|
+
const onmessage = transport.onmessage
|
|
1038
|
+
transport.onmessage = (msg, extra) => {
|
|
1039
|
+
const name = msg && msg.method === 'tools/call' && msg.params && msg.params.name
|
|
1040
|
+
const t = name && gitTools.get(name)
|
|
1041
|
+
if (!t || t.enabled) return onmessage(msg, extra)
|
|
1042
|
+
quiltNow().then((q) => {
|
|
1043
|
+
const no = q && seamRefusal(name, q.slug || q.id)
|
|
1044
|
+
if (!no) return onmessage(msg, extra)
|
|
1045
|
+
return transport.send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: no }], isError: true } })
|
|
1046
|
+
}).catch(() => onmessage(msg, extra))
|
|
1047
|
+
}
|
|
1126
1048
|
// The workspace library tools arrive a moment later (the client is told the tool list
|
|
1127
1049
|
// changed), so startup never waits on the API.
|
|
1128
1050
|
// fromPath reads only from the project: the session's folder, or where the agent runs.
|
|
1129
1051
|
const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
|
|
1130
1052
|
addWorkspaceTools(server, { projectDirs }).catch(() => {})
|
|
1053
|
+
// In a session that is a quilt, Seam's tools come the same way; looked at again every half minute.
|
|
1054
|
+
quiltNow().catch(() => {})
|
|
1055
|
+
const seamTimer = setInterval(() => quiltNow().catch(() => {}), 30_000)
|
|
1056
|
+
seamTimer.unref()
|
|
1131
1057
|
}
|
|
1132
1058
|
|
|
1133
1059
|
/**
|
package/src/merge3.js
CHANGED
|
@@ -79,7 +79,8 @@ export function hasMarkers (text) {
|
|
|
79
79
|
/**
|
|
80
80
|
* merge3, plus the clashes where neither side lost anything, resolved the way a person would:
|
|
81
81
|
* - both sides only added lines in the same place (base had nothing there): ours, then theirs,
|
|
82
|
-
* with lines both start (or end) with written once
|
|
82
|
+
* with lines both start (or end) with written once (not a line of only brackets and
|
|
83
|
+
* punctuation such as `],`: each block keeps its own), so two new sections under the same
|
|
83
84
|
* heading become one heading with both sections' lines;
|
|
84
85
|
* - both sides changed the same one-line `import { … } from '…'` of the same module: one import
|
|
85
86
|
* with every name either side has (a name either side removed stays removed).
|
|
@@ -100,12 +101,19 @@ export const mergeAdditive = eolAware(function mergeAdditive (base, ours, theirs
|
|
|
100
101
|
return { text: out.join('\n'), conflicts }
|
|
101
102
|
})
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Whether a line both added blocks start or end with can be written once: one with words in it, or a
|
|
106
|
+
* blank one. A line of only brackets and punctuation (`],`, `}`, `)`) closes each block, so writing
|
|
107
|
+
* it once drops one block's closer (two new entries in ci/policy.json came out as invalid JSON).
|
|
108
|
+
*/
|
|
109
|
+
const sharable = (line) => /\w/.test(line) || !line.trim()
|
|
110
|
+
|
|
103
111
|
/** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
|
|
104
112
|
function addedBoth (a, b) {
|
|
105
113
|
let pre = 0
|
|
106
|
-
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
|
|
114
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre] && sharable(a[pre])) pre++
|
|
107
115
|
let post = 0
|
|
108
|
-
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
|
|
116
|
+
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post] && sharable(a[a.length - 1 - post])) post++
|
|
109
117
|
return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
|
|
110
118
|
}
|
|
111
119
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The website dashboard's summary of now (plans/dashboard ticket 9): for one person, what is going on in
|
|
2
|
+
// a session this relay holds. Who is in it and what each AI is doing, the board in brief, the latest
|
|
3
|
+
// file changes, held files and who waits for them, and what needs this person: messages to them not
|
|
4
|
+
// answered, tasks for them, files someone waits for from them, and, for the owner, commit requests and
|
|
5
|
+
// people asking to join. The accounts API asks for it (GET /v1/me/now, every 15 to 30 seconds from the
|
|
6
|
+
// page) and merges what each relay machine says; nothing here is streamed or stored.
|
|
7
|
+
import { waitingOn } from './duties.js'
|
|
8
|
+
import { readTasks, activeTasks } from './tasks.js'
|
|
9
|
+
|
|
10
|
+
export const NOW_LIMITS = { people: 24, recent: 8, detailRecent: 80, held: 24, needs: 24, text: 160 }
|
|
11
|
+
const EDIT_FRESH_MS = 4 * 60 * 1000 // a peer's edit counts as current this long (as relay-mcp.js)
|
|
12
|
+
|
|
13
|
+
const clip = (s, n = NOW_LIMITS.text) => {
|
|
14
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim()
|
|
15
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The name `account` ('person:<id>') has in `room`, or '' when it isn't the owner or a member. */
|
|
19
|
+
export function nameIn (room, account) {
|
|
20
|
+
const meta = room && room.meta
|
|
21
|
+
if (!meta || typeof account !== 'string') return ''
|
|
22
|
+
if (meta.ownerSub && meta.ownerSub === account) return meta.ownerName || 'owner'
|
|
23
|
+
const m = meta.members && Object.prototype.hasOwnProperty.call(meta.members, account) ? meta.members[account] : null
|
|
24
|
+
return m && !m.chat && typeof m.name === 'string' ? m.name : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The files a peer edited in the last few minutes, newest first. */
|
|
28
|
+
function freshEdits (editing, now) {
|
|
29
|
+
return Object.entries(editing || {})
|
|
30
|
+
.filter(([, ts]) => Number.isFinite(ts) && now - ts < EDIT_FRESH_MS)
|
|
31
|
+
.sort((a, b) => b[1] - a[1])
|
|
32
|
+
.map(([p]) => p)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Who is in the room now, each with what their AI is doing. */
|
|
36
|
+
function peopleIn (room, now) {
|
|
37
|
+
const out = []
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
const states = room.awareness && room.awareness.getStates ? [...room.awareness.getStates().values()] : []
|
|
40
|
+
for (const s of states) {
|
|
41
|
+
if (!s || typeof s.name !== 'string' || !s.name || seen.has(s.name)) continue
|
|
42
|
+
seen.add(s.name)
|
|
43
|
+
const ag = s.agent || {}
|
|
44
|
+
const working = ag.sharing !== false && (ag.status === 'working' || (s.work && s.work.state === 'working'))
|
|
45
|
+
out.push({
|
|
46
|
+
name: clip(s.name, 64),
|
|
47
|
+
kind: s.kind === 'agent' ? 'agent' : 'person',
|
|
48
|
+
tool: clip(ag.tool || s.tool || '', 40),
|
|
49
|
+
ai: ag.sharing === false ? 'paused' : working ? 'working' : (ag.tool ? 'idle' : ''),
|
|
50
|
+
focus: clip(s.focus || (s.work && s.work.state === 'working' ? s.work.note : '') || '', 120),
|
|
51
|
+
editing: freshEdits(s.editing, now).slice(0, 3)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
for (const h of room.hostedOnline ? room.hostedOnline() : []) {
|
|
55
|
+
if (!h || !h.name || seen.has(h.name)) continue
|
|
56
|
+
seen.add(h.name)
|
|
57
|
+
out.push({ name: clip(h.name, 64), kind: h.kind === 'agent' ? 'agent' : 'person', tool: 'hosted', ai: '', focus: '', editing: [] })
|
|
58
|
+
}
|
|
59
|
+
return out.slice(0, NOW_LIMITS.people)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The summary of `room` for `account`, or null when the account isn't in it. `now` is the time;
|
|
64
|
+
* `detail` (one session's page) keeps more of the latest file changes, for its Activity tab.
|
|
65
|
+
* The room is read through what Room already offers (server.js): meta, awareness, doc, claimList, pendingList.
|
|
66
|
+
*/
|
|
67
|
+
export function nowSummary (room, account, { now = Date.now(), detail = false } = {}) {
|
|
68
|
+
const me = nameIn(room, account)
|
|
69
|
+
if (!me) return null
|
|
70
|
+
const owner = !!(room.meta.ownerSub && room.meta.ownerSub === account)
|
|
71
|
+
const doc = room.doc
|
|
72
|
+
const chat = doc ? doc.getArray('chat').toArray() : []
|
|
73
|
+
const tasks = doc ? activeTasks(readTasks(doc.getMap('tasks'))) : []
|
|
74
|
+
const claims = room.claimList ? room.claimList() : []
|
|
75
|
+
const people = peopleIn(room, now)
|
|
76
|
+
|
|
77
|
+
const needs = []
|
|
78
|
+
for (const m of waitingOn(chat.filter((x) => x && (!x.to || x.to === me || x.by === me)), me, { now })) {
|
|
79
|
+
needs.push({ kind: m.kind, by: clip(m.by, 64), text: clip(m.text), at: m.ts })
|
|
80
|
+
}
|
|
81
|
+
for (const t of tasks) {
|
|
82
|
+
if (t.column === 'todo' && t.assignee === me && !t.forAi) needs.push({ kind: 'task', by: clip(t.by, 64), text: clip(t.title), at: t.ts, task: t.id })
|
|
83
|
+
}
|
|
84
|
+
for (const c of claims) {
|
|
85
|
+
if (c.by === me && c.queue && c.queue.length) needs.push({ kind: 'file', by: clip(c.queue[0].by, 64), text: clip(c.pattern, 200), at: c.queue[0].ts || c.ts, waiting: c.queue.length })
|
|
86
|
+
}
|
|
87
|
+
if (owner && doc) {
|
|
88
|
+
for (const r of doc.getMap('commitRequests').values()) {
|
|
89
|
+
if (r && r.id && r.state === 'open') needs.push({ kind: 'commit', by: clip(r.by, 64), text: clip(r.message), at: r.ts, files: Array.isArray(r.files) ? r.files.length : 0 })
|
|
90
|
+
}
|
|
91
|
+
for (const p of room.pendingList ? room.pendingList() : []) needs.push({ kind: 'join', by: clip(p.name, 64), text: p.kind === 'agent' ? 'An agent asks to join' : 'Asks to join', at: p.since, agent: p.kind === 'agent' })
|
|
92
|
+
}
|
|
93
|
+
needs.sort((a, b) => (b.at || 0) - (a.at || 0))
|
|
94
|
+
|
|
95
|
+
const byColumn = { todo: 0, doing: 0, qa: 0 }
|
|
96
|
+
for (const t of tasks) if (t.column in byColumn) byColumn[t.column]++
|
|
97
|
+
const recent = (doc ? doc.getArray('activity').toArray() : []).filter((x) => x && x.by).slice(-(detail ? NOW_LIMITS.detailRecent : NOW_LIMITS.recent)).reverse()
|
|
98
|
+
.map((x) => ({ by: clip(x.by, 64), kind: clip(x.kind, 20), path: clip(x.path || x.detail || '', 200), at: x.ts }))
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
room: room.name,
|
|
102
|
+
name: clip(room.meta.name || '', 80),
|
|
103
|
+
me,
|
|
104
|
+
owner,
|
|
105
|
+
people,
|
|
106
|
+
working: people.filter((p) => p.ai === 'working').length,
|
|
107
|
+
tasks: { open: byColumn.todo + byColumn.doing + byColumn.qa, ...byColumn, mine: tasks.filter((t) => t.column !== 'done' && t.assignee === me).length },
|
|
108
|
+
recent,
|
|
109
|
+
held: claims.slice(0, NOW_LIMITS.held).map((c) => ({ path: clip(c.pattern, 200), by: clip(c.by, 64), box: !!c.box, waiting: (c.queue || []).map((q) => clip(q.by, 64)) })),
|
|
110
|
+
needs: needs.slice(0, NOW_LIMITS.needs),
|
|
111
|
+
at: now
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/pathrules.js
CHANGED
|
@@ -20,6 +20,8 @@ export const CHAT_FILES = 'Chat files'
|
|
|
20
20
|
// copies of the project.
|
|
21
21
|
export const ALWAYS_IGNORED = [
|
|
22
22
|
'.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
|
|
23
|
+
// A Seam block's own record (a folder checked out with seam checkout): Seam's, never the session's.
|
|
24
|
+
'.seam',
|
|
23
25
|
`/${CHAT_FILES}`,
|
|
24
26
|
// Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
|
|
25
27
|
'.claude/settings.local.json',
|
|
@@ -66,6 +68,16 @@ export function makeIgnore (texts = []) {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/** The folders a .gitmodules text names as submodules (`path = …`), as safe relative paths. */
|
|
72
|
+
export function submodulePathsIn (text) {
|
|
73
|
+
const out = []
|
|
74
|
+
for (const m of String(text || '').matchAll(/^\s*path\s*=\s*(.+?)\s*$/gm)) {
|
|
75
|
+
const p = m[1].replace(/^"(.*)"$/, '$1').replace(/\/+$/, '')
|
|
76
|
+
if (isSafeRelPath(p)) out.push(p)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
export function isIgnored (ig, rel) {
|
|
70
82
|
if (!rel || rel === '.') return false
|
|
71
83
|
// Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
|
package/src/protocol.js
CHANGED
|
@@ -17,7 +17,7 @@ export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'
|
|
|
17
17
|
export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
|
|
18
18
|
export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
|
|
19
19
|
export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
|
|
20
|
-
export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
|
|
20
|
+
export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, quilt?, shadow?, reply?: { id, op, ok, error?, branch?, created?, base? } }
|
|
21
21
|
|
|
22
22
|
// Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
|
|
23
23
|
// activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
|
|
@@ -25,7 +25,15 @@ export const ROOM_DOC = ''
|
|
|
25
25
|
|
|
26
26
|
// What this app can do, sent when it connects. The relay turns away apps without what a session needs.
|
|
27
27
|
// branch-diffs: this app reads a branch document that holds only what differs from git (branch-view.js).
|
|
28
|
-
|
|
28
|
+
// quilts: this app works in a session whose files are in its squares' live documents (layout 3,
|
|
29
|
+
// Seam's plans/quilt-source-control/16 flag 4: sent by every app before anyone opens a block).
|
|
30
|
+
// quilt-folders: this app's folder follows its branch's square in place at layout 3 (session-follow.js);
|
|
31
|
+
// an app with `quilts` but not this (0.3.125) kept syncing to the read-only branch documents.
|
|
32
|
+
// quilt-moves: this app's folder follows a square whose base moves (main brought in): it re-projects onto
|
|
33
|
+
// the new head, says which head each edit was made on, and takes in only its own edits. The relay brings
|
|
34
|
+
// main into a square only when every folder on it can follow that (server.js outdatedOn); 0.3.133 and
|
|
35
|
+
// 0.3.134, which can but don't say so, are known by their asking to bring main in.
|
|
36
|
+
export const FEATURES = 'large-files,branches,branch-diffs,quilts,quilt-folders,quilt-moves'
|
|
29
37
|
|
|
30
38
|
// WebSocket close codes the relay uses to refuse a client for good.
|
|
31
39
|
export const CLOSE_AUTH_FAILED = 4401
|