kira-glasses-bridge 2.5.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 +26 -2
- package/src/index.mjs +49 -6
- 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 }, 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
|
|
@@ -143,8 +143,28 @@ export async function run({ prompt, sessionId, cwd, policy }, io) {
|
|
|
143
143
|
// Pre-tool hook: the policy is authoritative. It forces the prompt for what
|
|
144
144
|
// it does not allow (so a permissive local config cannot bypass the person)
|
|
145
145
|
// and refuses outright what it denies.
|
|
146
|
+
// A member with a restricted shell (a designer, a researcher) runs only what its preset allows.
|
|
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'])
|
|
146
151
|
const preToolUse = async input => {
|
|
147
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
|
+
}
|
|
161
|
+
// Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
|
|
162
|
+
const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
|
|
163
|
+
if (shellAllow && input.tool_name === 'Bash' && !segments(input.tool_input?.command).every(seg => shellAllow.test(seg))) {
|
|
164
|
+
const reason = `${persona.name} uses the shell only for git and for reading files; run that another way or leave it.`
|
|
165
|
+
io.emit({ kind: 'request_resolved', id: `persona-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: 'Bash', reason })
|
|
166
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
|
|
167
|
+
}
|
|
148
168
|
const { decision, reason } = judge(input.tool_name, input.tool_input)
|
|
149
169
|
if (decision === 'deny') io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: input.tool_name, reason })
|
|
150
170
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `Kira: ${reason}` } }
|
|
@@ -178,6 +198,10 @@ export async function run({ prompt, sessionId, cwd, policy }, io) {
|
|
|
178
198
|
cwd,
|
|
179
199
|
...(sessionId ? { resume: sessionId } : {}),
|
|
180
200
|
permissionMode: 'default',
|
|
201
|
+
...(abort ? { abortController: abort } : {}),
|
|
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.
|
|
203
|
+
...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
|
|
204
|
+
...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
|
|
181
205
|
canUseTool,
|
|
182
206
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
183
207
|
...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
|
|
@@ -210,7 +234,7 @@ export async function run({ prompt, sessionId, cwd, policy }, io) {
|
|
|
210
234
|
}
|
|
211
235
|
return { ok: !error, text, error, sessionId: session }
|
|
212
236
|
} catch (err) {
|
|
213
|
-
const message = `could not run Claude: ${err.message}`
|
|
237
|
+
const message = abort?.signal.aborted ? 'Stopped by the user.' : `could not run Claude: ${err.message}`
|
|
214
238
|
io.emit({ kind: 'result', ok: false, error: message, sessionId: session })
|
|
215
239
|
return { ok: false, error: message, sessionId: session }
|
|
216
240
|
}
|
package/src/index.mjs
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
import WebSocket from 'ws'
|
|
5
5
|
import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
|
|
6
6
|
import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
|
|
7
|
-
import { resolveCwd } from './claude-run.mjs'
|
|
7
|
+
import { resolveCwd, folderExists } from './claude-run.mjs'
|
|
8
8
|
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. */
|
|
@@ -81,11 +96,15 @@ const tools = {
|
|
|
81
96
|
if (s?.cwd) cwd = s.cwd
|
|
82
97
|
}
|
|
83
98
|
const dir = resolveCwd(cwd)
|
|
84
|
-
|
|
99
|
+
if (!folderExists(dir)) {
|
|
100
|
+
// The SDK would report a spawn failure as a broken binary; the folder is simply not there.
|
|
101
|
+
return { error: `The folder ${display(dir)} does not exist on this Mac. Link the project's real folder in the portal, or clone its repository there first (setup_project_folder); never guess a path.` }
|
|
102
|
+
}
|
|
103
|
+
log(`${agent.id} job=${jobId.slice(0, 8)}${args.persona?.name ? ` as ${args.persona.name}` : ''} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
|
|
85
104
|
const before = await snapshot(dir).catch(() => null)
|
|
86
105
|
let seq = 0
|
|
87
106
|
const io = {
|
|
88
|
-
emit: event =>
|
|
107
|
+
emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
|
|
89
108
|
ask: (request, signal) =>
|
|
90
109
|
new Promise((resolve, reject) => {
|
|
91
110
|
const key = `${jobId}:${request.id}`
|
|
@@ -104,16 +123,26 @@ const tools = {
|
|
|
104
123
|
}, { once: true })
|
|
105
124
|
}),
|
|
106
125
|
}
|
|
107
|
-
|
|
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)
|
|
108
130
|
for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
|
|
109
131
|
let change = null
|
|
110
132
|
if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
|
|
111
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` : ''}`)
|
|
112
134
|
const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
|
|
113
|
-
|
|
135
|
+
sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
|
|
114
136
|
})
|
|
115
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 }
|
|
116
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
|
+
},
|
|
117
146
|
/** Older API builds still call ask_claude. */
|
|
118
147
|
ask_claude(args, ws) {
|
|
119
148
|
return tools.run_agent({ ...args, agent: 'claude' }, ws)
|
|
@@ -155,6 +184,13 @@ end tell`
|
|
|
155
184
|
stage_files(args) {
|
|
156
185
|
return stageFiles(args)
|
|
157
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
|
+
},
|
|
158
194
|
/** Which GitHub repo a folder's origin points at (project link enrichment). */
|
|
159
195
|
async repo_of_folder(args) {
|
|
160
196
|
return { repo: await repoOfFolder(args.path) }
|
|
@@ -209,6 +245,12 @@ function connect() {
|
|
|
209
245
|
// Tell the API which tools this bridge build implements, so a stale
|
|
210
246
|
// bridge is reported as such instead of "unknown tool".
|
|
211
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
|
+
}
|
|
212
254
|
return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
|
|
213
255
|
}
|
|
214
256
|
if (msg.type === 'job.answer') {
|
|
@@ -232,6 +274,7 @@ function connect() {
|
|
|
232
274
|
})
|
|
233
275
|
ws.on('close', (code, reason) => {
|
|
234
276
|
clearInterval(ping)
|
|
277
|
+
if (live === ws) live = null
|
|
235
278
|
if (code === 4001) {
|
|
236
279
|
log('token rejected; create a new one in the portal')
|
|
237
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
|
+
}
|