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 +1 -1
- 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/config-folders.mjs +2 -0
- package/src/connection.mjs +7 -2
- package/src/index.mjs +2 -0
- package/src/job-tools.mjs +4 -2
- package/src/agents/policy.mjs +0 -88
- package/src/agents/shell.mjs +0 -108
package/package.json
CHANGED
|
@@ -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' },
|
package/src/agents/settings.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
10
|
-
|
|
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:
|
|
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
|
-
}
|