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 +2 -1
- package/src/agents/claude.mjs +231 -0
- package/src/agents/index.mjs +9 -0
- package/src/agents/protocol.mjs +41 -0
- package/src/claude-run.mjs +5 -53
- package/src/index.mjs +58 -12
- package/src/service.mjs +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "
|
|
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
|
+
}
|
package/src/claude-run.mjs
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
//
|
|
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,
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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} ·
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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
|
-
|
|
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)} ${
|
|
80
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|