kira-glasses-bridge 2.14.0 → 2.19.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 +3 -2
- package/src/agents/answers.mjs +43 -0
- package/src/agents/claude.mjs +42 -7
- package/src/agents/policy.mjs +25 -2
- package/src/agents/settings.mjs +23 -0
- package/src/claude-designs.mjs +7 -5
- package/src/git-tools.mjs +51 -0
- package/src/git.mjs +1 -1
- package/src/index.mjs +26 -66
- package/src/refs.mjs +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"start": "node src/cli.mjs run"
|
|
17
|
+
"start": "node src/cli.mjs run",
|
|
18
|
+
"test": "node --test test/*.test.mjs"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@anthropic-ai/claude-agent-sdk": "^0.3.278",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Questions a running job asks a person (an approval, a question for them), waiting for the first
|
|
2
|
+
// answer from any device, keyed `${jobId}:${requestId}`. A wait ends at its answer, after three
|
|
3
|
+
// minutes, or when the job is stopped.
|
|
4
|
+
const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
|
|
5
|
+
const waits = new Map()
|
|
6
|
+
|
|
7
|
+
/** Wait for the answer to one of a job's requests. */
|
|
8
|
+
export function awaitAnswer(jobId, request, signal) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const key = `${jobId}:${request.id}`
|
|
11
|
+
const end = () => {
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
waits.delete(key)
|
|
14
|
+
}
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
end()
|
|
17
|
+
reject(new Error('No answer within 3 minutes'))
|
|
18
|
+
}, ANSWER_TIMEOUT_MS)
|
|
19
|
+
timer.unref?.()
|
|
20
|
+
waits.set(key, answered => {
|
|
21
|
+
end()
|
|
22
|
+
resolve(answered)
|
|
23
|
+
})
|
|
24
|
+
const stopped = () => {
|
|
25
|
+
end()
|
|
26
|
+
reject(new Error('The job was stopped'))
|
|
27
|
+
}
|
|
28
|
+
signal?.addEventListener('abort', stopped, { once: true })
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An answer arrived: true if a request was waiting for it. */
|
|
33
|
+
export function answer(jobId, requestId, answered) {
|
|
34
|
+
const resolve = waits.get(`${jobId}:${requestId}`)
|
|
35
|
+
if (!resolve) return false
|
|
36
|
+
resolve(answered)
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A job ended: whatever it was still asking is dropped. */
|
|
41
|
+
export function forgetJob(jobId) {
|
|
42
|
+
for (const key of [...waits.keys()]) if (key.startsWith(`${jobId}:`)) waits.delete(key)
|
|
43
|
+
}
|
package/src/agents/claude.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { relative, resolve, sep } from 'node:path'
|
|
6
6
|
import { PERMISSION_OPTIONS, PLAN_OPTIONS, requestId } from './protocol.mjs'
|
|
7
7
|
import { decide, ruleFor } from './policy.mjs'
|
|
8
|
+
import { policyFor, sdkOptionsFor } from './settings.mjs'
|
|
8
9
|
|
|
9
10
|
const TEXT_CAP = 4000
|
|
10
11
|
const DIFF_CAP = 6000
|
|
@@ -23,6 +24,9 @@ const inside = (cwd, p) => {
|
|
|
23
24
|
return !!rel && !rel.startsWith('..') && !rel.startsWith(sep)
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
|
|
28
|
+
const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
|
|
29
|
+
|
|
26
30
|
/** Claude's tool call as a protocol action. */
|
|
27
31
|
export function describeAction(name, input, cwd) {
|
|
28
32
|
const i = input ?? {}
|
|
@@ -133,12 +137,14 @@ function applyAnswer(name, input, request, answer, suggestions) {
|
|
|
133
137
|
/** "Always allow" answers, kept for the rest of the process per session: sessionId -> Set of rules. */
|
|
134
138
|
const sessionRules = new Map()
|
|
135
139
|
|
|
136
|
-
export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io) {
|
|
140
|
+
export async function run({ prompt, sessionId, cwd, policy, persona, settings, abort }, io) {
|
|
137
141
|
let session = sessionId ?? null
|
|
138
142
|
let text = ''
|
|
139
143
|
let error = null
|
|
140
144
|
const rulesFor = () => (session && sessionRules.get(session)) || new Set()
|
|
141
|
-
|
|
145
|
+
// The composer's mode: "Ask" makes the policy ask for edits and commands (settings.mjs).
|
|
146
|
+
const ruled = policyFor(policy, settings)
|
|
147
|
+
const judge = (toolName, input) => decide(ruled, toolName, input ?? {}, cwd, rulesFor())
|
|
142
148
|
|
|
143
149
|
// Pre-tool hook: the policy is authoritative. It forces the prompt for what
|
|
144
150
|
// it does not allow (so a permissive local config cannot bypass the person)
|
|
@@ -191,38 +197,62 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
|
|
|
191
197
|
return applyAnswer(toolName, input, request, resolved.answer, suggestions)
|
|
192
198
|
}
|
|
193
199
|
|
|
200
|
+
// Background work (subagents, `run_in_background` shell commands, monitors)
|
|
201
|
+
// started by the model would outlive the turn: this is a one-shot run, so the
|
|
202
|
+
// moment the main thread stops the result is released, the process ends and
|
|
203
|
+
// that work is killed, never having reported. Claude's parting words ("the
|
|
204
|
+
// agents are still running; I'll compile the report when they finish") then
|
|
205
|
+
// became the job's final report, and Kira told the user it was still going.
|
|
206
|
+
// With this set, the tool has no background option: subagents run inline and
|
|
207
|
+
// their findings are in the answer.
|
|
208
|
+
// Kira has its own Gmail/Calendar/Drive tools; keep the user's claude.ai connectors out of the
|
|
209
|
+
// sessions it runs, so there's one pathway for acting on their accounts (Kira's).
|
|
210
|
+
const env = { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1', ENABLE_CLAUDEAI_MCP_SERVERS: 'false' }
|
|
211
|
+
/** Background tasks the model still managed to start (an older CLI ignoring the flag): tool_use id -> description. */
|
|
212
|
+
const orphans = new Map()
|
|
213
|
+
|
|
194
214
|
try {
|
|
195
215
|
const q = query({
|
|
196
216
|
prompt,
|
|
197
217
|
options: {
|
|
198
218
|
cwd,
|
|
219
|
+
env,
|
|
199
220
|
...(sessionId ? { resume: sessionId } : {}),
|
|
200
|
-
|
|
221
|
+
// The model, effort and permission mode the person chose in the composer (plan mode: plan first).
|
|
222
|
+
...sdkOptionsFor(settings),
|
|
201
223
|
...(abort ? { abortController: abort } : {}),
|
|
202
224
|
// A team member's soul, skills and conventions ride on top of Claude Code's own prompt; its preset says which tools it does without.
|
|
203
225
|
...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
|
|
204
226
|
...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
227
|
+
// Claude as the person's Kira: her tools come from her own MCP server, with a token the API
|
|
228
|
+
// made for this conversation. Claude Code loads them as it needs them (no tool ceiling).
|
|
229
|
+
...(persona?.kiraTools ? { mcpServers: { kira: kiraServer(persona.kiraTools) } } : {}),
|
|
208
230
|
canUseTool,
|
|
209
231
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
210
232
|
...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
|
|
211
233
|
},
|
|
212
234
|
})
|
|
213
235
|
for await (const m of q) {
|
|
236
|
+
// Messages from inside a subagent carry the id of the Subtask call that started it.
|
|
237
|
+
const nested = !!m.parent_tool_use_id
|
|
214
238
|
if (m.type === 'system' && m.subtype === 'init') {
|
|
215
239
|
session = m.session_id ?? session
|
|
216
240
|
io.emit({ kind: 'session', sessionId: session, model: m.model ?? null })
|
|
241
|
+
} else if (m.type === 'system' && m.subtype === 'task_started') {
|
|
242
|
+
if (m.is_backgrounded && m.tool_use_id) orphans.set(m.tool_use_id, m.description ?? m.subagent_type ?? 'task')
|
|
243
|
+
} else if (m.type === 'system' && m.subtype === 'task_notification') {
|
|
244
|
+
if (m.tool_use_id) orphans.delete(m.tool_use_id)
|
|
217
245
|
} else if (m.type === 'assistant') {
|
|
218
246
|
for (const b of m.message?.content ?? []) {
|
|
219
|
-
|
|
247
|
+
// A subagent's narration is for its parent, not the user; its actions still show what is going on while the Subtask runs.
|
|
248
|
+
if (b.type === 'text' && b.text?.trim() && !nested) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
|
|
220
249
|
else if (b.type === 'tool_use') io.emit({ kind: 'action', id: b.id, tool: b.name, ...describeAction(b.name, b.input, cwd) })
|
|
221
250
|
}
|
|
222
251
|
} else if (m.type === 'user') {
|
|
223
252
|
for (const b of m.message?.content ?? []) {
|
|
224
253
|
if (b?.type === 'tool_result') {
|
|
225
254
|
const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map(c => (c.type === 'text' ? c.text : '')).join('\n') : ''
|
|
255
|
+
if (/^Async agent launched successfully/.test(t.trim()) && !orphans.has(b.tool_use_id)) orphans.set(b.tool_use_id, 'subagent')
|
|
226
256
|
io.emit({ kind: 'action_result', id: b.tool_use_id, ok: !b.is_error, preview: short(t.trim(), 240) })
|
|
227
257
|
}
|
|
228
258
|
}
|
|
@@ -230,6 +260,11 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
|
|
|
230
260
|
session = m.session_id ?? session
|
|
231
261
|
if (m.subtype === 'success') text = String(m.result ?? '')
|
|
232
262
|
else error = `Claude stopped: ${m.subtype}${Array.isArray(m.errors) && m.errors.length ? ` (${m.errors.join('; ').slice(0, 300)})` : ''}`
|
|
263
|
+
// Work left running in the background dies with this process; say so rather than let "still running" stand as the outcome.
|
|
264
|
+
if (orphans.size) {
|
|
265
|
+
const note = `${orphans.size} background ${orphans.size === 1 ? 'task was' : 'tasks were'} still running when Claude ended its turn (${[...orphans.values()].slice(0, 5).join(', ')}); they were stopped with the session and their findings are not in this report. Send a follow-up to have that work done inline.`
|
|
266
|
+
text = text ? `${text}\n\n(${note})` : `(${note})`
|
|
267
|
+
}
|
|
233
268
|
const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd }
|
|
234
269
|
io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: session, stats })
|
|
235
270
|
return { ok: !error, text, error, sessionId: session, stats }
|
package/src/agents/policy.mjs
CHANGED
|
@@ -7,9 +7,29 @@
|
|
|
7
7
|
// and every piece has to be safe for the whole command to be safe.
|
|
8
8
|
import { relative, resolve, sep } from 'node:path'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Tools that only look, or that are Claude Code's own housekeeping (ToolSearch loads the definitions
|
|
12
|
+
* of tools it has but hasn't used yet; the MCP resource tools read what a server offers).
|
|
13
|
+
*/
|
|
14
|
+
export const READ_ONLY = new Set([
|
|
15
|
+
'Read',
|
|
16
|
+
'Glob',
|
|
17
|
+
'Grep',
|
|
18
|
+
'LS',
|
|
19
|
+
'TodoWrite',
|
|
20
|
+
'TodoRead',
|
|
21
|
+
'NotebookRead',
|
|
22
|
+
'Skill',
|
|
23
|
+
'ToolSearch',
|
|
24
|
+
'ListMcpResourcesTool',
|
|
25
|
+
'ReadMcpResourceTool',
|
|
26
|
+
])
|
|
27
|
+
/** A subagent: older Claude Code calls the tool Task, newer Agent. */
|
|
28
|
+
const DELEGATE = new Set(['Task', 'Agent'])
|
|
11
29
|
export const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
12
30
|
export const WEB = new Set(['WebFetch', 'WebSearch'])
|
|
31
|
+
/** Claude Code's name for the tools of Kira's MCP server (mcp__<server>__<tool>). */
|
|
32
|
+
export const KIRA_TOOLS = 'mcp__kira__'
|
|
13
33
|
|
|
14
34
|
/** Read-only commands: looking, listing, testing, type-checking, building. Nothing that writes, sends or installs. */
|
|
15
35
|
const SAFE_CMD = /^(?:ls|ll|pwd|cat|head|tail|wc|echo|printf|which|type|env|printenv|date|whoami|id|uname|tree|find|fd|grep|rg|ag|awk|cut|tr|sort|uniq|diff|comm|stat|file|du|df|basename|dirname|realpath|readlink|jq|yq|xxd|hexdump|strings|test|\[|true|false|sleep|md5|shasum|sha256sum|md5sum|column|nl|less|more|open -n --args|sed -n|sed -E -n|sed -n -E|git (?:status|diff|log|show|branch|rev-parse|remote|ls-files|ls-tree|blame|describe|stash list|tag|config --get|config -l|cat-file|grep|shortlog|count-objects|reflog)|node (?:-v|--version|-e|-p|--eval|--print)|npm (?:-v|--version|ls|list|view|outdated|why|test|run (?:test|lint|typecheck|check|build|format:check))|pnpm (?:-v|--version|ls|list|why|outdated|test|typecheck|lint|build|run (?:test|lint|typecheck|check|build))|yarn (?:-v|--version|list|test|lint|typecheck|build)|npx (?:tsc|eslint|prettier --check|jest|vitest|playwright test)|tsc|eslint|prettier --check|jest|vitest|cargo (?:check|test|build|fmt --check|clippy|tree|metadata)|go (?:test|build|vet|version|list|env)|python3? (?:-V|--version|-m pytest|-c)|pytest|ruby -v|make (?:test|check|lint|build)|mvn (?:test|compile)|gradle (?:test|build)|swift (?:build|test)|xcodebuild -list|brew (?:list|info|--version)|docker (?:ps|images|version)|gh (?:pr (?:list|view|status|checks)|issue (?:list|view)|run (?:list|view)|repo view|api)|heroku (?:logs|ps|releases|config(?::get)?|apps|info))(?:\s|$)/
|
|
@@ -80,6 +100,9 @@ export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
|
|
|
80
100
|
if (matchesRule(p.askRules ?? [], toolName, command)) return { decision: 'ask', reason: 'your policy always asks for this' }
|
|
81
101
|
if (matchesRule([...(p.allowRules ?? []), ...sessionRules], toolName, command)) return { decision: 'allow', reason: 'allowed by your policy' }
|
|
82
102
|
if (READ_ONLY.has(toolName)) return { decision: 'allow', reason: 'read-only' }
|
|
103
|
+
// Kira's own tools (her MCP server, attached when Claude is the person's Kira): her server asks
|
|
104
|
+
// for itself when something needs their yes (approvals), so the Mac doesn't ask a second time.
|
|
105
|
+
if (toolName.startsWith(KIRA_TOOLS)) return { decision: 'allow', reason: 'Kira’s own tool' }
|
|
83
106
|
if (EDITS.has(toolName)) {
|
|
84
107
|
const file = input?.file_path ?? input?.notebook_path
|
|
85
108
|
if (insideFolder(cwd, file)) return { decision: p.edits === 'ask' ? 'ask' : 'allow', reason: 'edit inside the project' }
|
|
@@ -91,7 +114,7 @@ export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
|
|
|
91
114
|
return { decision: 'ask', reason: 'shell command' }
|
|
92
115
|
}
|
|
93
116
|
if (WEB.has(toolName)) return { decision: p.web === 'ask' ? 'ask' : 'allow', reason: 'web' }
|
|
94
|
-
if (toolName
|
|
117
|
+
if (DELEGATE.has(toolName)) return { decision: p.delegate === 'ask' ? 'ask' : 'allow', reason: 'subagent' }
|
|
95
118
|
if (toolName === 'ExitPlanMode') return { decision: p.plan === 'allow' ? 'allow' : 'ask', reason: 'plan approval' }
|
|
96
119
|
return { decision: 'ask', reason: 'unfamiliar tool' }
|
|
97
120
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// How a job runs, as the person chose it in Kira's composer (genui run-settings.ts): the model,
|
|
2
|
+
// the effort, and the permission mode. Pure: the Agent SDK's options and the policy it judges by.
|
|
3
|
+
//
|
|
4
|
+
// { model?: string, effort?: 'low'|'medium'|'high', mode?: 'auto'|'ask'|'plan' }
|
|
5
|
+
|
|
6
|
+
const EFFORTS = new Set(['low', 'medium', 'high'])
|
|
7
|
+
const MODEL = /^claude-[a-z0-9-]{1,60}$/
|
|
8
|
+
|
|
9
|
+
/** The SDK's query options for these settings (left out when unset, so Claude Code's defaults stand). */
|
|
10
|
+
export function sdkOptionsFor(settings) {
|
|
11
|
+
const s = settings ?? {}
|
|
12
|
+
return {
|
|
13
|
+
permissionMode: s.mode === 'plan' ? 'plan' : 'default',
|
|
14
|
+
...(typeof s.model === 'string' && MODEL.test(s.model) ? { model: s.model } : {}),
|
|
15
|
+
...(EFFORTS.has(s.effort) ? { effort: s.effort } : {}),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The person's policy for this job: "Ask" asks before every edit and every command. */
|
|
20
|
+
export function policyFor(policy, settings) {
|
|
21
|
+
const p = policy ?? {}
|
|
22
|
+
return settings?.mode === 'ask' ? { ...p, edits: 'ask', shell: 'ask' } : p
|
|
23
|
+
}
|
package/src/claude-designs.mjs
CHANGED
|
@@ -155,14 +155,16 @@ async function designsOf(s) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Scan every session changed since `since` (ISO time; all of them when omitted)
|
|
159
|
-
*
|
|
160
|
-
*
|
|
158
|
+
* Scan every session changed since `since` (ISO time; all of them when omitted), or only the
|
|
159
|
+
* sessions named in `sessions` (a Claude job just finished in one). Returns the designs without
|
|
160
|
+
* their HTML, plus the HTML of those whose hash isn't in `known` (hashes the API already holds),
|
|
161
|
+
* so a rescan sends only what changed.
|
|
161
162
|
*/
|
|
162
|
-
export async function scanDesigns({ since, known = [] } = {}) {
|
|
163
|
+
export async function scanDesigns({ since, known = [], sessions: only } = {}) {
|
|
163
164
|
const after = since ? Date.parse(since) || 0 : 0
|
|
164
165
|
const have = new Set(known)
|
|
165
|
-
const
|
|
166
|
+
const pick = Array.isArray(only) && only.length ? new Set(only) : null
|
|
167
|
+
const sessions = (await sdkListSessions()).filter(s => (pick ? pick.has(s.sessionId) : s.lastModified > after))
|
|
166
168
|
const designs = []
|
|
167
169
|
let failed = 0
|
|
168
170
|
for (const s of sessions) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The bridge's git tools: worktrees for task attempts, what's in a folder's repository, and its
|
|
2
|
+
// branches and worktrees (what Kira offers after @). Spread into index.mjs's tool table.
|
|
3
|
+
import { resolveCwd } from './claude-run.mjs'
|
|
4
|
+
import { snapshot } from './git.mjs'
|
|
5
|
+
import { gitRefs } from './refs.mjs'
|
|
6
|
+
import {
|
|
7
|
+
listOutputs,
|
|
8
|
+
prepareWorktree,
|
|
9
|
+
pushWorktree,
|
|
10
|
+
removeWorktree,
|
|
11
|
+
repoOfFolder,
|
|
12
|
+
stageFiles,
|
|
13
|
+
uploadOutputs,
|
|
14
|
+
worktreeStatus,
|
|
15
|
+
} from './worktrees.mjs'
|
|
16
|
+
|
|
17
|
+
export const gitTools = {
|
|
18
|
+
/** A worktree for one task attempt, off the project's checkout of the repo. */
|
|
19
|
+
prepare_worktree: args => prepareWorktree(args),
|
|
20
|
+
worktree_status: args => worktreeStatus(args),
|
|
21
|
+
push_worktree: args => pushWorktree(args),
|
|
22
|
+
remove_worktree: args => removeWorktree(args),
|
|
23
|
+
/** A task's reference files, downloaded into the worktree's .kira/files for the agent. */
|
|
24
|
+
stage_files: args => stageFiles(args),
|
|
25
|
+
/** What a designer or researcher produced under its output folder, and sending it to Kira's storage. */
|
|
26
|
+
list_outputs: args => listOutputs(args),
|
|
27
|
+
upload_outputs: args => uploadOutputs(args),
|
|
28
|
+
|
|
29
|
+
/** Which GitHub repo a folder's origin points at (project link enrichment). */
|
|
30
|
+
async repo_of_folder(args) {
|
|
31
|
+
return { repo: await repoOfFolder(args.path) }
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Read-only git facts for a folder: repo, branch, head, dirty files. */
|
|
35
|
+
async git_status(args) {
|
|
36
|
+
const s = await snapshot(resolveCwd(args.path ?? args.cwd))
|
|
37
|
+
if (!s) return { error: 'not a git repository' }
|
|
38
|
+
const head = s.head?.slice(0, 7) ?? null
|
|
39
|
+
return {
|
|
40
|
+
cwd: s.cwd,
|
|
41
|
+
repo: s.repo,
|
|
42
|
+
branch: s.branch,
|
|
43
|
+
head,
|
|
44
|
+
dirty: s.dirty.slice(0, 50),
|
|
45
|
+
dirty_count: s.dirty.length,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** A folder's branches (newest first) and where each is checked out: here, or in a worktree. */
|
|
50
|
+
git_refs: args => gitRefs(resolveCwd(args.path ?? args.cwd)),
|
|
51
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
10
10
|
const DIFF_CAP = 400 * 1024 // bytes of unified diff kept per change
|
|
11
11
|
const COMMIT_CAP = 20
|
|
12
12
|
|
|
13
|
-
function git(cwd, args, { ok = [0], env = {} } = {}) {
|
|
13
|
+
export function git(cwd, args, { ok = [0], env = {} } = {}) {
|
|
14
14
|
return new Promise(resolve => {
|
|
15
15
|
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...env } }, (err, stdout) => {
|
|
16
16
|
if (err && !ok.includes(err.code)) return resolve(null)
|
package/src/index.mjs
CHANGED
|
@@ -8,18 +8,17 @@ import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
|
|
|
8
8
|
import { resolveCwd, folderExists } from './claude-run.mjs'
|
|
9
9
|
import { agentById, AGENTS } from './agents/index.mjs'
|
|
10
10
|
import { normalizeAnswer } from './agents/protocol.mjs'
|
|
11
|
+
import { answer, awaitAnswer, forgetJob } from './agents/answers.mjs'
|
|
11
12
|
import { openApp, installedApps } from './apps.mjs'
|
|
12
13
|
import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
|
|
13
14
|
import { initTerminals, terminalCall, terminalMessage, terminalsOffline } from './terminals.mjs'
|
|
14
|
-
import {
|
|
15
|
+
import { gitTools } from './git-tools.mjs'
|
|
15
16
|
|
|
16
17
|
import { createRequire } from 'node:module'
|
|
17
18
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
18
19
|
|
|
19
20
|
let URL_ = ''
|
|
20
21
|
let TOKEN = ''
|
|
21
|
-
/** Requests waiting on a person: `${jobId}:${requestId}` -> resolve({ answer, by }). */
|
|
22
|
-
const answerWaits = new Map()
|
|
23
22
|
/** Running jobs, by id, so a person can stop one. */
|
|
24
23
|
const running = new Map()
|
|
25
24
|
/**
|
|
@@ -35,7 +34,6 @@ function sendLive(obj) {
|
|
|
35
34
|
if (outbox.length >= OUTBOX_MAX) outbox.shift()
|
|
36
35
|
outbox.push(obj)
|
|
37
36
|
}
|
|
38
|
-
const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
39
|
* How the host hears from the bridge. The CLI passes nothing (logs to stdout, exits when the
|
|
@@ -45,6 +43,7 @@ const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
|
|
|
45
43
|
* openTerminal({ cwd, command }) open_terminal in the app's own terminal (a Kira tab), not
|
|
46
44
|
* Terminal.app; resolves to what to report, or null to fall back
|
|
47
45
|
* terminals the app's shells, for remote devices (terminals.mjs)
|
|
46
|
+
* browser({ op, … }) Kira in the app's browser dock: open, read, click, type (browser-kira.mjs)
|
|
48
47
|
*/
|
|
49
48
|
let hooks = {}
|
|
50
49
|
let stopped = false
|
|
@@ -112,7 +111,7 @@ const tools = {
|
|
|
112
111
|
return listProjects(args.max)
|
|
113
112
|
},
|
|
114
113
|
async scan_claude_designs(args) {
|
|
115
|
-
return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [] })
|
|
114
|
+
return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [], sessions: args.sessions })
|
|
116
115
|
},
|
|
117
116
|
async list_claude_sessions(args) {
|
|
118
117
|
return listSessions(args.max)
|
|
@@ -142,40 +141,30 @@ const tools = {
|
|
|
142
141
|
return { error: `The folder ${display(dir)} does not exist on this Mac. Link the project's real folder in the portal, or clone its repository there first (setup_project_folder); never guess a path.` }
|
|
143
142
|
}
|
|
144
143
|
log(`${agent.id} job=${jobId.slice(0, 8)}${args.persona?.name ? ` as ${args.persona.name}` : ''} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
|
|
145
|
-
const before = await snapshot(dir).catch(() => null)
|
|
146
144
|
let seq = 0
|
|
147
145
|
const io = {
|
|
148
146
|
emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
|
|
149
|
-
ask: (request, signal) =>
|
|
150
|
-
new Promise((resolve, reject) => {
|
|
151
|
-
const key = `${jobId}:${request.id}`
|
|
152
|
-
const timer = setTimeout(() => {
|
|
153
|
-
answerWaits.delete(key)
|
|
154
|
-
reject(new Error('No answer within 3 minutes'))
|
|
155
|
-
}, ANSWER_TIMEOUT_MS)
|
|
156
|
-
answerWaits.set(key, resolved => {
|
|
157
|
-
clearTimeout(timer)
|
|
158
|
-
resolve(resolved)
|
|
159
|
-
})
|
|
160
|
-
signal?.addEventListener('abort', () => {
|
|
161
|
-
clearTimeout(timer)
|
|
162
|
-
answerWaits.delete(key)
|
|
163
|
-
reject(new Error('The job was stopped'))
|
|
164
|
-
}, { once: true })
|
|
165
|
-
}),
|
|
147
|
+
ask: (request, signal) => awaitAnswer(jobId, request, signal),
|
|
166
148
|
}
|
|
167
149
|
const abort = new AbortController()
|
|
168
150
|
running.set(jobId, abort)
|
|
169
|
-
|
|
151
|
+
// Started is said at once. The git snapshot (what changed, reported at the end) is taken first but
|
|
152
|
+
// in the background: in a big repository it takes longer than the API waits for this answer.
|
|
153
|
+
const before = snapshot(dir).catch(() => null)
|
|
154
|
+
const persona = args.persona ?? null
|
|
155
|
+
const settings = args.settings ?? null
|
|
156
|
+
const job = { prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona, settings, abort }
|
|
157
|
+
void before.then(() => agent.run(job, io)).then(async outcome => {
|
|
170
158
|
running.delete(jobId)
|
|
171
|
-
|
|
159
|
+
forgetJob(jobId)
|
|
172
160
|
let change = null
|
|
173
|
-
|
|
161
|
+
const was = await before
|
|
162
|
+
if (was) change = await changesSince(was).catch(err => (log(`git after job failed: ${err.message}`), null))
|
|
174
163
|
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` : ''}`)
|
|
175
164
|
const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
|
|
176
165
|
sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
|
|
177
166
|
})
|
|
178
|
-
return { started: true, agent: agent.id, cwd: display(dir)
|
|
167
|
+
return { started: true, agent: agent.id, cwd: display(dir) }
|
|
179
168
|
},
|
|
180
169
|
/** Stop a running job: the agent is cut off; whatever it committed stays. */
|
|
181
170
|
stop_job(args) {
|
|
@@ -198,6 +187,12 @@ const tools = {
|
|
|
198
187
|
/** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
|
|
199
188
|
terminal: args => terminalCall(args),
|
|
200
189
|
|
|
190
|
+
/** Kira in the Mac app's browser dock: open a page, read it, click and type in it. */
|
|
191
|
+
async browser(args) {
|
|
192
|
+
if (!hooks.browser) return { error: 'the browser needs the Kira Mac app (this bridge has none)', reason: 'unavailable' }
|
|
193
|
+
return hooks.browser(args)
|
|
194
|
+
},
|
|
195
|
+
|
|
201
196
|
/** Open a terminal on this Mac in a folder and run a command there (used to hand a Claude session
|
|
202
197
|
* to the person): a Kira tab when the bridge runs inside the Mac app, else Terminal.app. */
|
|
203
198
|
async open_terminal(args) {
|
|
@@ -216,34 +211,7 @@ end tell`
|
|
|
216
211
|
return new Promise(resolve => execFile('osascript', ['-e', script], err => resolve(err ? { error: err.message } : { opened: true, cwd: display(dir), command })))
|
|
217
212
|
},
|
|
218
213
|
|
|
219
|
-
|
|
220
|
-
prepare_worktree(args) {
|
|
221
|
-
return prepareWorktree(args)
|
|
222
|
-
},
|
|
223
|
-
worktree_status(args) {
|
|
224
|
-
return worktreeStatus(args)
|
|
225
|
-
},
|
|
226
|
-
push_worktree(args) {
|
|
227
|
-
return pushWorktree(args)
|
|
228
|
-
},
|
|
229
|
-
remove_worktree(args) {
|
|
230
|
-
return removeWorktree(args)
|
|
231
|
-
},
|
|
232
|
-
/** A task's reference files, downloaded into the worktree's .kira/files for the agent. */
|
|
233
|
-
stage_files(args) {
|
|
234
|
-
return stageFiles(args)
|
|
235
|
-
},
|
|
236
|
-
/** What a designer or researcher produced under its output folder, and sending it to Kira's storage. */
|
|
237
|
-
list_outputs(args) {
|
|
238
|
-
return listOutputs(args)
|
|
239
|
-
},
|
|
240
|
-
upload_outputs(args) {
|
|
241
|
-
return uploadOutputs(args)
|
|
242
|
-
},
|
|
243
|
-
/** Which GitHub repo a folder's origin points at (project link enrichment). */
|
|
244
|
-
async repo_of_folder(args) {
|
|
245
|
-
return { repo: await repoOfFolder(args.path) }
|
|
246
|
-
},
|
|
214
|
+
...gitTools,
|
|
247
215
|
|
|
248
216
|
/** Which Claude account Claude Code on this Mac is signed in to (`claude auth status --json`). */
|
|
249
217
|
async claude_account() {
|
|
@@ -261,12 +229,7 @@ end tell`
|
|
|
261
229
|
)
|
|
262
230
|
},
|
|
263
231
|
|
|
264
|
-
|
|
265
|
-
async git_status(args) {
|
|
266
|
-
const s = await snapshot(resolveCwd(args.path ?? args.cwd))
|
|
267
|
-
if (!s) return { error: 'not a git repository' }
|
|
268
|
-
return { cwd: s.cwd, repo: s.repo, branch: s.branch, head: s.head?.slice(0, 7) ?? null, dirty: s.dirty.slice(0, 50), dirty_count: s.dirty.length }
|
|
269
|
-
},
|
|
232
|
+
|
|
270
233
|
}
|
|
271
234
|
|
|
272
235
|
/**
|
|
@@ -322,11 +285,8 @@ function connect() {
|
|
|
322
285
|
return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
|
|
323
286
|
}
|
|
324
287
|
if (msg.type === 'job.answer') {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
if (resolve) {
|
|
328
|
-
answerWaits.delete(key)
|
|
329
|
-
resolve({ answer: normalizeAnswer(msg.answer), by: msg.by ?? 'portal' })
|
|
288
|
+
const by = msg.by ?? 'portal'
|
|
289
|
+
if (answer(msg.jobId, msg.requestId, { answer: normalizeAnswer(msg.answer), by })) {
|
|
330
290
|
log(`request ${msg.requestId} for job ${String(msg.jobId).slice(0, 8)} answered by ${msg.by ?? 'portal'}`)
|
|
331
291
|
}
|
|
332
292
|
return
|
package/src/refs.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A folder's branches and worktrees, read from git: what Kira offers after @ ("@branch main"), so
|
|
2
|
+
// a message can point Claude at a place to work. Read-only; the parsers are pure and tested.
|
|
3
|
+
import { display, git } from './git.mjs'
|
|
4
|
+
|
|
5
|
+
const BRANCH_FORMAT = '%(refname:short)%09%(HEAD)%09%(committerdate:iso-strict)%09%(contents:subject)'
|
|
6
|
+
const MAX_BRANCHES = 60
|
|
7
|
+
|
|
8
|
+
/** `git branch --format=…` lines: name, whether it's checked out here, when it last moved, its last commit. */
|
|
9
|
+
export function parseBranches(out) {
|
|
10
|
+
return String(out ?? '')
|
|
11
|
+
.split('\n')
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map(line => {
|
|
14
|
+
const [name = '', head = '', at = '', subject = ''] = line.split('\t')
|
|
15
|
+
return { name, current: head.trim() === '*', at: at || null, subject }
|
|
16
|
+
})
|
|
17
|
+
.filter(b => b.name && !b.name.startsWith('('))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `git worktree list --porcelain`: each worktree's path, branch (null when detached) and head. */
|
|
21
|
+
export function parseWorktrees(out) {
|
|
22
|
+
return String(out ?? '')
|
|
23
|
+
.split('\n\n')
|
|
24
|
+
.map(block => {
|
|
25
|
+
const field = key =>
|
|
26
|
+
block
|
|
27
|
+
.split('\n')
|
|
28
|
+
.find(l => l.startsWith(`${key} `))
|
|
29
|
+
?.slice(key.length + 1) ?? null
|
|
30
|
+
const branch = field('branch')
|
|
31
|
+
return {
|
|
32
|
+
path: field('worktree'),
|
|
33
|
+
branch: branch ? branch.replace(/^refs\/heads\//, '') : null,
|
|
34
|
+
head: field('HEAD'),
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.filter(w => w.path)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Branches newest first, each with the worktree it's checked out in (if any). */
|
|
41
|
+
export function joinRefs(branches, worktrees, main) {
|
|
42
|
+
const where = new Map(worktrees.filter(w => w.branch).map(w => [w.branch, w.path]))
|
|
43
|
+
return [...branches]
|
|
44
|
+
.sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0) || String(b.at).localeCompare(String(a.at)))
|
|
45
|
+
.slice(0, MAX_BRANCHES)
|
|
46
|
+
.map(b => {
|
|
47
|
+
const path = where.get(b.name) ?? null
|
|
48
|
+
return { ...b, worktree: path && path !== main ? display(path) : null, here: path === main }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function gitRefs(dir) {
|
|
53
|
+
const main = (await git(dir, ['rev-parse', '--show-toplevel']))?.trim()
|
|
54
|
+
if (!main) return { error: 'not a git repository' }
|
|
55
|
+
const [branches, worktrees] = await Promise.all([
|
|
56
|
+
git(main, ['branch', `--format=${BRANCH_FORMAT}`, '--sort=-committerdate']),
|
|
57
|
+
git(main, ['worktree', 'list', '--porcelain']),
|
|
58
|
+
])
|
|
59
|
+
return { cwd: display(main), branches: joinRefs(parseBranches(branches), parseWorktrees(worktrees), main) }
|
|
60
|
+
}
|