kira-glasses-bridge 2.24.0 → 2.26.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/README.md CHANGED
@@ -4,7 +4,7 @@ Connects your Mac to **Kira**, the assistant for Even Realities G2 glasses. With
4
4
 
5
5
  ## Setup
6
6
 
7
- 1. In the Kira portal, open **Connections → Claude Code → Set up** and copy the command it shows.
7
+ 1. In the Kira portal, open **Settings → Devices** and choose **Set up with Terminal**, then copy the command it shows.
8
8
  2. Paste it into Terminal:
9
9
 
10
10
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.24.0",
3
+ "version": "2.26.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": {
@@ -12,6 +12,7 @@ import {
12
12
  exists,
13
13
  field,
14
14
  inside,
15
+ oneAtATime,
15
16
  readSmall,
16
17
  refuseLinks,
17
18
  splitFront,
@@ -19,8 +20,13 @@ import {
19
20
  } from './config-folders.mjs'
20
21
 
21
22
  const MAX_MD = 256 * 1024
22
- /** Claude Code loads the first 200 lines or 25KB of MEMORY.md; Kira keeps it to this. */
23
+ /** A MEMORY.md Kira reads or replaces whole (from the agent's editor) is at most this. */
23
24
  const MAX_MEMORY = 64 * 1024
25
+ /** Claude Code loads the first 200 lines or 25KB of MEMORY.md: what Kira adds stays inside that. */
26
+ const LOAD_LINES = 200
27
+ const LOAD_BYTES = 25 * 1024
28
+ /** A line Kira added after a task (rememberAgent): "- 2026-10-12: …". The only lines she ever drops. */
29
+ const KIRAS_LINE = /^- \d{4}-\d{2}-\d{2}: /
24
30
  const WHAT = 'a subagent'
25
31
  const NAMED = { loose: true }
26
32
  const claudeRoot = () => agentFolders()[0].root
@@ -113,19 +119,45 @@ export async function removeAgent({ slug } = {}, root = claudeRoot()) {
113
119
  /** Replace a subagent's MEMORY.md, whole (`name` is its front-matter name). */
114
120
  export async function writeAgentMemory({ name, md } = {}, root = FOLDERS.claude.agentMemory()) {
115
121
  const file = memoryFile(name, root)
116
- const text = String(md ?? '')
122
+ return oneAtATime(file, () => writeMemoryFile(name, file, String(md ?? ''), root))
123
+ }
124
+
125
+ async function writeMemoryFile(name, file, text, root) {
117
126
  if (Buffer.byteLength(text) > MAX_MEMORY) throw new Error('That memory is too large')
118
127
  await refuseLinks([root, join(file, '..'), file], 'edit')
119
128
  await writeWhole(file, text)
120
129
  return { name, path: display(file), memory: text }
121
130
  }
122
131
 
123
- /** Add a line to a subagent's MEMORY.md (what Kira keeps after a task); the oldest go when it's full. */
132
+ /**
133
+ * MEMORY.md with `entry` added at the end, kept inside what Claude Code loads (200 lines, 25KB) by
134
+ * dropping Kira's own oldest dated lines, never the person's. Null when it can't fit without
135
+ * dropping theirs (then it isn't added: past the window Claude Code wouldn't read it anyway).
136
+ */
137
+ export function withEntry(text, entry) {
138
+ const lines = text.trimEnd() ? text.trimEnd().split('\n') : []
139
+ lines.push(entry)
140
+ const fits = () => lines.length <= LOAD_LINES && Buffer.byteLength(`${lines.join('\n')}\n`) <= LOAD_BYTES
141
+ while (!fits()) {
142
+ const oldest = lines.findIndex((l, i) => i < lines.length - 1 && KIRAS_LINE.test(l))
143
+ if (oldest < 0) return null
144
+ lines.splice(oldest, 1)
145
+ }
146
+ return `${lines.join('\n')}\n`
147
+ }
148
+
149
+ /** Add a line to a subagent's MEMORY.md (what Kira keeps after a task); see withEntry. */
124
150
  export async function rememberAgent({ name, line } = {}, root = FOLDERS.claude.agentMemory()) {
151
+ const file = memoryFile(name, root)
125
152
  const entry = `- ${String(line ?? '').replace(/\s+/g, ' ').trim().slice(0, 400)}`
126
- let text = `${(await readMemory(name, root)).trimEnd()}\n${entry}`.trim() + '\n'
127
- while (Buffer.byteLength(text) > MAX_MEMORY && text.includes('\n')) text = text.slice(text.indexOf('\n') + 1)
128
- return writeAgentMemory({ name, md: text }, root)
153
+ return oneAtATime(file, async () => {
154
+ const was = await readSmall(file, MAX_MEMORY)
155
+ // One there that's too big to read is theirs, whole: left as it is.
156
+ if (was === null && (await exists(file))) return { name, path: display(file), memory: null, full: true }
157
+ const text = withEntry(was ?? '', entry)
158
+ if (text === null) return { name, path: display(file), memory: was, full: true }
159
+ return writeMemoryFile(name, file, text, root)
160
+ })
129
161
  }
130
162
 
131
163
  /** The bridge tools (index.mjs). */
@@ -0,0 +1,61 @@
1
+ // Kira decides each tool call a job's agent wants to make: the bridge asks the API (job.check), which
2
+ // answers from the person's rules (Settings › Permissions) with allow, ask, deny, or mode: no rule
3
+ // decided, so Claude Code's own permission mode does. An API too old to check never says it does,
4
+ // and its jobs are left to Claude Code's mode (asking before edits, settings.mjs); one that checks
5
+ // but can't be reached refuses.
6
+ import { homedir } from 'node:os'
7
+
8
+ const CHECK_TIMEOUT_MS = 30_000
9
+ const RESEND_MS = 1000
10
+ const MODE = { effect: 'mode', why: null, rule: null, suggest: '' }
11
+ /** What a call is checked on; the rest (a file's whole new text) stays on the Mac. */
12
+ const FIELDS = ['command', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'subagent_type']
13
+
14
+ let checking = false
15
+ let seq = 0
16
+
17
+ /** Waiting checks by id: the message, to send again after a reconnect, and who's waiting. */
18
+ const waits = new Map()
19
+
20
+ /** The API said hello: whether it decides tool calls. A check sent just before a disconnect is sent again. */
21
+ export function kiraChecks(on, send) {
22
+ checking = !!on
23
+ if (checking) for (const w of waits.values()) send(w.msg)
24
+ }
25
+
26
+ /** Whether Kira decides each tool call (else Claude Code's mode does, asking before edits). */
27
+ export const kiraDecides = () => checking
28
+
29
+ /**
30
+ * Ask Kira about one call. `send` delivers a message now and says whether it could; while the
31
+ * connection is down (an API deploy) it's tried again until the answer is overdue.
32
+ */
33
+ export function checkCall(jobId, { tool, input, cwd }, send) {
34
+ if (!checking) return Promise.resolve(MODE)
35
+ const id = `c${++seq}-${Date.now().toString(36)}`
36
+ const fields = Object.fromEntries(FIELDS.filter(f => typeof input?.[f] === 'string').map(f => [f, input[f]]))
37
+ const msg = { type: 'job.check', jobId, id, call: { tool, input: fields, cwd, home: homedir() } }
38
+ return new Promise(resolve => {
39
+ let resend = null
40
+ const done = decision => {
41
+ clearTimeout(timer)
42
+ clearTimeout(resend)
43
+ waits.delete(id)
44
+ resolve(decision)
45
+ }
46
+ const unreachable = { effect: 'deny', why: 'Kira could not be reached to check this, so it was not run.' }
47
+ const timer = setTimeout(() => done({ ...unreachable, rule: null, suggest: '' }), CHECK_TIMEOUT_MS)
48
+ timer.unref?.()
49
+ waits.set(id, { msg, done })
50
+ const attempt = () => {
51
+ if (!send(msg)) resend = setTimeout(attempt, RESEND_MS)
52
+ }
53
+ attempt()
54
+ })
55
+ }
56
+
57
+ /** Kira's answer to a check. */
58
+ export function decided(msg) {
59
+ const done = waits.get(String(msg.id))?.done
60
+ if (done) done({ effect: msg.effect, why: msg.why ?? null, rule: msg.rule ?? null, suggest: msg.suggest ?? '' })
61
+ }
@@ -0,0 +1,150 @@
1
+ // Claude Code's tool calls in Kira's agent protocol (protocol.mjs): each one as an action the job
2
+ // shows, as a request when a person has to decide, and the person's answer back as what the Agent
3
+ // SDK expects.
4
+ import { homedir } from 'node:os'
5
+ import { relative, resolve, sep } from 'node:path'
6
+ import { PLAN_OPTIONS, permissionOptions, requestId } from './protocol.mjs'
7
+
8
+ /** What an agent is told when it may not do something. */
9
+ export const NOT_ANOTHER_WAY =
10
+ 'Do not achieve the same effect another way; if the task cannot proceed without it, ' +
11
+ 'stop and say what you could not do.'
12
+
13
+ export const TEXT_CAP = 4000
14
+ const DIFF_CAP = 6000
15
+
16
+ export const short = (s, n = 160) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''))
17
+
18
+ export function displayPath(p, cwd) {
19
+ if (!p) return ''
20
+ const rel = relative(cwd, resolve(cwd, p))
21
+ if (rel && !rel.startsWith('..') && !rel.startsWith(sep)) return rel
22
+ const home = relative(homedir(), resolve(cwd, p))
23
+ return home && !home.startsWith('..') ? `~/${home}` : p
24
+ }
25
+
26
+ /** Claude's tool call as a protocol action. */
27
+ export function describeAction(name, input, cwd) {
28
+ const i = input ?? {}
29
+ const act = (type, label, target, more = {}) => ({ type, label, target, ...more })
30
+ const path = p => displayPath(p, cwd)
31
+ const cut = (old, now, n) => ({ old: short(old, n), new: short(now, n) })
32
+ switch (name) {
33
+ case 'Read':
34
+ return act('read', 'Read', path(i.file_path))
35
+ case 'Glob':
36
+ return act('search', 'Find', i.pattern)
37
+ case 'Grep':
38
+ return act('search', 'Search', `${i.pattern}${i.path ? ` in ${path(i.path)}` : ''}`)
39
+ case 'LS':
40
+ return act('read', 'List', path(i.path) || '.')
41
+ case 'Edit': {
42
+ const edit = { ...cut(i.old_string, i.new_string, DIFF_CAP), all: !!i.replace_all }
43
+ return act('edit', 'Edit', path(i.file_path), { detail: { edit } })
44
+ }
45
+ case 'MultiEdit': {
46
+ const edits = (i.edits ?? []).slice(0, 20).map(e => cut(e.old_string, e.new_string, 1500))
47
+ return act('edit', 'Edit', path(i.file_path), { detail: { edits } })
48
+ }
49
+ case 'Write':
50
+ return act('write', 'Write', path(i.file_path), { detail: { content: short(i.content, DIFF_CAP) } })
51
+ case 'NotebookEdit':
52
+ return act('edit', 'Edit notebook', path(i.notebook_path))
53
+ case 'Bash': {
54
+ const note = i.description ? short(i.description, 120) : undefined
55
+ return act('run', 'Run', short(i.command, 400), { note, detail: { command: short(i.command, 2000) } })
56
+ }
57
+ case 'WebFetch':
58
+ return act('fetch', 'Fetch', short(i.url, 200))
59
+ case 'WebSearch':
60
+ return act('fetch', 'Search the web', short(i.query, 160))
61
+ case 'Task':
62
+ return act('delegate', 'Subtask', short(i.description ?? i.prompt, 160))
63
+ case 'TodoWrite':
64
+ return act('plan', 'Plan', `${(i.todos ?? []).length} steps`)
65
+ case 'ExitPlanMode':
66
+ return act('plan', 'Plan ready', 'asks to go ahead')
67
+ case 'AskUserQuestion':
68
+ return act('other', 'Question', short((i.questions ?? []).map(q => q.question).join(' / '), 200))
69
+ default:
70
+ return act('other', name, short(JSON.stringify(i), 200))
71
+ }
72
+ }
73
+
74
+ /** Claude's tool call as a request, when a person has to decide; `verdict`: what Kira's rules said about it. */
75
+ export function toRequest(name, input, cwd, verdict) {
76
+ const act = describeAction(name, input, cwd)
77
+ if (name === 'AskUserQuestion') {
78
+ const questions = (input.questions ?? []).map((q, n) => ({
79
+ id: `q${n + 1}`,
80
+ question: q.question,
81
+ header: q.header,
82
+ multi: !!q.multiSelect,
83
+ options: (q.options ?? []).map((o, k) => ({ id: `o${k + 1}`, label: o.label, description: o.description })),
84
+ }))
85
+ const first = questions[0]
86
+ return {
87
+ id: requestId(),
88
+ type: 'choice',
89
+ title: questions.length === 1 ? first.question : `${questions.length} questions`,
90
+ options: first?.options ?? [],
91
+ multi: !!first?.multi,
92
+ allowText: true,
93
+ questions,
94
+ action: { tool: name, label: act.label, target: act.target },
95
+ }
96
+ }
97
+ if (name === 'ExitPlanMode') {
98
+ return {
99
+ id: requestId(),
100
+ type: 'plan',
101
+ title: 'Claude has a plan and asks to go ahead',
102
+ detail: short(input.plan ?? '', 6000),
103
+ options: PLAN_OPTIONS,
104
+ allowText: true,
105
+ action: { tool: name, label: act.label, target: act.target },
106
+ }
107
+ }
108
+ const edit = act.detail?.edit
109
+ const detail = act.detail?.command ?? act.detail?.content ?? (edit ? `- ${edit.old}\n+ ${edit.new}` : undefined)
110
+ return {
111
+ id: requestId(),
112
+ type: 'permission',
113
+ title: `Claude wants to ${act.label.toLowerCase()} ${act.target}`.trim(),
114
+ detail: detail ? short(detail, 2000) : undefined,
115
+ options: permissionOptions(verdict?.suggest),
116
+ // "No, and tell Claude what to do differently", as Claude Code asks.
117
+ allowText: true,
118
+ action: { tool: name, label: act.label, target: act.target },
119
+ ...(verdict?.suggest ? { rule: verdict.suggest } : {}),
120
+ ...(verdict?.why ? { why: verdict.why } : {}),
121
+ }
122
+ }
123
+
124
+ /** Turn a protocol answer back into what the SDK expects. */
125
+ export function applyAnswer(name, input, request, answer) {
126
+ const chosen = answer.optionIds ?? []
127
+ if (request.type === 'choice') {
128
+ const answers = {}
129
+ for (const q of request.questions ?? []) {
130
+ const per = answer.answers?.[q.id]
131
+ const ids = per?.optionIds ?? (q.id === 'q1' ? chosen : [])
132
+ const labels = ids.map(id => q.options.find(o => o.id === id)?.label).filter(Boolean)
133
+ const text = per?.text ?? (q.id === 'q1' ? answer.text : undefined)
134
+ answers[q.question] = labels.length ? labels.join(', ') : text ?? ''
135
+ }
136
+ const filled = Object.values(answers).some(Boolean)
137
+ const message = 'The user did not answer the question. Stop and say what you need to know.'
138
+ if (!filled) return { behavior: 'deny', message }
139
+ return { behavior: 'allow', updatedInput: { ...input, answers, ...(answer.text ? { response: answer.text } : {}) } }
140
+ }
141
+ if (request.type === 'plan') {
142
+ if (chosen.includes('approve')) return { behavior: 'allow', updatedInput: input }
143
+ const revise = 'The user asked you to revise the plan before going ahead.'
144
+ return { behavior: 'deny', message: answer.text ? `The user wants the plan changed: ${answer.text}` : revise }
145
+ }
146
+ // "Don't ask again" is kept by Kira as a rule of theirs (the request carries it), not by this session.
147
+ if (chosen.includes('always') || chosen.includes('allow')) return { behavior: 'allow', updatedInput: input }
148
+ if (answer.text) return { behavior: 'deny', message: `The user said no, and to do this instead: ${answer.text}` }
149
+ return { behavior: 'deny', message: `The user said no. ${NOT_ANOTHER_WAY}` }
150
+ }
@@ -1,6 +1,8 @@
1
- // Claude Code on this Mac, as the person signed in to it: which account it uses, and one piece
2
- // of text written with that sign-in (complete.mjs). The bridge's calls for both.
1
+ // Claude Code on this Mac, as the person signed in to it: which account it uses, the permission
2
+ // rules in their own settings, and one piece of text written with that sign-in (complete.mjs).
3
3
  import { execFile } from 'node:child_process'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { FOLDERS } from '../config-folders.mjs'
4
6
  import { completeText } from './complete.mjs'
5
7
 
6
8
  /** Which Claude account Claude Code on this Mac is signed in to (`claude auth status --json`). */
@@ -26,4 +28,26 @@ async function claudeAccount() {
26
28
  )
27
29
  }
28
30
 
29
- export const claudeTools = { claude_account: claudeAccount, complete_text: completeText }
31
+ /** The permissions block of a Claude Code settings file, as written there (nothing when it has none). */
32
+ export function permissionsIn(settings) {
33
+ const p = settings?.permissions ?? {}
34
+ const rules = v => (Array.isArray(v) ? v.filter(r => typeof r === 'string').slice(0, 200) : [])
35
+ const defaultMode = typeof p.defaultMode === 'string' ? p.defaultMode : null
36
+ return { allow: rules(p.allow), ask: rules(p.ask), deny: rules(p.deny), defaultMode }
37
+ }
38
+
39
+ /** The rules in ~/.claude/settings.json, which Claude Code applies to Kira's jobs as well. */
40
+ async function claudePermissions() {
41
+ const text = await readFile(FOLDERS.claude.settings(), 'utf8').catch(() => '{}')
42
+ try {
43
+ return permissionsIn(JSON.parse(text))
44
+ } catch {
45
+ return permissionsIn({})
46
+ }
47
+ }
48
+
49
+ export const claudeTools = {
50
+ claude_account: claudeAccount,
51
+ claude_permissions: claudePermissions,
52
+ complete_text: completeText,
53
+ }
@@ -1,191 +1,93 @@
1
1
  // Claude Code as an agent, through the Agent SDK. Runs in this process on the
2
2
  // user's Mac with their own Claude login; files never leave the machine.
3
3
  import { query } from '@anthropic-ai/claude-agent-sdk'
4
- import { homedir } from 'node:os'
5
- import { relative, resolve, sep } from 'node:path'
6
- import { PERMISSION_OPTIONS, PLAN_OPTIONS, requestId } from './protocol.mjs'
7
- import { decide, ruleFor } from './policy.mjs'
8
- import { policyFor, sdkOptionsFor } from './settings.mjs'
9
-
10
- const TEXT_CAP = 4000
11
- const DIFF_CAP = 6000
12
-
13
- const short = (s, n = 160) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''))
14
-
15
- function displayPath(p, cwd) {
16
- if (!p) return ''
17
- const rel = relative(cwd, resolve(cwd, p))
18
- if (rel && !rel.startsWith('..') && !rel.startsWith(sep)) return rel
19
- const home = relative(homedir(), resolve(cwd, p))
20
- return home && !home.startsWith('..') ? `~/${home}` : p
21
- }
22
- const inside = (cwd, p) => {
23
- const rel = relative(cwd, resolve(cwd, String(p ?? '')))
24
- return !!rel && !rel.startsWith('..') && !rel.startsWith(sep)
25
- }
4
+ import { resolve, sep } from 'node:path'
5
+ import {
6
+ NOT_ANOTHER_WAY,
7
+ TEXT_CAP,
8
+ applyAnswer,
9
+ describeAction,
10
+ displayPath,
11
+ short,
12
+ toRequest,
13
+ } from './claude-calls.mjs'
14
+ import { kiraDecides } from './checks.mjs'
15
+ import { sdkOptionsFor } from './settings.mjs'
26
16
 
27
17
  /** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
28
18
  const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
29
19
 
30
- /** Claude's tool call as a protocol action. */
31
- function describeAction(name, input, cwd) {
32
- const i = input ?? {}
33
- switch (name) {
34
- case 'Read':
35
- return { type: 'read', label: 'Read', target: displayPath(i.file_path, cwd) }
36
- case 'Glob':
37
- return { type: 'search', label: 'Find', target: i.pattern }
38
- case 'Grep':
39
- return { type: 'search', label: 'Search', target: `${i.pattern}${i.path ? ` in ${displayPath(i.path, cwd)}` : ''}` }
40
- case 'LS':
41
- return { type: 'read', label: 'List', target: displayPath(i.path, cwd) || '.' }
42
- case 'Edit':
43
- return { type: 'edit', label: 'Edit', target: displayPath(i.file_path, cwd), detail: { edit: { old: short(i.old_string, DIFF_CAP), new: short(i.new_string, DIFF_CAP), all: !!i.replace_all } } }
44
- case 'MultiEdit':
45
- return { type: 'edit', label: 'Edit', target: displayPath(i.file_path, cwd), detail: { edits: (i.edits ?? []).slice(0, 20).map(e => ({ old: short(e.old_string, 1500), new: short(e.new_string, 1500) })) } }
46
- case 'Write':
47
- return { type: 'write', label: 'Write', target: displayPath(i.file_path, cwd), detail: { content: short(i.content, DIFF_CAP) } }
48
- case 'NotebookEdit':
49
- return { type: 'edit', label: 'Edit notebook', target: displayPath(i.notebook_path, cwd) }
50
- case 'Bash':
51
- return { type: 'run', label: 'Run', target: short(i.command, 400), note: i.description ? short(i.description, 120) : undefined, detail: { command: short(i.command, 2000) } }
52
- case 'WebFetch':
53
- return { type: 'fetch', label: 'Fetch', target: short(i.url, 200) }
54
- case 'WebSearch':
55
- return { type: 'fetch', label: 'Search the web', target: short(i.query, 160) }
56
- case 'Task':
57
- return { type: 'delegate', label: 'Subtask', target: short(i.description ?? i.prompt, 160) }
58
- case 'TodoWrite':
59
- return { type: 'plan', label: 'Plan', target: `${(i.todos ?? []).length} steps` }
60
- case 'ExitPlanMode':
61
- return { type: 'plan', label: 'Plan ready', target: 'asks to go ahead' }
62
- case 'AskUserQuestion':
63
- return { type: 'other', label: 'Question', target: short((i.questions ?? []).map(q => q.question).join(' / '), 200) }
64
- default:
65
- return { type: 'other', label: name, target: short(JSON.stringify(i), 200) }
66
- }
67
- }
68
-
69
- /** Claude's tool call as a request, when a person has to decide. */
70
- function toRequest(name, input, cwd, suggestions) {
71
- const act = describeAction(name, input, cwd)
72
- if (name === 'AskUserQuestion') {
73
- const questions = (input.questions ?? []).map((q, n) => ({
74
- id: `q${n + 1}`,
75
- question: q.question,
76
- header: q.header,
77
- multi: !!q.multiSelect,
78
- options: (q.options ?? []).map((o, k) => ({ id: `o${k + 1}`, label: o.label, description: o.description })),
79
- }))
80
- const first = questions[0]
81
- return {
82
- id: requestId(),
83
- type: 'choice',
84
- title: questions.length === 1 ? first.question : `${questions.length} questions`,
85
- options: first?.options ?? [],
86
- multi: !!first?.multi,
87
- allowText: true,
88
- questions,
89
- action: { tool: name, label: act.label, target: act.target },
90
- }
91
- }
92
- if (name === 'ExitPlanMode') {
93
- return { id: requestId(), type: 'plan', title: 'Claude has a plan and asks to go ahead', detail: short(input.plan ?? '', 6000), options: PLAN_OPTIONS, allowText: true, action: { tool: name, label: act.label, target: act.target } }
94
- }
95
- const detail = act.detail?.command ?? act.detail?.content ?? (act.detail?.edit ? `- ${act.detail.edit.old}\n+ ${act.detail.edit.new}` : undefined)
96
- return {
97
- id: requestId(),
98
- type: 'permission',
99
- title: `Claude wants to ${act.label.toLowerCase()} ${act.target}`.trim(),
100
- detail: detail ? short(detail, 2000) : undefined,
101
- options: PERMISSION_OPTIONS,
102
- allowText: false,
103
- action: { tool: name, label: act.label, target: act.target },
104
- }
105
- }
106
-
107
- /** Turn a protocol answer back into what the SDK expects. */
108
- function applyAnswer(name, input, request, answer, suggestions) {
109
- const chosen = answer.optionIds ?? []
110
- if (request.type === 'choice') {
111
- const answers = {}
112
- for (const q of request.questions ?? []) {
113
- const per = answer.answers?.[q.id]
114
- const ids = per?.optionIds ?? (q.id === 'q1' ? chosen : [])
115
- const labels = ids.map(id => q.options.find(o => o.id === id)?.label).filter(Boolean)
116
- const text = per?.text ?? (q.id === 'q1' ? answer.text : undefined)
117
- answers[q.question] = labels.length ? labels.join(', ') : text ?? ''
118
- }
119
- const filled = Object.values(answers).some(Boolean)
120
- if (!filled) return { behavior: 'deny', message: 'The user did not answer the question. Stop and say what you need to know.' }
121
- return { behavior: 'allow', updatedInput: { ...input, answers, ...(answer.text ? { response: answer.text } : {}) } }
122
- }
123
- if (request.type === 'plan') {
124
- if (chosen.includes('approve')) return { behavior: 'allow', updatedInput: input }
125
- return { behavior: 'deny', message: answer.text ? `The user wants the plan changed: ${answer.text}` : 'The user asked you to revise the plan before going ahead.' }
126
- }
127
- if (chosen.includes('always') || chosen.includes('allow')) return { behavior: 'allow', updatedInput: input, ...(chosen.includes('always') && suggestions?.length ? { updatedPermissions: suggestions } : {}) }
128
- return { behavior: 'deny', message: `The user declined this${answer.text ? ` ("${answer.text}")` : ''}. Do not achieve the same effect another way; if the task cannot proceed without it, stop and say what you could not do.` }
129
- }
130
-
131
20
  /** Claude Code's own prompt, with what Kira adds for this run after it. */
132
21
  function systemPromptFor(persona) {
133
22
  return { type: 'preset', preset: 'claude_code', ...(persona.systemPrompt ? { append: persona.systemPrompt } : {}) }
134
23
  }
135
24
 
25
+ /** The pre-tool hook's answer: go ahead, ask, or refuse, and why. */
26
+ const hookAnswer = (permissionDecision, permissionDecisionReason) => ({
27
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision, permissionDecisionReason },
28
+ })
29
+
30
+ /** Questions for the person, not permissions: they always reach them, whatever the rules say. */
31
+ const QUESTIONS = new Set(['AskUserQuestion', 'ExitPlanMode'])
32
+
136
33
  /**
137
34
  * Run one instruction in a Claude session.
138
35
  * @param io.emit(event) a protocol event
139
36
  * @param io.ask(request) resolves to { answer, by } when a person answers (the caller times it out)
37
+ * @param io.check(call) what Kira's rules say about a tool call (checks.mjs)
140
38
  * @returns { ok, text, error, sessionId, stats }
141
39
  */
142
- /** "Always allow" answers, kept for the rest of the process per session: sessionId -> Set of rules. */
143
- const sessionRules = new Map()
144
-
145
- async function run({ prompt, sessionId, cwd, policy, persona, settings, abort }, io) {
40
+ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
146
41
  let session = sessionId ?? null
147
42
  let text = ''
148
43
  let error = null
149
- const rulesFor = () => (session && sessionRules.get(session)) || new Set()
150
- // The composer's mode: "Ask" makes the policy ask for edits and commands (settings.mjs).
151
- const ruled = policyFor(policy, settings)
152
- const judge = (toolName, input) => decide(ruled, toolName, input ?? {}, cwd, rulesFor())
44
+ /** What Kira's rules said about each tool call, by its id, for the question if Claude Code asks. */
45
+ const verdicts = new Map()
153
46
 
154
- // Pre-tool hook: the policy is authoritative. It forces the prompt for what
155
- // it does not allow (so a permissive local config cannot bypass the person)
156
- // and refuses outright what it denies.
157
47
  // A member with a restricted shell (a designer, a researcher) runs only what its preset allows.
158
48
  const shellAllow = persona?.shellAllow ? new RegExp(persona.shellAllow) : null
159
49
  // A member that only produces artifacts (a designer, a researcher) writes nowhere but its output folders.
160
50
  const within = (persona?.writeWithin ?? []).map(d => resolve(cwd, d))
161
51
  const writeTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
162
- const preToolUse = async input => {
52
+ /** Refused before it runs: the job shows why, and Claude is told (`told`, when it needs more). */
53
+ const refuse = (tool, reason, told = reason) => {
54
+ const answer = { optionIds: ['deny'] }
55
+ io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer, by: 'policy', tool, reason })
56
+ return hookAnswer('deny', told)
57
+ }
58
+ // Pre-tool hook: the member's limits, then Kira's rules (io.check asks the API). Allow and deny
59
+ // are final; ask forces the question even where the person's own Claude Code settings would allow
60
+ // it; a call no rule decides is left to Claude Code's permission mode, which asks through canUseTool.
61
+ const preToolUse = async (input, toolUseID) => {
163
62
  if (input.hook_event_name !== 'PreToolUse') return {}
164
63
  if (within.length && writeTools.has(input.tool_name)) {
165
64
  const target = resolve(cwd, String(input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? ''))
166
65
  if (!within.some(dir => target === dir || target.startsWith(dir + sep))) {
167
- const reason = `${persona.name} only writes under ${persona.writeWithin.join(', ')}; ${displayPath(target, cwd)} is off limits. Describe what an engineer should change in your spec instead.`
168
- io.emit({ kind: 'request_resolved', id: `persona-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: input.tool_name, reason })
169
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
66
+ const where = `${persona.writeWithin.join(', ')}; ${displayPath(target, cwd)} is off limits`
67
+ const instead = 'Describe what an engineer should change in your spec instead.'
68
+ return refuse(input.tool_name, `${persona.name} only writes under ${where}. ${instead}`)
170
69
  }
171
70
  }
172
71
  // Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
173
72
  const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
174
73
  if (shellAllow && input.tool_name === 'Bash' && !segments(input.tool_input?.command).every(seg => shellAllow.test(seg))) {
175
- const reason = `${persona.name} uses the shell only for git and for reading files; run that another way or leave it.`
176
- io.emit({ kind: 'request_resolved', id: `persona-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: 'Bash', reason })
177
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
74
+ const only = 'uses the shell only for git and for reading files; run that another way or leave it.'
75
+ return refuse('Bash', `${persona.name} ${only}`)
178
76
  }
179
- const { decision, reason } = judge(input.tool_name, input.tool_input)
180
- if (decision === 'deny') io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer: { optionIds: ['deny'] }, by: 'policy', tool: input.tool_name, reason })
181
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `Kira: ${reason}` } }
77
+ if (QUESTIONS.has(input.tool_name)) return {}
78
+ const verdict = await io.check({ tool: input.tool_name, input: input.tool_input ?? {}, cwd })
79
+ // Kept only for a question that may follow: an ask, or Claude Code's own mode asking.
80
+ if (toolUseID && (verdict.effect === 'ask' || verdict.effect === 'mode')) verdicts.set(toolUseID, verdict)
81
+ if (verdict.effect === 'mode') return {}
82
+ const reason = `Kira: ${verdict.why ?? 'your rules'}${verdict.rule ? ` (${verdict.rule})` : ''}`
83
+ if (verdict.effect === 'deny') return refuse(input.tool_name, reason, `${reason}. ${NOT_ANOTHER_WAY}`)
84
+ return hookAnswer(verdict.effect, reason)
182
85
  }
183
86
 
184
- const canUseTool = async (toolName, input, { signal, suggestions }) => {
185
- const { decision, reason } = judge(toolName, input)
186
- if (decision === 'allow') return { behavior: 'allow', updatedInput: input }
187
- if (decision === 'deny') return { behavior: 'deny', message: `Refused by the user's policy (${reason}). Do not achieve the same effect another way; say what you could not do.` }
188
- const request = toRequest(toolName, input, cwd, suggestions)
87
+ const canUseTool = async (toolName, input, { signal, toolUseID }) => {
88
+ const verdict = verdicts.get(toolUseID)
89
+ verdicts.delete(toolUseID)
90
+ const request = toRequest(toolName, input, cwd, verdict)
189
91
  io.emit({ kind: 'request', request })
190
92
  let resolved
191
93
  try {
@@ -193,13 +95,8 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
193
95
  } catch (err) {
194
96
  resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
195
97
  }
196
- const rule = ruleFor(toolName, input)
197
- io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by, rule })
198
- if ((resolved.answer.optionIds ?? []).includes('always') && session) {
199
- if (!sessionRules.has(session)) sessionRules.set(session, new Set())
200
- sessionRules.get(session).add(rule)
201
- }
202
- return applyAnswer(toolName, input, request, resolved.answer, suggestions)
98
+ io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
99
+ return applyAnswer(toolName, input, request, resolved.answer)
203
100
  }
204
101
 
205
102
  // Background work (subagents, `run_in_background` shell commands, monitors)
@@ -224,7 +121,7 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
224
121
  env,
225
122
  ...(sessionId ? { resume: sessionId } : {}),
226
123
  // The model, effort and permission mode the person chose in the composer (plan mode: plan first).
227
- ...sdkOptionsFor(settings),
124
+ ...sdkOptionsFor(settings, kiraDecides()),
228
125
  ...(abort ? { abortController: abort } : {}),
229
126
  // A Kira agent runs as its Claude Code subagent (~/.claude/agents/<name>.md, agent-files.mjs): the
230
127
  // file's body takes the place of Claude Code's default instructions, and what Kira adds for the
@@ -22,11 +22,17 @@ export function requestId() {
22
22
  return `r${++requestSeq}-${Date.now().toString(36)}`
23
23
  }
24
24
 
25
- export const PERMISSION_OPTIONS = [
26
- { id: 'allow', label: 'Allow', description: 'Let it do this once' },
27
- { id: 'always', label: 'Always allow', description: 'Let it do this for the rest of the session' },
28
- { id: 'deny', label: 'Deny', description: 'Do not do this' },
29
- ]
25
+ /**
26
+ * A permission's choices, in Claude Code's words. "Don't ask again" adds `rule` to the person's rules
27
+ * (Kira keeps it); without one (an API that doesn't check calls) it isn't offered.
28
+ */
29
+ export function permissionOptions(rule) {
30
+ return [
31
+ { id: 'allow', label: 'Yes' },
32
+ ...(rule ? [{ id: 'always', label: 'Yes, and don’t ask again', description: `Adds ${rule} to your rules` }] : []),
33
+ { id: 'deny', label: 'No', description: 'Tell Claude what to do differently' },
34
+ ]
35
+ }
30
36
 
31
37
  export const PLAN_OPTIONS = [
32
38
  { id: 'approve', label: 'Approve', description: 'Go ahead with this plan' },