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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.5.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 }, 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
- log(`${agent.id} job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
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 => 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 }),
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
- void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {} }, 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)
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
- 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 })
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
+ }