kira-glasses-bridge 2.4.0 → 2.6.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.4.0",
3
+ "version": "2.6.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 }, io) {
137
137
  let session = sessionId ?? null
138
138
  let text = ''
139
139
  let error = null
@@ -143,8 +143,17 @@ 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
146
148
  const preToolUse = async input => {
147
149
  if (input.hook_event_name !== 'PreToolUse') return {}
150
+ // Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
151
+ const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
152
+ if (shellAllow && input.tool_name === 'Bash' && !segments(input.tool_input?.command).every(seg => shellAllow.test(seg))) {
153
+ const reason = `${persona.name} uses the shell only for git and for reading files; run that another way or leave it.`
154
+ io.emit({ kind: 'request_resolved', id: `persona-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: 'Bash', reason })
155
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
156
+ }
148
157
  const { decision, reason } = judge(input.tool_name, input.tool_input)
149
158
  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
159
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `Kira: ${reason}` } }
@@ -178,6 +187,9 @@ export async function run({ prompt, sessionId, cwd, policy }, io) {
178
187
  cwd,
179
188
  ...(sessionId ? { resume: sessionId } : {}),
180
189
  permissionMode: 'default',
190
+ // 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
+ ...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
192
+ ...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
181
193
  canUseTool,
182
194
  hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
183
195
  ...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
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 } from './worktrees.mjs'
12
+ import { prepareWorktree, worktreeStatus, pushWorktree, removeWorktree, repoOfFolder, stageFiles } from './worktrees.mjs'
13
13
 
14
14
  import { createRequire } from 'node:module'
15
15
  const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
@@ -81,7 +81,11 @@ const tools = {
81
81
  if (s?.cwd) cwd = s.cwd
82
82
  }
83
83
  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)}`)
84
+ if (!folderExists(dir)) {
85
+ // The SDK would report a spawn failure as a broken binary; the folder is simply not there.
86
+ 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.` }
87
+ }
88
+ 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
89
  const before = await snapshot(dir).catch(() => null)
86
90
  let seq = 0
87
91
  const io = {
@@ -104,7 +108,7 @@ const tools = {
104
108
  }, { once: true })
105
109
  }),
106
110
  }
107
- void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {} }, io).then(async outcome => {
111
+ void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona: args.persona ?? null }, io).then(async outcome => {
108
112
  for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
109
113
  let change = null
110
114
  if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
@@ -151,6 +155,10 @@ end tell`
151
155
  remove_worktree(args) {
152
156
  return removeWorktree(args)
153
157
  },
158
+ /** A task's reference files, downloaded into the worktree's .kira/files for the agent. */
159
+ stage_files(args) {
160
+ return stageFiles(args)
161
+ },
154
162
  /** Which GitHub repo a folder's origin points at (project link enrichment). */
155
163
  async repo_of_folder(args) {
156
164
  return { repo: await repoOfFolder(args.path) }
package/src/worktrees.mjs CHANGED
@@ -5,7 +5,10 @@
5
5
  import { execFile } from 'node:child_process'
6
6
  import { homedir } from 'node:os'
7
7
  import { isAbsolute, join, resolve } from 'node:path'
8
- import { mkdir, stat } from 'node:fs/promises'
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'
9
12
  import { display } from './git.mjs'
10
13
  import { resolveUserPath } from './files.mjs'
11
14
 
@@ -162,3 +165,49 @@ export async function removeWorktree({ path: p, delete_branch: deleteBranch }) {
162
165
  if (deleteBranch && main && branch && branch !== 'HEAD') branchDeleted = (await git(main, ['branch', '-D', branch])).ok
163
166
  return { removed: true, branch, branch_deleted: branchDeleted }
164
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
+ }