kira-glasses-bridge 2.3.0 → 2.5.0
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/package.json +1 -1
- package/src/index.mjs +23 -0
- package/src/worktrees.mjs +213 -0
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { agentById, AGENTS } from './agents/index.mjs'
|
|
|
9
9
|
import { normalizeAnswer } from './agents/protocol.mjs'
|
|
10
10
|
import { openApp, installedApps } from './apps.mjs'
|
|
11
11
|
import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
|
|
12
|
+
import { prepareWorktree, worktreeStatus, pushWorktree, removeWorktree, repoOfFolder, stageFiles } from './worktrees.mjs'
|
|
12
13
|
|
|
13
14
|
import { createRequire } from 'node:module'
|
|
14
15
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
@@ -137,6 +138,28 @@ end tell`
|
|
|
137
138
|
return new Promise(resolve => execFile('osascript', ['-e', script], err => resolve(err ? { error: err.message } : { opened: true, cwd: display(dir), command })))
|
|
138
139
|
},
|
|
139
140
|
|
|
141
|
+
/** A worktree for one task attempt, off the project's checkout of the repo. */
|
|
142
|
+
prepare_worktree(args) {
|
|
143
|
+
return prepareWorktree(args)
|
|
144
|
+
},
|
|
145
|
+
worktree_status(args) {
|
|
146
|
+
return worktreeStatus(args)
|
|
147
|
+
},
|
|
148
|
+
push_worktree(args) {
|
|
149
|
+
return pushWorktree(args)
|
|
150
|
+
},
|
|
151
|
+
remove_worktree(args) {
|
|
152
|
+
return removeWorktree(args)
|
|
153
|
+
},
|
|
154
|
+
/** A task's reference files, downloaded into the worktree's .kira/files for the agent. */
|
|
155
|
+
stage_files(args) {
|
|
156
|
+
return stageFiles(args)
|
|
157
|
+
},
|
|
158
|
+
/** Which GitHub repo a folder's origin points at (project link enrichment). */
|
|
159
|
+
async repo_of_folder(args) {
|
|
160
|
+
return { repo: await repoOfFolder(args.path) }
|
|
161
|
+
},
|
|
162
|
+
|
|
140
163
|
/** Read-only git facts for a folder: repo, branch, head, dirty files. */
|
|
141
164
|
async git_status(args) {
|
|
142
165
|
const s = await snapshot(resolveCwd(args.path ?? args.cwd))
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A git worktree per task attempt, so agents working on different tasks never
|
|
2
|
+
// share a directory and the main checkout is never touched. Worktrees live
|
|
3
|
+
// under ~/.kira/worktrees/<owner>/<repo>/<branch>; the project's linked folder
|
|
4
|
+
// is only read (fetch, worktree add) and its working tree is left alone.
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
8
|
+
import { mkdir, stat, readFile, writeFile, appendFile, rm } from 'node:fs/promises'
|
|
9
|
+
import { pipeline } from 'node:stream/promises'
|
|
10
|
+
import { createWriteStream } from 'node:fs'
|
|
11
|
+
import { Readable } from 'node:stream'
|
|
12
|
+
import { display } from './git.mjs'
|
|
13
|
+
import { resolveUserPath } from './files.mjs'
|
|
14
|
+
|
|
15
|
+
const ROOT = join(homedir(), '.kira', 'worktrees')
|
|
16
|
+
const SETUP_TIMEOUT_MS = 5 * 60_000
|
|
17
|
+
const OUTPUT_CAP = 4000
|
|
18
|
+
|
|
19
|
+
function git(cwd, args, { timeout = 60_000 } = {}) {
|
|
20
|
+
return new Promise(resolve => {
|
|
21
|
+
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, timeout, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } }, (err, stdout, stderr) => {
|
|
22
|
+
resolve({ ok: !err, out: String(stdout ?? '').trim(), err: String(stderr ?? err?.message ?? '').trim() })
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function remoteToRepo(url) {
|
|
28
|
+
const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(url ?? '')
|
|
29
|
+
return m ? m[1] : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sameRepo = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase()
|
|
33
|
+
|
|
34
|
+
/** The GitHub repo a folder's origin points at, or null. */
|
|
35
|
+
export async function repoOfFolder(folder) {
|
|
36
|
+
const dir = resolveUserPath(String(folder ?? ''))
|
|
37
|
+
const r = await git(dir, ['remote', 'get-url', 'origin'])
|
|
38
|
+
return r.ok ? remoteToRepo(r.out) : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Among the project's folders, the checkout of this repo. */
|
|
42
|
+
async function findCheckout(folders, repo) {
|
|
43
|
+
for (const f of folders ?? []) {
|
|
44
|
+
let dir
|
|
45
|
+
try {
|
|
46
|
+
dir = resolveUserPath(String(f))
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const top = await git(dir, ['rev-parse', '--show-toplevel'])
|
|
51
|
+
if (!top.ok) continue
|
|
52
|
+
const origin = await git(top.out, ['remote', 'get-url', 'origin'])
|
|
53
|
+
const found = origin.ok ? remoteToRepo(origin.out) : null
|
|
54
|
+
if (sameRepo(found, repo)) return { checkout: top.out, repo: found }
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const exists = p => stat(p).then(() => true).catch(() => false)
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Make (or find) the worktree for a branch of a repo, based on `base`
|
|
63
|
+
* (default: the remote's default branch, else the checkout's current branch).
|
|
64
|
+
* Runs `setup_command` inside it when given (e.g. `pnpm install`), once, on
|
|
65
|
+
* creation. Idempotent: an existing worktree for the branch is returned as is.
|
|
66
|
+
*/
|
|
67
|
+
export async function prepareWorktree({ folders, repo, branch, base, setup_command: setupCommand }) {
|
|
68
|
+
if (!repo || !branch) return { error: 'repo and branch are required' }
|
|
69
|
+
const found = await findCheckout(folders, repo)
|
|
70
|
+
if (!found) return { error: `none of the project's folders is a checkout of ${repo}${folders?.length ? ` (looked in ${folders.map(String).join(', ')})` : ''}. Link the folder that has it.` }
|
|
71
|
+
const { checkout } = found
|
|
72
|
+
repo = found.repo // the origin's casing, so the path is the same however the repo was spelled
|
|
73
|
+
|
|
74
|
+
const path = join(ROOT, ...repo.split('/'), branch.replace(/[^\w.-]+/g, '-'))
|
|
75
|
+
if (await exists(join(path, '.git'))) {
|
|
76
|
+
const head = await git(path, ['rev-parse', 'HEAD'])
|
|
77
|
+
const mb = await git(path, ['merge-base', 'HEAD', `origin/${base || 'HEAD'}`])
|
|
78
|
+
return { path: display(path), abs: path, checkout: display(checkout), branch, existing: true, head: head.out || null, base_sha: mb.ok ? mb.out : null }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fresh remote refs when we can get them; offline, the local refs will do.
|
|
82
|
+
const fetched = await git(checkout, ['fetch', 'origin', '--prune', '--quiet'], { timeout: 90_000 })
|
|
83
|
+
let baseName = base
|
|
84
|
+
if (!baseName) {
|
|
85
|
+
const head = await git(checkout, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
86
|
+
baseName = head.ok ? head.out.replace(/^origin\//, '') : (await git(checkout, ['rev-parse', '--abbrev-ref', 'HEAD'])).out || 'main'
|
|
87
|
+
}
|
|
88
|
+
const candidates = [`refs/remotes/origin/${baseName}`, `refs/heads/${baseName}`]
|
|
89
|
+
let baseRef = null
|
|
90
|
+
for (const c of candidates) if ((await git(checkout, ['rev-parse', '--verify', '--quiet', c])).ok) { baseRef = c; break }
|
|
91
|
+
if (!baseRef) return { error: `base branch ${baseName} not found in ${display(checkout)}${fetched.ok ? '' : ' (fetch failed: ' + fetched.err.split('\n').pop() + ')'}` }
|
|
92
|
+
const baseSha = (await git(checkout, ['rev-parse', baseRef])).out
|
|
93
|
+
|
|
94
|
+
await mkdir(join(path, '..'), { recursive: true })
|
|
95
|
+
const branchExists = (await git(checkout, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).ok
|
|
96
|
+
const add = branchExists ? await git(checkout, ['worktree', 'add', path, branch]) : await git(checkout, ['worktree', 'add', '-b', branch, path, baseRef])
|
|
97
|
+
if (!add.ok) {
|
|
98
|
+
// A stale registration (folder deleted by hand) blocks re-adding; prune once and retry.
|
|
99
|
+
await git(checkout, ['worktree', 'prune'])
|
|
100
|
+
const retry = branchExists ? await git(checkout, ['worktree', 'add', path, branch]) : await git(checkout, ['worktree', 'add', '-b', branch, path, baseRef])
|
|
101
|
+
if (!retry.ok) return { error: `git worktree add failed: ${retry.err.split('\n').pop()}` }
|
|
102
|
+
}
|
|
103
|
+
// Starting from origin/<base> makes git track it; the branch is ours, and "pushed" must mean our branch is on origin.
|
|
104
|
+
if (!branchExists) await git(path, ['branch', '--unset-upstream'])
|
|
105
|
+
|
|
106
|
+
let setup = null
|
|
107
|
+
if (setupCommand && String(setupCommand).trim()) {
|
|
108
|
+
setup = await new Promise(resolve => {
|
|
109
|
+
execFile('sh', ['-lc', String(setupCommand)], { cwd: path, timeout: SETUP_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, CI: '1' } }, (err, stdout, stderr) => {
|
|
110
|
+
const out = `${stdout ?? ''}${stderr ?? ''}`.trim()
|
|
111
|
+
resolve({ ok: !err, command: String(setupCommand), output: out.length > OUTPUT_CAP ? `…${out.slice(-OUTPUT_CAP)}` : out })
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return { path: display(path), abs: path, checkout: display(checkout), branch, base: baseName, base_sha: baseSha, existing: false, fetched: fetched.ok, setup }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Where a worktree stands: branch, commits and files changed against its base, and whether it has been pushed. */
|
|
119
|
+
export async function worktreeStatus({ path: p, base }) {
|
|
120
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
121
|
+
if (!(await exists(join(path, '.git')))) return { error: 'no worktree at that path', path: display(path) }
|
|
122
|
+
const [branch, head, status] = await Promise.all([git(path, ['rev-parse', '--abbrev-ref', 'HEAD']), git(path, ['rev-parse', 'HEAD']), git(path, ['status', '--porcelain=v1'])])
|
|
123
|
+
const baseRef = base ? `origin/${base}` : 'origin/HEAD'
|
|
124
|
+
const mb = await git(path, ['merge-base', 'HEAD', baseRef])
|
|
125
|
+
const ahead = mb.ok ? await git(path, ['rev-list', '--count', `${mb.out}..HEAD`]) : { ok: false, out: '0' }
|
|
126
|
+
const diff = mb.ok ? await git(path, ['diff', '--shortstat', mb.out, 'HEAD']) : { out: '' }
|
|
127
|
+
const remoteRef = `refs/remotes/origin/${branch.out}`
|
|
128
|
+
const onOrigin = (await git(path, ['rev-parse', '--verify', '--quiet', remoteRef])).ok
|
|
129
|
+
const unpushed = onOrigin ? await git(path, ['rev-list', '--count', `${remoteRef}..HEAD`]) : null
|
|
130
|
+
const commits = mb.ok ? (await git(path, ['log', '--format=%h %s', `${mb.out}..HEAD`, '--max-count=20'])).out.split('\n').filter(Boolean) : []
|
|
131
|
+
return {
|
|
132
|
+
path: display(path),
|
|
133
|
+
branch: branch.out,
|
|
134
|
+
head: head.out.slice(0, 7),
|
|
135
|
+
dirty: status.out.split('\n').filter(Boolean).length,
|
|
136
|
+
commits_ahead: Number(ahead.out || 0),
|
|
137
|
+
commits,
|
|
138
|
+
diffstat: diff.out,
|
|
139
|
+
pushed: onOrigin && Number(unpushed?.out || 0) === 0,
|
|
140
|
+
on_origin: onOrigin,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Push the worktree's branch to origin, setting upstream. */
|
|
145
|
+
export async function pushWorktree({ path: p }) {
|
|
146
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
147
|
+
const branch = (await git(path, ['rev-parse', '--abbrev-ref', 'HEAD'])).out
|
|
148
|
+
if (!branch || branch === 'HEAD') return { error: 'worktree is not on a branch' }
|
|
149
|
+
const r = await git(path, ['push', '--set-upstream', 'origin', branch], { timeout: 120_000 })
|
|
150
|
+
if (!r.ok) return { error: `push failed: ${r.err.split('\n').filter(Boolean).pop()}` }
|
|
151
|
+
return { pushed: true, branch, head: (await git(path, ['rev-parse', 'HEAD'])).out.slice(0, 7) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Remove a worktree (and optionally its branch) once its work is merged or abandoned. */
|
|
155
|
+
export async function removeWorktree({ path: p, delete_branch: deleteBranch }) {
|
|
156
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
157
|
+
if (!path.startsWith(ROOT + '/')) return { error: 'refusing to remove a folder outside ~/.kira/worktrees' }
|
|
158
|
+
if (!(await exists(path))) return { removed: false, note: 'already gone' }
|
|
159
|
+
const common = await git(path, ['rev-parse', '--git-common-dir'])
|
|
160
|
+
const branch = (await git(path, ['rev-parse', '--abbrev-ref', 'HEAD'])).out
|
|
161
|
+
const main = common.ok ? (isAbsolute(common.out) ? common.out : resolve(path, common.out)).replace(/\/\.git$/, '') : null
|
|
162
|
+
const r = await git(main ?? path, ['worktree', 'remove', '--force', path])
|
|
163
|
+
if (!r.ok) return { error: `worktree remove failed: ${r.err.split('\n').pop()}` }
|
|
164
|
+
let branchDeleted = false
|
|
165
|
+
if (deleteBranch && main && branch && branch !== 'HEAD') branchDeleted = (await git(main, ['branch', '-D', branch])).ok
|
|
166
|
+
return { removed: true, branch, branch_deleted: branchDeleted }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const STAGE_DIR = '.kira/files'
|
|
170
|
+
const STAGE_MAX_BYTES = 200 * 1024 * 1024
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Put a task's reference files into the worktree under .kira/files (excluded
|
|
174
|
+
* from git through .git/info/exclude, never committed), so the agent can open
|
|
175
|
+
* them like anything else on disk. Downloads from short-lived signed URLs.
|
|
176
|
+
*/
|
|
177
|
+
export async function stageFiles({ path: p, files }) {
|
|
178
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
179
|
+
if (!(await exists(join(path, '.git')))) return { error: 'no worktree at that path', path: display(path) }
|
|
180
|
+
const dir = join(path, STAGE_DIR)
|
|
181
|
+
await rm(dir, { recursive: true, force: true })
|
|
182
|
+
await mkdir(dir, { recursive: true })
|
|
183
|
+
// A worktree's .git is a file pointing at the real gitdir; git tells us where its exclude file lives.
|
|
184
|
+
const excl = await git(path, ['rev-parse', '--git-path', 'info/exclude'])
|
|
185
|
+
if (excl.ok) {
|
|
186
|
+
const file = isAbsolute(excl.out) ? excl.out : join(path, excl.out)
|
|
187
|
+
await mkdir(join(file, '..'), { recursive: true })
|
|
188
|
+
const current = await readFile(file, 'utf8').catch(() => '')
|
|
189
|
+
if (!current.split('\n').includes('/.kira/')) await appendFile(file, `${current && !current.endsWith('\n') ? '\n' : ''}/.kira/\n`)
|
|
190
|
+
}
|
|
191
|
+
const staged = []
|
|
192
|
+
let total = 0
|
|
193
|
+
const seen = new Set()
|
|
194
|
+
for (const f of files ?? []) {
|
|
195
|
+
let name = String(f.name ?? 'file').replace(/[\/\\]/g, '_')
|
|
196
|
+
if (seen.has(name)) name = `${name.replace(/(\.[^.]+)?$/, '')}-${seen.size}${/\.[^.]+$/.exec(name)?.[0] ?? ''}`
|
|
197
|
+
seen.add(name)
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(String(f.url))
|
|
200
|
+
if (!res.ok || !res.body) throw new Error(`download failed (${res.status})`)
|
|
201
|
+
const len = Number(res.headers.get('content-length') ?? 0)
|
|
202
|
+
if (total + len > STAGE_MAX_BYTES) throw new Error('over the staging size limit')
|
|
203
|
+
const dest = join(dir, name)
|
|
204
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
|
|
205
|
+
const size = (await stat(dest)).size
|
|
206
|
+
total += size
|
|
207
|
+
staged.push({ name, path: `${STAGE_DIR}/${name}`, bytes: size, description: f.description || undefined })
|
|
208
|
+
} catch (err) {
|
|
209
|
+
staged.push({ name, error: err.message })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { dir: `${STAGE_DIR}/`, staged, bytes: total }
|
|
213
|
+
}
|