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 +1 -1
- package/package.json +1 -1
- package/src/agent-files.mjs +38 -6
- package/src/agents/checks.mjs +61 -0
- package/src/agents/claude-calls.mjs +150 -0
- package/src/agents/claude-local.mjs +27 -3
- package/src/agents/claude.mjs +54 -157
- package/src/agents/protocol.mjs +11 -5
- package/src/agents/settings.mjs +10 -10
- package/src/appollo/asc-login.mjs +83 -0
- package/src/appollo/asc-run.mjs +35 -0
- package/src/appollo/asc.mjs +114 -0
- package/src/appollo/doctor.mjs +152 -0
- package/src/appollo/index.mjs +7 -0
- package/src/appollo/workspace.mjs +112 -0
- package/src/cli.mjs +1 -1
- package/src/config-folders.mjs +24 -2
- package/src/connection.mjs +7 -2
- package/src/index.mjs +4 -1
- package/src/job-tools.mjs +4 -2
- package/src/skill-files.mjs +20 -1
- package/src/agents/policy.mjs +0 -88
- package/src/agents/shell.mjs +0 -108
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 **
|
|
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
package/src/agent-files.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
2
|
-
// of text written with that sign-in (complete.mjs).
|
|
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
|
-
|
|
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
|
+
}
|
package/src/agents/claude.mjs
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
return
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
package/src/agents/protocol.mjs
CHANGED
|
@@ -22,11 +22,17 @@ export function requestId() {
|
|
|
22
22
|
return `r${++requestSeq}-${Date.now().toString(36)}`
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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' },
|