kira-glasses-bridge 2.15.0 → 2.20.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.15.0",
3
+ "version": "2.20.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)
@@ -199,7 +205,9 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
199
205
  // became the job's final report, and Kira told the user it was still going.
200
206
  // With this set, the tool has no background option: subagents run inline and
201
207
  // their findings are in the answer.
202
- const env = { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' }
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' }
203
211
  /** Background tasks the model still managed to start (an older CLI ignoring the flag): tool_use id -> description. */
204
212
  const orphans = new Map()
205
213
 
@@ -210,14 +218,15 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
210
218
  cwd,
211
219
  env,
212
220
  ...(sessionId ? { resume: sessionId } : {}),
213
- permissionMode: 'default',
221
+ // The model, effort and permission mode the person chose in the composer (plan mode: plan first).
222
+ ...sdkOptionsFor(settings),
214
223
  ...(abort ? { abortController: abort } : {}),
215
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.
216
225
  ...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
217
226
  ...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
218
- // Kira has its own Gmail/Calendar/Drive tools; keep the user's claude.ai connectors out of
219
- // the sessions it runs, so there's one pathway for acting on their accounts (Kira's).
220
- 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) } } : {}),
221
230
  canUseTool,
222
231
  hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
223
232
  ...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
@@ -7,15 +7,84 @@
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
- /** Read-only commands: looking, listing, testing, type-checking, building. Nothing that writes, sends or installs. */
15
- 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|$)/
34
+ /**
35
+ * Read-only commands: looking, listing, testing, type-checking, building. Nothing that writes, sends
36
+ * or installs, nothing that runs code given inline (node -e, python -c, awk), nothing that prints
37
+ * secrets (heroku config).
38
+ */
39
+ /** Plain commands that only look. */
40
+ const LOOKERS = [
41
+ 'ls', 'll', 'pwd', 'cat', 'head', 'tail', 'wc', 'echo', 'printf', 'which', 'type', 'env', 'printenv',
42
+ 'date', 'whoami', 'id', 'uname', 'tree', 'find', 'fd', 'grep', 'rg', 'ag', 'cut', 'tr', 'sort', 'uniq',
43
+ 'diff', 'comm', 'stat', 'file', 'du', 'df', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq',
44
+ 'xxd', 'hexdump', 'strings', 'test', '[', 'true', 'false', 'sleep', 'md5', 'shasum', 'sha256sum',
45
+ 'md5sum', 'column', 'nl', 'less', 'more', 'tsc', 'eslint', 'jest', 'vitest', 'pytest',
46
+ ]
47
+ /** Tools with their read-only, test and build subcommands. */
48
+ const SUBCOMMANDS = {
49
+ git: [
50
+ 'status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote', 'ls-files', 'ls-tree', 'blame', 'describe',
51
+ 'stash list', 'tag', 'config --get', 'config -l', 'cat-file', 'grep', 'shortlog', 'count-objects', 'reflog',
52
+ ],
53
+ node: ['-v', '--version'],
54
+ npm: ['-v', '--version', 'ls', 'list', 'view', 'outdated', 'why', 'test', 'run test', 'run lint', 'run typecheck',
55
+ 'run check', 'run build', 'run format:check'],
56
+ pnpm: ['-v', '--version', 'ls', 'list', 'why', 'outdated', 'test', 'typecheck', 'lint', 'build', 'run test',
57
+ 'run lint', 'run typecheck', 'run check', 'run build'],
58
+ yarn: ['-v', '--version', 'list', 'test', 'lint', 'typecheck', 'build'],
59
+ npx: ['tsc', 'eslint', 'prettier --check', 'jest', 'vitest', 'playwright test'],
60
+ prettier: ['--check'],
61
+ cargo: ['check', 'test', 'build', 'fmt --check', 'clippy', 'tree', 'metadata'],
62
+ go: ['test', 'build', 'vet', 'version', 'list', 'env'],
63
+ python: ['-V', '--version', '-m pytest'],
64
+ python3: ['-V', '--version', '-m pytest'],
65
+ ruby: ['-v'],
66
+ make: ['test', 'check', 'lint', 'build'],
67
+ mvn: ['test', 'compile'],
68
+ gradle: ['test', 'build'],
69
+ swift: ['build', 'test'],
70
+ xcodebuild: ['-list'],
71
+ brew: ['list', 'info', '--version'],
72
+ docker: ['ps', 'images', 'version'],
73
+ gh: ['pr list', 'pr view', 'pr status', 'pr checks', 'issue list', 'issue view', 'run list', 'run view', 'repo view'],
74
+ heroku: ['logs', 'ps', 'releases', 'apps', 'info'],
75
+ open: ['-n --args'],
76
+ sed: ['-n', '-E -n', '-n -E'],
77
+ }
78
+ const escape = w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
79
+ const withSubs = Object.entries(SUBCOMMANDS).flatMap(([tool, subs]) => subs.map(sub => `${tool} ${sub}`))
80
+ const SAFE_PHRASES = [...LOOKERS, ...withSubs]
81
+ const SAFE_CMD = new RegExp(`^(?:${SAFE_PHRASES.map(escape).join('|')})(?:\\s|$)`)
16
82
 
17
83
  /** Redirections that write somewhere other than nowhere. */
18
84
  const WRITES = /(^|[^&\d])>(?!&)|>>|\btee\b|\bxargs\b|\beval\b|\bexec\b|`/
85
+ /** find and fd can run or delete what they find: then they aren't looking any more. */
86
+ const FINDER_ACTS =
87
+ /^(?:find|fd)\b.*\s(?:-exec(?:dir)?|-ok(?:dir)?|-delete|-fprint\w*|-fls|-x|--exec(?:-batch)?|-X)\b/
19
88
 
20
89
  function pieces(command) {
21
90
  // Strip quoted strings so separators inside them do not split; keep a placeholder.
@@ -56,7 +125,8 @@ export function shellIsSafe(command, allowRules = []) {
56
125
  c = c.slice(0, m.index) + '""' + c.slice(m.index + m[0].length)
57
126
  }
58
127
  if (WRITES.test(c.replace(/2>&1|2>\/dev\/null|>\/dev\/null|&>\/dev\/null/g, ''))) return false
59
- return pieces(c).every(p => SAFE_CMD.test(p) || allowRules.some(r => r && (p === r || p.startsWith(String(r) + ' '))))
128
+ const allowedByRule = p => allowRules.some(r => r && (p === r || p.startsWith(String(r) + ' ')))
129
+ return pieces(c).every(p => (SAFE_CMD.test(p) && !FINDER_ACTS.test(p)) || allowedByRule(p))
60
130
  }
61
131
 
62
132
  /** The rule key an "always allow" answer produces. */
@@ -80,6 +150,9 @@ export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
80
150
  if (matchesRule(p.askRules ?? [], toolName, command)) return { decision: 'ask', reason: 'your policy always asks for this' }
81
151
  if (matchesRule([...(p.allowRules ?? []), ...sessionRules], toolName, command)) return { decision: 'allow', reason: 'allowed by your policy' }
82
152
  if (READ_ONLY.has(toolName)) return { decision: 'allow', reason: 'read-only' }
153
+ // Kira's own tools (her MCP server, attached when Claude is the person's Kira): her server asks
154
+ // for itself when something needs their yes (approvals), so the Mac doesn't ask a second time.
155
+ if (toolName.startsWith(KIRA_TOOLS)) return { decision: 'allow', reason: 'Kira’s own tool' }
83
156
  if (EDITS.has(toolName)) {
84
157
  const file = input?.file_path ?? input?.notebook_path
85
158
  if (insideFolder(cwd, file)) return { decision: p.edits === 'ask' ? 'ask' : 'allow', reason: 'edit inside the project' }
@@ -91,7 +164,7 @@ export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
91
164
  return { decision: 'ask', reason: 'shell command' }
92
165
  }
93
166
  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' }
167
+ if (DELEGATE.has(toolName)) return { decision: p.delegate === 'ask' ? 'ask' : 'allow', reason: 'subagent' }
95
168
  if (toolName === 'ExitPlanMode') return { decision: p.plan === 'allow' ? 'allow' : 'ask', reason: 'plan approval' }
96
169
  return { decision: 'ask', reason: 'unfamiliar tool' }
97
170
  }
@@ -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
+ }
@@ -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
@@ -143,40 +141,30 @@ const tools = {
143
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.` }
144
142
  }
145
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)}`)
146
- const before = await snapshot(dir).catch(() => null)
147
144
  let seq = 0
148
145
  const io = {
149
146
  emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
150
- ask: (request, signal) =>
151
- new Promise((resolve, reject) => {
152
- const key = `${jobId}:${request.id}`
153
- const timer = setTimeout(() => {
154
- answerWaits.delete(key)
155
- reject(new Error('No answer within 3 minutes'))
156
- }, ANSWER_TIMEOUT_MS)
157
- answerWaits.set(key, resolved => {
158
- clearTimeout(timer)
159
- resolve(resolved)
160
- })
161
- signal?.addEventListener('abort', () => {
162
- clearTimeout(timer)
163
- answerWaits.delete(key)
164
- reject(new Error('The job was stopped'))
165
- }, { once: true })
166
- }),
147
+ ask: (request, signal) => awaitAnswer(jobId, request, signal),
167
148
  }
168
149
  const abort = new AbortController()
169
150
  running.set(jobId, abort)
170
- 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 => {
171
158
  running.delete(jobId)
172
- for (const key of [...answerWaits.keys()]) if (key.startsWith(`${jobId}:`)) answerWaits.delete(key)
159
+ forgetJob(jobId)
173
160
  let change = null
174
- 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))
175
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` : ''}`)
176
164
  const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
177
165
  sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
178
166
  })
179
- 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) }
180
168
  },
181
169
  /** Stop a running job: the agent is cut off; whatever it committed stays. */
182
170
  stop_job(args) {
@@ -223,34 +211,7 @@ end tell`
223
211
  return new Promise(resolve => execFile('osascript', ['-e', script], err => resolve(err ? { error: err.message } : { opened: true, cwd: display(dir), command })))
224
212
  },
225
213
 
226
- /** A worktree for one task attempt, off the project's checkout of the repo. */
227
- prepare_worktree(args) {
228
- return prepareWorktree(args)
229
- },
230
- worktree_status(args) {
231
- return worktreeStatus(args)
232
- },
233
- push_worktree(args) {
234
- return pushWorktree(args)
235
- },
236
- remove_worktree(args) {
237
- return removeWorktree(args)
238
- },
239
- /** A task's reference files, downloaded into the worktree's .kira/files for the agent. */
240
- stage_files(args) {
241
- return stageFiles(args)
242
- },
243
- /** What a designer or researcher produced under its output folder, and sending it to Kira's storage. */
244
- list_outputs(args) {
245
- return listOutputs(args)
246
- },
247
- upload_outputs(args) {
248
- return uploadOutputs(args)
249
- },
250
- /** Which GitHub repo a folder's origin points at (project link enrichment). */
251
- async repo_of_folder(args) {
252
- return { repo: await repoOfFolder(args.path) }
253
- },
214
+ ...gitTools,
254
215
 
255
216
  /** Which Claude account Claude Code on this Mac is signed in to (`claude auth status --json`). */
256
217
  async claude_account() {
@@ -268,12 +229,7 @@ end tell`
268
229
  )
269
230
  },
270
231
 
271
- /** Read-only git facts for a folder: repo, branch, head, dirty files. */
272
- async git_status(args) {
273
- const s = await snapshot(resolveCwd(args.path ?? args.cwd))
274
- if (!s) return { error: 'not a git repository' }
275
- 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 }
276
- },
232
+
277
233
  }
278
234
 
279
235
  /**
@@ -329,11 +285,8 @@ function connect() {
329
285
  return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
330
286
  }
331
287
  if (msg.type === 'job.answer') {
332
- const key = `${msg.jobId}:${msg.requestId}`
333
- const resolve = answerWaits.get(key)
334
- if (resolve) {
335
- answerWaits.delete(key)
336
- 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 })) {
337
290
  log(`request ${msg.requestId} for job ${String(msg.jobId).slice(0, 8)} answered by ${msg.by ?? 'portal'}`)
338
291
  }
339
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
+ }