heyquilt 0.3.121 → 0.3.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/control.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Local control API (127.0.0.1 only) so the CLI and the MCP server can talk to
|
|
2
2
|
// a running session. Discovery info is written to .quilt/daemon.json.
|
|
3
|
+
import { readAccount } from './account.js'
|
|
3
4
|
import http from 'node:http'
|
|
4
5
|
import fs from 'node:fs'
|
|
5
6
|
import path from 'node:path'
|
|
@@ -10,6 +11,14 @@ import { createRun, waitRun, endRun, secretNames, apiCall } from './secrets-clie
|
|
|
10
11
|
import { runMailTool, MAIL_TOOLS } from './mail-tools.js'
|
|
11
12
|
import { runWakeTool } from './wake-tool.js'
|
|
12
13
|
import { wakeCheck } from './wake.js'
|
|
14
|
+
import { seamAccess, sessionCredential } from './seam-access.js'
|
|
15
|
+
import { runSessionSeamTool } from './session-seam.js'
|
|
16
|
+
import { appSeamUrl } from './seam-url.js'
|
|
17
|
+
import { squareFor } from './session-follow.js'
|
|
18
|
+
import { shareWithPeople } from './session-layout3.js'
|
|
19
|
+
import { QUILT_MOVE_TIMEOUT_MS } from './connection.js'
|
|
20
|
+
import { FEATURES } from './protocol.js'
|
|
21
|
+
import { currentVersion } from './releases.js'
|
|
13
22
|
|
|
14
23
|
export async function startControl (session, extras = {}) {
|
|
15
24
|
const token = crypto.randomBytes(16).toString('hex')
|
|
@@ -21,10 +30,45 @@ export async function startControl (session, extras = {}) {
|
|
|
21
30
|
// Step 2 of joining (wake.js): an agent that hasn't chosen how Quilt wakes it is reminded with each answer.
|
|
22
31
|
const wake = wakeCheck({
|
|
23
32
|
fetchWake: async () => {
|
|
24
|
-
|
|
33
|
+
// A person's cloud AI (aiOf) is woken by its machine (autowork.js), never asked how.
|
|
34
|
+
if (session.kind !== 'agent' || session.aiOf || !extras.apiAuth) return { method: 'n/a' }
|
|
25
35
|
return (await apiCall(await secretsApi(), 'GET', '/v1/agents/me/wake')).wake
|
|
26
36
|
}
|
|
27
37
|
})
|
|
38
|
+
// Seam's tools for this folder's AI sessions, in a session that is a quilt (session-seam.js), as
|
|
39
|
+
// this session's member: its person, or its agent on a cloud machine (seam-access.js).
|
|
40
|
+
// Kept on the session, so its AI's runs (autowork.js) read Seam as the same member.
|
|
41
|
+
if (extras.apiAuth && !session.apiAuth) session.apiAuth = extras.apiAuth
|
|
42
|
+
const seam = session.seamMember ? session.seamMember() : (session.seam || (session.seam = seamAccess({ api: () => appSeamUrl(), credential: sessionCredential({ apiAuth: extras.apiAuth, account: readAccount }) })))
|
|
43
|
+
const shared = new Set() // squares this person holds and has shared with the session's other people
|
|
44
|
+
const seamTool = async (b) => {
|
|
45
|
+
const q = session.quilt
|
|
46
|
+
if (!q || !q.id) return { content: [{ type: 'text', text: 'This session is not a quilt, so it has no Seam tools.' }], isError: true }
|
|
47
|
+
const branch = session.squareBranchKey ? session.squareBranchKey() : session.branchKey()
|
|
48
|
+
const at = squareFor(q, branch)
|
|
49
|
+
const square = at ? at.square : ((q.squares || {})[branch] || null)
|
|
50
|
+
const fail = (text) => ({ content: [{ type: 'text', text }], isError: true })
|
|
51
|
+
// The folder's live block on that square: the folder itself, following it in place, or a
|
|
52
|
+
// separate one (session-follow.js). Its commit is `quilt commit`'s: what the relay holds.
|
|
53
|
+
const f = session.squareFollower
|
|
54
|
+
const here = f && f.here && at && f.here.square === at.square ? f.here : null
|
|
55
|
+
const sep = f && f.following && at && f.following.square === at.square ? f.following : null
|
|
56
|
+
const live = (here && here.block) || (sep && sep.block) || null
|
|
57
|
+
if (here && !here.block && (b.tool === 'seam_commit' || b.tool === 'seam_merge')) return fail(here.problem ? `This folder can't follow its square yet (${here.problem}); Quilt tries again by itself.` : 'This folder is still starting to follow its square; try again in a moment.')
|
|
58
|
+
let c
|
|
59
|
+
try { c = await seam.client(q.id, square ? [square] : []) } catch (err) { return fail(err.message) }
|
|
60
|
+
// The folder's live work is read and committed as this member.
|
|
61
|
+
if (live) live.client = c.client
|
|
62
|
+
if (c.person && square && !shared.has(square)) {
|
|
63
|
+
shared.add(square)
|
|
64
|
+
// The square's holder shares it with the session's other people, so their AIs (working for them) write there too.
|
|
65
|
+
shareWithPeople(c.client, q.id, square, session.members || []).catch(() => shared.delete(square))
|
|
66
|
+
}
|
|
67
|
+
const next = at ? async () => session.conn.branchRequest({ op: 'quilt-next', token: c.token, branch }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS }) : null
|
|
68
|
+
return runSessionSeamTool(String(b.tool || ''), b.args || {}, { client: c.client, quilt: q.id, square, live, next, branch })
|
|
69
|
+
}
|
|
70
|
+
// The app's square panel (ui-server.js) commits and merges through the same tools, as the same member.
|
|
71
|
+
session.seamTool = seamTool
|
|
28
72
|
const routes = {
|
|
29
73
|
'GET /status': (b) => {
|
|
30
74
|
// To one of the AI sessions working through this app, "you" is that session (persona.js).
|
|
@@ -98,7 +142,8 @@ export async function startControl (session, extras = {}) {
|
|
|
98
142
|
'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
|
|
99
143
|
'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
|
|
100
144
|
// The chronology: { path, by, since, task, limit } (see Session.historyQuery).
|
|
101
|
-
|
|
145
|
+
// In a session linked to a quilt (Seam), the quilt's log too (Session.historyLinked): { entries, note? }.
|
|
146
|
+
'POST /history': (b) => session.historyLinked(b),
|
|
102
147
|
'GET /tree': () => session.tree(),
|
|
103
148
|
'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
|
|
104
149
|
'GET /commits': () => session.commitStatus({ includeMe: false }),
|
|
@@ -106,6 +151,25 @@ export async function startControl (session, extras = {}) {
|
|
|
106
151
|
// The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
|
|
107
152
|
'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
|
|
108
153
|
'POST /branches/sync': () => session.syncBranchNow(),
|
|
154
|
+
// `quilt session migrate --dry-run`: what making the session a quilt would do (the relay's dry run).
|
|
155
|
+
'POST /quilt-plan': () => session.quiltPlan(),
|
|
156
|
+
// The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
|
|
157
|
+
'GET /quilt': () => ({ quilt: session.quilt || null }),
|
|
158
|
+
// One of Seam's tools (session-seam.js SEAM_SESSION_TOOLS), { tool, args }: an MCP tool result.
|
|
159
|
+
'POST /seam': (b) => seamTool(b),
|
|
160
|
+
// `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
|
|
161
|
+
'POST /quilt-migrate': () => session.quiltMigrate(process.env.SEAM_TOKEN || (readAccount() || {}).token),
|
|
162
|
+
// "Make this a quilt" (`quilt session migrate`, the app's button): made a quilt and moved into its squares in one go.
|
|
163
|
+
'POST /quilt-make': () => session.quiltMake(process.env.SEAM_TOKEN || (readAccount() || {}).token),
|
|
164
|
+
// `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
|
|
165
|
+
'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
|
|
166
|
+
'POST /quilt-unswitch': (b) => session.quiltUnswitch(process.env.SEAM_TOKEN || (readAccount() || {}).token, Array.isArray(b.carried) ? b.carried : []),
|
|
167
|
+
// `quilt session migrate --shadow <quilt>`: link the session to the quilt an import of its git remote fills (16 step 1).
|
|
168
|
+
'POST /quilt-shadow': (b) => session.quiltShadowLink(process.env.SEAM_TOKEN || (readAccount() || {}).token, b.quilt ?? null),
|
|
169
|
+
// `quilt session follow <folder>`: at layout 3, that folder follows this branch's square, live.
|
|
170
|
+
'POST /quilt-follow': (b) => session.quiltFollow(null, b.dir),
|
|
171
|
+
// `quilt commit <message>`: at layout 3, the square this folder follows is committed, as this person.
|
|
172
|
+
'POST /square-commit': (b) => session.squareCommit(String(b.message || '')),
|
|
109
173
|
'POST /commit-request': (b) => session.requestCommit(b.message, { files: b.files == null ? null : b.files, description: b.description || '', task: b.task || null, via: b.via ? String(b.via) : null }),
|
|
110
174
|
// quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
|
|
111
175
|
'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
|
|
@@ -209,7 +273,8 @@ export async function startControl (session, extras = {}) {
|
|
|
209
273
|
const auth = await secretsApi()
|
|
210
274
|
return { text: await runMailTool(b.tool, b.args && typeof b.args === 'object' ? b.args : {}, { call: (method, route, body) => apiCall(auth, method, route, body), room: session.room, me: session.actorName ? session.actorName(b.via) : session.name }) }
|
|
211
275
|
},
|
|
212
|
-
|
|
276
|
+
// `version` and `features` (what this app tells the relay it can do): the CLI checks them before a move (bin/quilt.js).
|
|
277
|
+
'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid, version: appVersion(), features: session.features || FEATURES })
|
|
213
278
|
}
|
|
214
279
|
const server = http.createServer(async (req, res) => {
|
|
215
280
|
const reply = (code, body) => {
|
|
@@ -287,15 +352,76 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
|
|
|
287
352
|
}
|
|
288
353
|
}
|
|
289
354
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
355
|
+
const appVersion = () => { try { return currentVersion() } catch { return '' } }
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt).
|
|
359
|
+
* Without it, it waits as long as the session takes (a move into Seam can take minutes): node:http, not fetch, whose
|
|
360
|
+
* own limit gave up after 5 minutes with nothing but "fetch failed". A session that can't be reached, or that
|
|
361
|
+
* stopped while answering, throws with what happened and what to do (`unreachable`).
|
|
362
|
+
*/
|
|
363
|
+
export function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
|
|
364
|
+
const data = body ? JSON.stringify(body) : null
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
let timer = null
|
|
367
|
+
let late = null // the timeout's error, once it fired: whatever the socket says after it
|
|
368
|
+
const failed = (err) => { clearTimeout(timer); reject(late || (err.name === 'TimeoutError' ? err : unreachable(daemon, err))) }
|
|
369
|
+
const req = http.request({
|
|
370
|
+
host: '127.0.0.1',
|
|
371
|
+
port: daemon.port,
|
|
372
|
+
path: route,
|
|
373
|
+
method,
|
|
374
|
+
// A connection each: a kept-alive one outlives an app that restarted, and the next call would find it cut.
|
|
375
|
+
agent: false,
|
|
376
|
+
headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json', ...(data ? { 'content-length': Buffer.byteLength(data) } : {}) }
|
|
377
|
+
}, (res) => {
|
|
378
|
+
let raw = ''
|
|
379
|
+
res.setEncoding('utf8')
|
|
380
|
+
res.on('data', (c) => { raw += c })
|
|
381
|
+
res.on('error', failed)
|
|
382
|
+
res.on('end', () => {
|
|
383
|
+
clearTimeout(timer)
|
|
384
|
+
let json
|
|
385
|
+
try { json = JSON.parse(raw) } catch { return reject(new Error(`Quilt's app sent something that isn't an answer (HTTP ${res.statusCode})`)) }
|
|
386
|
+
if (res.statusCode < 200 || res.statusCode >= 300) return reject(Object.assign(new Error(json.error || `HTTP ${res.statusCode}`), { status: res.statusCode }))
|
|
387
|
+
resolve(json)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
req.on('error', failed)
|
|
391
|
+
if (timeoutMs) {
|
|
392
|
+
timer = setTimeout(() => {
|
|
393
|
+
late = Object.assign(new Error(`The session in ${daemon.dir || 'this folder'} did not answer within ${Math.round(timeoutMs / 1000)} s`), { name: 'TimeoutError' })
|
|
394
|
+
req.destroy(late)
|
|
395
|
+
}, timeoutMs)
|
|
396
|
+
}
|
|
397
|
+
req.end(data || undefined)
|
|
297
398
|
})
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The control server couldn't be reached (nothing listens: the app closed this session or restarted), or
|
|
403
|
+
* it closed the connection before answering (the session stopped meanwhile: left, quit, or sent away by
|
|
404
|
+
* the relay). `cut` tells the two apart for callers that say more (a move into Seam goes on without the app).
|
|
405
|
+
*/
|
|
406
|
+
export function unreachable (daemon, err) {
|
|
407
|
+
const code = (err && err.code) || ''
|
|
408
|
+
const where = daemon && daemon.dir ? ` for ${daemon.dir}` : ''
|
|
409
|
+
const cut = code !== 'ECONNREFUSED'
|
|
410
|
+
const msg = cut
|
|
411
|
+
? `Quilt's app${where} stopped answering in the middle of this (${code || err.message}): the session there stopped (left, Quilt quit or restarted, or the relay sent this app away to update). Open the session in Quilt again, then check before trying again.`
|
|
412
|
+
: `Quilt's app${where} isn't answering: the session there is not running any more (Quilt quit, restarted, or left it). Open it in Quilt (or run quilt join here), then try again.`
|
|
413
|
+
return Object.assign(new Error(msg), { unreachable: true, cut, code })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Why `quilt session migrate` (or --switch, --back) must not start through this app, from its GET /info,
|
|
418
|
+
* or null. The move ends by sending away every app that can't follow a square (no `quilt-folders`): the
|
|
419
|
+
* owner's own app too, cutting this command's answer off mid-way. An app older than this check says
|
|
420
|
+
* neither its features nor its version; `cliVersion` is the quilt command's own.
|
|
421
|
+
*/
|
|
422
|
+
export function moveRefusal (info, cliVersion = '') {
|
|
423
|
+
const features = info && typeof info.features === 'string' ? info.features.split(',') : null
|
|
424
|
+
if (features && features.includes('quilt-folders')) return null
|
|
425
|
+
const was = info && info.version ? ` (${info.version})` : ''
|
|
426
|
+
return `Quilt's app for this folder${was} is older than this quilt command${cliVersion ? ` (${cliVersion})` : ''}: the move sends it away to update at the end, and this command would lose its answer. Update Quilt, or restart it so it runs the new version, then run this again.`
|
|
301
427
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The forced date (Seam's plans/quilt-source-control/16, flag 9): QUILT_SOURCE_FORCE_AT=<date> on the
|
|
2
|
+
// relay. Unset (or not a date), nothing happens. From the date on, a session still in git mode is
|
|
3
|
+
// moved on without anyone losing work: one with no git remote becomes a quilt without history on its
|
|
4
|
+
// owner's next open; one with a git remote that has not cut over keeps chat, tasks, the agent feed and
|
|
5
|
+
// the audit trail, and its live file sync stops for good (git, its remote, clones and CI untouched).
|
|
6
|
+
// Pure: no I/O (test/unit/force-date.test.js). The words are in ui/force-words.js, which the app loads too.
|
|
7
|
+
export { syncStoppedWords, forceAheadWords } from './ui/force-words.js'
|
|
8
|
+
|
|
9
|
+
/** The date as ms, or null when unset or not a date. */
|
|
10
|
+
export function forceAtMs (env = process.env) {
|
|
11
|
+
const v = String(env.QUILT_SOURCE_FORCE_AT || '').trim()
|
|
12
|
+
if (!v) return null
|
|
13
|
+
const ms = Date.parse(v)
|
|
14
|
+
return Number.isFinite(ms) ? ms : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the date means for a session now: null (no date, not yet, or already a quilt in its squares),
|
|
19
|
+
* 'stop' (a git remote, not cut over: live sync stops), or 'migrate' (no git remote: its owner's next
|
|
20
|
+
* open makes it a quilt without history).
|
|
21
|
+
*/
|
|
22
|
+
export function forcedAction ({ at, now = Date.now(), layout = 2, remote = false, stopped = false }) {
|
|
23
|
+
if (stopped) return 'stop'
|
|
24
|
+
if (at == null || now < at || layout === 3) return null
|
|
25
|
+
return remote ? 'stop' : 'migrate'
|
|
26
|
+
}
|
|
27
|
+
|
package/src/fsutil.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import crypto from 'node:crypto'
|
|
4
|
-
import {
|
|
4
|
+
import { isUtf8 } from 'node:buffer'
|
|
5
|
+
import { makeIgnore, isIgnored, scopeIgnore, submodulePathsIn, IGNORE_FILES } from './pathrules.js'
|
|
5
6
|
|
|
6
7
|
export { IGNORE_FILES } from './pathrules.js'
|
|
7
8
|
export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
|
|
@@ -27,6 +28,9 @@ export function closeWatcher (w) {
|
|
|
27
28
|
*/
|
|
28
29
|
export function loadIgnore (root) {
|
|
29
30
|
const ig = makeIgnore()
|
|
31
|
+
// A submodule (.gitmodules) is a repository of its own that git keeps as one commit id: its files
|
|
32
|
+
// are its own git's, never the session's (nor a Seam square's, which holds it as a gitlink).
|
|
33
|
+
for (const p of submodulePaths(root)) ig.add(`/${p}`)
|
|
30
34
|
const visit = (dirRel) => {
|
|
31
35
|
const dir = path.join(root, dirRel)
|
|
32
36
|
for (const file of IGNORE_FILES) {
|
|
@@ -44,6 +48,11 @@ export function loadIgnore (root) {
|
|
|
44
48
|
return ig
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/** The folders .gitmodules names as submodules (`path = …`), as safe relative paths. */
|
|
52
|
+
export function submodulePaths (root) {
|
|
53
|
+
try { return submodulePathsIn(fs.readFileSync(path.join(root, '.gitmodules'), 'utf8')) } catch { return [] }
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Resolves a relative path to an absolute one inside root, making sure no
|
|
49
58
|
* symlinked parent directory points outside of it (or nowhere: a dangling
|
|
@@ -71,8 +80,9 @@ export function resolveInside (root, rel) {
|
|
|
71
80
|
export function looksBinary (buf) {
|
|
72
81
|
const n = Math.min(buf.length, 8000)
|
|
73
82
|
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
|
74
|
-
// Invalid UTF-8 is treated as binary so it round-trips byte for byte.
|
|
75
|
-
|
|
83
|
+
// Invalid UTF-8 is treated as binary so it round-trips byte for byte. Checked in place: decoding
|
|
84
|
+
// and encoding every file again to compare made two copies of each one.
|
|
85
|
+
return !isUtf8(buf)
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
export function sha1 (buf) {
|
package/src/gitstate.js
CHANGED
|
@@ -916,8 +916,44 @@ export async function committedAs (root, sha, rel, text) {
|
|
|
916
916
|
return blob.trim() !== '' && blob.trim() === hashed.trim()
|
|
917
917
|
}
|
|
918
918
|
|
|
919
|
-
|
|
920
|
-
|
|
919
|
+
/** Which of `paths` are links (mode 120000) at commit `sha`: a Set, or null when git can't say. */
|
|
920
|
+
export async function linksAt (root, sha, paths) {
|
|
921
|
+
if (!paths.length) return new Set()
|
|
922
|
+
const out = await run(root, ['ls-tree', '-z', sha, '--', ...paths])
|
|
923
|
+
if (out === null) return null
|
|
924
|
+
return new Set(out.split('\0').filter((l) => l.startsWith('120000 ')).map((l) => l.slice(l.indexOf('\t') + 1)))
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The paths a folder's branch changed since it left the remote's default branch: `committed`, from
|
|
929
|
+
* the merge base of HEAD and origin/HEAD (else origin/main, origin/master) to HEAD; `uncommitted`,
|
|
930
|
+
* what differs from HEAD (staged or not, deletions too) and files git does not track and does not
|
|
931
|
+
* ignore. `gitDir` reads a .git kept elsewhere (set aside while the folder follows a square). Two
|
|
932
|
+
* Sets, or null when git can't say (no remote default branch, no common history).
|
|
933
|
+
*/
|
|
934
|
+
export async function ownSinceFork (root, { gitDir = null } = {}) {
|
|
935
|
+
const opts = gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}
|
|
936
|
+
let main = ''
|
|
937
|
+
for (const ref of ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
|
|
938
|
+
main = ((await run(root, ['rev-parse', '-q', '--verify', `${ref}^{commit}`], opts)) || '').trim()
|
|
939
|
+
if (main) break
|
|
940
|
+
}
|
|
941
|
+
if (!main) return null
|
|
942
|
+
const fork = ((await run(root, ['merge-base', 'HEAD', main], opts)) || '').trim()
|
|
943
|
+
if (!fork) return null
|
|
944
|
+
const lists = [
|
|
945
|
+
await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', fork, 'HEAD'], opts),
|
|
946
|
+
await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', 'HEAD'], opts),
|
|
947
|
+
await run(root, ['ls-files', '--others', '--exclude-standard', '--exclude=/.quilt/', '-z'], opts)
|
|
948
|
+
]
|
|
949
|
+
if (lists.some((l) => l === null)) return null
|
|
950
|
+
const names = (l) => l.split('\0').filter(Boolean)
|
|
951
|
+
return { committed: new Set(names(lists[0])), uncommitted: new Set([...names(lists[1]), ...names(lists[2])]) }
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** A file's bytes at commit `sha` (null when git can't say); `gitDir` reads from a .git kept elsewhere (set aside while the folder follows a square). */
|
|
955
|
+
export async function blobAt (root, sha, rel, { gitDir = null } = {}) {
|
|
956
|
+
const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true, ...(gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}) })
|
|
921
957
|
return out === null ? null : out
|
|
922
958
|
}
|
|
923
959
|
|
package/src/history.js
CHANGED
|
@@ -252,6 +252,22 @@ export function queryHistory (entries, { path, by, since, task, limit = 50 } = {
|
|
|
252
252
|
return list
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
export const LEGACY_DAYS = 90
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The chronology once the session became a quilt at `at` (an ISO date, meta.quilt.at): entries from
|
|
259
|
+
* before then are its legacy tail (Seam's plans/quilt-source-control/16), marked `legacy: true` for
|
|
260
|
+
* LEGACY_DAYS and then left out. Not a quilt (no `at`): the entries as they are.
|
|
261
|
+
*/
|
|
262
|
+
export function legacyTail (entries, { at, now = Date.now(), history = false } = {}) {
|
|
263
|
+
const t = at ? Date.parse(at) : NaN
|
|
264
|
+
if (!Number.isFinite(t)) return entries
|
|
265
|
+
if (now - t > LEGACY_DAYS * 24 * 60 * 60 * 1000) return entries.filter((e) => !(e && e.ts < t))
|
|
266
|
+
// With history (the quilt is the session's imported git remote), git's own writes before then
|
|
267
|
+
// (pulls, first copies) are the log's imported patches: they are dropped, not shown twice.
|
|
268
|
+
return entries.filter((e) => !(history && e && e.ts < t && (e.pulled || e.seed))).map((e) => e && e.ts < t ? { ...e, legacy: true } : e)
|
|
269
|
+
}
|
|
270
|
+
|
|
255
271
|
function ago (ts, now) {
|
|
256
272
|
const s = Math.max(0, Math.round((now - ts) / 1000))
|
|
257
273
|
if (s < 60) return `${s}s ago`
|
|
@@ -265,10 +281,18 @@ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}
|
|
|
265
281
|
if (!list.length) return 'No changes match.'
|
|
266
282
|
const out = []
|
|
267
283
|
for (const e of list) {
|
|
284
|
+
if (e.seam) {
|
|
285
|
+
// A commit in the quilt's log (quilt-history.js): who, which patch, its message, how many files.
|
|
286
|
+
const where = e.patch ? `patch ${e.patch.line && e.patch.line !== 'main' ? `${e.patch.line}:` : ''}${e.patch.no}` : `commit ${e.short || String(e.commit || '').slice(0, 12)}`
|
|
287
|
+
const n = e.files ? ` (${e.files} file${e.files === 1 ? '' : 's'})` : ''
|
|
288
|
+
out.push(`[${ago(e.ts, now)}] ${e.by} committed ${where}: ${e.message || '(no message)'}${n}${e.task ? ` [task ${e.task.id}]` : ''}`)
|
|
289
|
+
if (withDiff && e.diff) out.push(e.diff, '')
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
268
292
|
const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
|
|
269
293
|
const what = e.detail ? ` (${e.detail})` : ''
|
|
270
294
|
const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''
|
|
271
|
-
out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}`)
|
|
295
|
+
out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}${e.legacy ? ' (before the session became a quilt)' : ''}`)
|
|
272
296
|
if (withDiff && e.diff) out.push(e.diff, '')
|
|
273
297
|
}
|
|
274
298
|
return out.join('\n').replace(/\n+$/, '')
|
package/src/mcp.js
CHANGED
|
@@ -46,6 +46,7 @@ import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
|
46
46
|
import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
|
|
47
47
|
import { appWorkspaces } from './app-workspaces.js'
|
|
48
48
|
import { TRANSCRIPT_DESCRIPTION, renderLines } from './voice.js'
|
|
49
|
+
import { seamGate, seamNote, seamStatus, seamToolSpec, seamOnly, seamWorkflow, seamRefusal, SEAM_SESSION_TOOLS, GIT_TOOLS } from './session-seam.js'
|
|
49
50
|
|
|
50
51
|
// The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
|
|
51
52
|
const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
|
|
@@ -118,11 +119,25 @@ export const MCP_INSTRUCTIONS =
|
|
|
118
119
|
MAIL_GUIDE + ' ' +
|
|
119
120
|
TASK_WORKFLOW
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The instructions for a folder in a session that is a quilt (session-seam.js): every sentence
|
|
124
|
+
* about git left out, and how work lands on Seam in its place. Nothing there names git or GitHub.
|
|
125
|
+
*/
|
|
126
|
+
export const mcpQuiltInstructions = (quilt) => `${seamOnly(MCP_INSTRUCTIONS)} ${seamNote(quilt)}`
|
|
127
|
+
|
|
128
|
+
/** The session this folder is in, as its app says, when it answers at once: whether it is a quilt picks the instructions. */
|
|
129
|
+
async function quiltAtStart () {
|
|
130
|
+
const d = findDaemon()
|
|
131
|
+
if (!d) return null
|
|
132
|
+
try { return (await rawCall(d, 'GET', '/quilt', undefined, { timeoutMs: 1500 })).quilt || null } catch { return null }
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
export async function runMcp () {
|
|
136
|
+
const startQuilt = await quiltAtStart()
|
|
122
137
|
const server = new McpServer(
|
|
123
138
|
{ name: 'quilt', version: '0.1.0' },
|
|
124
139
|
// The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
|
|
125
|
-
{ instructions: MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
140
|
+
{ instructions: startQuilt && startQuilt.id ? mcpQuiltInstructions(startQuilt.slug || startQuilt.id) : MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
126
141
|
)
|
|
127
142
|
|
|
128
143
|
// This AI session, by an id of its own, so the app can tell it from the others working through
|
|
@@ -199,10 +214,19 @@ export async function runMcp () {
|
|
|
199
214
|
// When this person lets their AI pick up work by itself: the next task, said as it finishes one.
|
|
200
215
|
const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\n➡️ ${n}` : '' } catch { return '' } }
|
|
201
216
|
const gates = (...list) => async (d) => { for (const g of list) { const r = g && await g(d); if (r) return r } return '' }
|
|
217
|
+
// An AI session started before its session moved into Seam (or back to git) was told the other
|
|
218
|
+
// way in its instructions, which never change once given: the next answer says the way now, once.
|
|
219
|
+
let toldQuilt = !!(startQuilt && startQuilt.id)
|
|
220
|
+
const moved = async () => {
|
|
221
|
+
const q = await quiltNow()
|
|
222
|
+
if (q === undefined || !!q === toldQuilt) return ''
|
|
223
|
+
toldQuilt = !!q
|
|
224
|
+
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'
|
|
225
|
+
}
|
|
202
226
|
const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
|
|
203
227
|
const d = findDaemon(joined ? joined.dir : undefined)
|
|
204
228
|
if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
|
|
205
|
-
const before = async () => introduce() + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
229
|
+
const before = async () => introduce() + (await moved()) + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
206
230
|
const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\n📥 ${n}` : '' }
|
|
207
231
|
try {
|
|
208
232
|
const refused = gate ? await gate(d) : ''
|
|
@@ -221,12 +245,51 @@ export async function runMcp () {
|
|
|
221
245
|
return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
|
|
222
246
|
}
|
|
223
247
|
|
|
248
|
+
// Seam (Seam's plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
|
|
249
|
+
// refuse with the Seam tool to use instead, the same for every AI tool (session-seam.js).
|
|
250
|
+
// undefined when the app can't say (none, or no answer): unknown, not "not a quilt".
|
|
251
|
+
const quiltNow = async () => {
|
|
252
|
+
const d = findDaemon(joined ? joined.dir : undefined)
|
|
253
|
+
if (!d) return undefined
|
|
254
|
+
let q = null
|
|
255
|
+
try { q = (await call(d, 'GET', '/quilt')).quilt || null } catch { return undefined }
|
|
256
|
+
syncSeamTools(q)
|
|
257
|
+
return q
|
|
258
|
+
}
|
|
259
|
+
// The git-era tools are hidden while the session is a quilt (GIT_TOOLS), and refuse with the Seam tool if called.
|
|
260
|
+
const gitTools = new Map()
|
|
261
|
+
const gated = seamGate(server.registerTool.bind(server), quiltNow)
|
|
262
|
+
server.registerTool = (name, spec, handler) => {
|
|
263
|
+
const t = gated(name, spec, handler)
|
|
264
|
+
if (GIT_TOOLS.has(name)) gitTools.set(name, t)
|
|
265
|
+
return t
|
|
266
|
+
}
|
|
267
|
+
// Seam's everyday tools, served by this server itself while the session is a quilt (none
|
|
268
|
+
// otherwise): the app runs them as this folder's member, on its square (control.js /seam).
|
|
269
|
+
// The client is told when they come and go, as with the workspace library's tools.
|
|
270
|
+
const seamTools = new Map()
|
|
271
|
+
const seamCall = (name, args) => withDaemon(async (d) => {
|
|
272
|
+
await quiltNow() // the session may have stopped being a quilt: the tool list follows
|
|
273
|
+
const r = await call(d, 'POST', '/seam', { tool: name, args: args || {} })
|
|
274
|
+
const text = ((r && r.content) || []).map((c) => c.text || '').join('\n')
|
|
275
|
+
if (r && r.isError) throw new Error(text)
|
|
276
|
+
return text
|
|
277
|
+
}, { gate: name === 'seam_commit' || name === 'seam_merge' ? gateFor(name) : null })
|
|
278
|
+
const syncSeamTools = (q) => {
|
|
279
|
+
const on = !!(q && q.id)
|
|
280
|
+
if (on && !seamTools.size) for (const name of SEAM_SESSION_TOOLS) seamTools.set(name, server.registerTool(name, seamToolSpec(name), (args) => seamCall(name, args)))
|
|
281
|
+
for (const t of seamTools.values()) if (t.enabled !== on) { if (on) t.enable(); else t.disable() }
|
|
282
|
+
for (const t of gitTools.values()) if (t.enabled === on) { if (on) t.disable(); else t.enable() }
|
|
283
|
+
}
|
|
284
|
+
|
|
224
285
|
server.registerTool('quilt_status', {
|
|
225
286
|
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.',
|
|
226
287
|
inputSchema: {}
|
|
227
288
|
}, () => withDaemon(async (d) => {
|
|
228
289
|
const st = await call(d, 'GET', '/status')
|
|
229
|
-
|
|
290
|
+
const q = await quiltNow()
|
|
291
|
+
const md = renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
|
|
292
|
+
return q ? `${seamNote(q.slug || q.id)}\n\n${seamStatus(md)}` : md
|
|
230
293
|
}))
|
|
231
294
|
|
|
232
295
|
server.registerTool('quilt_tasks', {
|
|
@@ -281,8 +344,11 @@ export async function runMcp () {
|
|
|
281
344
|
if (column === 'qa') patch.qaNotes = qaNotes
|
|
282
345
|
if (column === 'done') patch.verified = verified
|
|
283
346
|
const { task, merged, branch } = await call(d, 'POST', '/tasks/update', patch)
|
|
347
|
+
const q = column === 'doing' || column === 'qa' ? await quiltNow() : null
|
|
348
|
+
if (q && column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments }, workflow: seamWorkflow(TASK_WORKFLOW) })
|
|
284
349
|
if (column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments } }) + (branch ? `\nYour folder is on ${branch} now (Quilt made it for this task): commit only the files you change and push there; the pull request opens by itself. Move the task to QA when it is done and tested.` : '')
|
|
285
|
-
|
|
350
|
+
// In a quilt a pull request from before the switch is git's: the task's work lands with seam_merge.
|
|
351
|
+
if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + (task.pr && !q ? ` Pull request #${task.pr.number} waits for a person to move the task to Done, which merges it.` : '') + await nextUp(d)
|
|
286
352
|
if (column === 'done') return `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)}` + await nextUp(d)
|
|
287
353
|
return `Moved "${task.title}" to ${columnName(task.column)}.`
|
|
288
354
|
}, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
|
|
@@ -719,6 +785,8 @@ export async function runMcp () {
|
|
|
719
785
|
readerOptions: { chatDir: process.cwd() },
|
|
720
786
|
onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
|
|
721
787
|
onFatal: async (err) => { logs.push(`stopped: ${err.message}`); await leave() },
|
|
788
|
+
// A session this agent starts is a quilt from the start when the API says so (src/quilt-default.js).
|
|
789
|
+
newSession: starting && !inviteServer,
|
|
722
790
|
workspace
|
|
723
791
|
})
|
|
724
792
|
joined = { run, dir, invite: run.invite }
|
|
@@ -1041,8 +1109,8 @@ export async function runMcp () {
|
|
|
1041
1109
|
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
1042
1110
|
}
|
|
1043
1111
|
}, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
|
|
1044
|
-
const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
|
|
1045
|
-
return formatHistory(entries, { withDiff: !!with_diff })
|
|
1112
|
+
const { entries, note } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30, with_diff: !!with_diff })
|
|
1113
|
+
return `${formatHistory(entries, { withDiff: !!with_diff })}${note ? `\n\n${note}` : ''}`
|
|
1046
1114
|
}))
|
|
1047
1115
|
|
|
1048
1116
|
server.registerTool('quilt_partner_feed', {
|
|
@@ -1111,12 +1179,30 @@ export async function runMcp () {
|
|
|
1111
1179
|
if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
|
|
1112
1180
|
}
|
|
1113
1181
|
|
|
1114
|
-
|
|
1182
|
+
const transport = new StdioServerTransport()
|
|
1183
|
+
await server.connect(transport)
|
|
1184
|
+
// A git-era tool hidden while the session is a quilt, called from a tool list read before the
|
|
1185
|
+
// move (a client that missed the list's change): answered with the Seam way, not "disabled".
|
|
1186
|
+
const onmessage = transport.onmessage
|
|
1187
|
+
transport.onmessage = (msg, extra) => {
|
|
1188
|
+
const name = msg && msg.method === 'tools/call' && msg.params && msg.params.name
|
|
1189
|
+
const t = name && gitTools.get(name)
|
|
1190
|
+
if (!t || t.enabled) return onmessage(msg, extra)
|
|
1191
|
+
quiltNow().then((q) => {
|
|
1192
|
+
const no = q && seamRefusal(name, q.slug || q.id)
|
|
1193
|
+
if (!no) return onmessage(msg, extra)
|
|
1194
|
+
return transport.send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: no }], isError: true } })
|
|
1195
|
+
}).catch(() => onmessage(msg, extra))
|
|
1196
|
+
}
|
|
1115
1197
|
// The workspace library tools arrive a moment later (the client is told the tool list
|
|
1116
1198
|
// changed), so startup never waits on the API.
|
|
1117
1199
|
// fromPath reads only from the project: the session's folder, or where the agent runs.
|
|
1118
1200
|
const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
|
|
1119
1201
|
addWorkspaceTools(server, { projectDirs }).catch(() => {})
|
|
1202
|
+
// In a session that is a quilt, Seam's tools come the same way; looked at again every half minute.
|
|
1203
|
+
quiltNow().catch(() => {})
|
|
1204
|
+
const seamTimer = setInterval(() => quiltNow().catch(() => {}), 30_000)
|
|
1205
|
+
seamTimer.unref()
|
|
1120
1206
|
}
|
|
1121
1207
|
|
|
1122
1208
|
/**
|
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
|
|