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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.26.0",
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": [
@@ -1,10 +1,12 @@
1
- // Subagents on this Mac: Claude Code's (one Markdown file each in ~/.claude/agents, front matter
2
- // for its settings and the body as its system prompt, with `memory: user` kept in
3
- // ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (~/.codex/agents/<name>.toml, listed only:
4
- // the bridge doesn't run Codex yet). Kira's agents are Claude Code subagents: she writes their
5
- // files and memory here, and a task runs as one (agents/claude.mjs).
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
- /** A TOML string value on its own line (`name = "x"`), enough for Codex's name and description. */
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
- const m = new RegExp(`^${key}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|'([^']*)')`, 'm').exec(toml)
37
- return m ? (m[1] ?? m[2]).replace(/\\"/g, '"') : null
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
- /** One of Claude Code's subagents, with its memory. */
82
- export async function readAgent({ slug } = {}, root = claudeRoot(), memoryRoot = FOLDERS.claude.agentMemory()) {
83
- const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
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
- return { slug, ...summary, path: display(file), md, memory: await readMemory(summary.name, memoryRoot) }
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 Claude Code subagent's file, whole: replacing the one there, or with `create` only a new
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 = claudeRoot()) {
95
- const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
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
- if (!/^---\r?\n/.test(text)) throw new Error('A subagent file starts with front matter between --- lines')
128
+ kind.check(text)
98
129
  if (Buffer.byteLength(text) > MAX_MD) throw new Error('That subagent file is too large')
99
- await refuseLinks([root, file], 'edit')
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
- return { slug, ...agentSummary(text, slug), path: display(file), providers: ['claude'] }
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 Claude Code subagent's file. Its memory folder stays (it's what it learned). */
106
- export async function removeAgent({ slug } = {}, root = claudeRoot()) {
107
- const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
108
- await refuseLinks([root, file], 'remove')
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 => {
@@ -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: the member's limits, then Kira's rules (io.check asks the API). Allow and deny
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'] } }
@@ -1,8 +1,9 @@
1
1
  // Coding agents this bridge can run. Each adapter speaks agents/protocol.mjs;
2
- // adding Codex or another agent is a new file here and nothing else.
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
@@ -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?: 'auto'|'ask'|'plan' }
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 = { auto: 'acceptEdits', ask: 'default', plan: 'plan' }
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 mode = Object.hasOwn(MODES, s.mode) ? MODES[s.mode] : MODES.auto
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 } : {}),
@@ -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 only Claude Code's. */
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' },
@@ -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 ping
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
- ping = setInterval(() => send(ws, { type: 'ping' }), 30_000)
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
- clearInterval(ping)
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
- send(ws, { type: 'capabilities', ...settings.capabilities() })
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
- return { tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform, agents }
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
+ }