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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- export function describeAction(name, input, cwd) {
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
- export async function run({ prompt, sessionId, cwd, policy, persona, settings, abort }, io) {
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
@@ -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
- export const READ_ONLY = new Set([
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
- export const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
30
- export const WEB = new Set(['WebFetch', 'WebSearch'])
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
- export const KIRA_TOOLS = 'mcp__kira__'
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
- if (toolName === 'Bash' && c && (c === rule || c.startsWith(rule + ' ') || pieces(c).some(p => p === rule || p.startsWith(rule + ' ')))) return true
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
- export function insideFolder(cwd, p) {
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
- if (matchesRule(p.askRules ?? [], toolName, command)) return { decision: 'ask', reason: 'your policy always asks for this' }
151
- if (matchesRule([...(p.allowRules ?? []), ...sessionRules], toolName, command)) return { decision: 'allow', reason: 'allowed by your policy' }
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
- if (insideFolder(cwd, file)) return { decision: p.edits === 'ask' ? 'ask' : 'allow', reason: 'edit inside the project' }
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 { decision: 'allow', reason: 'read-only command' }
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
- export async function resolveApp(name) {
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
- export const CONFIG_PATH = join(KIRA_DIR, 'bridge.json')
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 WebSocket from 'ws'
5
- import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
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
- let URL_ = ''
22
- let TOKEN = ''
23
- /** Running jobs, by id, so a person can stop one. */
24
- const running = new Map()
25
- /**
26
- * The live connection, and what could not be sent while there was none. Jobs
27
- * outlive connections (every API deploy reconnects us), so their events and
28
- * results go through here rather than the socket they were started on.
29
- */
30
- let live = null
31
- const outbox = []
32
- const OUTBOX_MAX = 2000
33
- function sendLive(obj) {
34
- if (live && live.readyState === WebSocket.OPEN) return live.send(JSON.stringify(obj))
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
- * How the host hears from the bridge. The CLI passes nothing (logs to stdout, exits when the
41
- * token is rejected); an app embedding it (the Kira desktop app) passes its own:
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
- hooks = hostHooks
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 (!URL_ || !TOKEN) {
60
- throw new Error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
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(() => hooks.terminals ?? null, obj => live?.readyState === WebSocket.OPEN && live.send(JSON.stringify(obj)))
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
- stopped = true
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
- export const LABEL = 'com.kira.bridge'
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
+ }