kira-glasses-bridge 2.15.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 +16 -7
- package/src/agents/policy.mjs +25 -2
- package/src/agents/settings.mjs +23 -0
- package/src/git-tools.mjs +51 -0
- package/src/git.mjs +1 -1
- package/src/index.mjs +18 -65
- 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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
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 } : {}),
|
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
|
+
}
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
159
|
+
forgetJob(jobId)
|
|
173
160
|
let change = null
|
|
174
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
333
|
-
|
|
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
|
+
}
|