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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.14.0",
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
+ }
@@ -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
- const judge = (toolName, input) => decide(policy, toolName, input ?? {}, cwd, rulesFor())
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
- permissionMode: 'default',
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
- // Kira has its own Gmail/Calendar/Drive tools; keep the user's claude.ai connectors out of
206
- // the sessions it runs, so there's one pathway for acting on their accounts (Kira's).
207
- env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: 'false' },
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
- if (b.type === 'text' && b.text?.trim()) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
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 }
@@ -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
- export const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'TodoWrite', 'TodoRead', 'NotebookRead', 'Skill'])
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 === 'Task') return { decision: p.delegate === 'ask' ? 'ask' : 'allow', reason: 'subagent' }
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
+ }
@@ -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). Returns the
159
- * designs without their HTML, plus the HTML of those whose hash isn't in `known` (hashes the API
160
- * already holds), so a rescan sends only what changed.
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 sessions = (await sdkListSessions()).filter(s => s.lastModified > after)
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 { prepareWorktree, worktreeStatus, pushWorktree, removeWorktree, repoOfFolder, stageFiles, listOutputs, uploadOutputs } from './worktrees.mjs'
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
- void agent.run({ prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona: args.persona ?? null, abort }, io).then(async outcome => {
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
- for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
159
+ forgetJob(jobId)
172
160
  let change = null
173
- if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
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), git: before ? { repo: before.repo, branch: before.branch, head: before.head?.slice(0, 7) ?? null } : null }
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
- /** A worktree for one task attempt, off the project's checkout of the repo. */
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
- /** Read-only git facts for a folder: repo, branch, head, dirty files. */
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 key = `${msg.jobId}:${msg.requestId}`
326
- const resolve = answerWaits.get(key)
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
+ }