kira-glasses-bridge 2.26.0 → 2.28.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 +2 -1
- package/src/agent-files.mjs +59 -25
- package/src/agents/claude.mjs +4 -22
- package/src/agents/codex-agent.mjs +47 -0
- package/src/agents/codex-items.mjs +96 -0
- package/src/agents/codex-local.mjs +42 -0
- package/src/agents/codex-rpc.mjs +91 -0
- package/src/agents/codex.mjs +147 -0
- package/src/agents/index.mjs +3 -2
- package/src/agents/settings.mjs +7 -4
- package/src/config-folders.mjs +1 -1
- package/src/connection.mjs +12 -5
- package/src/heartbeat.mjs +34 -0
- package/src/index.mjs +5 -2
- package/src/job-tools.mjs +5 -0
- package/src/kira-plugin.mjs +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.28.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": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@anthropic-ai/claude-agent-sdk": "^0.3.278",
|
|
22
|
+
"smol-toml": "1.9.0",
|
|
22
23
|
"ws": "^8.18.0"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
package/src/agent-files.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// Subagents on this Mac: Claude Code's (one Markdown file each in
|
|
2
|
-
// for its settings and the body as its system prompt, with
|
|
3
|
-
// ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Subagents on this Mac, in each agent's own format: Claude Code's (one Markdown file each in
|
|
2
|
+
// ~/.claude/agents, front matter for its settings and the body as its system prompt, with
|
|
3
|
+
// `memory: user` kept in ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (one TOML file each
|
|
4
|
+
// in ~/.codex/agents: name, description, developer_instructions and any config key; Codex keeps no
|
|
5
|
+
// memory per agent). Kira's agents are one or the other: she writes their files (and a Claude Code
|
|
6
|
+
// one's memory) here, and a task runs as one (agents/claude.mjs, agents/codex-agent.mjs).
|
|
6
7
|
import { readdir, unlink } from 'node:fs/promises'
|
|
7
8
|
import { join } from 'node:path'
|
|
9
|
+
import { parse } from 'smol-toml'
|
|
8
10
|
import {
|
|
9
11
|
FOLDERS,
|
|
10
12
|
agentFolders,
|
|
@@ -29,12 +31,34 @@ const LOAD_BYTES = 25 * 1024
|
|
|
29
31
|
const KIRAS_LINE = /^- \d{4}-\d{2}-\d{2}: /
|
|
30
32
|
const WHAT = 'a subagent'
|
|
31
33
|
const NAMED = { loose: true }
|
|
32
|
-
const claudeRoot = () => agentFolders()[0].root
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
+
/** Each agent's folder and file: where Kira writes it, and what a file of it must be. */
|
|
36
|
+
const KINDS = {
|
|
37
|
+
claude: { root: () => agentFolders()[0].root, ext: '.md', check: checkMd },
|
|
38
|
+
codex: { root: () => agentFolders()[1].root, ext: '.toml', check: checkToml },
|
|
39
|
+
}
|
|
40
|
+
const kindOf = provider => KINDS[provider ?? 'claude'] ?? KINDS.claude
|
|
41
|
+
|
|
42
|
+
function checkMd(text) {
|
|
43
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A subagent file starts with front matter between --- lines')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkToml(text) {
|
|
47
|
+
try {
|
|
48
|
+
parse(text)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`A Codex agent file is TOML: ${err.message}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A Codex agent file's string field, or null (and null for all when it isn't TOML). */
|
|
35
55
|
const tomlField = (toml, key) => {
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
try {
|
|
57
|
+
const v = parse(toml)[key]
|
|
58
|
+
return typeof v === 'string' ? v : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
38
62
|
}
|
|
39
63
|
|
|
40
64
|
/** The name and description a tool shows, from a subagent file (its file name when it has none). */
|
|
@@ -78,34 +102,44 @@ async function readMemory(name, root) {
|
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
/**
|
|
106
|
+
* One subagent, `provider`'s (Claude Code's by default), with its memory (Claude Code's only). `md`
|
|
107
|
+
* is its file's text whatever the format: Markdown for Claude Code, TOML for Codex.
|
|
108
|
+
*/
|
|
109
|
+
export async function readAgent({ slug, provider } = {}, root, memoryRoot = FOLDERS.claude.agentMemory()) {
|
|
110
|
+
const kind = kindOf(provider)
|
|
111
|
+
const file = inside(root ?? kind.root(), slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
84
112
|
const md = await readSmall(file, MAX_MD)
|
|
85
113
|
if (md === null) return { error: `No subagent at ${display(file)}` }
|
|
86
|
-
const summary = agentSummary(md, slug)
|
|
87
|
-
|
|
114
|
+
const summary = agentSummary(md, slug, kind.ext)
|
|
115
|
+
const memory = kind === KINDS.claude ? await readMemory(summary.name, memoryRoot) : ''
|
|
116
|
+
return { slug, ...summary, path: display(file), md, memory }
|
|
88
117
|
}
|
|
89
118
|
|
|
90
119
|
/**
|
|
91
|
-
* Write a
|
|
92
|
-
* one (a subagent already there, Kira's or not, is never overwritten by a new one of that name).
|
|
120
|
+
* Write a subagent's file (`provider`'s), whole: replacing the one there, or with `create` only a
|
|
121
|
+
* new one (a subagent already there, Kira's or not, is never overwritten by a new one of that name).
|
|
93
122
|
*/
|
|
94
|
-
export async function writeAgent({ slug, md, create = false } = {}, root
|
|
95
|
-
const
|
|
123
|
+
export async function writeAgent({ slug, md, create = false, provider } = {}, root) {
|
|
124
|
+
const kind = kindOf(provider)
|
|
125
|
+
const dir = root ?? kind.root()
|
|
126
|
+
const file = inside(dir, slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
96
127
|
const text = String(md ?? '')
|
|
97
|
-
|
|
128
|
+
kind.check(text)
|
|
98
129
|
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That subagent file is too large')
|
|
99
|
-
await refuseLinks([
|
|
130
|
+
await refuseLinks([dir, file], 'edit')
|
|
100
131
|
if (create && (await exists(file))) throw new Error(`A subagent named ${slug} is already on your Mac`)
|
|
101
132
|
await writeWhole(file, text)
|
|
102
|
-
|
|
133
|
+
const providers = [kind === KINDS.codex ? 'codex' : 'claude']
|
|
134
|
+
return { slug, ...agentSummary(text, slug, kind.ext), path: display(file), providers }
|
|
103
135
|
}
|
|
104
136
|
|
|
105
|
-
/** Remove a
|
|
106
|
-
export async function removeAgent({ slug } = {}, root
|
|
107
|
-
const
|
|
108
|
-
|
|
137
|
+
/** Remove a subagent's file (`provider`'s). A Claude Code one's memory folder stays (it's what it learned). */
|
|
138
|
+
export async function removeAgent({ slug, provider } = {}, root) {
|
|
139
|
+
const kind = kindOf(provider)
|
|
140
|
+
const dir = root ?? kind.root()
|
|
141
|
+
const file = inside(dir, slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
142
|
+
await refuseLinks([dir, file], 'remove')
|
|
109
143
|
const removed = await unlink(file).then(
|
|
110
144
|
() => true,
|
|
111
145
|
e => {
|
package/src/agents/claude.mjs
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
// Claude Code as an agent, through the Agent SDK. Runs in this process on the
|
|
2
2
|
// user's Mac with their own Claude login; files never leave the machine.
|
|
3
3
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
4
|
-
import { resolve, sep } from 'node:path'
|
|
5
4
|
import {
|
|
6
5
|
NOT_ANOTHER_WAY,
|
|
7
6
|
TEXT_CAP,
|
|
8
7
|
applyAnswer,
|
|
9
8
|
describeAction,
|
|
10
|
-
displayPath,
|
|
11
9
|
short,
|
|
12
10
|
toRequest,
|
|
13
11
|
} from './claude-calls.mjs'
|
|
14
12
|
import { kiraDecides } from './checks.mjs'
|
|
15
13
|
import { sdkOptionsFor } from './settings.mjs'
|
|
14
|
+
import { kiraPluginOptions } from '../kira-plugin.mjs'
|
|
16
15
|
|
|
17
16
|
/** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
|
|
18
17
|
const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -44,36 +43,17 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
44
43
|
/** What Kira's rules said about each tool call, by its id, for the question if Claude Code asks. */
|
|
45
44
|
const verdicts = new Map()
|
|
46
45
|
|
|
47
|
-
// A member with a restricted shell (a designer, a researcher) runs only what its preset allows.
|
|
48
|
-
const shellAllow = persona?.shellAllow ? new RegExp(persona.shellAllow) : null
|
|
49
|
-
// A member that only produces artifacts (a designer, a researcher) writes nowhere but its output folders.
|
|
50
|
-
const within = (persona?.writeWithin ?? []).map(d => resolve(cwd, d))
|
|
51
|
-
const writeTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
|
|
52
46
|
/** Refused before it runs: the job shows why, and Claude is told (`told`, when it needs more). */
|
|
53
47
|
const refuse = (tool, reason, told = reason) => {
|
|
54
48
|
const answer = { optionIds: ['deny'] }
|
|
55
49
|
io.emit({ kind: 'request_resolved', id: `policy-${Date.now().toString(36)}`, answer, by: 'policy', tool, reason })
|
|
56
50
|
return hookAnswer('deny', told)
|
|
57
51
|
}
|
|
58
|
-
// Pre-tool hook:
|
|
52
|
+
// Pre-tool hook: Kira's rules (io.check asks the API), a member's role among them. Allow and deny
|
|
59
53
|
// are final; ask forces the question even where the person's own Claude Code settings would allow
|
|
60
54
|
// it; a call no rule decides is left to Claude Code's permission mode, which asks through canUseTool.
|
|
61
55
|
const preToolUse = async (input, toolUseID) => {
|
|
62
56
|
if (input.hook_event_name !== 'PreToolUse') return {}
|
|
63
|
-
if (within.length && writeTools.has(input.tool_name)) {
|
|
64
|
-
const target = resolve(cwd, String(input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? ''))
|
|
65
|
-
if (!within.some(dir => target === dir || target.startsWith(dir + sep))) {
|
|
66
|
-
const where = `${persona.writeWithin.join(', ')}; ${displayPath(target, cwd)} is off limits`
|
|
67
|
-
const instead = 'Describe what an engineer should change in your spec instead.'
|
|
68
|
-
return refuse(input.tool_name, `${persona.name} only writes under ${where}. ${instead}`)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// Every command in a chain ("ls; rm -rf x", "a && b", "a | b") has to be allowed, not only the first.
|
|
72
|
-
const segments = c => String(c ?? '').split(/&&|\|\||;|\|/).map(x => x.trim()).filter(Boolean)
|
|
73
|
-
if (shellAllow && input.tool_name === 'Bash' && !segments(input.tool_input?.command).every(seg => shellAllow.test(seg))) {
|
|
74
|
-
const only = 'uses the shell only for git and for reading files; run that another way or leave it.'
|
|
75
|
-
return refuse('Bash', `${persona.name} ${only}`)
|
|
76
|
-
}
|
|
77
57
|
if (QUESTIONS.has(input.tool_name)) return {}
|
|
78
58
|
const verdict = await io.check({ tool: input.tool_name, input: input.tool_input ?? {}, cwd })
|
|
79
59
|
// Kept only for a question that may follow: an ask, or Claude Code's own mode asking.
|
|
@@ -134,6 +114,8 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
134
114
|
// Claude as the person's Kira: her tools come from her own MCP server, with a token the API
|
|
135
115
|
// made for this conversation. Claude Code loads them as it needs them (no tool ceiling).
|
|
136
116
|
...(persona?.kiraTools ? { mcpServers: { kira: kiraServer(persona.kiraTools) } } : {}),
|
|
117
|
+
// Her built-in skills, as Claude Code's own: a plugin the bridge writes (kira-plugin.mjs).
|
|
118
|
+
...(await kiraPluginOptions(persona)),
|
|
137
119
|
canUseTool,
|
|
138
120
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
139
121
|
...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A Kira agent that runs on Codex: its Codex agent file (~/.codex/agents/<handle>.toml, written by
|
|
2
|
+
// agent-files.mjs). Codex's app server has no "start this thread as agent X" (its agent roles are a
|
|
3
|
+
// config layer it applies when it spawns a subagent: codex-rs/agent-roles), so a job applies the
|
|
4
|
+
// file the way Codex applies a role: its `developer_instructions` are the thread's developer
|
|
5
|
+
// instructions (what Kira adds for the run after them) and its other settings (model,
|
|
6
|
+
// model_reasoning_effort…) the thread's config. Fields from codex-rs/agent-roles/src/agent_role_config.rs.
|
|
7
|
+
import { parse } from 'smol-toml'
|
|
8
|
+
import { FOLDERS, display, inside, readSmall } from '../config-folders.mjs'
|
|
9
|
+
|
|
10
|
+
const MAX_TOML = 256 * 1024
|
|
11
|
+
/** What names and describes the agent: Codex reads them for its spawn list, never as config. */
|
|
12
|
+
const NOT_CONFIG = new Set(['name', 'description', 'nickname_candidates', 'developer_instructions'])
|
|
13
|
+
|
|
14
|
+
/** A table's settings as Codex's config overrides take them: one dotted key per value. */
|
|
15
|
+
function flatten(table, prefix = '', out = {}) {
|
|
16
|
+
for (const [k, v] of Object.entries(table)) {
|
|
17
|
+
const key = prefix + k
|
|
18
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) flatten(v, `${key}.`, out)
|
|
19
|
+
else out[key] = v instanceof Date ? v.toISOString() : v
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An agent file as a thread takes it: its instructions and the rest of its config. Throws when it isn't TOML. */
|
|
25
|
+
export function roleLayer(toml) {
|
|
26
|
+
const table = parse(String(toml ?? ''))
|
|
27
|
+
const rest = Object.fromEntries(Object.entries(table).filter(([k]) => !NOT_CONFIG.has(k)))
|
|
28
|
+
const said = table.developer_instructions
|
|
29
|
+
return { instructions: typeof said === 'string' ? said.trim() : '', config: flatten(rest) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The thread's developer instructions: the agent's own, then what Kira adds for this run. */
|
|
33
|
+
export const developerInstructions = (role, persona) =>
|
|
34
|
+
[role?.instructions, persona?.systemPrompt].filter(Boolean).join('\n\n')
|
|
35
|
+
|
|
36
|
+
/** The Codex agent a job runs as (null for none); throws, saying where, when its file isn't there. */
|
|
37
|
+
export async function codexAgentOf(persona, root = FOLDERS.codex.agents()) {
|
|
38
|
+
if (!persona?.agent) return null
|
|
39
|
+
const file = inside(root, persona.agent, 'a Codex agent', { loose: true, ext: '.toml' })
|
|
40
|
+
const toml = await readSmall(file, MAX_TOML)
|
|
41
|
+
if (toml === null) throw new Error(`@${persona.agent}'s Codex agent isn't on this Mac (${display(file)})`)
|
|
42
|
+
try {
|
|
43
|
+
return roleLayer(toml)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`${display(file)} isn't TOML Codex can read: ${err.message}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Codex's thread items and settings in Kira's agent protocol (protocol.mjs). Pure.
|
|
2
|
+
import { displayPath, short } from './claude-calls.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The composer's permission modes in Codex's own terms: accept edits = it writes in the project
|
|
6
|
+
* (workspace-write) and asks before commands it doesn't trust; ask before edits = read-only, so every
|
|
7
|
+
* change and command asks; plan = read-only and it never asks, so it can only look and propose.
|
|
8
|
+
*/
|
|
9
|
+
const MODES = {
|
|
10
|
+
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
11
|
+
default: { sandbox: 'read-only', approvalPolicy: 'untrusted' },
|
|
12
|
+
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
13
|
+
}
|
|
14
|
+
/** The composer's older names for the same modes. */
|
|
15
|
+
const OLD_MODES = { auto: 'acceptEdits', ask: 'default' }
|
|
16
|
+
const EFFORTS = new Set(['low', 'medium', 'high'])
|
|
17
|
+
/** A model as Codex's own list names it (codex-local.mjs); never a Claude one. */
|
|
18
|
+
const MODEL = /^(?!claude)[a-z0-9][a-z0-9.-]{0,63}$/
|
|
19
|
+
|
|
20
|
+
/** thread/start's options for these settings; what's unset is left to the person's ~/.codex/config.toml. */
|
|
21
|
+
export function codexOptionsFor(settings, kiraDecides = true) {
|
|
22
|
+
const s = settings ?? {}
|
|
23
|
+
const named = OLD_MODES[s.mode] ?? s.mode
|
|
24
|
+
const mode = Object.hasOwn(MODES, named) ? named : 'acceptEdits'
|
|
25
|
+
// Without Kira deciding each call, nothing is written without asking (as with Claude Code).
|
|
26
|
+
const chosen = kiraDecides || mode === 'plan' ? MODES[mode] : MODES.default
|
|
27
|
+
return {
|
|
28
|
+
...chosen,
|
|
29
|
+
...(typeof s.model === 'string' && MODEL.test(s.model) ? { model: s.model } : {}),
|
|
30
|
+
...(EFFORTS.has(s.effort) ? { effort: s.effort } : {}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Codex runs each command as `/bin/zsh -lc '<script>'`. */
|
|
35
|
+
const WRAPPED = /^(?:\/(?:usr\/)?bin\/)?(?:ba|z)?sh -l?c (?:'((?:[^']|'\\'')*)'|"((?:[^"\\$`]|\\["\\$`])*)")$/s
|
|
36
|
+
|
|
37
|
+
/** The script Codex runs, as the person would have typed it; anything unusual is left as it is (and asks). */
|
|
38
|
+
export function scriptOf(command) {
|
|
39
|
+
const m = WRAPPED.exec(String(command ?? '').trim())
|
|
40
|
+
if (!m) return String(command ?? '')
|
|
41
|
+
return m[1] !== undefined ? m[1].replaceAll("'\\''", "'") : m[2].replace(/\\(["\\$`])/g, '$1')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Codex's call as Kira's rules read it: a command is Bash, each changed file an Edit. */
|
|
45
|
+
export function callsOf(method, params, changes) {
|
|
46
|
+
if (method === 'item/commandExecution/requestApproval') {
|
|
47
|
+
return [{ tool: 'Bash', input: { command: scriptOf(params.command) }, cwd: params.cwd }]
|
|
48
|
+
}
|
|
49
|
+
return (changes ?? []).map(c => ({ tool: 'Edit', input: { file_path: c.path } }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The strictest of several verdicts: any refusal refuses, any ask asks, and allow only when all allow. */
|
|
53
|
+
export function strictest(verdicts) {
|
|
54
|
+
const order = ['deny', 'ask', 'mode', 'allow']
|
|
55
|
+
return [...verdicts].sort((a, b) => order.indexOf(a.effect) - order.indexOf(b.effect))[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const act = (type, label, target, more = {}) => ({ type, label, target, ...more })
|
|
59
|
+
|
|
60
|
+
/** One of Codex's items as a protocol action (null for what isn't one: messages, reasoning). */
|
|
61
|
+
export function itemAction(item, cwd) {
|
|
62
|
+
switch (item.type) {
|
|
63
|
+
case 'commandExecution': {
|
|
64
|
+
const command = scriptOf(item.command)
|
|
65
|
+
return act('run', 'Run', short(command, 400), { tool: 'Bash', detail: { command: short(command, 2000) } })
|
|
66
|
+
}
|
|
67
|
+
case 'fileChange': {
|
|
68
|
+
const paths = (item.changes ?? []).map(c => displayPath(c.path, cwd))
|
|
69
|
+
const more = paths.length > 1 ? ` and ${paths.length - 1} more` : ''
|
|
70
|
+
const diff = (item.changes ?? []).map(c => c.diff).join('\n')
|
|
71
|
+
return act('edit', 'Edit', `${paths[0] ?? ''}${more}`, { tool: 'Edit', detail: { content: short(diff, 6000) } })
|
|
72
|
+
}
|
|
73
|
+
case 'mcpToolCall':
|
|
74
|
+
return act('other', item.tool, short(JSON.stringify(item.arguments ?? {}), 200), {
|
|
75
|
+
tool: `mcp__${item.server}__${item.tool}`,
|
|
76
|
+
})
|
|
77
|
+
case 'webSearch':
|
|
78
|
+
return act('fetch', 'Search the web', short(item.query ?? '', 160), { tool: 'WebSearch' })
|
|
79
|
+
case 'plan':
|
|
80
|
+
return act('plan', 'Plan', short(item.text, 160), { tool: 'Plan' })
|
|
81
|
+
case 'collabAgentToolCall':
|
|
82
|
+
return act('delegate', 'Subtask', short(item.prompt ?? '', 160), { tool: 'Subtask' })
|
|
83
|
+
default:
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A finished item's outcome: whether it went through, and what it said. */
|
|
89
|
+
export function itemResult(item) {
|
|
90
|
+
if (item.type === 'commandExecution') {
|
|
91
|
+
const ok = item.status === 'completed' && (item.exitCode ?? 0) === 0
|
|
92
|
+
return { ok, preview: short(String(item.aggregatedOutput ?? '').trim(), 240) }
|
|
93
|
+
}
|
|
94
|
+
if (item.type === 'mcpToolCall') return { ok: !item.error && item.status === 'completed', preview: '' }
|
|
95
|
+
return { ok: item.status ? item.status === 'completed' : true, preview: '' }
|
|
96
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Codex on this Mac, as the person signed in to it: whether it's there and signed in, and the models
|
|
2
|
+
// it offers them (its own list, so the composer never names a model Codex wouldn't take).
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { codexBin, openCodex } from './codex-rpc.mjs'
|
|
5
|
+
|
|
6
|
+
const ASK_MS = 15_000
|
|
7
|
+
|
|
8
|
+
/** Codex's models for this person, as `model/list` gives them: the default first. */
|
|
9
|
+
export function modelsOf(list) {
|
|
10
|
+
const models = (Array.isArray(list?.data) ? list.data : [])
|
|
11
|
+
.filter(m => !m.hidden)
|
|
12
|
+
.map(m => ({
|
|
13
|
+
id: String(m.model ?? m.id ?? ''),
|
|
14
|
+
label: String(m.displayName || m.model || m.id || ''),
|
|
15
|
+
description: typeof m.description === 'string' ? m.description : undefined,
|
|
16
|
+
default: !!m.isDefault,
|
|
17
|
+
}))
|
|
18
|
+
.filter(m => m.id)
|
|
19
|
+
return [...models.filter(m => m.default), ...models.filter(m => !m.default)]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether Codex is here and signed in, and its models: `{ installed, signedIn, models }` or `{ error }`. */
|
|
23
|
+
async function codexModels() {
|
|
24
|
+
const bin = codexBin()
|
|
25
|
+
if (!bin) return { installed: false, signedIn: false, models: [] }
|
|
26
|
+
const codex = openCodex(bin, { cwd: homedir(), env: process.env, onNotify() {}, onRequest: async () => ({}) })
|
|
27
|
+
const timer = setTimeout(() => codex.close(), ASK_MS)
|
|
28
|
+
try {
|
|
29
|
+
await codex.request('initialize', { clientInfo: { name: 'kira', title: 'Kira', version: '1' }, capabilities: null })
|
|
30
|
+
codex.notify('initialized')
|
|
31
|
+
const account = await codex.request('account/read', {})
|
|
32
|
+
if (!account?.account) return { installed: true, signedIn: false, models: [] }
|
|
33
|
+
return { installed: true, signedIn: true, models: modelsOf(await codex.request('model/list', {})) }
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { error: `Codex didn't answer: ${err.message}` }
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer)
|
|
38
|
+
codex.close()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const codexTools = { codex_models: codexModels }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Codex's app server (`codex app-server`): JSON-RPC, one message per line over stdio. Requests get
|
|
2
|
+
// answers by id; notifications and the server's own requests (approvals) go to the handlers.
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { delimiter, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
/** Where ChatGPT's Mac app keeps the Codex it ships. */
|
|
8
|
+
const CHATGPT_CODEX = '/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex'
|
|
9
|
+
|
|
10
|
+
/** The person's own Codex: KIRA_CODEX_BIN, then `codex` on the PATH, then the one in ChatGPT.app. */
|
|
11
|
+
export function codexBin(env = process.env) {
|
|
12
|
+
if (env.KIRA_CODEX_BIN) return env.KIRA_CODEX_BIN
|
|
13
|
+
const onPath = String(env.PATH ?? '')
|
|
14
|
+
.split(delimiter)
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.map(dir => join(dir, 'codex'))
|
|
17
|
+
.find(p => existsSync(p))
|
|
18
|
+
if (onPath) return onPath
|
|
19
|
+
return existsSync(CHATGPT_CODEX) ? CHATGPT_CODEX : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start Codex's app server. `onNotify(method, params)` hears what happens; `onRequest(method, params)`
|
|
24
|
+
* answers what Codex asks (its result is sent back). Returns { request, close, done }.
|
|
25
|
+
*/
|
|
26
|
+
export function openCodex(bin, { cwd, env, onNotify, onRequest }) {
|
|
27
|
+
const child = spawn(bin, ['app-server'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
28
|
+
const waiting = new Map()
|
|
29
|
+
let seq = 0
|
|
30
|
+
let buffer = ''
|
|
31
|
+
let stderr = ''
|
|
32
|
+
const write = msg => child.stdin.writable && child.stdin.write(`${JSON.stringify(msg)}\n`)
|
|
33
|
+
|
|
34
|
+
const handle = async msg => {
|
|
35
|
+
if (msg.id != null && msg.method) {
|
|
36
|
+
try {
|
|
37
|
+
write({ id: msg.id, result: await onRequest(msg.method, msg.params ?? {}) })
|
|
38
|
+
} catch (err) {
|
|
39
|
+
write({ id: msg.id, error: { code: -32603, message: String(err?.message ?? err) } })
|
|
40
|
+
}
|
|
41
|
+
} else if (msg.id != null) {
|
|
42
|
+
const w = waiting.get(msg.id)
|
|
43
|
+
waiting.delete(msg.id)
|
|
44
|
+
if (msg.error) w?.reject(new Error(msg.error.message ?? 'Codex refused the request'))
|
|
45
|
+
else w?.resolve(msg.result)
|
|
46
|
+
} else if (msg.method) {
|
|
47
|
+
onNotify(msg.method, msg.params ?? {})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
child.stdout.on('data', chunk => {
|
|
51
|
+
buffer += chunk
|
|
52
|
+
for (let i = buffer.indexOf('\n'); i >= 0; i = buffer.indexOf('\n')) {
|
|
53
|
+
const line = buffer.slice(0, i).trim()
|
|
54
|
+
buffer = buffer.slice(i + 1)
|
|
55
|
+
if (!line) continue
|
|
56
|
+
try {
|
|
57
|
+
void handle(JSON.parse(line))
|
|
58
|
+
} catch {
|
|
59
|
+
// Not a protocol line: ignored.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
child.stderr.on('data', chunk => {
|
|
64
|
+
stderr = (stderr + chunk).slice(-2000)
|
|
65
|
+
})
|
|
66
|
+
const done = new Promise(resolve => {
|
|
67
|
+
const end = err => {
|
|
68
|
+
const why = err?.message ?? (stderr.trim().split('\n').pop() || 'Codex stopped')
|
|
69
|
+
for (const w of waiting.values()) w.reject(new Error(why))
|
|
70
|
+
waiting.clear()
|
|
71
|
+
resolve(why)
|
|
72
|
+
}
|
|
73
|
+
child.on('error', end)
|
|
74
|
+
child.on('exit', () => end())
|
|
75
|
+
})
|
|
76
|
+
const request = (method, params) =>
|
|
77
|
+
new Promise((resolve, reject) => {
|
|
78
|
+
const id = ++seq
|
|
79
|
+
waiting.set(id, { resolve, reject })
|
|
80
|
+
if (!write({ id, method, params })) {
|
|
81
|
+
waiting.delete(id)
|
|
82
|
+
reject(new Error('Codex is not running'))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
const notify = (method, params) => write(params ? { method, params } : { method })
|
|
86
|
+
const close = () => {
|
|
87
|
+
child.stdin.end()
|
|
88
|
+
child.kill()
|
|
89
|
+
}
|
|
90
|
+
return { request, notify, close, done }
|
|
91
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Codex as an agent, through its app server (codex-rpc.mjs). Runs on the person's Mac with their own
|
|
2
|
+
// Codex and its sign-in (ChatGPT or their key); files never leave the machine. Each command and file
|
|
3
|
+
// change Codex asks about is decided by Kira's rules (io.check), and by the person when they ask.
|
|
4
|
+
import { permissionOptions, requestId } from './protocol.mjs'
|
|
5
|
+
import { TEXT_CAP, short } from './claude-calls.mjs'
|
|
6
|
+
import { callsOf, codexOptionsFor, itemAction, itemResult, strictest } from './codex-items.mjs'
|
|
7
|
+
import { codexBin, openCodex } from './codex-rpc.mjs'
|
|
8
|
+
import { kiraDecides } from './checks.mjs'
|
|
9
|
+
import { codexAgentOf, developerInstructions } from './codex-agent.mjs'
|
|
10
|
+
import { kiraSkillRoots } from '../kira-plugin.mjs'
|
|
11
|
+
|
|
12
|
+
const COMMAND = 'item/commandExecution/requestApproval'
|
|
13
|
+
const APPROVALS = new Set([COMMAND, 'item/fileChange/requestApproval'])
|
|
14
|
+
const NOT_INSTALLED =
|
|
15
|
+
'Codex is not installed on this Mac. Install it (npm install -g @openai/codex, or the ChatGPT app) and sign in.'
|
|
16
|
+
|
|
17
|
+
/** Kira's MCP server as Codex connects to it: Streamable HTTP, the token read from the environment. */
|
|
18
|
+
const kiraServer = ({ url }) => ({ 'mcp_servers.kira.url': url, 'mcp_servers.kira.bearer_token_env_var': 'KIRA_TOKEN' })
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run one instruction in a Codex thread. Same contract as claude.mjs:
|
|
22
|
+
* io.emit(event), io.ask(request, signal), io.check(call); returns { ok, text, error, sessionId, stats }.
|
|
23
|
+
*/
|
|
24
|
+
async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
25
|
+
const bin = codexBin()
|
|
26
|
+
if (!bin) {
|
|
27
|
+
io.emit({ kind: 'result', ok: false, error: NOT_INSTALLED, sessionId })
|
|
28
|
+
return { ok: false, error: NOT_INSTALLED, sessionId }
|
|
29
|
+
}
|
|
30
|
+
const { effort, ...options } = codexOptionsFor(settings, kiraDecides())
|
|
31
|
+
let thread = sessionId ?? null
|
|
32
|
+
let turnId = null
|
|
33
|
+
let text = ''
|
|
34
|
+
let error = null
|
|
35
|
+
/** The files each change will touch, from when it started: what its approval is checked on. */
|
|
36
|
+
const changes = new Map()
|
|
37
|
+
let finished
|
|
38
|
+
const ended = new Promise(resolve => (finished = resolve))
|
|
39
|
+
|
|
40
|
+
const decide = async (method, params) => {
|
|
41
|
+
const calls = callsOf(method, params, changes.get(params.itemId)).map(c => ({ ...c, cwd: c.cwd ?? cwd }))
|
|
42
|
+
const verdict = strictest(await Promise.all(calls.map(c => io.check(c)))) ?? { effect: 'mode' }
|
|
43
|
+
if (verdict.effect === 'allow') return { decision: 'accept' }
|
|
44
|
+
const action = itemAction(
|
|
45
|
+
method === COMMAND
|
|
46
|
+
? { type: 'commandExecution', command: params.command }
|
|
47
|
+
: { type: 'fileChange', changes: changes.get(params.itemId) ?? [] },
|
|
48
|
+
cwd,
|
|
49
|
+
)
|
|
50
|
+
if (verdict.effect === 'deny') {
|
|
51
|
+
const reason = `Kira: ${verdict.why ?? 'your rules'}${verdict.rule ? ` (${verdict.rule})` : ''}`
|
|
52
|
+
const id = `policy-${Date.now().toString(36)}`
|
|
53
|
+
const answer = { optionIds: ['deny'] }
|
|
54
|
+
io.emit({ kind: 'request_resolved', id, answer, by: 'policy', tool: action.tool, reason })
|
|
55
|
+
return { decision: 'decline' }
|
|
56
|
+
}
|
|
57
|
+
const request = {
|
|
58
|
+
id: requestId(),
|
|
59
|
+
type: 'permission',
|
|
60
|
+
title: `Codex wants to ${action.label.toLowerCase()} ${action.target}`.trim(),
|
|
61
|
+
detail: short(action.detail?.command ?? action.detail?.content ?? params.reason ?? '', 2000) || undefined,
|
|
62
|
+
options: permissionOptions(verdict.suggest),
|
|
63
|
+
action: { tool: action.tool, label: action.label, target: action.target },
|
|
64
|
+
...(verdict.suggest ? { rule: verdict.suggest } : {}),
|
|
65
|
+
...(verdict.why ? { why: verdict.why } : {}),
|
|
66
|
+
}
|
|
67
|
+
io.emit({ kind: 'request', request })
|
|
68
|
+
let resolved
|
|
69
|
+
try {
|
|
70
|
+
resolved = await io.ask(request, abort?.signal)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
|
|
73
|
+
}
|
|
74
|
+
io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
|
|
75
|
+
const yes = (resolved.answer.optionIds ?? []).some(id => id === 'allow' || id === 'always')
|
|
76
|
+
return { decision: yes ? 'accept' : 'decline' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onNotify = (method, p) => {
|
|
80
|
+
if (method === 'item/started') {
|
|
81
|
+
if (p.item.type === 'fileChange') changes.set(p.item.id, p.item.changes ?? [])
|
|
82
|
+
const action = itemAction(p.item, cwd)
|
|
83
|
+
if (action) io.emit({ kind: 'action', id: p.item.id, ...action })
|
|
84
|
+
} else if (method === 'item/completed') {
|
|
85
|
+
if (p.item.type === 'agentMessage' && p.item.text?.trim()) {
|
|
86
|
+
text = p.item.text
|
|
87
|
+
io.emit({ kind: 'message', text: short(p.item.text, TEXT_CAP) })
|
|
88
|
+
} else if (itemAction(p.item, cwd)) {
|
|
89
|
+
io.emit({ kind: 'action_result', id: p.item.id, ...itemResult(p.item) })
|
|
90
|
+
}
|
|
91
|
+
changes.delete(p.item.id)
|
|
92
|
+
} else if (method === 'error' && !p.willRetry) {
|
|
93
|
+
error = `Codex stopped: ${p.error?.message ?? 'error'}`
|
|
94
|
+
} else if (method === 'turn/completed' && p.turn?.id === turnId) {
|
|
95
|
+
const t = p.turn
|
|
96
|
+
if (t.status === 'failed') error = `Codex stopped: ${t.error?.message ?? 'failed'}`
|
|
97
|
+
if (t.status === 'interrupted') error = 'Stopped by the user.'
|
|
98
|
+
finished({ durationMs: t.durationMs ?? undefined })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const onRequest = async (method, params) => {
|
|
102
|
+
if (APPROVALS.has(method)) return decide(method, params)
|
|
103
|
+
throw new Error(`Kira does not answer ${method}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const env = { ...process.env, ...(persona?.kiraTools ? { KIRA_TOKEN: persona.kiraTools.token } : {}) }
|
|
107
|
+
const codex = openCodex(bin, { cwd, env, onNotify, onRequest })
|
|
108
|
+
void codex.done.then(why => finished({ crashed: why }))
|
|
109
|
+
const stop = () => {
|
|
110
|
+
if (thread && turnId) void codex.request('turn/interrupt', { threadId: thread, turnId }).catch(() => {})
|
|
111
|
+
}
|
|
112
|
+
abort?.signal.addEventListener('abort', stop, { once: true })
|
|
113
|
+
try {
|
|
114
|
+
await codex.request('initialize', { clientInfo: { name: 'kira', title: 'Kira', version: '1' }, capabilities: null })
|
|
115
|
+
codex.notify('initialized')
|
|
116
|
+
// A Kira agent on Codex: its agent file, applied as Codex applies a role (codex-agent.mjs).
|
|
117
|
+
const role = await codexAgentOf(persona)
|
|
118
|
+
// Kira's built-in skills, as Codex's own for this app server: the folder Claude Code's plugin uses.
|
|
119
|
+
const roots = await kiraSkillRoots(persona)
|
|
120
|
+
if (roots.length) await codex.request('skills/extraRoots/set', { extraRoots: roots })
|
|
121
|
+
const config = { ...role?.config, ...(persona?.kiraTools ? kiraServer(persona.kiraTools) : {}) }
|
|
122
|
+
const said = developerInstructions(role, persona)
|
|
123
|
+
const instructions = said ? { developerInstructions: said } : {}
|
|
124
|
+
const opened = thread
|
|
125
|
+
? await codex.request('thread/resume', { threadId: thread, cwd, ...options, ...instructions, config })
|
|
126
|
+
: await codex.request('thread/start', { cwd, ...options, ...instructions, config })
|
|
127
|
+
thread = opened.thread.id
|
|
128
|
+
io.emit({ kind: 'session', sessionId: thread, model: opened.model ?? null })
|
|
129
|
+
const input = [{ type: 'text', text: prompt, text_elements: [] }]
|
|
130
|
+
const started = await codex.request('turn/start', { threadId: thread, input, ...(effort ? { effort } : {}) })
|
|
131
|
+
turnId = started.turn.id
|
|
132
|
+
const end = await ended
|
|
133
|
+
if (end.crashed && !error) error = abort?.signal.aborted ? 'Stopped by the user.' : `Codex stopped: ${end.crashed}`
|
|
134
|
+
const stats = end.durationMs ? { durationMs: end.durationMs } : undefined
|
|
135
|
+
io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: thread, stats })
|
|
136
|
+
return { ok: !error, text, error, sessionId: thread, stats }
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const message = abort?.signal.aborted ? 'Stopped by the user.' : `could not run Codex: ${err.message}`
|
|
139
|
+
io.emit({ kind: 'result', ok: false, error: message, sessionId: thread })
|
|
140
|
+
return { ok: false, error: message, sessionId: thread }
|
|
141
|
+
} finally {
|
|
142
|
+
abort?.signal.removeEventListener('abort', stop)
|
|
143
|
+
codex.close()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const codex = { id: 'codex', name: 'Codex', run, capabilities: { resume: true, requests: ['permission'] } }
|
package/src/agents/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Coding agents this bridge can run. Each adapter speaks agents/protocol.mjs;
|
|
2
|
-
//
|
|
2
|
+
// another agent is a new file here and nothing else.
|
|
3
3
|
import { claude } from './claude.mjs'
|
|
4
|
+
import { codex } from './codex.mjs'
|
|
4
5
|
|
|
5
|
-
export const AGENTS = { claude }
|
|
6
|
+
export const AGENTS = { claude, codex }
|
|
6
7
|
|
|
7
8
|
export function agentById(id) {
|
|
8
9
|
return AGENTS[id ?? 'claude'] ?? null
|
package/src/agents/settings.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// How a job runs, as the person chose it in Kira's composer (genui run-settings.ts): the model,
|
|
2
2
|
// the effort, and the permission mode. Pure: the Agent SDK's options.
|
|
3
3
|
//
|
|
4
|
-
// { model?: string, effort?: 'low'|'medium'|'high', mode?: '
|
|
4
|
+
// { model?: string, effort?: 'low'|'medium'|'high', mode?: 'acceptEdits'|'default'|'plan' }
|
|
5
5
|
|
|
6
6
|
const EFFORTS = new Set(['low', 'medium', 'high'])
|
|
7
7
|
const MODEL = /^claude-[a-z0-9-]{1,60}$/
|
|
8
|
-
/** The composer's modes are Claude Code's own: accept edits, ask before edits (its default), plan first. */
|
|
9
|
-
const MODES =
|
|
8
|
+
/** The composer's modes are Claude Code's own ids: accept edits, ask before edits (its default), plan first. */
|
|
9
|
+
const MODES = new Set(['acceptEdits', 'default', 'plan'])
|
|
10
|
+
/** The ids an older Kira API sent (auto, ask), read as the modes they meant. */
|
|
11
|
+
const OLD = { auto: 'acceptEdits', ask: 'default' }
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* The SDK's query options for these settings (left out when unset, so Claude Code's defaults stand).
|
|
@@ -14,7 +16,8 @@ const MODES = { auto: 'acceptEdits', ask: 'default', plan: 'plan' }
|
|
|
14
16
|
*/
|
|
15
17
|
export function sdkOptionsFor(settings, kiraDecides = true) {
|
|
16
18
|
const s = settings ?? {}
|
|
17
|
-
const
|
|
19
|
+
const said = Object.hasOwn(OLD, s.mode) ? OLD[s.mode] : s.mode
|
|
20
|
+
const mode = MODES.has(said) ? said : 'acceptEdits'
|
|
18
21
|
return {
|
|
19
22
|
permissionMode: kiraDecides ? mode : mode === 'plan' ? 'plan' : 'default',
|
|
20
23
|
...(typeof s.model === 'string' && MODEL.test(s.model) ? { model: s.model } : {}),
|
package/src/config-folders.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const skillFolders = () => [
|
|
|
38
38
|
{ root: FOLDERS.codex.skills(), loadedBy: ['codex'] },
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
/** The subagent folders, their file format and who loads each; Kira writes
|
|
41
|
+
/** The subagent folders, their file format and who loads each; Kira writes both (agent-files.mjs). */
|
|
42
42
|
export const agentFolders = () => [
|
|
43
43
|
{ root: FOLDERS.claude.agents(), loadedBy: ['claude'], ext: '.md' },
|
|
44
44
|
{ root: FOLDERS.codex.agents(), loadedBy: ['codex'], ext: '.toml' },
|
package/src/connection.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import WebSocket from 'ws'
|
|
|
4
4
|
import { normalizeAnswer } from './agents/protocol.mjs'
|
|
5
5
|
import { answer } from './agents/answers.mjs'
|
|
6
6
|
import { decided, kiraChecks } from './agents/checks.mjs'
|
|
7
|
+
import { heartbeat } from './heartbeat.mjs'
|
|
7
8
|
import { terminalMessage, terminalsOffline } from './terminals.mjs'
|
|
8
9
|
import { host, log, status } from './host.mjs'
|
|
9
10
|
|
|
@@ -15,6 +16,8 @@ import { host, log, status } from './host.mjs'
|
|
|
15
16
|
let live = null
|
|
16
17
|
const outbox = []
|
|
17
18
|
const OUTBOX_MAX = 2000
|
|
19
|
+
/** A connect that hangs (half-open network, a dyno mid-restart) is abandoned after this long. */
|
|
20
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000
|
|
18
21
|
|
|
19
22
|
let settings = null
|
|
20
23
|
let stopped = false
|
|
@@ -63,16 +66,16 @@ function open() {
|
|
|
63
66
|
if (stopped) return
|
|
64
67
|
status('connecting')
|
|
65
68
|
const { url, token } = settings
|
|
66
|
-
const ws = new WebSocket(`${url}?token=${encodeURIComponent(token)}
|
|
67
|
-
let
|
|
69
|
+
const ws = new WebSocket(`${url}?token=${encodeURIComponent(token)}`, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS })
|
|
70
|
+
let stopHeartbeat = () => {}
|
|
68
71
|
ws.on('open', () => {
|
|
69
72
|
backoff = 1000
|
|
70
73
|
log(`connected to ${url}`)
|
|
71
|
-
|
|
74
|
+
stopHeartbeat = heartbeat(ws, { onDead: () => log('no answer from the API; reconnecting') })
|
|
72
75
|
})
|
|
73
76
|
ws.on('message', raw => onMessage(ws, raw))
|
|
74
77
|
ws.on('close', (code, reason) => {
|
|
75
|
-
|
|
78
|
+
stopHeartbeat()
|
|
76
79
|
onClose(ws, code, reason)
|
|
77
80
|
})
|
|
78
81
|
ws.on('error', err => log(`socket error: ${err.message}`))
|
|
@@ -102,7 +105,11 @@ async function onMessage(ws, raw) {
|
|
|
102
105
|
function registered(ws, msg) {
|
|
103
106
|
// Tell the API which tools this bridge build implements, so a stale
|
|
104
107
|
// bridge is reported as such instead of "unknown tool".
|
|
105
|
-
|
|
108
|
+
// runningJobs: the jobs this bridge still has (running, or with a result waiting in the outbox);
|
|
109
|
+
// the API fails the person's other running jobs at once instead of after its long sweep.
|
|
110
|
+
const caps = settings.capabilities()
|
|
111
|
+
const queued = outbox.map(o => o.jobId).filter(id => typeof id === 'string')
|
|
112
|
+
send(ws, { type: 'capabilities', ...caps, runningJobs: [...new Set([...caps.runningJobs, ...queued])] })
|
|
106
113
|
live = ws
|
|
107
114
|
kiraChecks(msg.checks, sendIfLive)
|
|
108
115
|
// Job events and results from while we were disconnected, in order.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Dead-socket detection. A socket can die without a close (the Mac woke on another network,
|
|
2
|
+
// a router dropped it); the bridge would sit "connected" and deaf. The API answers every
|
|
3
|
+
// `ping` with `pong`, so anything not heard back in time means the socket is gone.
|
|
4
|
+
|
|
5
|
+
/** How often the bridge pings (also keeps Heroku's 55 s idle limit away). */
|
|
6
|
+
const PING_EVERY_MS = 30_000
|
|
7
|
+
/** How long a pong may take before the socket is given up. */
|
|
8
|
+
const PONG_WITHIN_MS = 10_000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ping `ws` every `every` ms; terminate it if no message at all arrives within `within` ms of a
|
|
12
|
+
* ping (any message proves it alive). Returns a stop function for when the socket closes.
|
|
13
|
+
*/
|
|
14
|
+
export function heartbeat(ws, { every = PING_EVERY_MS, within = PONG_WITHIN_MS, onDead = () => {} } = {}) {
|
|
15
|
+
let deadline = null
|
|
16
|
+
const heard = () => {
|
|
17
|
+
clearTimeout(deadline)
|
|
18
|
+
deadline = null
|
|
19
|
+
}
|
|
20
|
+
ws.on('message', heard)
|
|
21
|
+
const timer = setInterval(() => {
|
|
22
|
+
if (ws.readyState !== 1 || deadline) return
|
|
23
|
+
ws.send(JSON.stringify({ type: 'ping' }))
|
|
24
|
+
deadline = setTimeout(() => {
|
|
25
|
+
onDead()
|
|
26
|
+
ws.terminate()
|
|
27
|
+
}, within)
|
|
28
|
+
}, every)
|
|
29
|
+
return () => {
|
|
30
|
+
clearInterval(timer)
|
|
31
|
+
heard()
|
|
32
|
+
ws.off('message', heard)
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -6,11 +6,12 @@ import { agentTools } from './agent-files.mjs'
|
|
|
6
6
|
import { appolloBridgeTools } from './appollo/index.mjs'
|
|
7
7
|
import { AGENTS } from './agents/index.mjs'
|
|
8
8
|
import { claudeTools } from './agents/claude-local.mjs'
|
|
9
|
+
import { codexTools } from './agents/codex-local.mjs'
|
|
9
10
|
import { connect, disconnect, sendIfLive } from './connection.mjs'
|
|
10
11
|
import { fileTools } from './file-tools.mjs'
|
|
11
12
|
import { gitTools } from './git-tools.mjs'
|
|
12
13
|
import { host, log, setHooks } from './host.mjs'
|
|
13
|
-
import { jobTools } from './job-tools.mjs'
|
|
14
|
+
import { jobTools, runningJobIds } from './job-tools.mjs'
|
|
14
15
|
import { macTools } from './mac-tools.mjs'
|
|
15
16
|
import { sessionTools } from './session-tools.mjs'
|
|
16
17
|
import { skillTools } from './skill-files.mjs'
|
|
@@ -25,6 +26,7 @@ const tools = {
|
|
|
25
26
|
...jobTools,
|
|
26
27
|
...gitTools,
|
|
27
28
|
...claudeTools,
|
|
29
|
+
...codexTools,
|
|
28
30
|
...skillTools,
|
|
29
31
|
...agentTools,
|
|
30
32
|
...appolloBridgeTools,
|
|
@@ -33,7 +35,8 @@ const tools = {
|
|
|
33
35
|
/** What this bridge build implements, told to the API after its hello. */
|
|
34
36
|
function capabilities() {
|
|
35
37
|
const agents = Object.values(AGENTS).map(a => ({ id: a.id, name: a.name, capabilities: a.capabilities }))
|
|
36
|
-
|
|
38
|
+
const platform = process.platform
|
|
39
|
+
return { tools: Object.keys(tools), version: BRIDGE_VERSION, platform, agents, runningJobs: runningJobIds() }
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
package/src/job-tools.mjs
CHANGED
|
@@ -13,6 +13,11 @@ import { jobEndLine, jobStartLine, promptOf } from './job-format.mjs'
|
|
|
13
13
|
/** Running jobs, by id, so a person can stop one. */
|
|
14
14
|
const running = new Map()
|
|
15
15
|
|
|
16
|
+
/** The ids of the jobs running here now. */
|
|
17
|
+
export function runningJobIds() {
|
|
18
|
+
return [...running.keys()]
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export const jobTools = {
|
|
17
22
|
/**
|
|
18
23
|
* Run an instruction with a coding agent. Acknowledges at once, streams every
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Kira's built-in skills, one folder on this Mac that both agents load as their own. The API sends
|
|
2
|
+
// the ones a person's Kira has with each job (persona.skills); this brings the folder in line with
|
|
3
|
+
// them (each written whole, the ones gone removed). It is a Claude Code plugin
|
|
4
|
+
// (~/.kira/claude-plugin: .claude-plugin/plugin.json and skills/<name>/SKILL.md), which Claude Code
|
|
5
|
+
// loads with the job (the Agent SDK's `plugins`: /kira:draft-page). Its skills/ folder is plain
|
|
6
|
+
// Agent Skills (<name>/SKILL.md), which Codex loads as an extra skills root for the job
|
|
7
|
+
// (`skills/extraRoots/set` on its app server: $draft-page).
|
|
8
|
+
import { readdir, rm } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { KIRA_DIR } from './config.mjs'
|
|
11
|
+
import { inside, isName, oneAtATime, refuseLinks, writeWhole } from './config-folders.mjs'
|
|
12
|
+
import { log } from './host.mjs'
|
|
13
|
+
|
|
14
|
+
/** Its name in Claude Code: the start of every skill's /kira:<name>. */
|
|
15
|
+
const PLUGIN = 'kira'
|
|
16
|
+
const MANIFEST = {
|
|
17
|
+
name: PLUGIN,
|
|
18
|
+
description: 'Kira’s skills: how Kira drafts pages, starts projects, researches and uses the apps you connected.',
|
|
19
|
+
}
|
|
20
|
+
/** A skill past this isn't one of ours. */
|
|
21
|
+
const MAX_MD = 256 * 1024
|
|
22
|
+
|
|
23
|
+
const pluginRoot = () => join(KIRA_DIR, 'claude-plugin')
|
|
24
|
+
|
|
25
|
+
/** The skills worth writing: a plain name and a SKILL.md with front matter, not too large. */
|
|
26
|
+
function wanted(skills) {
|
|
27
|
+
const out = new Map()
|
|
28
|
+
for (const s of Array.isArray(skills) ? skills : []) {
|
|
29
|
+
const md = typeof s?.md === 'string' ? s.md : ''
|
|
30
|
+
if (isName(s?.name) && /^---\r?\n/.test(md) && Buffer.byteLength(md) <= MAX_MD) out.set(s.name, md)
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Write the plugin with exactly these skills; its folder. One write at a time (jobs can overlap). */
|
|
36
|
+
export function writeKiraPlugin(skills, root = pluginRoot()) {
|
|
37
|
+
return oneAtATime(root, async () => {
|
|
38
|
+
const want = wanted(skills)
|
|
39
|
+
const dir = join(root, 'skills')
|
|
40
|
+
await refuseLinks([root, dir], 'write')
|
|
41
|
+
await writeWhole(join(root, '.claude-plugin', 'plugin.json'), `${JSON.stringify(MANIFEST, null, 2)}\n`)
|
|
42
|
+
for (const [name, md] of want) await writeWhole(join(inside(dir, name, 'a skill folder'), 'SKILL.md'), md)
|
|
43
|
+
const there = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
44
|
+
for (const e of there) {
|
|
45
|
+
if (e.isDirectory() && isName(e.name) && !want.has(e.name)) await rm(join(dir, e.name), { recursive: true })
|
|
46
|
+
}
|
|
47
|
+
return root
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The folder written for a job's skills, or null when it has none or it can't be written (the job
|
|
53
|
+
* still runs; `warn` says why, naming the agent that goes without them).
|
|
54
|
+
*/
|
|
55
|
+
async function writtenFor(persona, agent, warn, root) {
|
|
56
|
+
if (!Array.isArray(persona?.skills) || !persona.skills.length) return null
|
|
57
|
+
try {
|
|
58
|
+
return await writeKiraPlugin(persona.skills, root)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
warn(`Kira's skills couldn't be written for ${agent}: ${err.message}`)
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The Agent SDK query options that load Kira's skills for a Claude Code job (none without them). */
|
|
66
|
+
export async function kiraPluginOptions(persona, warn = log, root = pluginRoot()) {
|
|
67
|
+
const path = await writtenFor(persona, 'Claude Code', warn, root)
|
|
68
|
+
return path ? { plugins: [{ type: 'local', path }] } : {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The skills roots Codex is given for a job (`skills/extraRoots/set`): the plugin's skills/ folder, or none. */
|
|
72
|
+
export async function kiraSkillRoots(persona, warn = log, root = pluginRoot()) {
|
|
73
|
+
const path = await writtenFor(persona, 'Codex', warn, root)
|
|
74
|
+
return path ? [join(path, 'skills')] : []
|
|
75
|
+
}
|