kira-glasses-bridge 2.25.0 → 2.27.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.27.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,73 @@
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 {
5
+ NOT_ANOTHER_WAY,
6
+ TEXT_CAP,
7
+ applyAnswer,
8
+ describeAction,
9
+ short,
10
+ toRequest,
11
+ } from './claude-calls.mjs'
12
+ import { kiraDecides } from './checks.mjs'
13
+ import { sdkOptionsFor } from './settings.mjs'
14
+ import { kiraPluginOptions } from '../kira-plugin.mjs'
26
15
 
27
16
  /** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
28
17
  const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
29
18
 
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
19
  /** Claude Code's own prompt, with what Kira adds for this run after it. */
132
20
  function systemPromptFor(persona) {
133
21
  return { type: 'preset', preset: 'claude_code', ...(persona.systemPrompt ? { append: persona.systemPrompt } : {}) }
134
22
  }
135
23
 
24
+ /** The pre-tool hook's answer: go ahead, ask, or refuse, and why. */
25
+ const hookAnswer = (permissionDecision, permissionDecisionReason) => ({
26
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision, permissionDecisionReason },
27
+ })
28
+
29
+ /** Questions for the person, not permissions: they always reach them, whatever the rules say. */
30
+ const QUESTIONS = new Set(['AskUserQuestion', 'ExitPlanMode'])
31
+
136
32
  /**
137
33
  * Run one instruction in a Claude session.
138
34
  * @param io.emit(event) a protocol event
139
35
  * @param io.ask(request) resolves to { answer, by } when a person answers (the caller times it out)
36
+ * @param io.check(call) what Kira's rules say about a tool call (checks.mjs)
140
37
  * @returns { ok, text, error, sessionId, stats }
141
38
  */
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) {
39
+ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
146
40
  let session = sessionId ?? null
147
41
  let text = ''
148
42
  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())
43
+ /** What Kira's rules said about each tool call, by its id, for the question if Claude Code asks. */
44
+ const verdicts = new Map()
153
45
 
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
- // A member with a restricted shell (a designer, a researcher) runs only what its preset allows.
158
- const shellAllow = persona?.shellAllow ? new RegExp(persona.shellAllow) : null
159
- // A member that only produces artifacts (a designer, a researcher) writes nowhere but its output folders.
160
- const within = (persona?.writeWithin ?? []).map(d => resolve(cwd, d))
161
- const writeTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
162
- const preToolUse = async input => {
46
+ /** Refused before it runs: the job shows why, and Claude is told (`told`, when it needs more). */
47
+ const refuse = (tool, reason, told = reason) => {
48
+ const answer = { optionIds: ['deny'] }
49
+ io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer, by: 'policy', tool, reason })
50
+ return hookAnswer('deny', told)
51
+ }
52
+ // Pre-tool hook: Kira's rules (io.check asks the API), a member's role among them. Allow and deny
53
+ // are final; ask forces the question even where the person's own Claude Code settings would allow
54
+ // it; a call no rule decides is left to Claude Code's permission mode, which asks through canUseTool.
55
+ const preToolUse = async (input, toolUseID) => {
163
56
  if (input.hook_event_name !== 'PreToolUse') return {}
164
- if (within.length && writeTools.has(input.tool_name)) {
165
- const target = resolve(cwd, String(input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? ''))
166
- 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 } }
170
- }
171
- }
172
- // Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
173
- const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
174
- 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 } }
178
- }
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}` } }
57
+ if (QUESTIONS.has(input.tool_name)) return {}
58
+ const verdict = await io.check({ tool: input.tool_name, input: input.tool_input ?? {}, cwd })
59
+ // Kept only for a question that may follow: an ask, or Claude Code's own mode asking.
60
+ if (toolUseID && (verdict.effect === 'ask' || verdict.effect === 'mode')) verdicts.set(toolUseID, verdict)
61
+ if (verdict.effect === 'mode') return {}
62
+ const reason = `Kira: ${verdict.why ?? 'your rules'}${verdict.rule ? ` (${verdict.rule})` : ''}`
63
+ if (verdict.effect === 'deny') return refuse(input.tool_name, reason, `${reason}. ${NOT_ANOTHER_WAY}`)
64
+ return hookAnswer(verdict.effect, reason)
182
65
  }
183
66
 
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)
67
+ const canUseTool = async (toolName, input, { signal, toolUseID }) => {
68
+ const verdict = verdicts.get(toolUseID)
69
+ verdicts.delete(toolUseID)
70
+ const request = toRequest(toolName, input, cwd, verdict)
189
71
  io.emit({ kind: 'request', request })
190
72
  let resolved
191
73
  try {
@@ -193,13 +75,8 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
193
75
  } catch (err) {
194
76
  resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
195
77
  }
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)
78
+ io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
79
+ return applyAnswer(toolName, input, request, resolved.answer)
203
80
  }
204
81
 
205
82
  // Background work (subagents, `run_in_background` shell commands, monitors)
@@ -224,7 +101,7 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
224
101
  env,
225
102
  ...(sessionId ? { resume: sessionId } : {}),
226
103
  // The model, effort and permission mode the person chose in the composer (plan mode: plan first).
227
- ...sdkOptionsFor(settings),
104
+ ...sdkOptionsFor(settings, kiraDecides()),
228
105
  ...(abort ? { abortController: abort } : {}),
229
106
  // A Kira agent runs as its Claude Code subagent (~/.claude/agents/<name>.md, agent-files.mjs): the
230
107
  // file's body takes the place of Claude Code's default instructions, and what Kira adds for the
@@ -237,6 +114,8 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
237
114
  // Claude as the person's Kira: her tools come from her own MCP server, with a token the API
238
115
  // made for this conversation. Claude Code loads them as it needs them (no tool ceiling).
239
116
  ...(persona?.kiraTools ? { mcpServers: { kira: kiraServer(persona.kiraTools) } } : {}),
117
+ // Her built-in skills, as Claude Code's own: a plugin the bridge writes (kira-plugin.mjs).
118
+ ...(await kiraPluginOptions(persona)),
240
119
  canUseTool,
241
120
  hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
242
121
  ...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
@@ -0,0 +1,96 @@
1
+ // Codex's thread items and settings in Kira's agent protocol (protocol.mjs). Pure.
2
+ import { displayPath, short } from './claude-calls.mjs'
3
+
4
+ /**
5
+ * The composer's permission modes in Codex's own terms: accept edits = it writes in the project
6
+ * (workspace-write) and asks before commands it doesn't trust; ask before edits = read-only, so every
7
+ * change and command asks; plan = read-only and it never asks, so it can only look and propose.
8
+ */
9
+ const MODES = {
10
+ acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
11
+ default: { sandbox: 'read-only', approvalPolicy: 'untrusted' },
12
+ plan: { sandbox: 'read-only', approvalPolicy: 'never' },
13
+ }
14
+ /** The composer's older names for the same modes. */
15
+ const OLD_MODES = { auto: 'acceptEdits', ask: 'default' }
16
+ const EFFORTS = new Set(['low', 'medium', 'high'])
17
+ /** A model as Codex's own list names it (codex-local.mjs); never a Claude one. */
18
+ const MODEL = /^(?!claude)[a-z0-9][a-z0-9.-]{0,63}$/
19
+
20
+ /** thread/start's options for these settings; what's unset is left to the person's ~/.codex/config.toml. */
21
+ export function codexOptionsFor(settings, kiraDecides = true) {
22
+ const s = settings ?? {}
23
+ const named = OLD_MODES[s.mode] ?? s.mode
24
+ const mode = Object.hasOwn(MODES, named) ? named : 'acceptEdits'
25
+ // Without Kira deciding each call, nothing is written without asking (as with Claude Code).
26
+ const chosen = kiraDecides || mode === 'plan' ? MODES[mode] : MODES.default
27
+ return {
28
+ ...chosen,
29
+ ...(typeof s.model === 'string' && MODEL.test(s.model) ? { model: s.model } : {}),
30
+ ...(EFFORTS.has(s.effort) ? { effort: s.effort } : {}),
31
+ }
32
+ }
33
+
34
+ /** Codex runs each command as `/bin/zsh -lc '<script>'`. */
35
+ const WRAPPED = /^(?:\/(?:usr\/)?bin\/)?(?:ba|z)?sh -l?c (?:'((?:[^']|'\\'')*)'|"((?:[^"\\$`]|\\["\\$`])*)")$/s
36
+
37
+ /** The script Codex runs, as the person would have typed it; anything unusual is left as it is (and asks). */
38
+ export function scriptOf(command) {
39
+ const m = WRAPPED.exec(String(command ?? '').trim())
40
+ if (!m) return String(command ?? '')
41
+ return m[1] !== undefined ? m[1].replaceAll("'\\''", "'") : m[2].replace(/\\(["\\$`])/g, '$1')
42
+ }
43
+
44
+ /** Codex's call as Kira's rules read it: a command is Bash, each changed file an Edit. */
45
+ export function callsOf(method, params, changes) {
46
+ if (method === 'item/commandExecution/requestApproval') {
47
+ return [{ tool: 'Bash', input: { command: scriptOf(params.command) }, cwd: params.cwd }]
48
+ }
49
+ return (changes ?? []).map(c => ({ tool: 'Edit', input: { file_path: c.path } }))
50
+ }
51
+
52
+ /** The strictest of several verdicts: any refusal refuses, any ask asks, and allow only when all allow. */
53
+ export function strictest(verdicts) {
54
+ const order = ['deny', 'ask', 'mode', 'allow']
55
+ return [...verdicts].sort((a, b) => order.indexOf(a.effect) - order.indexOf(b.effect))[0] ?? null
56
+ }
57
+
58
+ const act = (type, label, target, more = {}) => ({ type, label, target, ...more })
59
+
60
+ /** One of Codex's items as a protocol action (null for what isn't one: messages, reasoning). */
61
+ export function itemAction(item, cwd) {
62
+ switch (item.type) {
63
+ case 'commandExecution': {
64
+ const command = scriptOf(item.command)
65
+ return act('run', 'Run', short(command, 400), { tool: 'Bash', detail: { command: short(command, 2000) } })
66
+ }
67
+ case 'fileChange': {
68
+ const paths = (item.changes ?? []).map(c => displayPath(c.path, cwd))
69
+ const more = paths.length > 1 ? ` and ${paths.length - 1} more` : ''
70
+ const diff = (item.changes ?? []).map(c => c.diff).join('\n')
71
+ return act('edit', 'Edit', `${paths[0] ?? ''}${more}`, { tool: 'Edit', detail: { content: short(diff, 6000) } })
72
+ }
73
+ case 'mcpToolCall':
74
+ return act('other', item.tool, short(JSON.stringify(item.arguments ?? {}), 200), {
75
+ tool: `mcp__${item.server}__${item.tool}`,
76
+ })
77
+ case 'webSearch':
78
+ return act('fetch', 'Search the web', short(item.query ?? '', 160), { tool: 'WebSearch' })
79
+ case 'plan':
80
+ return act('plan', 'Plan', short(item.text, 160), { tool: 'Plan' })
81
+ case 'collabAgentToolCall':
82
+ return act('delegate', 'Subtask', short(item.prompt ?? '', 160), { tool: 'Subtask' })
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ /** A finished item's outcome: whether it went through, and what it said. */
89
+ export function itemResult(item) {
90
+ if (item.type === 'commandExecution') {
91
+ const ok = item.status === 'completed' && (item.exitCode ?? 0) === 0
92
+ return { ok, preview: short(String(item.aggregatedOutput ?? '').trim(), 240) }
93
+ }
94
+ if (item.type === 'mcpToolCall') return { ok: !item.error && item.status === 'completed', preview: '' }
95
+ return { ok: item.status ? item.status === 'completed' : true, preview: '' }
96
+ }