kira-glasses-bridge 2.6.0 → 2.8.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/agents/claude.mjs +14 -2
- package/src/index.mjs +43 -4
- package/src/worktrees.mjs +60 -0
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -133,7 +133,7 @@ function applyAnswer(name, input, request, answer, suggestions) {
|
|
|
133
133
|
/** "Always allow" answers, kept for the rest of the process per session: sessionId -> Set of rules. */
|
|
134
134
|
const sessionRules = new Map()
|
|
135
135
|
|
|
136
|
-
export async function run({ prompt, sessionId, cwd, policy, persona }, io) {
|
|
136
|
+
export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io) {
|
|
137
137
|
let session = sessionId ?? null
|
|
138
138
|
let text = ''
|
|
139
139
|
let error = null
|
|
@@ -145,8 +145,19 @@ export async function run({ prompt, sessionId, cwd, policy, persona }, io) {
|
|
|
145
145
|
// and refuses outright what it denies.
|
|
146
146
|
// A member with a restricted shell (a designer, a researcher) runs only what its preset allows.
|
|
147
147
|
const shellAllow = persona?.shellAllow ? new RegExp(persona.shellAllow) : null
|
|
148
|
+
// A member that only produces artifacts (a designer, a researcher) writes nowhere but its output folders.
|
|
149
|
+
const within = (persona?.writeWithin ?? []).map(d => resolve(cwd, d))
|
|
150
|
+
const writeTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
|
|
148
151
|
const preToolUse = async input => {
|
|
149
152
|
if (input.hook_event_name !== 'PreToolUse') return {}
|
|
153
|
+
if (within.length && writeTools.has(input.tool_name)) {
|
|
154
|
+
const target = resolve(cwd, String(input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? ''))
|
|
155
|
+
if (!within.some(dir => target === dir || target.startsWith(dir + sep))) {
|
|
156
|
+
const reason = `${persona.name} only writes under ${persona.writeWithin.join(', ')}; ${displayPath(target, cwd)} is off limits. Describe what an engineer should change in your spec instead.`
|
|
157
|
+
io.emit({ kind: 'request_resolved', id: `persona-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: input.tool_name, reason })
|
|
158
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
150
161
|
// Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
|
|
151
162
|
const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
|
|
152
163
|
if (shellAllow && input.tool_name === 'Bash' && !segments(input.tool_input?.command).every(seg => shellAllow.test(seg))) {
|
|
@@ -187,6 +198,7 @@ export async function run({ prompt, sessionId, cwd, policy, persona }, io) {
|
|
|
187
198
|
cwd,
|
|
188
199
|
...(sessionId ? { resume: sessionId } : {}),
|
|
189
200
|
permissionMode: 'default',
|
|
201
|
+
...(abort ? { abortController: abort } : {}),
|
|
190
202
|
// A team member's soul, skills and conventions ride on top of Claude Code's own prompt; its preset says which tools it does without.
|
|
191
203
|
...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
|
|
192
204
|
...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
|
|
@@ -222,7 +234,7 @@ export async function run({ prompt, sessionId, cwd, policy, persona }, io) {
|
|
|
222
234
|
}
|
|
223
235
|
return { ok: !error, text, error, sessionId: session }
|
|
224
236
|
} catch (err) {
|
|
225
|
-
const message = `could not run Claude: ${err.message}`
|
|
237
|
+
const message = abort?.signal.aborted ? 'Stopped by the user.' : `could not run Claude: ${err.message}`
|
|
226
238
|
io.emit({ kind: 'result', ok: false, error: message, sessionId: session })
|
|
227
239
|
return { ok: false, error: message, sessionId: session }
|
|
228
240
|
}
|
package/src/index.mjs
CHANGED
|
@@ -9,7 +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
|
+
import { prepareWorktree, worktreeStatus, pushWorktree, removeWorktree, repoOfFolder, stageFiles, listOutputs, uploadOutputs } from './worktrees.mjs'
|
|
13
13
|
|
|
14
14
|
import { createRequire } from 'node:module'
|
|
15
15
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
@@ -18,6 +18,21 @@ let URL_ = ''
|
|
|
18
18
|
let TOKEN = ''
|
|
19
19
|
/** Requests waiting on a person: `${jobId}:${requestId}` -> resolve({ answer, by }). */
|
|
20
20
|
const answerWaits = new Map()
|
|
21
|
+
/** Running jobs, by id, so a person can stop one. */
|
|
22
|
+
const running = new Map()
|
|
23
|
+
/**
|
|
24
|
+
* The live connection, and what could not be sent while there was none. Jobs
|
|
25
|
+
* outlive connections (every API deploy reconnects us), so their events and
|
|
26
|
+
* results go through here rather than the socket they were started on.
|
|
27
|
+
*/
|
|
28
|
+
let live = null
|
|
29
|
+
const outbox = []
|
|
30
|
+
const OUTBOX_MAX = 2000
|
|
31
|
+
function sendLive(obj) {
|
|
32
|
+
if (live && live.readyState === WebSocket.OPEN) return live.send(JSON.stringify(obj))
|
|
33
|
+
if (outbox.length >= OUTBOX_MAX) outbox.shift()
|
|
34
|
+
outbox.push(obj)
|
|
35
|
+
}
|
|
21
36
|
const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
|
|
22
37
|
|
|
23
38
|
/** Start the bridge with resolved settings. Runs until the process exits. */
|
|
@@ -89,7 +104,7 @@ const tools = {
|
|
|
89
104
|
const before = await snapshot(dir).catch(() => null)
|
|
90
105
|
let seq = 0
|
|
91
106
|
const io = {
|
|
92
|
-
emit: event =>
|
|
107
|
+
emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
|
|
93
108
|
ask: (request, signal) =>
|
|
94
109
|
new Promise((resolve, reject) => {
|
|
95
110
|
const key = `${jobId}:${request.id}`
|
|
@@ -108,16 +123,26 @@ const tools = {
|
|
|
108
123
|
}, { once: true })
|
|
109
124
|
}),
|
|
110
125
|
}
|
|
111
|
-
|
|
126
|
+
const abort = new AbortController()
|
|
127
|
+
running.set(jobId, abort)
|
|
128
|
+
void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona: args.persona ?? null, abort }, io).then(async outcome => {
|
|
129
|
+
running.delete(jobId)
|
|
112
130
|
for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
|
|
113
131
|
let change = null
|
|
114
132
|
if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
|
|
115
133
|
log(`job ${jobId.slice(0, 8)} ${outcome.ok ? 'done' : 'error'}${change ? ` · ${change.stats.files} files +${change.stats.adds} -${change.stats.dels}, ${change.commits.length} commits` : ''}`)
|
|
116
134
|
const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
|
|
117
|
-
|
|
135
|
+
sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
|
|
118
136
|
})
|
|
119
137
|
return { started: true, agent: agent.id, cwd: display(dir), git: before ? { repo: before.repo, branch: before.branch, head: before.head?.slice(0, 7) ?? null } : null }
|
|
120
138
|
},
|
|
139
|
+
/** Stop a running job: the agent is cut off; whatever it committed stays. */
|
|
140
|
+
stop_job(args) {
|
|
141
|
+
const abort = running.get(String(args.job_id ?? ''))
|
|
142
|
+
if (!abort) return { stopped: false, note: 'no such job running here' }
|
|
143
|
+
abort.abort()
|
|
144
|
+
return { stopped: true }
|
|
145
|
+
},
|
|
121
146
|
/** Older API builds still call ask_claude. */
|
|
122
147
|
ask_claude(args, ws) {
|
|
123
148
|
return tools.run_agent({ ...args, agent: 'claude' }, ws)
|
|
@@ -159,6 +184,13 @@ end tell`
|
|
|
159
184
|
stage_files(args) {
|
|
160
185
|
return stageFiles(args)
|
|
161
186
|
},
|
|
187
|
+
/** What a designer or researcher produced under its output folder, and sending it to Kira's storage. */
|
|
188
|
+
list_outputs(args) {
|
|
189
|
+
return listOutputs(args)
|
|
190
|
+
},
|
|
191
|
+
upload_outputs(args) {
|
|
192
|
+
return uploadOutputs(args)
|
|
193
|
+
},
|
|
162
194
|
/** Which GitHub repo a folder's origin points at (project link enrichment). */
|
|
163
195
|
async repo_of_folder(args) {
|
|
164
196
|
return { repo: await repoOfFolder(args.path) }
|
|
@@ -213,6 +245,12 @@ function connect() {
|
|
|
213
245
|
// Tell the API which tools this bridge build implements, so a stale
|
|
214
246
|
// bridge is reported as such instead of "unknown tool".
|
|
215
247
|
send(ws, { type: 'capabilities', tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform, agents: Object.values(AGENTS).map(a => ({ id: a.id, name: a.name, capabilities: a.capabilities })) })
|
|
248
|
+
live = ws
|
|
249
|
+
// Job events and results from while we were disconnected, in order.
|
|
250
|
+
if (outbox.length) {
|
|
251
|
+
log(`sending ${outbox.length} queued job messages`)
|
|
252
|
+
for (const o of outbox.splice(0)) ws.send(JSON.stringify(o))
|
|
253
|
+
}
|
|
216
254
|
return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
|
|
217
255
|
}
|
|
218
256
|
if (msg.type === 'job.answer') {
|
|
@@ -236,6 +274,7 @@ function connect() {
|
|
|
236
274
|
})
|
|
237
275
|
ws.on('close', (code, reason) => {
|
|
238
276
|
clearInterval(ping)
|
|
277
|
+
if (live === ws) live = null
|
|
239
278
|
if (code === 4001) {
|
|
240
279
|
log('token rejected; create a new one in the portal')
|
|
241
280
|
process.exit(1)
|
package/src/worktrees.mjs
CHANGED
|
@@ -211,3 +211,63 @@ export async function stageFiles({ path: p, files }) {
|
|
|
211
211
|
}
|
|
212
212
|
return { dir: `${STAGE_DIR}/`, staged, bytes: total }
|
|
213
213
|
}
|
|
214
|
+
|
|
215
|
+
const OUTPUT_MAX_FILES = 60
|
|
216
|
+
const OUTPUT_MAX_BYTES = 40 * 1024 * 1024
|
|
217
|
+
|
|
218
|
+
function mimeOf(name) {
|
|
219
|
+
const ext = name.toLowerCase().split('.').pop()
|
|
220
|
+
return { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript', md: 'text/markdown', txt: 'text/plain', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf', csv: 'text/csv' }[ext] ?? 'application/octet-stream'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Files under an output folder of a worktree (a designer's design/, a researcher's docs/research/), with sizes and types. */
|
|
224
|
+
export async function listOutputs({ path: p, dir }) {
|
|
225
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
226
|
+
const base = join(path, String(dir ?? '').replace(/^\/+|\/+$/g, ''))
|
|
227
|
+
if (!base.startsWith(path + '/')) return { error: 'bad output folder' }
|
|
228
|
+
const { readdir } = await import('node:fs/promises')
|
|
229
|
+
const out = []
|
|
230
|
+
let total = 0
|
|
231
|
+
const walk = async d => {
|
|
232
|
+
let entries = []
|
|
233
|
+
try {
|
|
234
|
+
entries = await readdir(d, { withFileTypes: true })
|
|
235
|
+
} catch {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
for (const e of entries) {
|
|
239
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
240
|
+
const full = join(d, e.name)
|
|
241
|
+
if (e.isDirectory()) await walk(full)
|
|
242
|
+
else if (e.isFile() && out.length < OUTPUT_MAX_FILES) {
|
|
243
|
+
const size = (await stat(full)).size
|
|
244
|
+
if (total + size > OUTPUT_MAX_BYTES) continue
|
|
245
|
+
total += size
|
|
246
|
+
out.push({ rel: full.slice(base.length + 1), bytes: size, mime: mimeOf(e.name) })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await walk(base)
|
|
251
|
+
return { dir, files: out, bytes: total }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** PUT each output file to the signed URL Kira gave for it. */
|
|
255
|
+
export async function uploadOutputs({ path: p, dir, files }) {
|
|
256
|
+
const path = resolveUserPath(String(p ?? ''))
|
|
257
|
+
const base = dir ? join(path, String(dir).replace(/^\/+|\/+$/g, '')) : path
|
|
258
|
+
const uploaded = []
|
|
259
|
+
const failed = []
|
|
260
|
+
for (const f of files ?? []) {
|
|
261
|
+
try {
|
|
262
|
+
const full = resolve(base, String(f.rel))
|
|
263
|
+
if (!full.startsWith(path + '/')) throw new Error('outside the worktree')
|
|
264
|
+
const body = await readFile(full)
|
|
265
|
+
const res = await fetch(String(f.url), { method: 'PUT', headers: { 'content-type': String(f.mime ?? 'application/octet-stream') }, body })
|
|
266
|
+
if (!res.ok) throw new Error(`upload failed (${res.status})`)
|
|
267
|
+
uploaded.push(f.rel)
|
|
268
|
+
} catch (err) {
|
|
269
|
+
failed.push({ rel: f.rel, error: err.message })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { uploaded, failed }
|
|
273
|
+
}
|