kira-glasses-bridge 2.21.0 → 2.23.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/claude.mjs +2 -2
- package/src/agents/policy.mjs +19 -101
- package/src/agents/shell.mjs +108 -0
- package/src/apps.mjs +1 -1
- package/src/claude-config.mjs +153 -0
- package/src/config.mjs +1 -1
- package/src/connection.mjs +137 -0
- package/src/file-tools.mjs +45 -0
- package/src/files.mjs +0 -3
- package/src/host.mjs +29 -0
- package/src/index.mjs +35 -294
- package/src/job-format.mjs +21 -0
- package/src/job-tools.mjs +96 -0
- package/src/mac-tools.mjs +62 -0
- package/src/service.mjs +1 -1
- package/src/session-tools.mjs +20 -0
- package/src/terminal-command.mjs +15 -0
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const inside = (cwd, p) => {
|
|
|
28
28
|
const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
|
|
29
29
|
|
|
30
30
|
/** Claude's tool call as a protocol action. */
|
|
31
|
-
|
|
31
|
+
function describeAction(name, input, cwd) {
|
|
32
32
|
const i = input ?? {}
|
|
33
33
|
switch (name) {
|
|
34
34
|
case 'Read':
|
|
@@ -137,7 +137,7 @@ function applyAnswer(name, input, request, answer, suggestions) {
|
|
|
137
137
|
/** "Always allow" answers, kept for the rest of the process per session: sessionId -> Set of rules. */
|
|
138
138
|
const sessionRules = new Map()
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
async function run({ prompt, sessionId, cwd, policy, persona, settings, abort }, io) {
|
|
141
141
|
let session = sessionId ?? null
|
|
142
142
|
let text = ''
|
|
143
143
|
let error = null
|
package/src/agents/policy.mjs
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
// Shell commands are split into their pieces (`;`, `&&`, `||`, `|`, newlines)
|
|
7
7
|
// and every piece has to be safe for the whole command to be safe.
|
|
8
8
|
import { relative, resolve, sep } from 'node:path'
|
|
9
|
+
import { pieces, ruleFor, shellIsSafe } from './shell.mjs'
|
|
10
|
+
|
|
11
|
+
export { ruleFor }
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Tools that only look, or that are Claude Code's own housekeeping (ToolSearch loads the definitions
|
|
12
15
|
* of tools it has but hasn't used yet; the MCP resource tools read what a server offers).
|
|
13
16
|
*/
|
|
14
|
-
|
|
17
|
+
const READ_ONLY = new Set([
|
|
15
18
|
'Read',
|
|
16
19
|
'Glob',
|
|
17
20
|
'Grep',
|
|
@@ -26,76 +29,10 @@ export const READ_ONLY = new Set([
|
|
|
26
29
|
])
|
|
27
30
|
/** A subagent: older Claude Code calls the tool Task, newer Agent. */
|
|
28
31
|
const DELEGATE = new Set(['Task', 'Agent'])
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
33
|
+
const WEB = new Set(['WebFetch', 'WebSearch'])
|
|
31
34
|
/** Claude Code's name for the tools of Kira's MCP server (mcp__<server>__<tool>). */
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Read-only commands: looking, listing, testing, type-checking, building. Nothing that writes, sends
|
|
36
|
-
* or installs, nothing that runs code given inline (node -e, python -c, awk), nothing that prints
|
|
37
|
-
* secrets (heroku config).
|
|
38
|
-
*/
|
|
39
|
-
/** Plain commands that only look. */
|
|
40
|
-
const LOOKERS = [
|
|
41
|
-
'ls', 'll', 'pwd', 'cat', 'head', 'tail', 'wc', 'echo', 'printf', 'which', 'type', 'env', 'printenv',
|
|
42
|
-
'date', 'whoami', 'id', 'uname', 'tree', 'find', 'fd', 'grep', 'rg', 'ag', 'cut', 'tr', 'sort', 'uniq',
|
|
43
|
-
'diff', 'comm', 'stat', 'file', 'du', 'df', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq',
|
|
44
|
-
'xxd', 'hexdump', 'strings', 'test', '[', 'true', 'false', 'sleep', 'md5', 'shasum', 'sha256sum',
|
|
45
|
-
'md5sum', 'column', 'nl', 'less', 'more', 'tsc', 'eslint', 'jest', 'vitest', 'pytest',
|
|
46
|
-
]
|
|
47
|
-
/** Tools with their read-only, test and build subcommands. */
|
|
48
|
-
const SUBCOMMANDS = {
|
|
49
|
-
git: [
|
|
50
|
-
'status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote', 'ls-files', 'ls-tree', 'blame', 'describe',
|
|
51
|
-
'stash list', 'tag', 'config --get', 'config -l', 'cat-file', 'grep', 'shortlog', 'count-objects', 'reflog',
|
|
52
|
-
],
|
|
53
|
-
node: ['-v', '--version'],
|
|
54
|
-
npm: ['-v', '--version', 'ls', 'list', 'view', 'outdated', 'why', 'test', 'run test', 'run lint', 'run typecheck',
|
|
55
|
-
'run check', 'run build', 'run format:check'],
|
|
56
|
-
pnpm: ['-v', '--version', 'ls', 'list', 'why', 'outdated', 'test', 'typecheck', 'lint', 'build', 'run test',
|
|
57
|
-
'run lint', 'run typecheck', 'run check', 'run build'],
|
|
58
|
-
yarn: ['-v', '--version', 'list', 'test', 'lint', 'typecheck', 'build'],
|
|
59
|
-
npx: ['tsc', 'eslint', 'prettier --check', 'jest', 'vitest', 'playwright test'],
|
|
60
|
-
prettier: ['--check'],
|
|
61
|
-
cargo: ['check', 'test', 'build', 'fmt --check', 'clippy', 'tree', 'metadata'],
|
|
62
|
-
go: ['test', 'build', 'vet', 'version', 'list', 'env'],
|
|
63
|
-
python: ['-V', '--version', '-m pytest'],
|
|
64
|
-
python3: ['-V', '--version', '-m pytest'],
|
|
65
|
-
ruby: ['-v'],
|
|
66
|
-
make: ['test', 'check', 'lint', 'build'],
|
|
67
|
-
mvn: ['test', 'compile'],
|
|
68
|
-
gradle: ['test', 'build'],
|
|
69
|
-
swift: ['build', 'test'],
|
|
70
|
-
xcodebuild: ['-list'],
|
|
71
|
-
brew: ['list', 'info', '--version'],
|
|
72
|
-
docker: ['ps', 'images', 'version'],
|
|
73
|
-
gh: ['pr list', 'pr view', 'pr status', 'pr checks', 'issue list', 'issue view', 'run list', 'run view', 'repo view'],
|
|
74
|
-
heroku: ['logs', 'ps', 'releases', 'apps', 'info'],
|
|
75
|
-
open: ['-n --args'],
|
|
76
|
-
sed: ['-n', '-E -n', '-n -E'],
|
|
77
|
-
}
|
|
78
|
-
const escape = w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
79
|
-
const withSubs = Object.entries(SUBCOMMANDS).flatMap(([tool, subs]) => subs.map(sub => `${tool} ${sub}`))
|
|
80
|
-
const SAFE_PHRASES = [...LOOKERS, ...withSubs]
|
|
81
|
-
const SAFE_CMD = new RegExp(`^(?:${SAFE_PHRASES.map(escape).join('|')})(?:\\s|$)`)
|
|
82
|
-
|
|
83
|
-
/** Redirections that write somewhere other than nowhere. */
|
|
84
|
-
const WRITES = /(^|[^&\d])>(?!&)|>>|\btee\b|\bxargs\b|\beval\b|\bexec\b|`/
|
|
85
|
-
/** find and fd can run or delete what they find: then they aren't looking any more. */
|
|
86
|
-
const FINDER_ACTS =
|
|
87
|
-
/^(?:find|fd)\b.*\s(?:-exec(?:dir)?|-ok(?:dir)?|-delete|-fprint\w*|-fls|-x|--exec(?:-batch)?|-X)\b/
|
|
88
|
-
|
|
89
|
-
function pieces(command) {
|
|
90
|
-
// Strip quoted strings so separators inside them do not split; keep a placeholder.
|
|
91
|
-
const stripped = String(command).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
|
|
92
|
-
return stripped
|
|
93
|
-
.split(/\n|;|&&|\|\||\|/)
|
|
94
|
-
.map(p => p.trim())
|
|
95
|
-
// Shell keywords are structure, not commands: drop them, and a "for x in …" header entirely.
|
|
96
|
-
.map(p => p.replace(/^(?:if|then|else|elif|fi|do|done|while|until)\b\s*/, '').replace(/^for\s+\w+\s+in\b.*$/, '').trim())
|
|
97
|
-
.filter(Boolean)
|
|
98
|
-
}
|
|
35
|
+
const KIRA_TOOLS = 'mcp__kira__'
|
|
99
36
|
|
|
100
37
|
function matchesRule(rules, toolName, command) {
|
|
101
38
|
const c = (command ?? '').trim()
|
|
@@ -103,41 +40,20 @@ function matchesRule(rules, toolName, command) {
|
|
|
103
40
|
const rule = String(r).trim()
|
|
104
41
|
if (!rule) continue
|
|
105
42
|
if (rule === toolName) return true
|
|
106
|
-
|
|
43
|
+
const starts = text => text === rule || text.startsWith(rule + ' ')
|
|
44
|
+
if (toolName === 'Bash' && c && (starts(c) || pieces(c).some(starts))) return true
|
|
107
45
|
}
|
|
108
46
|
return false
|
|
109
47
|
}
|
|
110
48
|
|
|
111
|
-
|
|
49
|
+
const allow = reason => ({ decision: 'allow', reason })
|
|
50
|
+
const ask = reason => ({ decision: 'ask', reason })
|
|
51
|
+
|
|
52
|
+
function insideFolder(cwd, p) {
|
|
112
53
|
const rel = relative(cwd, resolve(cwd, String(p ?? '')))
|
|
113
54
|
return !!rel && !rel.startsWith('..') && !rel.startsWith(sep)
|
|
114
55
|
}
|
|
115
56
|
|
|
116
|
-
/** Is this shell command read-only, judged piece by piece? Command substitutions are judged by what is inside them. */
|
|
117
|
-
export function shellIsSafe(command, allowRules = []) {
|
|
118
|
-
let c = String(command ?? '').trim()
|
|
119
|
-
if (!c) return false
|
|
120
|
-
// $(inner): safe only if the inner command is; then drop it so the outer check sees plain text.
|
|
121
|
-
for (let guard = 0; guard < 20; guard++) {
|
|
122
|
-
const m = /\$\(([^()]*)\)/.exec(c)
|
|
123
|
-
if (!m) break
|
|
124
|
-
if (!shellIsSafe(m[1], allowRules)) return false
|
|
125
|
-
c = c.slice(0, m.index) + '""' + c.slice(m.index + m[0].length)
|
|
126
|
-
}
|
|
127
|
-
if (WRITES.test(c.replace(/2>&1|2>\/dev\/null|>\/dev\/null|&>\/dev\/null/g, ''))) return false
|
|
128
|
-
const allowedByRule = p => allowRules.some(r => r && (p === r || p.startsWith(String(r) + ' ')))
|
|
129
|
-
return pieces(c).every(p => (SAFE_CMD.test(p) && !FINDER_ACTS.test(p)) || allowedByRule(p))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** The rule key an "always allow" answer produces. */
|
|
133
|
-
export function ruleFor(toolName, input) {
|
|
134
|
-
if (toolName !== 'Bash') return toolName
|
|
135
|
-
const first = pieces(String(input?.command ?? ''))[0] ?? ''
|
|
136
|
-
const words = first.split(/\s+/)
|
|
137
|
-
// Two words for package managers and git ("npm run", "git push"), one otherwise.
|
|
138
|
-
return /^(?:git|npm|pnpm|yarn|npx|cargo|go|docker|gh|heroku|make)$/.test(words[0]) && words[1] ? `${words[0]} ${words[1]}` : words[0]
|
|
139
|
-
}
|
|
140
|
-
|
|
141
57
|
/**
|
|
142
58
|
* Decide for one tool call. Returns { decision: 'allow'|'ask'|'deny', reason }.
|
|
143
59
|
* @param sessionRules rules allowed earlier in this session ("always allow")
|
|
@@ -147,20 +63,22 @@ export function decide(policy, toolName, input, cwd, sessionRules = new Set()) {
|
|
|
147
63
|
const command = toolName === 'Bash' ? String(input?.command ?? '') : ''
|
|
148
64
|
if (matchesRule(p.denyRules ?? [], toolName, command)) return { decision: 'deny', reason: 'refused by your policy' }
|
|
149
65
|
if (toolName === 'AskUserQuestion') return { decision: 'ask', reason: 'a question for you' }
|
|
150
|
-
|
|
151
|
-
if (matchesRule(
|
|
66
|
+
const allowRules = [...(p.allowRules ?? []), ...sessionRules]
|
|
67
|
+
if (matchesRule(p.askRules ?? [], toolName, command)) return ask('your policy always asks for this')
|
|
68
|
+
if (matchesRule(allowRules, toolName, command)) return allow('allowed by your policy')
|
|
152
69
|
if (READ_ONLY.has(toolName)) return { decision: 'allow', reason: 'read-only' }
|
|
153
70
|
// Kira's own tools (her MCP server, attached when Claude is the person's Kira): her server asks
|
|
154
71
|
// for itself when something needs their yes (approvals), so the Mac doesn't ask a second time.
|
|
155
72
|
if (toolName.startsWith(KIRA_TOOLS)) return { decision: 'allow', reason: 'Kira’s own tool' }
|
|
156
73
|
if (EDITS.has(toolName)) {
|
|
157
74
|
const file = input?.file_path ?? input?.notebook_path
|
|
158
|
-
|
|
75
|
+
const inside = 'edit inside the project'
|
|
76
|
+
if (insideFolder(cwd, file)) return p.edits === 'ask' ? ask(inside) : allow(inside)
|
|
159
77
|
return { decision: p.editsOutside === 'deny' ? 'deny' : 'ask', reason: 'edit outside the project folder' }
|
|
160
78
|
}
|
|
161
79
|
if (toolName === 'Bash') {
|
|
162
80
|
if (p.shell === 'allow') return { decision: 'allow', reason: 'shell allowed by your policy' }
|
|
163
|
-
if (p.shell !== 'ask' && shellIsSafe(command, p.allowRules ?? [])) return
|
|
81
|
+
if (p.shell !== 'ask' && shellIsSafe(command, p.allowRules ?? [])) return allow('read-only command')
|
|
164
82
|
return { decision: 'ask', reason: 'shell command' }
|
|
165
83
|
}
|
|
166
84
|
if (WEB.has(toolName)) return { decision: p.web === 'ask' ? 'ask' : 'allow', reason: 'web' }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Is a shell command only looking? The Mac lets Claude Code run it without asking only when every
|
|
2
|
+
// piece of it is (agents/policy.mjs). Read-only means: no writes, no sends, no installs, no code
|
|
3
|
+
// given inline (node -e, python -c, awk), nothing in the background, no secrets printed.
|
|
4
|
+
|
|
5
|
+
/** Plain commands that only look (not env or printenv: they run commands, and print secrets). */
|
|
6
|
+
const LOOKERS = [
|
|
7
|
+
'ls', 'll', 'pwd', 'cat', 'head', 'tail', 'wc', 'echo', 'printf', 'which', 'type',
|
|
8
|
+
'date', 'whoami', 'id', 'uname', 'tree', 'find', 'fd', 'grep', 'rg', 'ag', 'cut', 'tr', 'sort', 'uniq',
|
|
9
|
+
'diff', 'comm', 'stat', 'file', 'du', 'df', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq',
|
|
10
|
+
'xxd', 'hexdump', 'strings', 'test', '[', 'true', 'false', 'sleep', 'md5', 'shasum', 'sha256sum',
|
|
11
|
+
'md5sum', 'column', 'nl', 'less', 'more', 'tsc', 'eslint', 'jest', 'vitest', 'pytest',
|
|
12
|
+
]
|
|
13
|
+
/** Tools with their read-only, test and build subcommands. */
|
|
14
|
+
const SUBCOMMANDS = {
|
|
15
|
+
git: [
|
|
16
|
+
'status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'ls-tree', 'blame', 'describe', 'stash list',
|
|
17
|
+
'config --get', 'config -l', 'cat-file', 'grep', 'shortlog', 'count-objects', 'reflog',
|
|
18
|
+
'branch --list', 'branch -a', 'branch -r', 'branch -v', 'branch -vv', 'branch --show-current',
|
|
19
|
+
'tag -l', 'tag --list', 'remote -v', 'remote show',
|
|
20
|
+
],
|
|
21
|
+
node: ['-v', '--version'],
|
|
22
|
+
npm: ['-v', '--version', 'ls', 'list', 'view', 'outdated', 'why', 'test', 'run test', 'run lint', 'run typecheck',
|
|
23
|
+
'run check', 'run build', 'run format:check'],
|
|
24
|
+
pnpm: ['-v', '--version', 'ls', 'list', 'why', 'outdated', 'test', 'typecheck', 'lint', 'build', 'run test',
|
|
25
|
+
'run lint', 'run typecheck', 'run check', 'run build'],
|
|
26
|
+
yarn: ['-v', '--version', 'list', 'test', 'lint', 'typecheck', 'build'],
|
|
27
|
+
npx: ['tsc', 'eslint', 'prettier --check', 'jest', 'vitest', 'playwright test'],
|
|
28
|
+
prettier: ['--check'],
|
|
29
|
+
cargo: ['check', 'test', 'build', 'fmt --check', 'clippy', 'tree', 'metadata'],
|
|
30
|
+
go: ['test', 'build', 'vet', 'version', 'list', 'env'],
|
|
31
|
+
python: ['-V', '--version', '-m pytest'],
|
|
32
|
+
python3: ['-V', '--version', '-m pytest'],
|
|
33
|
+
ruby: ['-v'],
|
|
34
|
+
make: ['test', 'check', 'lint', 'build'],
|
|
35
|
+
mvn: ['test', 'compile'],
|
|
36
|
+
gradle: ['test', 'build'],
|
|
37
|
+
swift: ['build', 'test'],
|
|
38
|
+
xcodebuild: ['-list'],
|
|
39
|
+
brew: ['list', 'info', '--version'],
|
|
40
|
+
docker: ['ps', 'images', 'version'],
|
|
41
|
+
gh: ['pr list', 'pr view', 'pr status', 'pr checks', 'issue list', 'issue view', 'run list', 'run view', 'repo view'],
|
|
42
|
+
heroku: ['logs', 'ps', 'releases', 'apps', 'info'],
|
|
43
|
+
open: ['-n --args'],
|
|
44
|
+
sed: ['-n', '-E -n', '-n -E'],
|
|
45
|
+
}
|
|
46
|
+
/** These list only when given nothing else ("git branch -D x" deletes, "git tag v1" creates). */
|
|
47
|
+
const BARE = new Set(['git branch', 'git tag', 'git remote'])
|
|
48
|
+
|
|
49
|
+
const escape = w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
50
|
+
const withSubs = Object.entries(SUBCOMMANDS).flatMap(([tool, subs]) => subs.map(sub => `${tool} ${sub}`))
|
|
51
|
+
const SAFE_CMD = new RegExp(`^(?:${[...LOOKERS, ...withSubs].map(escape).join('|')})(?:\\s|$)`)
|
|
52
|
+
|
|
53
|
+
/** Redirections that write somewhere other than nowhere, and ways to run something else. */
|
|
54
|
+
const WRITES = /(^|[^&\d])>(?!&)|>>|\btee\b|\bxargs\b|\beval\b|\bexec\b|`/
|
|
55
|
+
/** Running in the background (a lone &), or process substitution: the rest is never judged. */
|
|
56
|
+
const HIDDEN = /(^|[^&>])&(?![&>])|<\(|>\(/
|
|
57
|
+
/** Flags that turn a looking command into one that writes or runs. */
|
|
58
|
+
const ACTS = [
|
|
59
|
+
/^(?:find|fd)\b.*\s(?:-exec(?:dir)?|-ok(?:dir)?|-delete|-fprint\w*|-fls|-x|--exec(?:-batch)?|-X)\b/,
|
|
60
|
+
/^sort\b.*\s(?:-o|--output)(?:\s|=|$)/,
|
|
61
|
+
/^(?:npx\s+)?eslint\b.*\s--fix\b/,
|
|
62
|
+
/^(?:npx\s+)?prettier\b.*\s(?:--write|-w)(?:\s|$)/,
|
|
63
|
+
/^(?:npx\s+)?(?:jest|vitest)\b.*\s(?:-u|--update\w*)(?:\s|=|$)/,
|
|
64
|
+
/^git\s+(?:diff|log|show)\b.*\s--output(?:\s|=)/,
|
|
65
|
+
/^go\s+env\b.*\s-w(?:\s|$)/,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** A command's pieces: what runs, split at ; && || | and newlines (quoted text left whole). */
|
|
69
|
+
export function pieces(command) {
|
|
70
|
+
const stripped = unquoted(command)
|
|
71
|
+
return stripped
|
|
72
|
+
.split(/\n|;|&&|\|\||\|/)
|
|
73
|
+
.map(p => p.trim())
|
|
74
|
+
// Shell keywords are structure, not commands: drop them, and a "for x in …" header entirely.
|
|
75
|
+
.map(p => p.replace(/^(?:if|then|else|elif|fi|do|done|while|until)\b\s*/, '').replace(/^for\s+\w+\s+in\b.*$/, ''))
|
|
76
|
+
.map(p => p.trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Quoted strings blanked, so separators and symbols inside them don't count. */
|
|
81
|
+
const unquoted = command => String(command).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
|
|
82
|
+
|
|
83
|
+
const looks = piece => (SAFE_CMD.test(piece) || BARE.has(piece)) && !ACTS.some(a => a.test(piece))
|
|
84
|
+
|
|
85
|
+
/** Is this shell command read-only, judged piece by piece? Command substitutions are judged by what is inside them. */
|
|
86
|
+
export function shellIsSafe(command, allowRules = []) {
|
|
87
|
+
let c = String(command ?? '').trim()
|
|
88
|
+
if (!c) return false
|
|
89
|
+
// $(inner): safe only if the inner command is; then drop it so the outer check sees plain text.
|
|
90
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
91
|
+
const m = /\$\(([^()]*)\)/.exec(c)
|
|
92
|
+
if (!m) break
|
|
93
|
+
if (!shellIsSafe(m[1], allowRules)) return false
|
|
94
|
+
c = c.slice(0, m.index) + '""' + c.slice(m.index + m[0].length)
|
|
95
|
+
}
|
|
96
|
+
const plain = unquoted(c).replace(/2>&1|2>\/dev\/null|>\/dev\/null|&>\/dev\/null/g, '')
|
|
97
|
+
if (WRITES.test(plain) || HIDDEN.test(plain)) return false
|
|
98
|
+
const allowedByRule = p => allowRules.some(r => r && (p === r || p.startsWith(String(r) + ' ')))
|
|
99
|
+
return pieces(c).every(p => looks(p) || allowedByRule(p))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The rule key an "always allow" answer produces: two words for package managers and git, one otherwise. */
|
|
103
|
+
export function ruleFor(toolName, input) {
|
|
104
|
+
if (toolName !== 'Bash') return toolName
|
|
105
|
+
const words = (pieces(String(input?.command ?? ''))[0] ?? '').split(/\s+/)
|
|
106
|
+
const twoWords = /^(?:git|npm|pnpm|yarn|npx|cargo|go|docker|gh|heroku|make)$/.test(words[0]) && words[1]
|
|
107
|
+
return twoWords ? `${words[0]} ${words[1]}` : words[0]
|
|
108
|
+
}
|
package/src/apps.mjs
CHANGED
|
@@ -73,7 +73,7 @@ function norm(s) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Resolve a spoken app name to an installed bundle name, or null. */
|
|
76
|
-
|
|
76
|
+
async function resolveApp(name) {
|
|
77
77
|
const apps = await installedApps()
|
|
78
78
|
const q = norm(name)
|
|
79
79
|
if (!q) return null
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Claude Code's own skills on this Mac: one SKILL.md each in ~/.claude/skills/<slug>/ (or under
|
|
2
|
+
// $CLAUDE_CONFIG_DIR), which Claude Code and the bridge's Agent SDK jobs load natively. Kira lists
|
|
3
|
+
// all of them (hers and any other), and writes and removes the ones a person makes in Kira. Every
|
|
4
|
+
// path is confined to that folder: a slug is one plain folder name, and a symlinked folder is never
|
|
5
|
+
// written through.
|
|
6
|
+
import { lstat, mkdir, readdir, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join, relative } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
11
|
+
/** A SKILL.md past this isn't one: skipped when listing, refused when writing. */
|
|
12
|
+
const MAX_MD = 256 * 1024
|
|
13
|
+
|
|
14
|
+
/** Where Claude Code keeps personal skills. */
|
|
15
|
+
function skillsRoot() {
|
|
16
|
+
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'skills')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The folder for a skill, after checking its slug can't name anything else. */
|
|
20
|
+
function folder(root, slug) {
|
|
21
|
+
const s = String(slug ?? '')
|
|
22
|
+
if (!SLUG.test(s)) throw new Error(`“${s}” isn’t a skill folder name (lowercase letters, digits and dashes)`)
|
|
23
|
+
return join(root, s)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** As people read it: under the home folder with ~. */
|
|
27
|
+
function display(full) {
|
|
28
|
+
const rel = relative(homedir(), full)
|
|
29
|
+
return rel && !rel.startsWith('..') ? `~/${rel}` : full
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A front-matter value: quoted or plain on its line, or a `|` / `>` block below it. */
|
|
33
|
+
function field(front, key) {
|
|
34
|
+
const lines = front.split('\n')
|
|
35
|
+
const at = lines.findIndex(l => l.startsWith(`${key}:`))
|
|
36
|
+
if (at < 0) return null
|
|
37
|
+
const raw = lines[at].slice(key.length + 1).trim()
|
|
38
|
+
if (/^[|>][+-]?$/.test(raw)) {
|
|
39
|
+
const block = []
|
|
40
|
+
for (const l of lines.slice(at + 1)) {
|
|
41
|
+
if (l.trim() && !/^\s/.test(l)) break
|
|
42
|
+
block.push(l.trim())
|
|
43
|
+
}
|
|
44
|
+
return block.join(raw.startsWith('|') ? '\n' : ' ').trim() || null
|
|
45
|
+
}
|
|
46
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw)
|
|
47
|
+
return (quoted ? quoted[2] : raw.replace(/\s+#.*$/, '')).trim() || null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The name and description Claude Code shows, from a SKILL.md (the folder's name when it has none). */
|
|
51
|
+
export function skillSummary(md, slug) {
|
|
52
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
|
|
53
|
+
const front = m ? m[1].replace(/\r\n/g, '\n') : ''
|
|
54
|
+
const body = m ? md.slice(m[0].length) : md
|
|
55
|
+
const firstLine = body.split('\n').map(l => l.trim()).find(Boolean) ?? ''
|
|
56
|
+
return { name: field(front, 'name') || slug, description: field(front, 'description') || firstLine }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function isSymlink(p) {
|
|
60
|
+
return lstat(p).then(
|
|
61
|
+
s => s.isSymbolicLink(),
|
|
62
|
+
() => false,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A folder that links elsewhere is changed where it really is, never through the link. */
|
|
67
|
+
async function refuseLinks(root, dir, verb) {
|
|
68
|
+
if ((await isSymlink(root)) || (await isSymlink(dir))) {
|
|
69
|
+
throw new Error(`${display(dir)} is a link to somewhere else; ${verb} it there`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every skill in the folder: its slug, name, description, where it is, and its SKILL.md. */
|
|
74
|
+
export async function listSkills(root = skillsRoot()) {
|
|
75
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(e => {
|
|
76
|
+
if (e.code === 'ENOENT') return []
|
|
77
|
+
throw e
|
|
78
|
+
})
|
|
79
|
+
const skills = []
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (!SLUG.test(e.name) || !(e.isDirectory() || e.isSymbolicLink())) continue
|
|
82
|
+
const file = join(root, e.name, 'SKILL.md')
|
|
83
|
+
const st = await stat(file).catch(() => null)
|
|
84
|
+
if (!st?.isFile() || st.size > MAX_MD) continue
|
|
85
|
+
const md = await readFile(file, 'utf8').catch(() => null)
|
|
86
|
+
if (md === null) continue
|
|
87
|
+
skills.push({ slug: e.name, ...skillSummary(md, e.name), path: display(join(root, e.name)), md })
|
|
88
|
+
}
|
|
89
|
+
skills.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
90
|
+
return { root: display(root), skills }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One skill's SKILL.md. */
|
|
94
|
+
export async function readSkill({ slug } = {}, root = skillsRoot()) {
|
|
95
|
+
const dir = folder(root, slug)
|
|
96
|
+
const md = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(() => null)
|
|
97
|
+
if (md === null) return { error: `No skill in ${display(dir)}` }
|
|
98
|
+
return { slug, ...skillSummary(md, slug), path: display(dir), md }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write a skill's SKILL.md, whole: replacing the one there, or with `create` only a new one (a skill
|
|
103
|
+
* already in that folder, Kira's or not, is never overwritten by a new one of the same name).
|
|
104
|
+
*/
|
|
105
|
+
export async function writeSkill({ slug, md, create = false } = {}, root = skillsRoot()) {
|
|
106
|
+
const dir = folder(root, slug)
|
|
107
|
+
const text = String(md ?? '')
|
|
108
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A SKILL.md starts with front matter between --- lines')
|
|
109
|
+
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That SKILL.md is too large')
|
|
110
|
+
await refuseLinks(root, dir, 'edit')
|
|
111
|
+
const file = join(dir, 'SKILL.md')
|
|
112
|
+
if (create && (await stat(file).catch(() => null))) throw new Error(`A skill named ${slug} is already on your Mac`)
|
|
113
|
+
await mkdir(dir, { recursive: true })
|
|
114
|
+
const tmp = join(dir, `.SKILL.md.${process.pid}.tmp`)
|
|
115
|
+
await writeFile(tmp, text, 'utf8')
|
|
116
|
+
await rename(tmp, file)
|
|
117
|
+
return { slug, ...skillSummary(text, slug), path: display(dir) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove a skill: its SKILL.md goes (so Claude Code stops loading it), and the folder too when
|
|
122
|
+
* nothing else is in it. Anything else a skill folder holds (scripts, references) stays.
|
|
123
|
+
*/
|
|
124
|
+
export async function removeSkill({ slug } = {}, root = skillsRoot()) {
|
|
125
|
+
const dir = folder(root, slug)
|
|
126
|
+
await refuseLinks(root, dir, 'remove')
|
|
127
|
+
const gone = await unlink(join(dir, 'SKILL.md')).then(
|
|
128
|
+
() => true,
|
|
129
|
+
e => {
|
|
130
|
+
if (e.code === 'ENOENT') return false
|
|
131
|
+
throw e
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
const left = await readdir(dir).catch(() => [])
|
|
135
|
+
if (!left.length) await rmdir(dir).catch(() => {})
|
|
136
|
+
return { slug, removed: gone, path: display(dir), kept: left.length ? left : undefined }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The bridge tools (index.mjs). */
|
|
140
|
+
export const skillTools = {
|
|
141
|
+
async list_skills() {
|
|
142
|
+
return listSkills()
|
|
143
|
+
},
|
|
144
|
+
async read_skill(args) {
|
|
145
|
+
return readSkill(args)
|
|
146
|
+
},
|
|
147
|
+
async write_skill(args) {
|
|
148
|
+
return writeSkill(args)
|
|
149
|
+
},
|
|
150
|
+
async remove_skill(args) {
|
|
151
|
+
return removeSkill(args)
|
|
152
|
+
},
|
|
153
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
|
|
7
7
|
export const KIRA_DIR = join(homedir(), '.kira')
|
|
8
|
-
|
|
8
|
+
const CONFIG_PATH = join(KIRA_DIR, 'bridge.json')
|
|
9
9
|
export const LOG_PATH = join(KIRA_DIR, 'bridge.log')
|
|
10
10
|
export const DEFAULT_URL = 'wss://kira.glass/api/bridge/ws'
|
|
11
11
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The socket to the Kira API: connecting, reconnecting with backoff, and routing what arrives
|
|
2
|
+
// (answers to parked questions, terminal traffic, tool calls) to whoever handles it.
|
|
3
|
+
import WebSocket from 'ws'
|
|
4
|
+
import { normalizeAnswer } from './agents/protocol.mjs'
|
|
5
|
+
import { answer } from './agents/answers.mjs'
|
|
6
|
+
import { terminalMessage, terminalsOffline } from './terminals.mjs'
|
|
7
|
+
import { host, log, status } from './host.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The live connection, and what could not be sent while there was none. Jobs
|
|
11
|
+
* outlive connections (every API deploy reconnects us), so their events and
|
|
12
|
+
* results go through here rather than the socket they were started on.
|
|
13
|
+
*/
|
|
14
|
+
let live = null
|
|
15
|
+
const outbox = []
|
|
16
|
+
const OUTBOX_MAX = 2000
|
|
17
|
+
|
|
18
|
+
let settings = null
|
|
19
|
+
let stopped = false
|
|
20
|
+
let reconnectTimer = null
|
|
21
|
+
let backoff = 1000
|
|
22
|
+
|
|
23
|
+
/** A job's event or result: sent now, or queued until the next connection. */
|
|
24
|
+
export function sendLive(obj) {
|
|
25
|
+
if (live && live.readyState === WebSocket.OPEN) return live.send(JSON.stringify(obj))
|
|
26
|
+
if (outbox.length >= OUTBOX_MAX) outbox.shift()
|
|
27
|
+
outbox.push(obj)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sent only if connected right now (terminal output is live or nothing). */
|
|
31
|
+
export function sendIfLive(obj) {
|
|
32
|
+
return live?.readyState === WebSocket.OPEN && live.send(JSON.stringify(obj))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Connect and keep connecting until disconnect(). `tools` maps a tool name to its function;
|
|
37
|
+
* `capabilities()` is what the bridge tells the API it implements, after its hello.
|
|
38
|
+
*/
|
|
39
|
+
export function connect({ url, token, tools, capabilities }) {
|
|
40
|
+
settings = { url, token, tools, capabilities }
|
|
41
|
+
stopped = false
|
|
42
|
+
open()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Disconnect and stop reconnecting. */
|
|
46
|
+
export function disconnect() {
|
|
47
|
+
stopped = true
|
|
48
|
+
clearTimeout(reconnectTimer)
|
|
49
|
+
const ws = live
|
|
50
|
+
live = null
|
|
51
|
+
try {
|
|
52
|
+
ws?.close(1000, 'stopped')
|
|
53
|
+
} catch {
|
|
54
|
+
/* already closed */
|
|
55
|
+
}
|
|
56
|
+
status('disconnected')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function open() {
|
|
60
|
+
if (stopped) return
|
|
61
|
+
status('connecting')
|
|
62
|
+
const { url, token } = settings
|
|
63
|
+
const ws = new WebSocket(`${url}?token=${encodeURIComponent(token)}`)
|
|
64
|
+
let ping
|
|
65
|
+
ws.on('open', () => {
|
|
66
|
+
backoff = 1000
|
|
67
|
+
log(`connected to ${url}`)
|
|
68
|
+
ping = setInterval(() => send(ws, { type: 'ping' }), 30_000)
|
|
69
|
+
})
|
|
70
|
+
ws.on('message', raw => onMessage(ws, raw))
|
|
71
|
+
ws.on('close', (code, reason) => {
|
|
72
|
+
clearInterval(ping)
|
|
73
|
+
onClose(ws, code, reason)
|
|
74
|
+
})
|
|
75
|
+
ws.on('error', err => log(`socket error: ${err.message}`))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function onMessage(ws, raw) {
|
|
79
|
+
let msg
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(raw.toString())
|
|
82
|
+
} catch {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (msg.type === 'hello') return registered(ws, msg)
|
|
86
|
+
if (msg.type === 'job.answer') return answered(msg)
|
|
87
|
+
if (terminalMessage(msg)) return
|
|
88
|
+
if (msg.type !== 'call') return
|
|
89
|
+
const fn = settings.tools[msg.tool]
|
|
90
|
+
if (!fn) return send(ws, { type: 'error', id: msg.id, error: `unknown tool ${msg.tool}` })
|
|
91
|
+
try {
|
|
92
|
+
send(ws, { type: 'result', id: msg.id, result: await fn(msg.args ?? {}, ws) })
|
|
93
|
+
} catch (err) {
|
|
94
|
+
send(ws, { type: 'error', id: msg.id, error: err.message })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function registered(ws, msg) {
|
|
99
|
+
// Tell the API which tools this bridge build implements, so a stale
|
|
100
|
+
// bridge is reported as such instead of "unknown tool".
|
|
101
|
+
send(ws, { type: 'capabilities', ...settings.capabilities() })
|
|
102
|
+
live = ws
|
|
103
|
+
// Job events and results from while we were disconnected, in order.
|
|
104
|
+
if (outbox.length) {
|
|
105
|
+
log(`sending ${outbox.length} queued job messages`)
|
|
106
|
+
for (const o of outbox.splice(0)) ws.send(JSON.stringify(o))
|
|
107
|
+
}
|
|
108
|
+
status('connected')
|
|
109
|
+
log(`registered as bridge ${msg.bridgeId} (${Object.keys(settings.tools).length} tools)`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function answered(msg) {
|
|
113
|
+
const by = msg.by ?? 'portal'
|
|
114
|
+
if (answer(msg.jobId, msg.requestId, { answer: normalizeAnswer(msg.answer), by })) {
|
|
115
|
+
log(`request ${msg.requestId} for job ${String(msg.jobId).slice(0, 8)} answered by ${by}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onClose(ws, code, reason) {
|
|
120
|
+
if (live === ws) live = null
|
|
121
|
+
terminalsOffline()
|
|
122
|
+
if (code === 4001) {
|
|
123
|
+
log('token rejected; create a new one in the portal')
|
|
124
|
+
status('rejected')
|
|
125
|
+
if (!host().onStatus) process.exit(1) // the CLI's behavior; an app decides for itself
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (stopped) return
|
|
129
|
+
status('disconnected')
|
|
130
|
+
log(`disconnected (${code} ${reason}); retrying in ${backoff / 1000}s`)
|
|
131
|
+
reconnectTimer = setTimeout(open, backoff)
|
|
132
|
+
backoff = Math.min(backoff * 2, 30_000)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function send(ws, obj) {
|
|
136
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
|
137
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The bridge's file tools: listing, reading and changing files and folders on this Mac. Tools
|
|
2
|
+
// that change something report the git diff they made, like Claude jobs do.
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { stat } from 'node:fs/promises'
|
|
5
|
+
import { changesSince, snapshot } from './git.mjs'
|
|
6
|
+
import { createFolder, listFiles, readUserFile, resolveUserPath, trashPath, writeUserFile } from './files.mjs'
|
|
7
|
+
|
|
8
|
+
export const fileTools = {
|
|
9
|
+
async list_files(args) {
|
|
10
|
+
return listFiles(args)
|
|
11
|
+
},
|
|
12
|
+
async read_file(args) {
|
|
13
|
+
return readUserFile(args)
|
|
14
|
+
},
|
|
15
|
+
async create_folder(args) {
|
|
16
|
+
return withChange(args.path, () => createFolder(args))
|
|
17
|
+
},
|
|
18
|
+
async write_file(args) {
|
|
19
|
+
return withChange(args.path, () => writeUserFile(args))
|
|
20
|
+
},
|
|
21
|
+
async delete_path(args) {
|
|
22
|
+
return withChange(args.path, () => trashPath(args))
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run a file-changing tool with a git snapshot around it, so the API can show
|
|
28
|
+
* the diff the same way it does for Claude jobs. Outside a repo: no change.
|
|
29
|
+
*/
|
|
30
|
+
async function withChange(pathArg, fn) {
|
|
31
|
+
let before = null
|
|
32
|
+
try {
|
|
33
|
+
const full = resolveUserPath(String(pathArg ?? ''))
|
|
34
|
+
const isDir = await stat(full)
|
|
35
|
+
.then(s => s.isDirectory())
|
|
36
|
+
.catch(() => false)
|
|
37
|
+
before = await snapshot(isDir ? full : dirname(full))
|
|
38
|
+
} catch {
|
|
39
|
+
before = null
|
|
40
|
+
}
|
|
41
|
+
const result = await fn()
|
|
42
|
+
if (!before || (result && result.error)) return result
|
|
43
|
+
const change = await changesSince(before).catch(() => null)
|
|
44
|
+
return change ? { ...result, change } : result
|
|
45
|
+
}
|
package/src/files.mjs
CHANGED
|
@@ -10,9 +10,6 @@ const TEXT_LIMIT = 12000
|
|
|
10
10
|
const READ_LIMIT = 2 * 1024 * 1024
|
|
11
11
|
const SKIP = new Set(['node_modules', '.git', 'Library', '.Trash'])
|
|
12
12
|
|
|
13
|
-
/** Common starting points, shown as the top level on the glasses. */
|
|
14
|
-
export const ROOTS = ['Desktop', 'Documents', 'Downloads', 'Developer']
|
|
15
|
-
|
|
16
13
|
/** Expand ~ and confine to the home folder. Throws on escape. */
|
|
17
14
|
export function resolveUserPath(p) {
|
|
18
15
|
const raw = String(p ?? '').trim()
|
package/src/host.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// How the host hears from the bridge. The CLI passes nothing (logs to stdout, exits when the
|
|
2
|
+
// token is rejected); an app embedding it (the Kira desktop app) passes its own:
|
|
3
|
+
// log(line) every log line
|
|
4
|
+
// onStatus(status) 'connecting' | 'connected' | 'disconnected' | 'rejected'
|
|
5
|
+
// openTerminal({ cwd, command }) open_terminal in the app's own terminal (a Kira tab), not
|
|
6
|
+
// Terminal.app; resolves to what to report, or null to fall back
|
|
7
|
+
// terminals the app's shells, for remote devices (terminals.mjs)
|
|
8
|
+
// browser({ op, … }) Kira in the app's browser dock: open, read, click, type (browser-kira.mjs)
|
|
9
|
+
|
|
10
|
+
let hooks = {}
|
|
11
|
+
|
|
12
|
+
export function setHooks(hostHooks) {
|
|
13
|
+
hooks = hostHooks
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The current host's hooks (read at call time: startBridge may replace them). */
|
|
17
|
+
export function host() {
|
|
18
|
+
return hooks
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function status(s) {
|
|
22
|
+
hooks.onStatus?.(s)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function log(m) {
|
|
26
|
+
const line = `[kira-bridge ${new Date().toLocaleTimeString()}] ${m}`
|
|
27
|
+
if (hooks.log) hooks.log(line)
|
|
28
|
+
else console.log(line)
|
|
29
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,317 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Kira bridge. Connects outbound to the Kira API and runs tools on this
|
|
3
3
|
// machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import { scanDesigns } from './claude-designs.mjs'
|
|
7
|
-
import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
|
|
8
|
-
import { resolveCwd, folderExists } from './claude-run.mjs'
|
|
9
|
-
import { agentById, AGENTS } from './agents/index.mjs'
|
|
10
|
-
import { normalizeAnswer } from './agents/protocol.mjs'
|
|
11
|
-
import { answer, awaitAnswer, forgetJob } from './agents/answers.mjs'
|
|
12
|
-
import { openApp, installedApps } from './apps.mjs'
|
|
13
|
-
import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
|
|
14
|
-
import { initTerminals, terminalCall, terminalMessage, terminalsOffline } from './terminals.mjs'
|
|
15
|
-
import { gitTools } from './git-tools.mjs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { AGENTS } from './agents/index.mjs'
|
|
16
6
|
import { claudeTools } from './agents/claude-local.mjs'
|
|
7
|
+
import { skillTools } from './claude-config.mjs'
|
|
8
|
+
import { connect, disconnect, sendIfLive } from './connection.mjs'
|
|
9
|
+
import { fileTools } from './file-tools.mjs'
|
|
10
|
+
import { gitTools } from './git-tools.mjs'
|
|
11
|
+
import { host, log, setHooks } from './host.mjs'
|
|
12
|
+
import { jobTools } from './job-tools.mjs'
|
|
13
|
+
import { macTools } from './mac-tools.mjs'
|
|
14
|
+
import { sessionTools } from './session-tools.mjs'
|
|
15
|
+
import { initTerminals } from './terminals.mjs'
|
|
17
16
|
|
|
18
|
-
import { createRequire } from 'node:module'
|
|
19
17
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (outbox.length >= OUTBOX_MAX) outbox.shift()
|
|
36
|
-
outbox.push(obj)
|
|
19
|
+
const tools = {
|
|
20
|
+
...fileTools,
|
|
21
|
+
...macTools,
|
|
22
|
+
...sessionTools,
|
|
23
|
+
...jobTools,
|
|
24
|
+
...gitTools,
|
|
25
|
+
...claudeTools,
|
|
26
|
+
...skillTools,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What this bridge build implements, told to the API after its hello. */
|
|
30
|
+
function capabilities() {
|
|
31
|
+
const agents = Object.values(AGENTS).map(a => ({ id: a.id, name: a.name, capabilities: a.capabilities }))
|
|
32
|
+
return { tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform, agents }
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* log(line) every log line
|
|
43
|
-
* onStatus(status) 'connecting' | 'connected' | 'disconnected' | 'rejected'
|
|
44
|
-
* openTerminal({ cwd, command }) open_terminal in the app's own terminal (a Kira tab), not
|
|
45
|
-
* Terminal.app; resolves to what to report, or null to fall back
|
|
46
|
-
* terminals the app's shells, for remote devices (terminals.mjs)
|
|
47
|
-
* browser({ op, … }) Kira in the app's browser dock: open, read, click, type (browser-kira.mjs)
|
|
36
|
+
* Start the bridge with resolved settings. Runs until stopBridge() or the process exits.
|
|
37
|
+
* `hostHooks` is how the host hears from it (host.mjs).
|
|
48
38
|
*/
|
|
49
|
-
let hooks = {}
|
|
50
|
-
let stopped = false
|
|
51
|
-
let reconnectTimer = null
|
|
52
|
-
|
|
53
|
-
/** Start the bridge with resolved settings. Runs until stopBridge() or the process exits. */
|
|
54
39
|
export function startBridge(config, hostHooks = {}) {
|
|
55
|
-
|
|
56
|
-
URL_ = config.url
|
|
57
|
-
TOKEN = config.token
|
|
40
|
+
setHooks(hostHooks)
|
|
58
41
|
if (config.claudeBin) process.env.KIRA_CLAUDE_BIN = config.claudeBin
|
|
59
|
-
if (!
|
|
60
|
-
throw new Error(
|
|
42
|
+
if (!config.url || !config.token) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)',
|
|
45
|
+
)
|
|
61
46
|
}
|
|
62
|
-
stopped = false
|
|
63
47
|
// Terminal output is live or nothing: never queued for later like job events.
|
|
64
|
-
initTerminals(() =>
|
|
48
|
+
initTerminals(() => host().terminals ?? null, sendIfLive)
|
|
65
49
|
log(`kira-bridge ${BRIDGE_VERSION} · agents: ${Object.keys(AGENTS).join(', ')}`)
|
|
66
|
-
connect()
|
|
50
|
+
connect({ url: config.url, token: config.token, tools, capabilities })
|
|
67
51
|
}
|
|
68
52
|
|
|
69
53
|
/** Disconnect and stop reconnecting (the app calls this on sign-out or a token change). */
|
|
70
54
|
export function stopBridge() {
|
|
71
|
-
|
|
72
|
-
clearTimeout(reconnectTimer)
|
|
73
|
-
const ws = live
|
|
74
|
-
live = null
|
|
75
|
-
try {
|
|
76
|
-
ws?.close(1000, 'stopped')
|
|
77
|
-
} catch {
|
|
78
|
-
/* already closed */
|
|
79
|
-
}
|
|
80
|
-
status('disconnected')
|
|
55
|
+
disconnect()
|
|
81
56
|
}
|
|
82
57
|
|
|
83
58
|
export const bridgeVersion = BRIDGE_VERSION
|
|
84
|
-
|
|
85
|
-
function status(s) {
|
|
86
|
-
hooks.onStatus?.(s)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const tools = {
|
|
90
|
-
async list_files(args) {
|
|
91
|
-
return listFiles(args)
|
|
92
|
-
},
|
|
93
|
-
async read_file(args) {
|
|
94
|
-
return readUserFile(args)
|
|
95
|
-
},
|
|
96
|
-
async create_folder(args) {
|
|
97
|
-
return withChange(args.path, () => createFolder(args))
|
|
98
|
-
},
|
|
99
|
-
async write_file(args) {
|
|
100
|
-
return withChange(args.path, () => writeUserFile(args))
|
|
101
|
-
},
|
|
102
|
-
async open_app(args) {
|
|
103
|
-
return openApp(args)
|
|
104
|
-
},
|
|
105
|
-
async delete_path(args) {
|
|
106
|
-
return withChange(args.path, () => trashPath(args))
|
|
107
|
-
},
|
|
108
|
-
async list_apps() {
|
|
109
|
-
return { apps: await installedApps() }
|
|
110
|
-
},
|
|
111
|
-
async list_claude_projects(args) {
|
|
112
|
-
return listProjects(args.max)
|
|
113
|
-
},
|
|
114
|
-
async scan_claude_designs(args) {
|
|
115
|
-
return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [], sessions: args.sessions })
|
|
116
|
-
},
|
|
117
|
-
async list_claude_sessions(args) {
|
|
118
|
-
return listSessions(args.max)
|
|
119
|
-
},
|
|
120
|
-
async read_claude_session(args) {
|
|
121
|
-
return readSession(String(args.session_id ?? ''), args.turns)
|
|
122
|
-
},
|
|
123
|
-
/**
|
|
124
|
-
* Run an instruction with a coding agent. Acknowledges at once, streams every
|
|
125
|
-
* protocol event as `job.event`, and reports the outcome as `job` when done.
|
|
126
|
-
* Requests that need a person park the job until `job.answer` arrives.
|
|
127
|
-
*/
|
|
128
|
-
async run_agent(args, ws) {
|
|
129
|
-
const jobId = String(args.job_id ?? '')
|
|
130
|
-
const agent = agentById(args.agent)
|
|
131
|
-
if (!agent) return { error: `unknown agent ${args.agent}` }
|
|
132
|
-
const prompt = String(args.prompt ?? args.message ?? args.text ?? args.instruction ?? '').trim()
|
|
133
|
-
if (!prompt) return { error: 'prompt is required' }
|
|
134
|
-
let cwd = args.cwd
|
|
135
|
-
if (!cwd && args.session_id) {
|
|
136
|
-
const s = await readSession(String(args.session_id), 1)
|
|
137
|
-
if (s?.cwd) cwd = s.cwd
|
|
138
|
-
}
|
|
139
|
-
const dir = resolveCwd(cwd)
|
|
140
|
-
if (!folderExists(dir)) {
|
|
141
|
-
// The SDK would report a spawn failure as a broken binary; the folder is simply not there.
|
|
142
|
-
return { error: `The folder ${display(dir)} does not exist on this Mac. Link the project's real folder in the portal, or clone its repository there first (setup_project_folder); never guess a path.` }
|
|
143
|
-
}
|
|
144
|
-
log(`${agent.id} job=${jobId.slice(0, 8)}${args.persona?.name ? ` as ${args.persona.name}` : ''} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
|
|
145
|
-
let seq = 0
|
|
146
|
-
const io = {
|
|
147
|
-
emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
|
|
148
|
-
ask: (request, signal) => awaitAnswer(jobId, request, signal),
|
|
149
|
-
}
|
|
150
|
-
const abort = new AbortController()
|
|
151
|
-
running.set(jobId, abort)
|
|
152
|
-
// Started is said at once. The git snapshot (what changed, reported at the end) is taken first but
|
|
153
|
-
// in the background: in a big repository it takes longer than the API waits for this answer.
|
|
154
|
-
const before = snapshot(dir).catch(() => null)
|
|
155
|
-
const persona = args.persona ?? null
|
|
156
|
-
const settings = args.settings ?? null
|
|
157
|
-
const job = { prompt, sessionId: args.session_id, cwd: dir, policy: args.policy ?? {}, persona, settings, abort }
|
|
158
|
-
void before.then(() => agent.run(job, io)).then(async outcome => {
|
|
159
|
-
running.delete(jobId)
|
|
160
|
-
forgetJob(jobId)
|
|
161
|
-
let change = null
|
|
162
|
-
const was = await before
|
|
163
|
-
if (was) change = await changesSince(was).catch(err => (log(`git after job failed: ${err.message}`), null))
|
|
164
|
-
log(`job ${jobId.slice(0, 8)} ${outcome.ok ? 'done' : 'error'}${change ? ` · ${change.stats.files} files +${change.stats.adds} -${change.stats.dels}, ${change.commits.length} commits` : ''}`)
|
|
165
|
-
const result = { session_id: outcome.sessionId, result: outcome.text ?? '', stats: outcome.stats, cwd: display(dir), change, agent: agent.id }
|
|
166
|
-
sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
|
|
167
|
-
})
|
|
168
|
-
return { started: true, agent: agent.id, cwd: display(dir) }
|
|
169
|
-
},
|
|
170
|
-
/** Stop a running job: the agent is cut off; whatever it committed stays. */
|
|
171
|
-
stop_job(args) {
|
|
172
|
-
const abort = running.get(String(args.job_id ?? ''))
|
|
173
|
-
if (!abort) return { stopped: false, note: 'no such job running here' }
|
|
174
|
-
abort.abort()
|
|
175
|
-
return { stopped: true }
|
|
176
|
-
},
|
|
177
|
-
/** Older API builds still call ask_claude. */
|
|
178
|
-
ask_claude(args, ws) {
|
|
179
|
-
return tools.run_agent({ ...args, agent: 'claude' }, ws)
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
/** A new project's folder on this Mac: created, cloned from its repo or started fresh. */
|
|
183
|
-
async setup_project_folder(args) {
|
|
184
|
-
const dir = resolveUserPath(String(args.path ?? ''))
|
|
185
|
-
return setupProjectFolder({ dir, name: args.name, description: args.description, sshUrl: args.ssh_url, httpsUrl: args.https_url })
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
/** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
|
|
189
|
-
terminal: args => terminalCall(args),
|
|
190
|
-
|
|
191
|
-
/** Kira in the Mac app's browser dock: open a page, read it, click and type in it. */
|
|
192
|
-
async browser(args) {
|
|
193
|
-
if (!hooks.browser) return { error: 'the browser needs the Kira Mac app (this bridge has none)', reason: 'unavailable' }
|
|
194
|
-
return hooks.browser(args)
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
/** Open a terminal on this Mac in a folder and run a command there (used to hand a Claude session
|
|
198
|
-
* to the person): a Kira tab when the bridge runs inside the Mac app, else Terminal.app. */
|
|
199
|
-
async open_terminal(args) {
|
|
200
|
-
const dir = resolveUserPath(String(args.cwd ?? ''))
|
|
201
|
-
const command = String(args.command ?? '').replace(/[^\w\s./:@=-]/g, '')
|
|
202
|
-
if (hooks.openTerminal) {
|
|
203
|
-
const r = await Promise.resolve(hooks.openTerminal({ cwd: dir, command })).catch(err => ({ error: err?.message ?? String(err) }))
|
|
204
|
-
if (r) return { ...r, cwd: display(dir), command }
|
|
205
|
-
}
|
|
206
|
-
if (process.platform !== 'darwin') return { error: 'opening a terminal is only supported on macOS' }
|
|
207
|
-
const { execFile } = await import('node:child_process')
|
|
208
|
-
const script = `tell application "Terminal"
|
|
209
|
-
activate
|
|
210
|
-
do script "cd ${JSON.stringify(dir)} && ${command}"
|
|
211
|
-
end tell`
|
|
212
|
-
return new Promise(resolve => execFile('osascript', ['-e', script], err => resolve(err ? { error: err.message } : { opened: true, cwd: display(dir), command })))
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
...gitTools,
|
|
216
|
-
...claudeTools,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Run a file-changing tool with a git snapshot around it, so the API can show
|
|
223
|
-
* the diff the same way it does for Claude jobs. Outside a repo: no change.
|
|
224
|
-
*/
|
|
225
|
-
async function withChange(pathArg, fn) {
|
|
226
|
-
let before = null
|
|
227
|
-
try {
|
|
228
|
-
const full = resolveUserPath(String(pathArg ?? ''))
|
|
229
|
-
const { dirname } = await import('node:path')
|
|
230
|
-
const { stat } = await import('node:fs/promises')
|
|
231
|
-
const isDir = await stat(full).then(s => s.isDirectory()).catch(() => false)
|
|
232
|
-
before = await snapshot(isDir ? full : dirname(full))
|
|
233
|
-
} catch {
|
|
234
|
-
before = null
|
|
235
|
-
}
|
|
236
|
-
const result = await fn()
|
|
237
|
-
if (!before || (result && result.error)) return result
|
|
238
|
-
const change = await changesSince(before).catch(() => null)
|
|
239
|
-
return change ? { ...result, change } : result
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
let backoff = 1000
|
|
243
|
-
function connect() {
|
|
244
|
-
if (stopped) return
|
|
245
|
-
status('connecting')
|
|
246
|
-
const ws = new WebSocket(`${URL_}?token=${encodeURIComponent(TOKEN)}`)
|
|
247
|
-
let ping
|
|
248
|
-
ws.on('open', () => {
|
|
249
|
-
backoff = 1000
|
|
250
|
-
log(`connected to ${URL_}`)
|
|
251
|
-
ping = setInterval(() => send(ws, { type: 'ping' }), 30_000)
|
|
252
|
-
})
|
|
253
|
-
ws.on('message', async raw => {
|
|
254
|
-
let msg
|
|
255
|
-
try {
|
|
256
|
-
msg = JSON.parse(raw.toString())
|
|
257
|
-
} catch {
|
|
258
|
-
return
|
|
259
|
-
}
|
|
260
|
-
if (msg.type === 'hello') {
|
|
261
|
-
// Tell the API which tools this bridge build implements, so a stale
|
|
262
|
-
// bridge is reported as such instead of "unknown tool".
|
|
263
|
-
send(ws, { type: 'capabilities', tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform, agents: Object.values(AGENTS).map(a => ({ id: a.id, name: a.name, capabilities: a.capabilities })) })
|
|
264
|
-
live = ws
|
|
265
|
-
// Job events and results from while we were disconnected, in order.
|
|
266
|
-
if (outbox.length) {
|
|
267
|
-
log(`sending ${outbox.length} queued job messages`)
|
|
268
|
-
for (const o of outbox.splice(0)) ws.send(JSON.stringify(o))
|
|
269
|
-
}
|
|
270
|
-
status('connected')
|
|
271
|
-
return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
|
|
272
|
-
}
|
|
273
|
-
if (msg.type === 'job.answer') {
|
|
274
|
-
const by = msg.by ?? 'portal'
|
|
275
|
-
if (answer(msg.jobId, msg.requestId, { answer: normalizeAnswer(msg.answer), by })) {
|
|
276
|
-
log(`request ${msg.requestId} for job ${String(msg.jobId).slice(0, 8)} answered by ${msg.by ?? 'portal'}`)
|
|
277
|
-
}
|
|
278
|
-
return
|
|
279
|
-
}
|
|
280
|
-
if (terminalMessage(msg)) return
|
|
281
|
-
if (msg.type !== 'call') return
|
|
282
|
-
const fn = tools[msg.tool]
|
|
283
|
-
if (!fn) return send(ws, { type: 'error', id: msg.id, error: `unknown tool ${msg.tool}` })
|
|
284
|
-
try {
|
|
285
|
-
send(ws, { type: 'result', id: msg.id, result: await fn(msg.args ?? {}, ws) })
|
|
286
|
-
} catch (err) {
|
|
287
|
-
send(ws, { type: 'error', id: msg.id, error: err.message })
|
|
288
|
-
}
|
|
289
|
-
})
|
|
290
|
-
ws.on('close', (code, reason) => {
|
|
291
|
-
clearInterval(ping)
|
|
292
|
-
if (live === ws) live = null
|
|
293
|
-
terminalsOffline()
|
|
294
|
-
if (code === 4001) {
|
|
295
|
-
log('token rejected; create a new one in the portal')
|
|
296
|
-
status('rejected')
|
|
297
|
-
if (!hooks.onStatus) process.exit(1) // the CLI's behavior; an app decides for itself
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
if (stopped) return
|
|
301
|
-
status('disconnected')
|
|
302
|
-
log(`disconnected (${code} ${reason}); retrying in ${backoff / 1000}s`)
|
|
303
|
-
reconnectTimer = setTimeout(connect, backoff)
|
|
304
|
-
backoff = Math.min(backoff * 2, 30_000)
|
|
305
|
-
})
|
|
306
|
-
ws.on('error', err => log(`socket error: ${err.message}`))
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function send(ws, obj) {
|
|
310
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
|
311
|
-
}
|
|
312
|
-
function log(m) {
|
|
313
|
-
const line = `[kira-bridge ${new Date().toLocaleTimeString()}] ${m}`
|
|
314
|
-
if (hooks.log) hooks.log(line)
|
|
315
|
-
else console.log(line)
|
|
316
|
-
}
|
|
317
|
-
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What a run_agent call asks for and how its outcome reads in the log: pure, so it's tested
|
|
2
|
+
// (test/job-format.test.mjs) apart from the socket and the agents.
|
|
3
|
+
|
|
4
|
+
/** The instruction, whichever name an API build sends it under. */
|
|
5
|
+
export function promptOf(args) {
|
|
6
|
+
return String(args.prompt ?? args.message ?? args.text ?? args.instruction ?? '').trim()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The log line when a job starts. */
|
|
10
|
+
export function jobStartLine({ agentId, jobId, persona, sessionId, dir, prompt }) {
|
|
11
|
+
const as = persona?.name ? ` as ${persona.name}` : ''
|
|
12
|
+
return `${agentId} job=${jobId.slice(0, 8)}${as} session=${sessionId ?? 'new'} in ${dir}: ${prompt.slice(0, 80)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The log line when a job ends: its outcome and, in a repository, what it changed. */
|
|
16
|
+
export function jobEndLine(jobId, ok, change) {
|
|
17
|
+
const outcome = `job ${jobId.slice(0, 8)} ${ok ? 'done' : 'error'}`
|
|
18
|
+
if (!change) return outcome
|
|
19
|
+
const { files, adds, dels } = change.stats
|
|
20
|
+
return `${outcome} · ${files} files +${adds} -${dels}, ${change.commits.length} commits`
|
|
21
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The bridge's coding-agent jobs: starting one (acknowledged at once, streamed as it runs,
|
|
2
|
+
// reported when done) and stopping one.
|
|
3
|
+
import { agentById } from './agents/index.mjs'
|
|
4
|
+
import { awaitAnswer, forgetJob } from './agents/answers.mjs'
|
|
5
|
+
import { resolveCwd, folderExists } from './claude-run.mjs'
|
|
6
|
+
import { readSession } from './claude-sessions.mjs'
|
|
7
|
+
import { changesSince, display, snapshot } from './git.mjs'
|
|
8
|
+
import { sendLive } from './connection.mjs'
|
|
9
|
+
import { log } from './host.mjs'
|
|
10
|
+
import { jobEndLine, jobStartLine, promptOf } from './job-format.mjs'
|
|
11
|
+
|
|
12
|
+
/** Running jobs, by id, so a person can stop one. */
|
|
13
|
+
const running = new Map()
|
|
14
|
+
|
|
15
|
+
export const jobTools = {
|
|
16
|
+
/**
|
|
17
|
+
* Run an instruction with a coding agent. Acknowledges at once, streams every
|
|
18
|
+
* protocol event as `job.event`, and reports the outcome as `job` when done.
|
|
19
|
+
* Requests that need a person park the job until `job.answer` arrives.
|
|
20
|
+
*/
|
|
21
|
+
async run_agent(args) {
|
|
22
|
+
const jobId = String(args.job_id ?? '')
|
|
23
|
+
const agent = agentById(args.agent)
|
|
24
|
+
if (!agent) return { error: `unknown agent ${args.agent}` }
|
|
25
|
+
const prompt = promptOf(args)
|
|
26
|
+
if (!prompt) return { error: 'prompt is required' }
|
|
27
|
+
const dir = resolveCwd(await jobCwd(args))
|
|
28
|
+
if (!folderExists(dir)) {
|
|
29
|
+
// The SDK would report a spawn failure as a broken binary; the folder is simply not there.
|
|
30
|
+
return {
|
|
31
|
+
error:
|
|
32
|
+
`The folder ${display(dir)} does not exist on this Mac. Link the project's real folder in the portal, ` +
|
|
33
|
+
`or clone its repository there first (setup_project_folder); never guess a path.`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const sessionId = args.session_id
|
|
37
|
+
log(jobStartLine({ agentId: agent.id, jobId, persona: args.persona, sessionId, dir: display(dir), prompt }))
|
|
38
|
+
let seq = 0
|
|
39
|
+
const io = {
|
|
40
|
+
emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
|
|
41
|
+
ask: (request, signal) => awaitAnswer(jobId, request, signal),
|
|
42
|
+
}
|
|
43
|
+
const abort = new AbortController()
|
|
44
|
+
running.set(jobId, abort)
|
|
45
|
+
// Started is said at once. The git snapshot (what changed, reported at the end) is taken first but
|
|
46
|
+
// in the background: in a big repository it takes longer than the API waits for this answer.
|
|
47
|
+
const before = snapshot(dir).catch(() => null)
|
|
48
|
+
const persona = args.persona ?? null
|
|
49
|
+
const settings = args.settings ?? null
|
|
50
|
+
const job = { prompt, sessionId, cwd: dir, policy: args.policy ?? {}, persona, settings, abort }
|
|
51
|
+
void before.then(() => agent.run(job, io)).then(outcome => finish({ jobId, agent, dir, before, outcome }))
|
|
52
|
+
return { started: true, agent: agent.id, cwd: display(dir) }
|
|
53
|
+
},
|
|
54
|
+
/** Stop a running job: the agent is cut off; whatever it committed stays. */
|
|
55
|
+
stop_job(args) {
|
|
56
|
+
const abort = running.get(String(args.job_id ?? ''))
|
|
57
|
+
if (!abort) return { stopped: false, note: 'no such job running here' }
|
|
58
|
+
abort.abort()
|
|
59
|
+
return { stopped: true }
|
|
60
|
+
},
|
|
61
|
+
/** Older API builds still call ask_claude. */
|
|
62
|
+
ask_claude(args) {
|
|
63
|
+
return jobTools.run_agent({ ...args, agent: 'claude' })
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The folder a job runs in: as given, else the folder of the session it continues. */
|
|
68
|
+
async function jobCwd(args) {
|
|
69
|
+
if (args.cwd || !args.session_id) return args.cwd
|
|
70
|
+
const s = await readSession(String(args.session_id), 1)
|
|
71
|
+
return s?.cwd ? s.cwd : args.cwd
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A job ended: what it changed in the repository, and its outcome, go to the API. */
|
|
75
|
+
async function finish({ jobId, agent, dir, before, outcome }) {
|
|
76
|
+
running.delete(jobId)
|
|
77
|
+
forgetJob(jobId)
|
|
78
|
+
let change = null
|
|
79
|
+
const was = await before
|
|
80
|
+
if (was) {
|
|
81
|
+
change = await changesSince(was).catch(err => {
|
|
82
|
+
log(`git after job failed: ${err.message}`)
|
|
83
|
+
return null
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
log(jobEndLine(jobId, outcome.ok, change))
|
|
87
|
+
const result = {
|
|
88
|
+
session_id: outcome.sessionId,
|
|
89
|
+
result: outcome.text ?? '',
|
|
90
|
+
stats: outcome.stats,
|
|
91
|
+
cwd: display(dir),
|
|
92
|
+
change,
|
|
93
|
+
agent: agent.id,
|
|
94
|
+
}
|
|
95
|
+
sendLive({ type: 'job', jobId, status: outcome.ok ? 'done' : 'error', result, error: outcome.error })
|
|
96
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The bridge's tools for this Mac itself: its apps, a terminal, a new project's folder, and
|
|
2
|
+
// (inside the Mac app only) its shells for other devices and its browser dock.
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { openApp, installedApps } from './apps.mjs'
|
|
5
|
+
import { resolveUserPath } from './files.mjs'
|
|
6
|
+
import { display, setupProjectFolder } from './git.mjs'
|
|
7
|
+
import { terminalCall } from './terminals.mjs'
|
|
8
|
+
import { safeCommand, terminalScript } from './terminal-command.mjs'
|
|
9
|
+
import { host } from './host.mjs'
|
|
10
|
+
|
|
11
|
+
export const macTools = {
|
|
12
|
+
async open_app(args) {
|
|
13
|
+
return openApp(args)
|
|
14
|
+
},
|
|
15
|
+
async list_apps() {
|
|
16
|
+
return { apps: await installedApps() }
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/** A new project's folder on this Mac: created, cloned from its repo or started fresh. */
|
|
20
|
+
async setup_project_folder(args) {
|
|
21
|
+
const dir = resolveUserPath(String(args.path ?? ''))
|
|
22
|
+
return setupProjectFolder({
|
|
23
|
+
dir,
|
|
24
|
+
name: args.name,
|
|
25
|
+
description: args.description,
|
|
26
|
+
sshUrl: args.ssh_url,
|
|
27
|
+
httpsUrl: args.https_url,
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
|
|
32
|
+
terminal: args => terminalCall(args),
|
|
33
|
+
|
|
34
|
+
/** Kira in the Mac app's browser dock: open a page, read it, click and type in it. */
|
|
35
|
+
async browser(args) {
|
|
36
|
+
const hooks = host()
|
|
37
|
+
if (!hooks.browser) {
|
|
38
|
+
return { error: 'the browser needs the Kira Mac app (this bridge has none)', reason: 'unavailable' }
|
|
39
|
+
}
|
|
40
|
+
return hooks.browser(args)
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/** Open a terminal on this Mac in a folder and run a command there (used to hand a Claude session
|
|
44
|
+
* to the person): a Kira tab when the bridge runs inside the Mac app, else Terminal.app. */
|
|
45
|
+
async open_terminal(args) {
|
|
46
|
+
const dir = resolveUserPath(String(args.cwd ?? ''))
|
|
47
|
+
const command = safeCommand(args.command)
|
|
48
|
+
const hooks = host()
|
|
49
|
+
if (hooks.openTerminal) {
|
|
50
|
+
const r = await Promise.resolve(hooks.openTerminal({ cwd: dir, command })).catch(err => ({
|
|
51
|
+
error: err?.message ?? String(err),
|
|
52
|
+
}))
|
|
53
|
+
if (r) return { ...r, cwd: display(dir), command }
|
|
54
|
+
}
|
|
55
|
+
if (process.platform !== 'darwin') return { error: 'opening a terminal is only supported on macOS' }
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
execFile('osascript', ['-e', terminalScript(dir, command)], err => {
|
|
58
|
+
resolve(err ? { error: err.message } : { opened: true, cwd: display(dir), command })
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
}
|
package/src/service.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { KIRA_DIR, LOG_PATH } from './config.mjs'
|
|
|
10
10
|
// working after npx's cache is pruned and survives package upgrades cleanly.
|
|
11
11
|
const APP_DIR = join(KIRA_DIR, 'app')
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
const LABEL = 'com.kira.bridge'
|
|
14
14
|
const PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
|
|
15
15
|
|
|
16
16
|
function nodePath() {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The bridge's read-only Claude Code tools: the projects and sessions on this Mac, a session's
|
|
2
|
+
// transcript, and the designs found in them.
|
|
3
|
+
import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
|
|
4
|
+
import { scanDesigns } from './claude-designs.mjs'
|
|
5
|
+
|
|
6
|
+
export const sessionTools = {
|
|
7
|
+
async list_claude_projects(args) {
|
|
8
|
+
return listProjects(args.max)
|
|
9
|
+
},
|
|
10
|
+
async scan_claude_designs(args) {
|
|
11
|
+
const known = Array.isArray(args.known) ? args.known : []
|
|
12
|
+
return scanDesigns({ since: args.since, known, sessions: args.sessions })
|
|
13
|
+
},
|
|
14
|
+
async list_claude_sessions(args) {
|
|
15
|
+
return listSessions(args.max)
|
|
16
|
+
},
|
|
17
|
+
async read_claude_session(args) {
|
|
18
|
+
return readSession(String(args.session_id ?? ''), args.turns)
|
|
19
|
+
},
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// open_terminal's command and the AppleScript that runs it in Terminal.app: pure, so it's tested
|
|
2
|
+
// (test/terminal-command.test.mjs).
|
|
3
|
+
|
|
4
|
+
/** Only characters a plain command needs: nothing that could break out of the script's quoting. */
|
|
5
|
+
export function safeCommand(command) {
|
|
6
|
+
return String(command ?? '').replace(/[^\w\s./:@=-]/g, '')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Terminal.app, in front, running `command` in `dir`. */
|
|
10
|
+
export function terminalScript(dir, command) {
|
|
11
|
+
return `tell application "Terminal"
|
|
12
|
+
activate
|
|
13
|
+
do script "cd ${JSON.stringify(dir)} && ${command}"
|
|
14
|
+
end tell`
|
|
15
|
+
}
|