kira-glasses-bridge 2.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.25.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": {
@@ -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' },
@@ -1,23 +1,23 @@
1
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.
2
+ // the effort, and the permission mode. Pure: the Agent SDK's options.
3
3
  //
4
4
  // { model?: string, effort?: 'low'|'medium'|'high', mode?: 'auto'|'ask'|'plan' }
5
5
 
6
6
  const EFFORTS = new Set(['low', 'medium', 'high'])
7
7
  const MODEL = /^claude-[a-z0-9-]{1,60}$/
8
+ /** The composer's modes are Claude Code's own: accept edits, ask before edits (its default), plan first. */
9
+ const MODES = { auto: 'acceptEdits', ask: 'default', plan: 'plan' }
8
10
 
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
+ /**
12
+ * The SDK's query options for these settings (left out when unset, so Claude Code's defaults stand).
13
+ * `kiraDecides`: Kira's rules check each call (checks.mjs); without them, edits are asked about.
14
+ */
15
+ export function sdkOptionsFor(settings, kiraDecides = true) {
11
16
  const s = settings ?? {}
17
+ const mode = Object.hasOwn(MODES, s.mode) ? MODES[s.mode] : MODES.auto
12
18
  return {
13
- permissionMode: s.mode === 'plan' ? 'plan' : 'default',
19
+ permissionMode: kiraDecides ? mode : mode === 'plan' ? 'plan' : 'default',
14
20
  ...(typeof s.model === 'string' && MODEL.test(s.model) ? { model: s.model } : {}),
15
21
  ...(EFFORTS.has(s.effort) ? { effort: s.effort } : {}),
16
22
  }
17
23
  }
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
- }