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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 => send(ws, { type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), 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
- void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona: args.persona ?? null }, io).then(async outcome => {
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
- send(ws, { type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
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
+ }