kira-glasses-bridge 1.1.0 → 2.0.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": "1.1.0",
3
+ "version": "2.0.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": {
@@ -17,6 +17,7 @@
17
17
  "start": "node src/cli.mjs run"
18
18
  },
19
19
  "dependencies": {
20
+ "@anthropic-ai/claude-agent-sdk": "^0.3.278",
20
21
  "ws": "^8.18.0"
21
22
  },
22
23
  "keywords": [
@@ -0,0 +1,231 @@
1
+ // Claude Code as an agent, through the Agent SDK. Runs in this process on the
2
+ // user's Mac with their own Claude login; files never leave the machine.
3
+ import { query } from '@anthropic-ai/claude-agent-sdk'
4
+ import { homedir } from 'node:os'
5
+ import { relative, resolve, sep } from 'node:path'
6
+ import { PERMISSION_OPTIONS, PLAN_OPTIONS, requestId } from './protocol.mjs'
7
+
8
+ const TEXT_CAP = 4000
9
+ const DIFF_CAP = 6000
10
+
11
+ const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'TodoRead', 'NotebookRead', 'Task', 'Skill'])
12
+ const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
13
+ const SAFE_SHELL = /^(?:ls|pwd|cat|head|tail|wc|echo|which|type|env|printenv|date|whoami|tree|find|grep|rg|awk|sort|uniq|diff|stat|file|du|df|git (?:status|diff|log|show|branch|rev-parse|remote|ls-files|blame|describe|stash list)|node (?:-v|--version)|npm (?:-v|--version|ls|list|view|test|run (?:test|lint|typecheck|check|build))|pnpm (?:-v|--version|ls|list|test|typecheck|lint|build|run (?:test|lint|typecheck|check|build))|yarn (?:-v|--version|test|lint|typecheck|build)|cargo (?:check|test|build|fmt|clippy)|go (?:test|build|vet|version)|python3? -m pytest|pytest|make (?:test|check|lint|build))(?:\s|$)/
14
+
15
+ const short = (s, n = 160) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''))
16
+
17
+ function displayPath(p, cwd) {
18
+ if (!p) return ''
19
+ const rel = relative(cwd, resolve(cwd, p))
20
+ if (rel && !rel.startsWith('..') && !rel.startsWith(sep)) return rel
21
+ const home = relative(homedir(), resolve(cwd, p))
22
+ return home && !home.startsWith('..') ? `~/${home}` : p
23
+ }
24
+ const inside = (cwd, p) => {
25
+ const rel = relative(cwd, resolve(cwd, String(p ?? '')))
26
+ return !!rel && !rel.startsWith('..') && !rel.startsWith(sep)
27
+ }
28
+
29
+ /** Claude's tool call as a protocol action. */
30
+ export function describeAction(name, input, cwd) {
31
+ const i = input ?? {}
32
+ switch (name) {
33
+ case 'Read':
34
+ return { type: 'read', label: 'Read', target: displayPath(i.file_path, cwd) }
35
+ case 'Glob':
36
+ return { type: 'search', label: 'Find', target: i.pattern }
37
+ case 'Grep':
38
+ return { type: 'search', label: 'Search', target: `${i.pattern}${i.path ? ` in ${displayPath(i.path, cwd)}` : ''}` }
39
+ case 'LS':
40
+ return { type: 'read', label: 'List', target: displayPath(i.path, cwd) || '.' }
41
+ case 'Edit':
42
+ return { type: 'edit', label: 'Edit', target: displayPath(i.file_path, cwd), detail: { edit: { old: short(i.old_string, DIFF_CAP), new: short(i.new_string, DIFF_CAP), all: !!i.replace_all } } }
43
+ case 'MultiEdit':
44
+ return { type: 'edit', label: 'Edit', target: displayPath(i.file_path, cwd), detail: { edits: (i.edits ?? []).slice(0, 20).map(e => ({ old: short(e.old_string, 1500), new: short(e.new_string, 1500) })) } }
45
+ case 'Write':
46
+ return { type: 'write', label: 'Write', target: displayPath(i.file_path, cwd), detail: { content: short(i.content, DIFF_CAP) } }
47
+ case 'NotebookEdit':
48
+ return { type: 'edit', label: 'Edit notebook', target: displayPath(i.notebook_path, cwd) }
49
+ case 'Bash':
50
+ return { type: 'run', label: 'Run', target: short(i.command, 400), note: i.description ? short(i.description, 120) : undefined, detail: { command: short(i.command, 2000) } }
51
+ case 'WebFetch':
52
+ return { type: 'fetch', label: 'Fetch', target: short(i.url, 200) }
53
+ case 'WebSearch':
54
+ return { type: 'fetch', label: 'Search the web', target: short(i.query, 160) }
55
+ case 'Task':
56
+ return { type: 'delegate', label: 'Subtask', target: short(i.description ?? i.prompt, 160) }
57
+ case 'TodoWrite':
58
+ return { type: 'plan', label: 'Plan', target: `${(i.todos ?? []).length} steps` }
59
+ case 'ExitPlanMode':
60
+ return { type: 'plan', label: 'Plan ready', target: 'asks to go ahead' }
61
+ case 'AskUserQuestion':
62
+ return { type: 'other', label: 'Question', target: short((i.questions ?? []).map(q => q.question).join(' / '), 200) }
63
+ default:
64
+ return { type: 'other', label: name, target: short(JSON.stringify(i), 200) }
65
+ }
66
+ }
67
+
68
+ /** Claude's tool call as a request, when a person has to decide. */
69
+ function toRequest(name, input, cwd, suggestions) {
70
+ const act = describeAction(name, input, cwd)
71
+ if (name === 'AskUserQuestion') {
72
+ const questions = (input.questions ?? []).map((q, n) => ({
73
+ id: `q${n + 1}`,
74
+ question: q.question,
75
+ header: q.header,
76
+ multi: !!q.multiSelect,
77
+ options: (q.options ?? []).map((o, k) => ({ id: `o${k + 1}`, label: o.label, description: o.description })),
78
+ }))
79
+ const first = questions[0]
80
+ return {
81
+ id: requestId(),
82
+ type: 'choice',
83
+ title: questions.length === 1 ? first.question : `${questions.length} questions`,
84
+ options: first?.options ?? [],
85
+ multi: !!first?.multi,
86
+ allowText: true,
87
+ questions,
88
+ action: { tool: name, label: act.label, target: act.target },
89
+ }
90
+ }
91
+ if (name === 'ExitPlanMode') {
92
+ return { id: requestId(), type: 'plan', title: 'Claude has a plan and asks to go ahead', detail: short(input.plan ?? '', 6000), options: PLAN_OPTIONS, allowText: true, action: { tool: name, label: act.label, target: act.target } }
93
+ }
94
+ const detail = act.detail?.command ?? act.detail?.content ?? (act.detail?.edit ? `- ${act.detail.edit.old}\n+ ${act.detail.edit.new}` : undefined)
95
+ return {
96
+ id: requestId(),
97
+ type: 'permission',
98
+ title: `Claude wants to ${act.label.toLowerCase()} ${act.target}`.trim(),
99
+ detail: detail ? short(detail, 2000) : undefined,
100
+ options: PERMISSION_OPTIONS,
101
+ allowText: false,
102
+ action: { tool: name, label: act.label, target: act.target },
103
+ }
104
+ }
105
+
106
+ /** Turn a protocol answer back into what the SDK expects. */
107
+ function applyAnswer(name, input, request, answer, suggestions) {
108
+ const chosen = answer.optionIds ?? []
109
+ if (request.type === 'choice') {
110
+ const answers = {}
111
+ for (const q of request.questions ?? []) {
112
+ const per = answer.answers?.[q.id]
113
+ const ids = per?.optionIds ?? (q.id === 'q1' ? chosen : [])
114
+ const labels = ids.map(id => q.options.find(o => o.id === id)?.label).filter(Boolean)
115
+ const text = per?.text ?? (q.id === 'q1' ? answer.text : undefined)
116
+ answers[q.question] = labels.length ? labels.join(', ') : text ?? ''
117
+ }
118
+ const filled = Object.values(answers).some(Boolean)
119
+ if (!filled) return { behavior: 'deny', message: 'The user did not answer the question. Stop and say what you need to know.' }
120
+ return { behavior: 'allow', updatedInput: { ...input, answers, ...(answer.text ? { response: answer.text } : {}) } }
121
+ }
122
+ if (request.type === 'plan') {
123
+ if (chosen.includes('approve')) return { behavior: 'allow', updatedInput: input }
124
+ return { behavior: 'deny', message: answer.text ? `The user wants the plan changed: ${answer.text}` : 'The user asked you to revise the plan before going ahead.' }
125
+ }
126
+ if (chosen.includes('always') || chosen.includes('allow')) return { behavior: 'allow', updatedInput: input, ...(chosen.includes('always') && suggestions?.length ? { updatedPermissions: suggestions } : {}) }
127
+ return { behavior: 'deny', message: `The user declined this${answer.text ? ` ("${answer.text}")` : ''}. Do not achieve the same effect another way; if the task cannot proceed without it, stop and say what you could not do.` }
128
+ }
129
+
130
+ /**
131
+ * Run one instruction in a Claude session.
132
+ * @param io.emit(event) a protocol event
133
+ * @param io.ask(request) resolves to { answer, by } when a person answers (the caller times it out)
134
+ * @returns { ok, text, error, sessionId, stats }
135
+ */
136
+ /** "Always allow" answers, remembered for the rest of the process per session: `${sessionId}` -> Set of rule keys. */
137
+ const sessionAllows = new Map()
138
+ function ruleKey(toolName, input) {
139
+ if (toolName === 'Bash') return `Bash:${String(input.command ?? '').trim().split(/\s+/)[0]}`
140
+ return toolName
141
+ }
142
+
143
+ /**
144
+ * The bridge's own policy, applied to every tool call regardless of what the
145
+ * user's local Claude Code settings would do. Returns 'allow' or 'ask'.
146
+ */
147
+ function policy(toolName, input, cwd, session) {
148
+ if (READ_ONLY.has(toolName)) return 'allow'
149
+ if (EDITS.has(toolName) && inside(cwd, input.file_path ?? input.notebook_path)) return 'allow'
150
+ if (toolName === 'Bash' && SAFE_SHELL.test(String(input.command ?? '').trim())) return 'allow'
151
+ if (session && sessionAllows.get(session)?.has(ruleKey(toolName, input))) return 'allow'
152
+ return 'ask'
153
+ }
154
+
155
+ export async function run({ prompt, sessionId, cwd }, io) {
156
+ let session = sessionId ?? null
157
+ let text = ''
158
+ let error = null
159
+
160
+ // Pre-tool hook: force the prompt for anything the policy does not allow, so
161
+ // a permissive local config (e.g. Bash(*) allowed) cannot bypass the person.
162
+ const preToolUse = async input => {
163
+ if (input.hook_event_name !== 'PreToolUse') return {}
164
+ const decision = policy(input.tool_name, input.tool_input ?? {}, cwd, session)
165
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: decision === 'ask' ? 'Kira asks the user' : 'Kira policy' } }
166
+ }
167
+
168
+ const canUseTool = async (toolName, input, { signal, suggestions }) => {
169
+ if (policy(toolName, input, cwd, session) === 'allow') return { behavior: 'allow', updatedInput: input }
170
+ const request = toRequest(toolName, input, cwd, suggestions)
171
+ io.emit({ kind: 'request', request })
172
+ let resolved
173
+ try {
174
+ resolved = await io.ask(request, signal)
175
+ } catch (err) {
176
+ resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
177
+ }
178
+ io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
179
+ if ((resolved.answer.optionIds ?? []).includes('always') && session) {
180
+ if (!sessionAllows.has(session)) sessionAllows.set(session, new Set())
181
+ sessionAllows.get(session).add(ruleKey(toolName, input))
182
+ }
183
+ return applyAnswer(toolName, input, request, resolved.answer, suggestions)
184
+ }
185
+
186
+ try {
187
+ const q = query({
188
+ prompt,
189
+ options: {
190
+ cwd,
191
+ ...(sessionId ? { resume: sessionId } : {}),
192
+ permissionMode: 'default',
193
+ canUseTool,
194
+ hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
195
+ ...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
196
+ },
197
+ })
198
+ for await (const m of q) {
199
+ if (m.type === 'system' && m.subtype === 'init') {
200
+ session = m.session_id ?? session
201
+ io.emit({ kind: 'session', sessionId: session, model: m.model ?? null })
202
+ } else if (m.type === 'assistant') {
203
+ for (const b of m.message?.content ?? []) {
204
+ if (b.type === 'text' && b.text?.trim()) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
205
+ else if (b.type === 'tool_use') io.emit({ kind: 'action', id: b.id, tool: b.name, ...describeAction(b.name, b.input, cwd) })
206
+ }
207
+ } else if (m.type === 'user') {
208
+ for (const b of m.message?.content ?? []) {
209
+ if (b?.type === 'tool_result') {
210
+ const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map(c => (c.type === 'text' ? c.text : '')).join('\n') : ''
211
+ io.emit({ kind: 'action_result', id: b.tool_use_id, ok: !b.is_error, preview: short(t.trim(), 240) })
212
+ }
213
+ }
214
+ } else if (m.type === 'result') {
215
+ session = m.session_id ?? session
216
+ if (m.subtype === 'success') text = String(m.result ?? '')
217
+ else error = `Claude stopped: ${m.subtype}${Array.isArray(m.errors) && m.errors.length ? ` (${m.errors.join('; ').slice(0, 300)})` : ''}`
218
+ const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd }
219
+ io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: session, stats })
220
+ return { ok: !error, text, error, sessionId: session, stats }
221
+ }
222
+ }
223
+ return { ok: !error, text, error, sessionId: session }
224
+ } catch (err) {
225
+ const message = `could not run Claude: ${err.message}`
226
+ io.emit({ kind: 'result', ok: false, error: message, sessionId: session })
227
+ return { ok: false, error: message, sessionId: session }
228
+ }
229
+ }
230
+
231
+ export const claude = { id: 'claude', name: 'Claude Code', run, capabilities: { resume: true, requests: ['permission', 'choice', 'text', 'plan'] } }
@@ -0,0 +1,9 @@
1
+ // Coding agents this bridge can run. Each adapter speaks agents/protocol.mjs;
2
+ // adding Codex or another agent is a new file here and nothing else.
3
+ import { claude } from './claude.mjs'
4
+
5
+ export const AGENTS = { claude }
6
+
7
+ export function agentById(id) {
8
+ return AGENTS[id ?? 'claude'] ?? null
9
+ }
@@ -0,0 +1,41 @@
1
+ // The agent protocol: what every coding agent adapter emits, whatever the
2
+ // agent. The API, the portal and the glasses only ever see these shapes.
3
+ //
4
+ // session { kind, sessionId, model? }
5
+ // message { kind, text } the agent said something
6
+ // action { kind, id, type, tool, label, target, note?, detail? }
7
+ // type: read | search | edit | write | run | fetch | delegate | plan | other
8
+ // detail: { edit: {old,new,all} | edits: [...] | content | command }
9
+ // action_result { kind, id, ok, preview? }
10
+ // request { kind, request: AgentRequest } needs a person; the job waits
11
+ // request_resolved { kind, id, answer, by } by: voice | lens | phone | portal | timeout | policy
12
+ // result { kind, ok, text?, error?, sessionId?, stats? }
13
+ //
14
+ // AgentRequest:
15
+ // { id, type: permission | choice | text | plan, title, detail?, options: [{id,label,description?}],
16
+ // multi?, allowText?, action?: {tool,label,target}, questions?: [{id, question, header, options, multi}] }
17
+ // RequestAnswer:
18
+ // { optionIds?: string[], text?: string, answers?: { [questionId]: { optionIds?, text? } } }
19
+
20
+ let requestSeq = 0
21
+ export function requestId() {
22
+ return `r${++requestSeq}-${Date.now().toString(36)}`
23
+ }
24
+
25
+ export const PERMISSION_OPTIONS = [
26
+ { id: 'allow', label: 'Allow', description: 'Let it do this once' },
27
+ { id: 'always', label: 'Always allow', description: 'Let it do this for the rest of the session' },
28
+ { id: 'deny', label: 'Deny', description: 'Do not do this' },
29
+ ]
30
+
31
+ export const PLAN_OPTIONS = [
32
+ { id: 'approve', label: 'Approve', description: 'Go ahead with this plan' },
33
+ { id: 'revise', label: 'Revise', description: 'Change the plan first; say what to change' },
34
+ ]
35
+
36
+ /** Normalize the many ways an answer can arrive into option ids and text. */
37
+ export function normalizeAnswer(answer) {
38
+ const a = answer ?? {}
39
+ const optionIds = Array.isArray(a.optionIds) ? a.optionIds.map(String) : a.optionId ? [String(a.optionId)] : a.option ? [String(a.option)] : []
40
+ return { optionIds, text: typeof a.text === 'string' && a.text.trim() ? a.text.trim() : undefined, answers: a.answers && typeof a.answers === 'object' ? a.answers : undefined }
41
+ }
@@ -1,27 +1,7 @@
1
- // Runs Claude Code headless: `claude -p --resume <id> --output-format json`.
2
- import { spawn } from 'node:child_process'
1
+ // Folder resolution shared by the agents and the file tools.
3
2
  import { existsSync } from 'node:fs'
4
3
  import { homedir } from 'node:os'
5
- import { isAbsolute, join, resolve } from 'node:path'
6
-
7
- /**
8
- * Find the claude binary. The bridge may be started from a shell whose PATH
9
- * lacks ~/.local/bin (launchd, a minimal terminal, a different shell), so
10
- * check the usual install locations before giving up.
11
- */
12
- export function findClaude() {
13
- if (process.env.KIRA_CLAUDE_BIN && existsSync(process.env.KIRA_CLAUDE_BIN)) return process.env.KIRA_CLAUDE_BIN
14
- const candidates = [
15
- join(homedir(), '.local', 'bin', 'claude'),
16
- join(homedir(), '.claude', 'local', 'claude'),
17
- '/opt/homebrew/bin/claude',
18
- '/usr/local/bin/claude',
19
- ]
20
- for (const c of candidates) if (existsSync(c)) return c
21
- return 'claude' // rely on PATH
22
- }
23
-
24
- const MAX_MS = 15 * 60 * 1000
4
+ import { isAbsolute, resolve } from 'node:path'
25
5
 
26
6
  /** Kira's paths are relative to home unless absolute, like the file tools. */
27
7
  export function resolveCwd(cwd) {
@@ -30,35 +10,7 @@ export function resolveCwd(cwd) {
30
10
  return isAbsolute(s) ? resolve(s) : resolve(homedir(), s)
31
11
  }
32
12
 
33
- export function runClaude({ prompt, sessionId, cwd }) {
34
- return new Promise(done => {
35
- const dir = resolveCwd(cwd)
36
- if (!existsSync(dir)) return done({ error: `folder not found: ${dir}` })
37
- const args = ['-p', '--output-format', 'json', '--permission-mode', 'acceptEdits']
38
- if (sessionId) args.push('--resume', sessionId)
39
- args.push(prompt)
40
- const child = spawn(findClaude(), args, { cwd: dir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
41
- let out = ''
42
- let err = ''
43
- const timer = setTimeout(() => {
44
- child.kill('SIGTERM')
45
- done({ error: `Claude did not finish within ${MAX_MS / 60000} minutes` })
46
- }, MAX_MS)
47
- child.stdout.on('data', d => (out += d))
48
- child.stderr.on('data', d => (err += d))
49
- child.on('error', e => {
50
- clearTimeout(timer)
51
- const hint = e.code === 'ENOENT' ? 'Claude Code is not installed or not on the bridge\'s PATH. Install it, or set KIRA_CLAUDE_BIN=/path/to/claude when starting the bridge.' : e.message
52
- done({ error: `could not start claude: ${hint}` })
53
- })
54
- child.on('close', code => {
55
- clearTimeout(timer)
56
- try {
57
- const j = JSON.parse(out)
58
- done({ session_id: j.session_id, result: j.result, is_error: !!j.is_error, cost_usd: j.total_cost_usd, duration_ms: j.duration_ms, num_turns: j.num_turns })
59
- } catch {
60
- done({ error: `claude exited with code ${code}: ${(err || out).slice(0, 500)}` })
61
- }
62
- })
63
- })
13
+
14
+ export function folderExists(dir) {
15
+ return existsSync(dir)
64
16
  }
package/src/git.mjs CHANGED
@@ -2,16 +2,17 @@
2
2
  // difference after it. Everything is read-only; the working tree is never
3
3
  // touched. Missing git or a folder outside a repo simply yields null.
4
4
  import { execFile } from 'node:child_process'
5
- import { homedir } from 'node:os'
6
- import { relative } from 'node:path'
5
+ import { homedir, tmpdir } from 'node:os'
6
+ import { join, relative } from 'node:path'
7
+ import { unlink } from 'node:fs/promises'
8
+ import { randomUUID } from 'node:crypto'
7
9
 
8
10
  const DIFF_CAP = 400 * 1024 // bytes of unified diff kept per change
9
- const UNTRACKED_CAP = 30 // new files whose content is included
10
11
  const COMMIT_CAP = 20
11
12
 
12
- function git(cwd, args, { ok = [0] } = {}) {
13
+ function git(cwd, args, { ok = [0], env = {} } = {}) {
13
14
  return new Promise(resolve => {
14
- execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }, (err, stdout) => {
15
+ execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...env } }, (err, stdout) => {
15
16
  if (err && !ok.includes(err.code)) return resolve(null)
16
17
  resolve(String(stdout ?? ''))
17
18
  })
@@ -23,15 +24,35 @@ export function display(full) {
23
24
  return rel && !rel.startsWith('..') ? `~/${rel}` : full
24
25
  }
25
26
 
26
- /** Repo identity plus the head and dirty files right now; null when not a git repo. */
27
+ /**
28
+ * The whole working tree (tracked and untracked, .gitignore respected) as a
29
+ * git tree object, built through a throwaway index so the real index, the
30
+ * working tree and the reflog are untouched. Two of these diff to exactly
31
+ * what happened in between.
32
+ */
33
+ async function treeOfWorkingCopy(root) {
34
+ const index = join(tmpdir(), `kira-index-${randomUUID()}`)
35
+ const env = { GIT_INDEX_FILE: index }
36
+ try {
37
+ const added = await git(root, ['add', '-A', '--', '.'], { env })
38
+ if (added === null) return null
39
+ const tree = await git(root, ['write-tree'], { env })
40
+ return tree?.trim() || null
41
+ } finally {
42
+ await unlink(index).catch(() => undefined)
43
+ }
44
+ }
45
+
46
+ /** Repo identity plus the head, dirty files and a tree of the working copy right now; null when not a git repo. */
27
47
  export async function snapshot(dir) {
28
48
  const root = (await git(dir, ['rev-parse', '--show-toplevel']))?.trim()
29
49
  if (!root) return null
30
- const [head, branch, remote, status] = await Promise.all([
50
+ const [head, branch, remote, status, tree] = await Promise.all([
31
51
  git(root, ['rev-parse', 'HEAD']),
32
52
  git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
33
53
  git(root, ['remote', 'get-url', 'origin']),
34
54
  git(root, ['status', '--porcelain=v1', '--untracked-files=all']),
55
+ treeOfWorkingCopy(root),
35
56
  ])
36
57
  return {
37
58
  root,
@@ -40,6 +61,7 @@ export async function snapshot(dir) {
40
61
  branch: branch?.trim() || null,
41
62
  repo: remoteToRepo(remote?.trim()),
42
63
  dirty: parseStatus(status ?? ''),
64
+ tree,
43
65
  }
44
66
  }
45
67
 
@@ -57,80 +79,77 @@ function parseStatus(out) {
57
79
  }
58
80
 
59
81
  /**
60
- * Everything different between the pre-job head and the working tree now:
61
- * commits made since, per-file stats, and one unified diff (committed and
62
- * uncommitted alike, new files included). Files that were already dirty
63
- * before the job are flagged so a reader can discount them.
82
+ * Exactly what happened between the snapshot and now: the working copy then
83
+ * versus the working copy now (committed or not, new files included), plus
84
+ * any commits made in between. Files that were already dirty before are
85
+ * flagged so a reader can discount hunks that were in flight.
64
86
  */
65
87
  export async function changesSince(before) {
66
88
  const { root } = before
67
- const base = before.head
89
+ const after = await treeOfWorkingCopy(root)
90
+ if (!before.tree || !after) return null
91
+ if (before.tree === after) {
92
+ // Nothing on disk moved; a commit alone (e.g. Claude committed pending work) still counts.
93
+ const commits = await commitsSince(root, before.head)
94
+ return commits.length ? summary(before, root, [], '', false, commits, after) : null
95
+ }
68
96
  const dirtyBefore = new Set(before.dirty.map(d => d.path))
69
- const [headNow, numstat, nameStatus, diff, untrackedOut] = await Promise.all([
70
- git(root, ['rev-parse', 'HEAD']),
71
- base ? git(root, ['diff', '--numstat', '-M', base]) : Promise.resolve(''),
72
- base ? git(root, ['diff', '--name-status', '-M', base]) : Promise.resolve(''),
73
- base ? git(root, ['diff', '-M', '--no-color', base]) : Promise.resolve(''),
74
- git(root, ['ls-files', '--others', '--exclude-standard']),
97
+ const [numstat, nameStatus, diff] = await Promise.all([
98
+ git(root, ['diff', '--numstat', '-M', before.tree, after]),
99
+ git(root, ['diff', '--name-status', '-M', before.tree, after]),
100
+ git(root, ['diff', '-M', '--no-color', before.tree, after]),
75
101
  ])
76
102
  const files = new Map()
77
103
  for (const line of (numstat ?? '').split('\n').filter(Boolean)) {
78
104
  const [a, d, ...rest] = line.split('\t')
79
- const path = rest.join('\t').replace(/^.*\{(.*) => (.*)\}.*$/, (m, o, n) => m.replace(`{${o} => ${n}}`, n)).replace(/^(.*) => (.*)$/, '$2')
80
- files.set(path, { path, adds: a === '-' ? 0 : Number(a), dels: d === '-' ? 0 : Number(d), binary: a === '-', status: 'M' })
105
+ const path = rest[rest.length - 1]
106
+ files.set(path, { path, adds: a === '-' ? 0 : Number(a), dels: d === '-' ? 0 : Number(d), binary: a === '-', status: 'M', preexisting: dirtyBefore.has(path) })
81
107
  }
82
108
  for (const line of (nameStatus ?? '').split('\n').filter(Boolean)) {
83
109
  const [st, ...rest] = line.split('\t')
84
- const path = rest[rest.length - 1]
85
- const f = files.get(path)
110
+ const f = files.get(rest[rest.length - 1])
86
111
  if (f) f.status = st[0]
87
112
  }
88
113
  let text = diff ?? ''
89
- const untracked = (untrackedOut ?? '').split('\n').filter(Boolean)
90
- let shown = 0
91
- for (const path of untracked) {
92
- const isNew = !dirtyBefore.has(path)
93
- const content = shown < UNTRACKED_CAP ? await git(root, ['diff', '--no-index', '--no-color', '--', '/dev/null', path], { ok: [0, 1] }) : null
94
- let adds = 0
95
- if (content) {
96
- adds = (content.match(/^\+(?!\+\+)/gm) ?? []).length
97
- if (text.length < DIFF_CAP) text += content
98
- shown++
99
- }
100
- files.set(path, { path, adds, dels: 0, binary: content === null && shown < UNTRACKED_CAP, status: 'A', untracked: true, preexisting: !isNew })
101
- }
102
- for (const f of files.values()) if (dirtyBefore.has(f.path)) f.preexisting = true
103
114
  const truncated = text.length > DIFF_CAP
104
115
  if (truncated) text = text.slice(0, DIFF_CAP) + '\n… diff truncated …\n'
116
+ const commits = await commitsSince(root, before.head)
117
+ return summary(before, root, [...files.values()], text, truncated, commits, after)
118
+ }
105
119
 
120
+ async function commitsSince(root, base) {
121
+ const headNow = (await git(root, ['rev-parse', 'HEAD']))?.trim()
122
+ if (!base || !headNow || headNow === base) return []
106
123
  const commits = []
107
- if (base && headNow && headNow.trim() !== base) {
108
- const log = await git(root, ['log', '--format=%H%x1f%s%x1f%cI', `${base}..HEAD`, `--max-count=${COMMIT_CAP}`])
109
- for (const line of (log ?? '').split('\n').filter(Boolean)) {
110
- const [sha, title, at] = line.split('\x1f')
111
- const ns = await git(root, ['show', '--numstat', '--format=', sha])
112
- let adds = 0, dels = 0, n = 0
113
- for (const l of (ns ?? '').split('\n').filter(Boolean)) {
114
- const [a, d] = l.split('\t')
115
- if (a !== '-') adds += Number(a)
116
- if (d !== '-') dels += Number(d)
117
- n++
118
- }
119
- commits.push({ sha, short: sha.slice(0, 7), title, at, files: n, adds, dels })
124
+ const log = await git(root, ['log', '--format=%H%x1f%s%x1f%cI', `${base}..HEAD`, `--max-count=${COMMIT_CAP}`])
125
+ for (const line of (log ?? '').split('\n').filter(Boolean)) {
126
+ const [sha, title, at] = line.split('\x1f')
127
+ const ns = await git(root, ['show', '--numstat', '--format=', sha])
128
+ let adds = 0, dels = 0, n = 0
129
+ for (const l of (ns ?? '').split('\n').filter(Boolean)) {
130
+ const [a, d] = l.split('\t')
131
+ if (a !== '-') adds += Number(a)
132
+ if (d !== '-') dels += Number(d)
133
+ n++
120
134
  }
135
+ commits.push({ sha, short: sha.slice(0, 7), title, at, files: n, adds, dels })
121
136
  }
122
- const list = [...files.values()]
123
- if (list.length === 0 && commits.length === 0) return null
137
+ return commits
138
+ }
139
+
140
+ async function summary(before, root, files, diff, truncated, commits, treeAfter) {
124
141
  return {
125
142
  cwd: before.cwd,
126
143
  repo: before.repo,
127
144
  branch: (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() || before.branch,
128
- head_before: base,
129
- head_after: headNow?.trim() || null,
145
+ head_before: before.head,
146
+ head_after: (await git(root, ['rev-parse', 'HEAD']))?.trim() || null,
147
+ tree_before: before.tree,
148
+ tree_after: treeAfter,
130
149
  commits,
131
- files: list,
132
- stats: { files: list.length, adds: list.reduce((s, f) => s + f.adds, 0), dels: list.reduce((s, f) => s + f.dels, 0) },
133
- diff: text,
150
+ files,
151
+ stats: { files: files.length, adds: files.reduce((s, f) => s + f.adds, 0), dels: files.reduce((s, f) => s + f.dels, 0) },
152
+ diff,
134
153
  truncated,
135
154
  }
136
155
  }
package/src/index.mjs CHANGED
@@ -4,7 +4,9 @@
4
4
  import WebSocket from 'ws'
5
5
  import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
6
6
  import { snapshot, changesSince, display } from './git.mjs'
7
- import { runClaude, findClaude, resolveCwd } from './claude-run.mjs'
7
+ import { resolveCwd } from './claude-run.mjs'
8
+ import { agentById, AGENTS } from './agents/index.mjs'
9
+ import { normalizeAnswer } from './agents/protocol.mjs'
8
10
  import { openApp, installedApps } from './apps.mjs'
9
11
  import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
10
12
 
@@ -13,6 +15,9 @@ const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.j
13
15
 
14
16
  let URL_ = ''
15
17
  let TOKEN = ''
18
+ /** Requests waiting on a person: `${jobId}:${requestId}` -> resolve({ answer, by }). */
19
+ const answerWaits = new Map()
20
+ const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
16
21
 
17
22
  /** Start the bridge with resolved settings. Runs until the process exits. */
18
23
  export function startBridge(config) {
@@ -23,7 +28,7 @@ export function startBridge(config) {
23
28
  console.error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
24
29
  process.exit(1)
25
30
  }
26
- log(`kira-bridge ${BRIDGE_VERSION} · claude binary: ${findClaude()}`)
31
+ log(`kira-bridge ${BRIDGE_VERSION} · agents: ${Object.keys(AGENTS).join(', ')}`)
27
32
  connect()
28
33
  }
29
34
 
@@ -58,28 +63,59 @@ const tools = {
58
63
  async read_claude_session(args) {
59
64
  return readSession(String(args.session_id ?? ''), args.turns)
60
65
  },
61
- // Acknowledge now, report the result as a job when Claude finishes.
62
- async ask_claude(args, ws) {
66
+ /**
67
+ * Run an instruction with a coding agent. Acknowledges at once, streams every
68
+ * protocol event as `job.event`, and reports the outcome as `job` when done.
69
+ * Requests that need a person park the job until `job.answer` arrives.
70
+ */
71
+ async run_agent(args, ws) {
63
72
  const jobId = String(args.job_id ?? '')
73
+ const agent = agentById(args.agent)
74
+ if (!agent) return { error: `unknown agent ${args.agent}` }
64
75
  const prompt = String(args.prompt ?? args.message ?? args.text ?? args.instruction ?? '').trim()
65
76
  if (!prompt) return { error: 'prompt is required' }
66
- // A resumed session runs in its own folder even when the caller did not say which.
67
77
  let cwd = args.cwd
68
78
  if (!cwd && args.session_id) {
69
79
  const s = await readSession(String(args.session_id), 1)
70
80
  if (s?.cwd) cwd = s.cwd
71
81
  }
72
82
  const dir = resolveCwd(cwd)
73
- log(`ask_claude job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
74
- // Git before and after, so the API can show exactly what this job changed.
83
+ log(`${agent.id} job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
75
84
  const before = await snapshot(dir).catch(() => null)
76
- void runClaude({ prompt, sessionId: args.session_id, cwd: dir }).then(async result => {
85
+ let seq = 0
86
+ const io = {
87
+ emit: event => send(ws, { type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
88
+ ask: (request, signal) =>
89
+ new Promise((resolve, reject) => {
90
+ const key = `${jobId}:${request.id}`
91
+ const timer = setTimeout(() => {
92
+ answerWaits.delete(key)
93
+ reject(new Error('No answer within 3 minutes'))
94
+ }, ANSWER_TIMEOUT_MS)
95
+ answerWaits.set(key, resolved => {
96
+ clearTimeout(timer)
97
+ resolve(resolved)
98
+ })
99
+ signal?.addEventListener('abort', () => {
100
+ clearTimeout(timer)
101
+ answerWaits.delete(key)
102
+ reject(new Error('The job was stopped'))
103
+ }, { once: true })
104
+ }),
105
+ }
106
+ void agent.run({ prompt, sessionId: args.session_id, cwd: dir }, io).then(async outcome => {
107
+ for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
77
108
  let change = null
78
109
  if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
79
- log(`job ${jobId.slice(0, 8)} ${result.error ? 'error' : 'done'}${change ? ` · ${change.stats.files} files +${change.stats.adds} -${change.stats.dels}, ${change.commits.length} commits` : ''}`)
80
- send(ws, { type: 'job', jobId, status: result.error ? 'error' : 'done', result: { ...result, cwd: display(dir), change }, error: result.error })
110
+ 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` : ''}`)
111
+ const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
112
+ send(ws, { type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
81
113
  })
82
- return { started: true, cwd: display(dir), git: before ? { repo: before.repo, branch: before.branch, head: before.head?.slice(0, 7) ?? null } : null }
114
+ 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 }
115
+ },
116
+ /** Older API builds still call ask_claude. */
117
+ ask_claude(args, ws) {
118
+ return tools.run_agent({ ...args, agent: 'claude' }, ws)
83
119
  },
84
120
 
85
121
  /** Read-only git facts for a folder: repo, branch, head, dirty files. */
@@ -130,9 +166,19 @@ function connect() {
130
166
  if (msg.type === 'hello') {
131
167
  // Tell the API which tools this bridge build implements, so a stale
132
168
  // bridge is reported as such instead of "unknown tool".
133
- send(ws, { type: 'capabilities', tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform })
169
+ 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 })) })
134
170
  return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
135
171
  }
172
+ if (msg.type === 'job.answer') {
173
+ const key = `${msg.jobId}:${msg.requestId}`
174
+ const resolve = answerWaits.get(key)
175
+ if (resolve) {
176
+ answerWaits.delete(key)
177
+ resolve({ answer: normalizeAnswer(msg.answer), by: msg.by ?? 'portal' })
178
+ log(`request ${msg.requestId} for job ${String(msg.jobId).slice(0, 8)} answered by ${msg.by ?? 'portal'}`)
179
+ }
180
+ return
181
+ }
136
182
  if (msg.type !== 'call') return
137
183
  const fn = tools[msg.tool]
138
184
  if (!fn) return send(ws, { type: 'error', id: msg.id, error: `unknown tool ${msg.tool}` })
package/src/service.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // launchd integration: run the bridge at login and keep it alive.
2
+ import { execSync } from 'node:child_process'
2
3
  import { execFileSync } from 'node:child_process'
3
4
  import { mkdirSync, writeFileSync, existsSync, unlinkSync, readFileSync, cpSync, rmSync } from 'node:fs'
4
5
  import { homedir } from 'node:os'
@@ -19,16 +20,22 @@ function packageRoot() {
19
20
  return dirname(new URL('.', import.meta.url).pathname.replace(/\/$/, ''))
20
21
  }
21
22
 
22
- /** Copy this package (src + package.json + node_modules/ws) into ~/.kira/app. */
23
+ /**
24
+ * Stage this package under ~/.kira/app and install its dependencies there with
25
+ * npm. The Agent SDK brings a per-platform native package, so a plain copy of
26
+ * node_modules is not enough; a real install is.
27
+ */
23
28
  function stageApp() {
24
29
  const root = packageRoot()
25
30
  rmSync(APP_DIR, { recursive: true, force: true })
26
31
  mkdirSync(APP_DIR, { recursive: true })
27
32
  cpSync(join(root, 'src'), join(APP_DIR, 'src'), { recursive: true })
28
33
  cpSync(join(root, 'package.json'), join(APP_DIR, 'package.json'))
29
- // ws is the only dependency; resolve it from wherever npm put it.
30
- const wsPkg = dirname(new URL(import.meta.resolve('ws/package.json')).pathname)
31
- cpSync(wsPkg, join(APP_DIR, 'node_modules', 'ws'), { recursive: true })
34
+ try {
35
+ execSync('npm install --omit=dev --no-audit --no-fund --loglevel=error', { cwd: APP_DIR, stdio: 'inherit' })
36
+ } catch (err) {
37
+ throw new Error(`could not install the bridge's dependencies in ${APP_DIR}: ${err.message}`)
38
+ }
32
39
  return join(APP_DIR, 'src', 'cli.mjs')
33
40
  }
34
41