heyquilt 0.3.122 → 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 +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- 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 -9
- 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/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- 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 +151 -17
- package/src/relay-outbound.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 +1517 -86
- 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 +848 -67
- 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 +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/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/ui/branches.js +0 -111
package/src/connection.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './protocol.js'
|
|
16
16
|
import { signChallenge, RESERVED_ROOM } from './identity.js'
|
|
17
17
|
import { PASS_REFRESH_MS } from './pass-source.js'
|
|
18
|
+
import { isUpdateRefusal, withUpdateSteps } from './update-check.js'
|
|
18
19
|
|
|
19
20
|
const ROOM_FULL_MESSAGE = 'This session is over the relay\'s size limit, so new changes can\'t be saved there. Start a new session, or host your own relay with a higher limit.'
|
|
20
21
|
|
|
@@ -22,9 +23,14 @@ const REQUEST_TIMEOUT_MS = 10000
|
|
|
22
23
|
// A request the relay answers after pushing to GitHub: pushes to one repository go at least ten seconds apart
|
|
23
24
|
// (relay-git.js PUSH_GAP_MS), so the answer can take longer than an ordinary one.
|
|
24
25
|
export const PUSH_REQUEST_TIMEOUT_MS = 60 * 1000
|
|
26
|
+
// Moving a session into its squares and back (the relay's quilt-migrate, -switch, -unswitch, -shadow,
|
|
27
|
+
// -next) is many calls to Seam; a large session's takes minutes, and the relay answers when it is done.
|
|
28
|
+
export const QUILT_MOVE_TIMEOUT_MS = 10 * 60 * 1000
|
|
25
29
|
const IDENTITY_CHANGED = "This computer's Quilt identity changed. Sign out and sign in again."
|
|
26
30
|
// Never quote the relay URL in errors: it names the room, and older relays may log it.
|
|
27
31
|
const BAD_ADDRESS = "Couldn't connect to the relay: the address isn't valid."
|
|
32
|
+
// The relay refused this app or CLI: an app too old for the session is told only how to update.
|
|
33
|
+
const refusedBy = (reason) => new Error(isUpdateRefusal(reason) ? withUpdateSteps(reason) : `Relay refused connection: ${reason}`)
|
|
28
34
|
// A pass that lapses this soon after connecting wasn't really valid: after a few in a
|
|
29
35
|
// row, the clock is the likely culprit, and reconnecting won't help.
|
|
30
36
|
const QUICK_EXPIRY_MS = 60 * 1000
|
|
@@ -58,7 +64,7 @@ export class Connection extends EventEmitter {
|
|
|
58
64
|
* @param {number} [opts.livenessMs] give up on a connection that stays silent this long
|
|
59
65
|
* @param {string} [opts.tool] the app or AI tool this is, for the session's audit trail
|
|
60
66
|
*/
|
|
61
|
-
constructor ({ server, room, secret, key, viewSecret, kind = 'human', name, identity, doc, branch = null, onStaleBranch = null, beforeRemote, features = FEATURES, passes = null, passRefreshMs = PASS_REFRESH_MS, livenessMs = LIVENESS_MS, tool = '' }) {
|
|
67
|
+
constructor ({ server, room, secret, key, viewSecret, kind = 'human', name, identity, doc, branch = null, onStaleBranch = null, beforeRemote, features = FEATURES, passes = null, passRefreshMs = PASS_REFRESH_MS, livenessMs = LIVENESS_MS, tool = '', roomDoc = true }) {
|
|
62
68
|
super()
|
|
63
69
|
if (room === RESERVED_ROOM) throw new Error(`"${RESERVED_ROOM}" is not a session name`)
|
|
64
70
|
// Secrets travel in headers, never in the URL: proxies log URLs, and Fly's did (issue 011).
|
|
@@ -103,6 +109,11 @@ export class Connection extends EventEmitter {
|
|
|
103
109
|
this.autoJoin = null // the id of the automatic join, whose refusal is said (branch-refused)
|
|
104
110
|
this.roomSynced = false
|
|
105
111
|
this.branchSynced = false
|
|
112
|
+
// roomDoc: false: only the branch is synced, never the room's document (chat, tasks, activity),
|
|
113
|
+
// nor anyone's presence. A folder's live block (live-block.js) is that: its app's own connection
|
|
114
|
+
// has the room already, so the block's would only download it all again at every square.
|
|
115
|
+
this.roomDocOn = roomDoc !== false
|
|
116
|
+
if (!this.roomDocOn) this.awareness.setLocalState(null)
|
|
106
117
|
// Which document of the branch this app's copy belongs to (the relay starts a new one when a
|
|
107
118
|
// branch is started again after being removed). While the relay hasn't said, a saved copy is
|
|
108
119
|
// held back (branchHeld); a copy of an older one is never merged in: onStaleBranch(reply)
|
|
@@ -218,7 +229,7 @@ export class Connection extends EventEmitter {
|
|
|
218
229
|
this.emit('fatal', new Error('This relay needs a relay key to start new sessions. Join a session someone started there with their invite link instead.'))
|
|
219
230
|
this.close()
|
|
220
231
|
} else if (res.statusCode === 401 || res.statusCode === 400 || res.statusCode === 403) {
|
|
221
|
-
this.emit('fatal',
|
|
232
|
+
this.emit('fatal', refusedBy(reason))
|
|
222
233
|
this.close()
|
|
223
234
|
} else if (res.statusCode === 413) {
|
|
224
235
|
this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
|
|
@@ -261,7 +272,7 @@ export class Connection extends EventEmitter {
|
|
|
261
272
|
this.emit('fatal', Object.assign(new Error(String(reason) || 'The session owner did not let you in'), { denied: true }))
|
|
262
273
|
this.close()
|
|
263
274
|
} else if (code === CLOSE_AUTH_FAILED || code === CLOSE_NAME_TAKEN) {
|
|
264
|
-
this.emit('fatal',
|
|
275
|
+
this.emit('fatal', refusedBy(String(reason) || 'identity check failed'))
|
|
265
276
|
this.close()
|
|
266
277
|
} else if (code === CLOSE_ROOM_FULL) {
|
|
267
278
|
this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
|
|
@@ -342,7 +353,8 @@ export class Connection extends EventEmitter {
|
|
|
342
353
|
}
|
|
343
354
|
|
|
344
355
|
startSync () {
|
|
345
|
-
this.send(syncStep1Message(this.doc))
|
|
356
|
+
if (this.roomDocOn) this.send(syncStep1Message(this.doc))
|
|
357
|
+
else this.roomSynced = true
|
|
346
358
|
if (this.branchKey !== null) {
|
|
347
359
|
// The relay handles messages in order: the join lands before the branch's sync step 1.
|
|
348
360
|
const id = crypto.randomBytes(8).toString('hex')
|
|
@@ -353,6 +365,7 @@ export class Connection extends EventEmitter {
|
|
|
353
365
|
if (this.branchEpoch) this.branchHeld = true
|
|
354
366
|
else this.send(syncStep1Message(this.branchDoc, this.branchKey))
|
|
355
367
|
}
|
|
368
|
+
if (!this.roomDocOn) return this.noteSynced()
|
|
356
369
|
// Set again rather than resent: that moves our clock on, so the relay takes the state
|
|
357
370
|
// even when it still holds the clock from before a drop (it would ignore a repeat).
|
|
358
371
|
const mine = this.awareness.getLocalState()
|
|
@@ -367,7 +380,7 @@ export class Connection extends EventEmitter {
|
|
|
367
380
|
const type = decoding.readVarUint(dec)
|
|
368
381
|
if (type === MSG_SYNC) {
|
|
369
382
|
const docId = decoding.readVarString(dec)
|
|
370
|
-
const doc = docId === ROOM_DOC ? this.doc : docId === this.branchKey && !this.branchHeld ? this.branchDoc : null
|
|
383
|
+
const doc = docId === ROOM_DOC ? (this.roomDocOn ? this.doc : null) : docId === this.branchKey && !this.branchHeld ? this.branchDoc : null
|
|
371
384
|
if (!doc) return // a branch this connection has left (or whose document the relay hasn't confirmed yet)
|
|
372
385
|
// Give the owner a chance to capture unsaved local edits so remote
|
|
373
386
|
// changes merge with them instead of overwriting them.
|
|
@@ -382,10 +395,24 @@ export class Connection extends EventEmitter {
|
|
|
382
395
|
this.noteSynced()
|
|
383
396
|
}
|
|
384
397
|
} else if (type === MSG_BRANCHES) {
|
|
385
|
-
const { branches, reply, pushed, quilt } = JSON.parse(decoding.readVarString(dec))
|
|
398
|
+
const { branches, reply, pushed, quilt, shadow, readOnly, moving, force, importing } = JSON.parse(decoding.readVarString(dec))
|
|
399
|
+
// "Make this a quilt" on a session with a git remote: Seam imports it ({ slug, repo }), or null.
|
|
400
|
+
const im = importing && typeof importing.repo === 'string' ? { slug: String(importing.slug || ''), repo: importing.repo } : null
|
|
401
|
+
if (JSON.stringify(im) !== JSON.stringify(this.importing || null)) { this.importing = im; this.emit('importing', im) }
|
|
402
|
+
// The forced date (16 flag 9): { at, remote, migrate? } before or at it, { stopped: { at, why } } once live sync stopped; else none.
|
|
403
|
+
const fz = force && typeof force === 'object' ? force : null
|
|
404
|
+
if (JSON.stringify(fz) !== JSON.stringify(this.force || null)) { this.force = fz; this.emit('force', fz) }
|
|
405
|
+
// At layout 3 a write of ours reached a read-only branch document: the relay did not keep it.
|
|
406
|
+
if (readOnly && typeof readOnly.why === 'string') this.emit('read-only', readOnly)
|
|
386
407
|
// The session is a quilt (Seam): its link, or null while it is not.
|
|
387
408
|
const link = quilt && typeof quilt.id === 'string' ? quilt : null
|
|
388
409
|
if (JSON.stringify(link) !== JSON.stringify(this.quilt || null)) { this.quilt = link; this.emit('quilt', link) }
|
|
410
|
+
// The quilt an import of the session's git remote fills while git is still the authority (16 step 1).
|
|
411
|
+
const sh = shadow && typeof shadow.id === 'string' ? { id: shadow.id, slug: String(shadow.slug || '') } : null
|
|
412
|
+
if (JSON.stringify(sh) !== JSON.stringify(this.quiltShadow || null)) { this.quiltShadow = sh; this.emit('quilt-shadow', sh) }
|
|
413
|
+
// The owner is moving the session into Seam ('in') or back to its branches ('back') right now: { kind, since }, or null.
|
|
414
|
+
const mv = moving && (moving.kind === 'in' || moving.kind === 'back') ? { kind: moving.kind, since: Number(moving.since) || 0 } : null
|
|
415
|
+
if (JSON.stringify(mv) !== JSON.stringify(this.moving || null)) { this.moving = mv; this.emit('moving', mv) }
|
|
389
416
|
// GitHub moved a branch the session follows (the relay heard it from GitHub): look now.
|
|
390
417
|
if (pushed && typeof pushed.branch === 'string') this.emit('pushed', pushed)
|
|
391
418
|
if (reply && this.joining && reply.id === this.joining.id) {
|
|
@@ -414,9 +441,9 @@ export class Connection extends EventEmitter {
|
|
|
414
441
|
if (Array.isArray(branches)) this.emit('branches', branches)
|
|
415
442
|
this.settle(reply, 'the relay refused that branch change')
|
|
416
443
|
} else if (type === MSG_AWARENESS) {
|
|
417
|
-
awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(dec), REMOTE)
|
|
444
|
+
if (this.roomDocOn) awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(dec), REMOTE)
|
|
418
445
|
} else if (type === MSG_QUERY_AWARENESS) {
|
|
419
|
-
this.send(awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
|
|
446
|
+
if (this.roomDocOn) this.send(awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
|
|
420
447
|
} else if (type === MSG_ACCESS) {
|
|
421
448
|
const was = this.access
|
|
422
449
|
this.access = JSON.parse(decoding.readVarString(dec))
|
|
@@ -486,7 +513,7 @@ export class Connection extends EventEmitter {
|
|
|
486
513
|
}
|
|
487
514
|
|
|
488
515
|
/** Any other branch request (remove). */
|
|
489
|
-
branchRequest (req) { return this.request(MSG_BRANCH, req, 'branch changes') }
|
|
516
|
+
branchRequest (req, { timeoutMs = REQUEST_TIMEOUT_MS } = {}) { return this.request(MSG_BRANCH, req, 'branch changes', null, timeoutMs) }
|
|
490
517
|
|
|
491
518
|
waitForBranchSync () {
|
|
492
519
|
if (this.branchSynced) return Promise.resolve()
|
package/src/control.js
CHANGED
|
@@ -11,6 +11,14 @@ import { createRun, waitRun, endRun, secretNames, apiCall } from './secrets-clie
|
|
|
11
11
|
import { runMailTool, MAIL_TOOLS } from './mail-tools.js'
|
|
12
12
|
import { runWakeTool } from './wake-tool.js'
|
|
13
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'
|
|
14
22
|
|
|
15
23
|
export async function startControl (session, extras = {}) {
|
|
16
24
|
const token = crypto.randomBytes(16).toString('hex')
|
|
@@ -22,10 +30,45 @@ export async function startControl (session, extras = {}) {
|
|
|
22
30
|
// Step 2 of joining (wake.js): an agent that hasn't chosen how Quilt wakes it is reminded with each answer.
|
|
23
31
|
const wake = wakeCheck({
|
|
24
32
|
fetchWake: async () => {
|
|
25
|
-
|
|
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' }
|
|
26
35
|
return (await apiCall(await secretsApi(), 'GET', '/v1/agents/me/wake')).wake
|
|
27
36
|
}
|
|
28
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
|
|
29
72
|
const routes = {
|
|
30
73
|
'GET /status': (b) => {
|
|
31
74
|
// To one of the AI sessions working through this app, "you" is that session (persona.js).
|
|
@@ -99,7 +142,8 @@ export async function startControl (session, extras = {}) {
|
|
|
99
142
|
'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
|
|
100
143
|
'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
|
|
101
144
|
// The chronology: { path, by, since, task, limit } (see Session.historyQuery).
|
|
102
|
-
|
|
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),
|
|
103
147
|
'GET /tree': () => session.tree(),
|
|
104
148
|
'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
|
|
105
149
|
'GET /commits': () => session.commitStatus({ includeMe: false }),
|
|
@@ -111,13 +155,21 @@ export async function startControl (session, extras = {}) {
|
|
|
111
155
|
'POST /quilt-plan': () => session.quiltPlan(),
|
|
112
156
|
// The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
|
|
113
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),
|
|
114
160
|
// `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
|
|
115
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),
|
|
116
164
|
// `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
|
|
117
165
|
'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
|
|
118
|
-
'POST /quilt-unswitch': () => session.quiltUnswitch(),
|
|
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),
|
|
119
169
|
// `quilt session follow <folder>`: at layout 3, that folder follows this branch's square, live.
|
|
120
|
-
'POST /quilt-follow': (b) => session.quiltFollow(
|
|
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 || '')),
|
|
121
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 }),
|
|
122
174
|
// quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
|
|
123
175
|
'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
|
|
@@ -221,7 +273,8 @@ export async function startControl (session, extras = {}) {
|
|
|
221
273
|
const auth = await secretsApi()
|
|
222
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 }) }
|
|
223
275
|
},
|
|
224
|
-
|
|
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 })
|
|
225
278
|
}
|
|
226
279
|
const server = http.createServer(async (req, res) => {
|
|
227
280
|
const reply = (code, body) => {
|
|
@@ -299,15 +352,76 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
|
|
|
299
352
|
}
|
|
300
353
|
}
|
|
301
354
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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)
|
|
309
398
|
})
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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.`
|
|
313
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
|
@@ -256,14 +256,16 @@ export const LEGACY_DAYS = 90
|
|
|
256
256
|
|
|
257
257
|
/**
|
|
258
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 (plans/quilt-source-control/16), marked `legacy: true` for
|
|
259
|
+
* before then are its legacy tail (Seam's plans/quilt-source-control/16), marked `legacy: true` for
|
|
260
260
|
* LEGACY_DAYS and then left out. Not a quilt (no `at`): the entries as they are.
|
|
261
261
|
*/
|
|
262
|
-
export function legacyTail (entries, { at, now = Date.now() } = {}) {
|
|
262
|
+
export function legacyTail (entries, { at, now = Date.now(), history = false } = {}) {
|
|
263
263
|
const t = at ? Date.parse(at) : NaN
|
|
264
264
|
if (!Number.isFinite(t)) return entries
|
|
265
265
|
if (now - t > LEGACY_DAYS * 24 * 60 * 60 * 1000) return entries.filter((e) => !(e && e.ts < t))
|
|
266
|
-
|
|
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)
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
function ago (ts, now) {
|
|
@@ -279,6 +281,14 @@ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}
|
|
|
279
281
|
if (!list.length) return 'No changes match.'
|
|
280
282
|
const out = []
|
|
281
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
|
+
}
|
|
282
292
|
const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
|
|
283
293
|
const what = e.detail ? ` (${e.detail})` : ''
|
|
284
294
|
const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''
|