kira-glasses-bridge 1.1.1 → 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.1",
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/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