heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Whether an agent's Quilt (its "image": the build it runs) is current. Every
|
|
2
|
+
// Quilt MCP tells an agent in each answer when a newer Quilt is out, and
|
|
3
|
+
// quilt_check_update answers for whatever image the agent names.
|
|
4
|
+
import { currentVersion, latestRelease, compareVersions } from './releases.js'
|
|
5
|
+
|
|
6
|
+
export const UPDATE_HOW = 'Update Quilt (the desktop app: Settings → About → Update Quilt; the CLI: npm i -g github:DanielCarmichaelGit/heyquilt, or on a Linux server run its install script again: curl -fsSL https://github.com/DanielCarmichaelGit/heyquilt/releases/latest/download/install.sh | sh), then rejoin the session.'
|
|
7
|
+
|
|
8
|
+
/** True when `image` looks like a Quilt version. */
|
|
9
|
+
export const validImage = (image) => /^v?\d+\.\d+\.\d+$/.test(String(image || '').trim())
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The line to show an agent whose image is older than `latest`, or '' when it is current
|
|
13
|
+
* (or when the image is unknown or unparseable: nobody is told to update on a guess).
|
|
14
|
+
*/
|
|
15
|
+
export function updateNotice (image, latest) {
|
|
16
|
+
if (!validImage(image) || !validImage(latest)) return ''
|
|
17
|
+
const mine = String(image).trim().replace(/^v/, '')
|
|
18
|
+
const newest = String(latest).trim().replace(/^v/, '')
|
|
19
|
+
if (compareVersions(mine, newest) >= 0) return ''
|
|
20
|
+
return `You must update your app: you run Quilt ${mine} and ${newest} is out. ${UPDATE_HOW}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Keeps the newest Quilt version this process knows of: its own, or GitHub's latest
|
|
25
|
+
* release when that is newer. `latest()` answers at once from what it knows;
|
|
26
|
+
* `refresh()` asks GitHub (cached ten minutes by latestRelease) and is run on a timer.
|
|
27
|
+
*/
|
|
28
|
+
export class UpdateCheck {
|
|
29
|
+
constructor ({ mine = currentVersion(), fetchLatest = latestRelease, everyMs = 10 * 60 * 1000 } = {}) {
|
|
30
|
+
this.mine = mine
|
|
31
|
+
this.newest = mine
|
|
32
|
+
this.fetchLatest = fetchLatest
|
|
33
|
+
this.everyMs = everyMs
|
|
34
|
+
this.timer = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async refresh () {
|
|
38
|
+
const r = await this.fetchLatest().catch(() => null)
|
|
39
|
+
if (r && validImage(r.version) && compareVersions(r.version, this.newest) > 0) this.newest = r.version
|
|
40
|
+
return this.newest
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Starts refreshing in the background (never blocks a tool call). */
|
|
44
|
+
start () {
|
|
45
|
+
if (this.timer) return this
|
|
46
|
+
this.refresh().catch(() => {})
|
|
47
|
+
this.timer = setInterval(() => this.refresh().catch(() => {}), this.everyMs)
|
|
48
|
+
this.timer.unref()
|
|
49
|
+
return this
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop () { if (this.timer) clearInterval(this.timer); this.timer = null }
|
|
53
|
+
|
|
54
|
+
latest () { return this.newest }
|
|
55
|
+
|
|
56
|
+
/** The update line for `image` (this process's own version when none is given), or ''. */
|
|
57
|
+
notice (image = this.mine) { return updateNotice(image, this.newest) }
|
|
58
|
+
|
|
59
|
+
/** What quilt_check_update answers. */
|
|
60
|
+
describe (image) {
|
|
61
|
+
const given = image != null && String(image).trim() !== ''
|
|
62
|
+
if (given && !validImage(image)) return `"${String(image).trim().slice(0, 40)}" is not a Quilt version (expected something like ${this.newest}). ${this.describe()}`
|
|
63
|
+
const mine = given ? String(image).trim().replace(/^v/, '') : this.mine
|
|
64
|
+
const n = updateNotice(mine, this.newest)
|
|
65
|
+
return n || `Quilt ${mine} is current (the newest release is ${this.newest}).`
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/upstream.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Catching a folder's branch up with commits it doesn't have yet (pushed from
|
|
2
|
+
// a worktree, merged on GitHub, moved by another worktree), without anyone
|
|
3
|
+
// running git pull. Quilt never merges git history: the branch only ever moves
|
|
4
|
+
// forward to a commit it is behind. What it merges is the session's
|
|
5
|
+
// uncommitted work into those commits' files, file by file, the way
|
|
6
|
+
// `git pull --autostash` would, except nothing is written unless every file
|
|
7
|
+
// merges cleanly.
|
|
8
|
+
//
|
|
9
|
+
// planCatchUp is pure: session.js reads git and the disk, asks for a plan,
|
|
10
|
+
// and applies it (bringIn there).
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { mergeAdditive as merge3 } from './merge3.js'
|
|
13
|
+
|
|
14
|
+
const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A file's content id as Quilt records what it published (`published` in the room document):
|
|
18
|
+
* the sha1 of its bytes. A key here is text or "bin:<sha1>" (the sha1 of the bytes already).
|
|
19
|
+
*/
|
|
20
|
+
export const contentId = (k) => typeof k !== 'string' ? null : k.startsWith('bin:') ? k.slice(4) : crypto.createHash('sha1').update(Buffer.from(k, 'utf8')).digest('hex')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether `key` is a version of `rel` the session itself published to GitHub (a commit the relay
|
|
24
|
+
* made from the session's copy, on any branch). `published`: Map path -> [content ids], or null.
|
|
25
|
+
*/
|
|
26
|
+
export const wasPublished = (published, rel, key) => {
|
|
27
|
+
if (!published || key === null || key === undefined) return false
|
|
28
|
+
const ids = published.get(rel)
|
|
29
|
+
return !!ids && ids.includes(contentId(key))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How the folder at commit `from` (its files on disk: the commit plus the
|
|
34
|
+
* session's uncommitted work) becomes the folder at `to` with that work kept.
|
|
35
|
+
*
|
|
36
|
+
* - changes: Map path -> git status letter (A, M, D, T...) between the commits.
|
|
37
|
+
* - base / theirs: Map path -> the file at `from` / at `to` (text, "bin:<sha1>",
|
|
38
|
+
* null when absent, undefined when git couldn't read it).
|
|
39
|
+
* - disk(rel): the file in the folder now (same keys).
|
|
40
|
+
* - moved: candidates for a file the session moved: Map path -> its text, for
|
|
41
|
+
* files on disk that `from` didn't have (new or moved here).
|
|
42
|
+
* - emptied: folders the session emptied (had files at `from`, none on disk now).
|
|
43
|
+
*
|
|
44
|
+
* Returns { writes: Map path -> key (null: delete), moves: [{ from, to }],
|
|
45
|
+
* conflicts: [{ path, why }], strays: [path] }. With conflicts, writes is empty:
|
|
46
|
+
* a catch-up is all or nothing.
|
|
47
|
+
*/
|
|
48
|
+
/** What the session published (relay commits), read from the room document: Map path -> [content ids]. */
|
|
49
|
+
export function publishedOf (doc) {
|
|
50
|
+
const out = new Map()
|
|
51
|
+
try { doc.getMap('published').forEach((v, k) => { if (Array.isArray(v)) out.set(k, v.filter((x) => typeof x === 'string')) }) } catch {}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Records that these files' contents (path -> bytes, null for deleted) were published; the last 20 per path are kept. */
|
|
56
|
+
export function notePublished (doc, files, origin) {
|
|
57
|
+
const entries = []
|
|
58
|
+
for (const [rel, buf] of files) if (buf) entries.push([rel, crypto.createHash('sha1').update(buf).digest('hex')])
|
|
59
|
+
notePublishedIds(doc, entries, origin)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The same, by content id (sha1 of the bytes): [[path, id]], as a folder reports what it pushed. */
|
|
63
|
+
export function notePublishedIds (doc, entries, origin) {
|
|
64
|
+
const map = doc.getMap('published')
|
|
65
|
+
doc.transact(() => {
|
|
66
|
+
for (const [rel, id] of entries) {
|
|
67
|
+
if (typeof rel !== 'string' || !/^[0-9a-f]{40}$/.test(id || '')) continue
|
|
68
|
+
const was = Array.isArray(map.get(rel)) ? map.get(rel) : []
|
|
69
|
+
if (was.includes(id)) continue
|
|
70
|
+
map.set(rel, [...was, id].slice(-20))
|
|
71
|
+
}
|
|
72
|
+
}, origin)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function planCatchUp ({ changes, base, theirs, disk, moved = new Map(), emptied = new Set(), published = null }) {
|
|
76
|
+
const writes = new Map()
|
|
77
|
+
const conflicts = []
|
|
78
|
+
const moves = []
|
|
79
|
+
const strays = []
|
|
80
|
+
const taken = new Set()
|
|
81
|
+
for (const rel of changes.keys()) {
|
|
82
|
+
const b = base.get(rel)
|
|
83
|
+
const t = theirs.get(rel)
|
|
84
|
+
const o = disk(rel)
|
|
85
|
+
if (b === undefined || t === undefined || o === undefined) {
|
|
86
|
+
conflicts.push({ path: rel, why: 'git could not read it (too large, or behind a filter such as Git LFS)' })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (o === b) { // untouched here: the commits' version
|
|
90
|
+
if (t !== o) writes.set(rel, t)
|
|
91
|
+
if (b === null && t !== null && emptied.has(dirOf(rel))) strays.push(rel)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (o === t) continue // the session already has it
|
|
95
|
+
// The commits bring a version of this file the session itself published (an agent committed it
|
|
96
|
+
// to a branch, and the branch was merged, squashed or rebased in): the session's copy is that
|
|
97
|
+
// version or newer, so it stays as it is. Matched by content, so every merge strategy counts.
|
|
98
|
+
if (o !== null && wasPublished(published, rel, t)) continue
|
|
99
|
+
if (o === null && isText(b) && isText(t)) {
|
|
100
|
+
// Gone here, changed there: the session may have moved it. Followed by content, as git
|
|
101
|
+
// follows a rename, into the moved copy, which takes the commits' change.
|
|
102
|
+
const to = findMove(rel, b, moved, taken)
|
|
103
|
+
if (to) {
|
|
104
|
+
const r = merge3(b, moved.get(to), t)
|
|
105
|
+
if (!r.conflicts.length) { taken.add(to); moves.push({ from: rel, to }); if (r.text !== moved.get(to)) writes.set(to, r.text); continue }
|
|
106
|
+
conflicts.push({ path: rel, why: `moved to ${to} here, and changed in the same lines there` })
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
conflicts.push({ path: rel, why: 'deleted here, changed in the new commits' })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (t === null) { conflicts.push({ path: rel, why: 'changed here, deleted in the new commits' }); continue }
|
|
113
|
+
if (b === null) { conflicts.push({ path: rel, why: 'added both here and in the new commits, differently' }); continue }
|
|
114
|
+
if (!isText(b) || !isText(o) || !isText(t)) { conflicts.push({ path: rel, why: 'a binary file changed both here and in the new commits' }); continue }
|
|
115
|
+
const r = merge3(b, o, t)
|
|
116
|
+
if (r.conflicts.length) { conflicts.push({ path: rel, why: 'changed in the same lines here and in the new commits' }); continue }
|
|
117
|
+
if (r.text !== o) writes.set(rel, r.text)
|
|
118
|
+
}
|
|
119
|
+
if (conflicts.length) return { writes: new Map(), moves: [], conflicts, strays: [] }
|
|
120
|
+
return { writes, moves, conflicts, strays }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const dirOf = (rel) => rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
|
|
124
|
+
const nameOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1)
|
|
125
|
+
|
|
126
|
+
/** The moved copy of a file: same name, and at least half its lines the same. */
|
|
127
|
+
function findMove (rel, text, moved, taken) {
|
|
128
|
+
let best = null
|
|
129
|
+
let score = 0.5
|
|
130
|
+
for (const [to, t] of moved) {
|
|
131
|
+
if (taken.has(to) || nameOf(to) !== nameOf(rel) || !isText(t)) continue
|
|
132
|
+
const s = similarity(text, t)
|
|
133
|
+
if (s >= score) { best = to; score = s }
|
|
134
|
+
}
|
|
135
|
+
return best
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Share of lines in common (multiset), against the longer file. */
|
|
139
|
+
export function similarity (a, b) {
|
|
140
|
+
const count = new Map()
|
|
141
|
+
const la = a.split('\n')
|
|
142
|
+
const lb = b.split('\n')
|
|
143
|
+
for (const l of la) count.set(l, (count.get(l) || 0) + 1)
|
|
144
|
+
let same = 0
|
|
145
|
+
for (const l of lb) { const n = count.get(l); if (n) { same++; count.set(l, n - 1) } }
|
|
146
|
+
return same / Math.max(la.length, lb.length, 1)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One line for the AI and the log about why a catch-up waits. */
|
|
150
|
+
export function catchUpAdvice ({ branch, upstream, behind, conflicts, diverged, ahead }) {
|
|
151
|
+
const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
|
|
152
|
+
if (diverged) return `${branch} and ${upstream} have both moved on (${n(ahead, 'commit')} here, ${n(behind, 'commit')} there). Quilt never merges git history: pull or rebase it yourself in this folder, and the session takes in whatever that changes.`
|
|
153
|
+
const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
|
|
154
|
+
return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and the session's uncommitted work clashes with them in ${list}. Quilt brought nothing in. Run git pull in this folder and resolve those; the session takes in the result.`
|
|
155
|
+
}
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Webhooks: an agent asks to be told, by an HTTP POST to a URL of its own, when
|
|
2
|
+
// it is mentioned in chat (@name), sent a direct message, or handed a task. The
|
|
3
|
+
// agent sets the subscription itself (quilt_webhook_subscribe), so a cloud agent
|
|
4
|
+
// with a routine behind a webhook trigger wakes up instead of polling quilt_inbox.
|
|
5
|
+
//
|
|
6
|
+
// One POST per event, JSON, signed with the subscription's secret so the agent can
|
|
7
|
+
// check it came from Quilt (`x-quilt-signature: sha256=<hmac>` over `<ts>.<body>`).
|
|
8
|
+
// A failed delivery is tried again a few times; after that the event is still in
|
|
9
|
+
// quilt_inbox, which never depends on the webhook.
|
|
10
|
+
//
|
|
11
|
+
// The pure parts (shapes, signing, validation) are shared by the relay, which
|
|
12
|
+
// delivers for hosted agents, and the local session, which delivers for agents
|
|
13
|
+
// joined from a computer.
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
import dns from 'node:dns'
|
|
16
|
+
import net from 'node:net'
|
|
17
|
+
|
|
18
|
+
export const WEBHOOK_EVENTS = ['chat.mention', 'chat.dm', 'task.assigned']
|
|
19
|
+
export const EVENT_OF_KIND = { mention: 'chat.mention', dm: 'chat.dm', task: 'task.assigned' }
|
|
20
|
+
export const MAX_URL = 2000
|
|
21
|
+
export const MAX_SECRET = 200
|
|
22
|
+
export const MIN_SECRET = 16
|
|
23
|
+
export const MAX_BEARER = 500
|
|
24
|
+
export const DELIVERY_TIMEOUT_MS = 10_000
|
|
25
|
+
// Waits before the second, third and fourth tries.
|
|
26
|
+
export const RETRY_DELAYS_MS = [1000, 5000, 25_000]
|
|
27
|
+
|
|
28
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
|
|
29
|
+
const isPrivateIp = (host) => {
|
|
30
|
+
const h = host.replace(/^\[|\]$/g, '')
|
|
31
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
|
|
32
|
+
const [a, b] = h.split('.').map(Number)
|
|
33
|
+
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
34
|
+
}
|
|
35
|
+
if (h.includes(':')) return /^(::1|::|fc|fd|fe80)/i.test(h)
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Addresses that are not on the public internet: unspecified, loopback, private, shared
|
|
40
|
+
// (CGNAT), link-local, benchmarking, multicast and reserved; for IPv6 also unique-local,
|
|
41
|
+
// link-local, site-local, multicast, IPv4-compatible and NAT64. Two lists, because a
|
|
42
|
+
// BlockList matches IPv4 rules against IPv4-mapped IPv6 addresses (wanted) and IPv6 rules
|
|
43
|
+
// covering ::ffff:0:0/96 against every IPv4 address (not wanted).
|
|
44
|
+
const PRIVATE_V4 = new net.BlockList()
|
|
45
|
+
for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE_V4.addSubnet(a, p, 'ipv4')
|
|
46
|
+
const PRIVATE_V6 = new net.BlockList()
|
|
47
|
+
for (const [a, p] of [['::', 96], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) PRIVATE_V6.addSubnet(a, p, 'ipv6')
|
|
48
|
+
|
|
49
|
+
/** Whether `address` (an IP, as DNS answers it) is not on the public internet. Anything that isn't an address counts as private. */
|
|
50
|
+
export function isPrivateAddress (address) {
|
|
51
|
+
let a = String(address || '').replace(/^\[|\]$/g, '').split('%')[0]
|
|
52
|
+
const mapped = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
|
|
53
|
+
if (mapped) a = mapped[1]
|
|
54
|
+
const kind = net.isIP(a)
|
|
55
|
+
if (kind === 4) return PRIVATE_V4.check(a, 'ipv4')
|
|
56
|
+
if (kind === 6) return PRIVATE_V6.check(a, 'ipv6') || PRIVATE_V4.check(a, 'ipv6')
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a webhook URL's host is on the public internet as it resolves now: { ok: true }, or
|
|
62
|
+
* { ok: false, reason }. A trailing dot is the same host; a local name or address never reaches
|
|
63
|
+
* DNS; every address the name resolves to must be public. Never throws. `lookup` is for tests.
|
|
64
|
+
* (parseWebhookUrl only reads the URL as written; this is for sends from Quilt's own servers.)
|
|
65
|
+
*/
|
|
66
|
+
export async function publicWebhookHost (url, { lookup = dns.promises.lookup } = {}) {
|
|
67
|
+
let host
|
|
68
|
+
try { host = new URL(url).hostname.toLowerCase() } catch { return { ok: false, reason: 'not a valid URL' } }
|
|
69
|
+
const bare = host.replace(/^\[|\]$/g, '').replace(/\.+$/, '')
|
|
70
|
+
if (!bare || LOCAL_HOSTS.has(bare) || bare.endsWith('.localhost')) return { ok: false, reason: `${host} is a local address` }
|
|
71
|
+
if (net.isIP(bare)) return isPrivateAddress(bare) ? { ok: false, reason: `${host} is a local or private address` } : { ok: true }
|
|
72
|
+
let addrs
|
|
73
|
+
try { addrs = await lookup(bare, { all: true, verbatim: true }) } catch (err) { return { ok: false, reason: `${host} did not resolve (${err?.code || err?.message || err})` } }
|
|
74
|
+
if (!Array.isArray(addrs) || !addrs.length) return { ok: false, reason: `${host} did not resolve` }
|
|
75
|
+
const bad = addrs.find((a) => isPrivateAddress(a?.address))
|
|
76
|
+
if (bad) return { ok: false, reason: `${host} resolves to a local or private address (${bad.address})` }
|
|
77
|
+
return { ok: true }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The URL a subscription may POST to: https, no credentials, and not the relay's own
|
|
82
|
+
* network. `allowLocal` (the local session) also takes http to this computer, for an
|
|
83
|
+
* agent whose receiver runs here.
|
|
84
|
+
*/
|
|
85
|
+
export function parseWebhookUrl (raw, { allowLocal = false } = {}) {
|
|
86
|
+
const s = String(raw || '').trim()
|
|
87
|
+
if (!s) throw new Error('Give the URL Quilt should POST to.')
|
|
88
|
+
if (s.length > MAX_URL) throw new Error('That URL is too long.')
|
|
89
|
+
let u
|
|
90
|
+
try { u = new URL(s) } catch { throw new Error('That is not a valid URL.') }
|
|
91
|
+
if (u.username || u.password) throw new Error('The URL may not carry a user name or password.')
|
|
92
|
+
const host = u.hostname.toLowerCase()
|
|
93
|
+
const local = LOCAL_HOSTS.has(host) || host.endsWith('.localhost') || isPrivateIp(host)
|
|
94
|
+
if (u.protocol === 'http:') {
|
|
95
|
+
if (!allowLocal || !local) throw new Error('The URL must use https (http is only for a receiver on this computer).')
|
|
96
|
+
} else if (u.protocol !== 'https:') {
|
|
97
|
+
throw new Error('The URL must start with https://.')
|
|
98
|
+
} else if (local && !allowLocal) {
|
|
99
|
+
throw new Error('The URL must be reachable from the internet, not a local or private address.')
|
|
100
|
+
}
|
|
101
|
+
return u.toString()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The event names in `list` (default: all), or an error naming a bad one. */
|
|
105
|
+
export function parseWebhookEvents (list) {
|
|
106
|
+
if (list == null) return WEBHOOK_EVENTS.slice()
|
|
107
|
+
if (!Array.isArray(list)) throw new Error(`events: give a list of ${WEBHOOK_EVENTS.join(', ')}.`)
|
|
108
|
+
const out = []
|
|
109
|
+
for (const e of list) {
|
|
110
|
+
const n = String(e || '').trim()
|
|
111
|
+
if (!WEBHOOK_EVENTS.includes(n)) throw new Error(`Unknown event "${n}". Events: ${WEBHOOK_EVENTS.join(', ')}.`)
|
|
112
|
+
if (!out.includes(n)) out.push(n)
|
|
113
|
+
}
|
|
114
|
+
if (!out.length) throw new Error(`events: give at least one of ${WEBHOOK_EVENTS.join(', ')}.`)
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const newSecret = () => crypto.randomBytes(24).toString('hex')
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A subscription from what an agent gave: { url, secret, events, since }. A secret the
|
|
122
|
+
* agent chose is kept (16 to 200 characters); without one, a new one is made and the
|
|
123
|
+
* agent is shown it once, in the tool's answer.
|
|
124
|
+
*/
|
|
125
|
+
export function makeSubscription ({ url, secret, events, bearer } = {}, { allowLocal = false, now = Date.now } = {}) {
|
|
126
|
+
const u = parseWebhookUrl(url, { allowLocal })
|
|
127
|
+
const ev = parseWebhookEvents(events)
|
|
128
|
+
let s = secret == null ? '' : String(secret)
|
|
129
|
+
if (s && (s.length < MIN_SECRET || s.length > MAX_SECRET)) throw new Error(`The secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and Quilt makes one).`)
|
|
130
|
+
const made = !s
|
|
131
|
+
if (made) s = newSecret()
|
|
132
|
+
// A key the receiver wants on every POST (a Grok Bot routine's sender key, for example), sent as Authorization: Bearer <key>.
|
|
133
|
+
const b = bearer == null ? '' : String(bearer).trim()
|
|
134
|
+
if (b.length > MAX_BEARER || /[\r\n]/.test(b)) throw new Error(`The bearer key must be one line of at most ${MAX_BEARER} characters.`)
|
|
135
|
+
return { url: u, secret: s, events: ev, since: now(), made, ...(b ? { bearer: b } : {}) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The subscription as an agent sees it (its secret only when it was just made). */
|
|
139
|
+
export function describeSubscription (sub, { showSecret = false } = {}) {
|
|
140
|
+
if (!sub) return 'No webhook: Quilt only answers quilt_inbox when you ask.'
|
|
141
|
+
const lines = [`Webhook: Quilt POSTs to ${sub.url} on ${sub.events.join(', ')}${sub.bearer ? ', with your bearer key in the Authorization header' : ''}.`]
|
|
142
|
+
if (showSecret) lines.push(`Secret (shown once; check x-quilt-signature with it): ${sub.secret}`)
|
|
143
|
+
lines.push('Each POST is JSON ({ event, id, session, workspace, room, to, by, text, ts, task?, context? }; session: the session it is from, which you name as `session` in the tools you answer with, so an answer never lands in another session; workspace: { id } or null; context: the messages before this one between you and its sender, oldest first) with x-quilt-event, x-quilt-delivery, x-quilt-timestamp and ' +
|
|
144
|
+
'x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"). Answer 2xx quickly; a failed POST is retried a few times. ' +
|
|
145
|
+
'What you are told is still in quilt_inbox. Stop with quilt_webhook_unsubscribe.')
|
|
146
|
+
return lines.join('\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The body for one inbox event (see inbox.js) to `to`, in `room`: `session` is the room (the id
|
|
151
|
+
* to answer in, with every tool's `session`), `workspace` the workspace it is in, when known.
|
|
152
|
+
*/
|
|
153
|
+
export function webhookPayload (e, { room = '', to = '', workspace = '' } = {}) {
|
|
154
|
+
const p = { event: EVENT_OF_KIND[e.kind] || e.kind, id: String(e.id || ''), room, session: room, workspace: workspace ? { id: String(workspace) } : null, to, by: String(e.by || ''), text: String(e.text || ''), ts: Number(e.ts) || Date.now() }
|
|
155
|
+
if (e.kind === 'task' && e.task) p.task = { id: e.task.id, title: e.task.title, column: e.task.column, assignee: e.task.assignee ?? null, forAi: !!e.task.forAi, tool: e.task.tool || '', files: e.task.files || [] }
|
|
156
|
+
if (e.commit) p.commit = true // work this member asked to commit was committed: nothing to answer
|
|
157
|
+
if (e.nudge) p.nudge = true // a reminder from Quilt (uncommitted work): nothing to answer
|
|
158
|
+
// The conversation before this with whoever it is from (conversation.js), so an agent woken
|
|
159
|
+
// fresh knows what "that" refers to; `earlier` older messages are a quilt_conversation away.
|
|
160
|
+
if (Array.isArray(e.context) && e.context.length) {
|
|
161
|
+
p.context = e.context.map((m) => ({ id: m.id, by: m.by, ...(m.to ? { to: m.to } : {}), text: m.text, ts: m.ts }))
|
|
162
|
+
if (e.earlier) p.earlier = e.earlier
|
|
163
|
+
}
|
|
164
|
+
return p
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `sha256=<hex>`: HMAC-SHA256 with `secret` over `<ts>.<body>`. */
|
|
168
|
+
export function signWebhook (secret, ts, body) {
|
|
169
|
+
return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(`${ts}.${body}`).digest('hex')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** For receivers: whether `signature` is right for this body and timestamp. */
|
|
173
|
+
export function verifyWebhook (secret, ts, body, signature) {
|
|
174
|
+
const want = Buffer.from(signWebhook(secret, ts, body))
|
|
175
|
+
const got = Buffer.from(String(signature || ''))
|
|
176
|
+
return want.length === got.length && crypto.timingSafeEqual(want, got)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether a response means "try again later". */
|
|
180
|
+
const retryable = (status) => status === 408 || status === 429 || status >= 500
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* POSTs `payload` to `sub.url`, signed, trying again after each delay in `delays` when
|
|
184
|
+
* the receiver is down, slow or answers 5xx/429. Resolves { ok, status, attempts, error? };
|
|
185
|
+
* never throws. `fetch`, `delays` and `sleep` are for tests.
|
|
186
|
+
*/
|
|
187
|
+
export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {} } = {}) {
|
|
188
|
+
const body = JSON.stringify(payload)
|
|
189
|
+
const delivery = crypto.randomUUID()
|
|
190
|
+
let status = 0
|
|
191
|
+
let error = ''
|
|
192
|
+
let attempt = 0
|
|
193
|
+
while (attempt < delays.length + 1) {
|
|
194
|
+
attempt++
|
|
195
|
+
const ts = String(now())
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(sub.url, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: {
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'user-agent': 'quilt-webhook',
|
|
202
|
+
'x-quilt-event': payload.event,
|
|
203
|
+
'x-quilt-delivery': delivery,
|
|
204
|
+
'x-quilt-timestamp': ts,
|
|
205
|
+
'x-quilt-signature': signWebhook(sub.secret, ts, body),
|
|
206
|
+
...(sub.bearer ? { authorization: `Bearer ${sub.bearer}` } : {})
|
|
207
|
+
},
|
|
208
|
+
body,
|
|
209
|
+
redirect: 'manual',
|
|
210
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
211
|
+
})
|
|
212
|
+
status = res.status
|
|
213
|
+
error = ''
|
|
214
|
+
if (res.ok) return { ok: true, status, attempts: attempt, delivery }
|
|
215
|
+
if (!retryable(status)) break
|
|
216
|
+
} catch (err) {
|
|
217
|
+
status = 0
|
|
218
|
+
error = err && err.message ? err.message : String(err)
|
|
219
|
+
}
|
|
220
|
+
if (attempt <= delays.length) await sleep(delays[attempt - 1])
|
|
221
|
+
}
|
|
222
|
+
log(`webhook to ${sub.url} failed (${payload.event}): ${error || `HTTP ${status}`}`)
|
|
223
|
+
return { ok: false, status, attempts: attempt, error: error || `HTTP ${status}`, delivery }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Delivers each of `events` the subscription asks for, one after another (order kept). */
|
|
227
|
+
export async function deliverEvents (sub, events, { room = '', to = '', workspace = '' } = {}, opts = {}) {
|
|
228
|
+
const out = []
|
|
229
|
+
for (const e of events || []) {
|
|
230
|
+
const p = webhookPayload(e, { room, to, workspace })
|
|
231
|
+
if (!sub.events.includes(p.event)) continue
|
|
232
|
+
out.push(await deliverWebhook(sub, p, opts))
|
|
233
|
+
}
|
|
234
|
+
return out
|
|
235
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `quilt workspace ...`: the workspace library from a shell, for agents (and people) that work
|
|
2
|
+
// with commands rather than MCP. Each command runs the same tool the MCP offers
|
|
3
|
+
// (workspace-tools.js), as whoever the MCP would act as here (mcp.js workspaceActor): this
|
|
4
|
+
// folder's agent, or the person signed in on this computer.
|
|
5
|
+
import { registerWorkspaceTools } from './workspace-tools.js'
|
|
6
|
+
|
|
7
|
+
export const WORKSPACE_USAGE = `Usage:
|
|
8
|
+
quilt workspace list Workspaces you can reach
|
|
9
|
+
quilt workspace files <workspace> [folder] [--glob g] What is in its library
|
|
10
|
+
quilt workspace get <workspace> <path> [--version n] Read a file (text is printed; anything else is saved and its path printed)
|
|
11
|
+
quilt workspace put <workspace> <path> <local file> [--note "what it is"]
|
|
12
|
+
Upload a file from this project (a new version if the path exists)
|
|
13
|
+
quilt workspace write <workspace> <path> <text> [--note n]
|
|
14
|
+
Save text as a file
|
|
15
|
+
quilt workspace mkdir <workspace> <folder> Make a folder (and any above it)
|
|
16
|
+
quilt workspace mv <workspace> <path> <new path> Rename or move a file or folder
|
|
17
|
+
quilt workspace rm <workspace> <path> Delete a file or folder
|
|
18
|
+
quilt workspace webhook <https url> | webhook-off Workspace events to your URL (agents)
|
|
19
|
+
<workspace> is its name or id.`
|
|
20
|
+
|
|
21
|
+
/** Pulls `--name value` options out of `args`: { opts, rest }. */
|
|
22
|
+
function options (args, names) {
|
|
23
|
+
const opts = {}
|
|
24
|
+
const rest = []
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
const m = String(args[i]).match(/^--([a-z]+)(?:=(.*))?$/)
|
|
27
|
+
if (m && names.includes(m[1])) {
|
|
28
|
+
opts[m[1]] = m[2] !== undefined ? m[2] : args[++i]
|
|
29
|
+
if (opts[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`)
|
|
30
|
+
} else rest.push(args[i])
|
|
31
|
+
}
|
|
32
|
+
return { opts, rest }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Which tool a command runs, with what arguments; throws the usage when it doesn't fit. */
|
|
36
|
+
export function toolCall (sub, args) {
|
|
37
|
+
const { opts, rest } = options(args, ['glob', 'version', 'note'])
|
|
38
|
+
const [ws, a, b] = rest
|
|
39
|
+
const need = (n) => { if (rest.length < n) throw new Error(WORKSPACE_USAGE) }
|
|
40
|
+
switch (sub) {
|
|
41
|
+
case 'list': case 'ls': return ['quilt_workspaces', {}]
|
|
42
|
+
case 'files': need(1); return ['quilt_workspace_files', { workspace: ws, ...(a !== undefined ? { folder: a } : {}), ...(opts.glob ? { glob: opts.glob } : {}) }]
|
|
43
|
+
case 'get': case 'read': need(2); return ['quilt_workspace_read_file', { workspace: ws, path: a, ...(opts.version ? { version: Number(opts.version) } : {}) }]
|
|
44
|
+
case 'put': case 'upload': need(3); return ['quilt_workspace_write_file', { workspace: ws, path: a, fromPath: b, ...(opts.note ? { note: opts.note } : {}) }]
|
|
45
|
+
case 'write': need(3); return ['quilt_workspace_write_file', { workspace: ws, path: a, text: rest.slice(2).join(' '), ...(opts.note ? { note: opts.note } : {}) }]
|
|
46
|
+
case 'mkdir': need(2); return ['quilt_workspace_make_folder', { workspace: ws, path: a }]
|
|
47
|
+
case 'mv': case 'move': need(3); return ['quilt_workspace_move_file', { workspace: ws, path: a, to: b }]
|
|
48
|
+
case 'rm': case 'delete': need(2); return ['quilt_workspace_delete_file', { workspace: ws, path: a }]
|
|
49
|
+
case 'webhook': need(1); return ['quilt_workspace_webhook', { url: ws }]
|
|
50
|
+
case 'webhook-off': return ['quilt_workspace_webhook_off', {}]
|
|
51
|
+
default: throw new Error(WORKSPACE_USAGE)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Runs one command with `access` (localWorkspaceAccess): { ok, text }. */
|
|
56
|
+
export async function runWorkspaceCommand (sub, args, access) {
|
|
57
|
+
const [name, input] = toolCall(sub, args)
|
|
58
|
+
const tools = new Map()
|
|
59
|
+
registerWorkspaceTools({ registerTool: (n, def, fn) => tools.set(n, fn) }, access)
|
|
60
|
+
const r = await tools.get(name)(input)
|
|
61
|
+
return { ok: !r.isError, text: r.content.map((c) => c.text).join('\n') }
|
|
62
|
+
}
|