kira-glasses-bridge 2.30.0 → 2.31.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.30.0",
3
+ "version": "2.31.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": {
@@ -0,0 +1,66 @@
1
+ // Asking the person what Codex asks (codex.mjs): the request card, the wait for its answer (none in
2
+ // time, or the job stopped, is the timeout's), and Kira's rules refusing before anyone is asked.
3
+ import { strictest } from './codex-items.mjs'
4
+ import { formRequest, formResponse } from './codex-forms.mjs'
5
+ import {
6
+ permissionsAsked,
7
+ permissionsRequest,
8
+ permissionsResponse,
9
+ userInputRequest,
10
+ userInputResponse,
11
+ } from './codex-requests.mjs'
12
+
13
+ /** Show the request and wait for its answer: `{ answer, by }`. */
14
+ export async function askPerson(io, request, signal) {
15
+ io.emit({ kind: 'request', request })
16
+ let resolved
17
+ try {
18
+ resolved = await io.ask(request, signal)
19
+ } catch (err) {
20
+ resolved = { answer: { optionIds: [], text: err.message }, by: 'timeout' }
21
+ }
22
+ io.emit({ kind: 'request_resolved', id: request.id, answer: resolved.answer, by: resolved.by })
23
+ return resolved
24
+ }
25
+
26
+ /** Kira's rules said no: the thread shows it as answered by them, and nobody is asked. */
27
+ export function refusedByPolicy(io, tool, verdict) {
28
+ const reason = `Kira: ${verdict.why ?? 'your rules'}${verdict.rule ? ` (${verdict.rule})` : ''}`
29
+ const id = `policy-${Date.now().toString(36)}`
30
+ io.emit({ kind: 'request_resolved', id, answer: { optionIds: ['deny'] }, by: 'policy', tool, reason })
31
+ }
32
+
33
+ export const said = (resolved, ...ids) => (resolved.answer.optionIds ?? []).some(id => ids.includes(id))
34
+
35
+ /** More access: every folder Kira's rules allow and nothing they can't check goes through; else asks. */
36
+ async function permissions(params, { io, cwd, signal }) {
37
+ const { calls, unchecked } = permissionsAsked(params, cwd)
38
+ const verdicts = await Promise.all(calls.map(c => io.check(c)))
39
+ const verdict = strictest([...verdicts, ...(unchecked ? [{ effect: 'ask' }] : [])]) ?? { effect: 'mode' }
40
+ if (verdict.effect === 'allow') return permissionsResponse(params, true)
41
+ if (verdict.effect === 'deny') {
42
+ refusedByPolicy(io, 'Permissions', verdict)
43
+ return permissionsResponse(params, false)
44
+ }
45
+ const resolved = await askPerson(io, permissionsRequest(params, cwd, verdict), signal)
46
+ return permissionsResponse(params, said(resolved, 'allow', 'always'))
47
+ }
48
+
49
+ async function userInput(params, { io, signal }) {
50
+ const request = userInputRequest(params)
51
+ const resolved = request ? await askPerson(io, request, signal) : null
52
+ return userInputResponse(params, request, resolved)
53
+ }
54
+
55
+ async function elicitation(params, { io, signal }) {
56
+ const request = formRequest(params)
57
+ const resolved = request ? await askPerson(io, request, signal) : null
58
+ return formResponse(params, request, resolved)
59
+ }
60
+
61
+ /** What Codex asks besides its approvals, by method: each answers with Codex's response. */
62
+ export const CODEX_ASKS = {
63
+ 'item/tool/requestUserInput': userInput,
64
+ 'item/permissions/requestApproval': permissions,
65
+ 'mcpServer/elicitation/request': elicitation,
66
+ }
@@ -0,0 +1,107 @@
1
+ // An MCP server asking the person for something through Codex (mcpServer/elicitation/request), in
2
+ // Kira's agent protocol: a form's fields as questions (a pick for a list or yes/no, words for the rest),
3
+ // a link to open or a plain yes/no as a choice; and the answer back as MCP's accept, decline or cancel
4
+ // with the form's content. Pure.
5
+ import { short } from './claude-calls.mjs'
6
+ import { requestId } from './protocol.mjs'
7
+
8
+ const ACCEPT = [
9
+ { id: 'accept', label: 'Accept' },
10
+ { id: 'decline', label: 'Decline' },
11
+ ]
12
+ const OPEN = [
13
+ { id: 'accept', label: 'Done', description: 'I opened it and did what it asks' },
14
+ { id: 'decline', label: 'Decline' },
15
+ ]
16
+ const YES_NO = [
17
+ { label: 'Yes', value: true },
18
+ { label: 'No', value: false },
19
+ ]
20
+
21
+ /** A field's choices as [{ label, value }], from whichever enum shape MCP gives; [] for words. */
22
+ function choicesOf(f) {
23
+ if (f.type === 'boolean') return YES_NO
24
+ const list = f.type === 'array' ? f.items ?? {} : f
25
+ if (list.oneOf ?? list.anyOf) return (list.oneOf ?? list.anyOf).map(o => ({ label: o.title, value: o.const }))
26
+ return (list.enum ?? []).map((value, k) => ({ label: f.enumNames?.[k] ?? value, value }))
27
+ }
28
+
29
+ /** A form's fields, in its order: what each asks and what its answers mean. */
30
+ export function fieldsOf(schema) {
31
+ const required = new Set(schema?.required ?? [])
32
+ return Object.entries(schema?.properties ?? {}).map(([key, f], n) => ({
33
+ key,
34
+ id: `q${n + 1}`,
35
+ type: f.type,
36
+ question: f.description || f.title || key,
37
+ header: f.title || key,
38
+ multi: f.type === 'array',
39
+ choices: choicesOf(f),
40
+ required: required.has(key),
41
+ ...(f.default !== undefined && f.default !== null ? { default: f.default } : {}),
42
+ }))
43
+ }
44
+
45
+ const choiceOptions = choices => choices.map((c, k) => ({ id: `o${k + 1}`, label: String(c.label) }))
46
+ const base = (params, more) => ({
47
+ id: requestId(),
48
+ type: 'choice',
49
+ title: short(params.message || `${params.serverName} asks`, 300),
50
+ action: { tool: `mcp__${params.serverName}`, label: 'Asks', target: short(params.message, 200) },
51
+ ...more,
52
+ })
53
+
54
+ /** The request for a server's ask; null for a kind Kira doesn't show (Codex is told no). */
55
+ export function formRequest(params) {
56
+ if (params.mode === 'url') return base(params, { detail: params.url, options: OPEN })
57
+ if (params.mode !== 'form') return null
58
+ const fields = fieldsOf(params.requestedSchema)
59
+ if (!fields.length) return base(params, { options: ACCEPT })
60
+ const questions = fields.map(f => ({
61
+ id: f.id,
62
+ question: f.question,
63
+ header: f.header,
64
+ multi: f.multi,
65
+ options: choiceOptions(f.choices),
66
+ }))
67
+ return base(params, { options: questions[0].options, multi: questions[0].multi, allowText: true, questions })
68
+ }
69
+
70
+ /** One field's value from its answer (undefined when there's none it can take). */
71
+ function valueOf(f, a) {
72
+ const picked = (a.optionIds ?? []).map(id => f.choices[Number(String(id).slice(1)) - 1]).filter(Boolean)
73
+ const text = typeof a.text === 'string' ? a.text.trim() : ''
74
+ const said = s => String(s).toLowerCase() === text.toLowerCase()
75
+ const named = f.choices.find(c => said(c.label) || said(c.value))
76
+ if (f.multi) {
77
+ const values = [...picked, ...(named ? [named] : [])].map(c => c.value)
78
+ return values.length ? values : undefined
79
+ }
80
+ if (picked.length) return picked[0].value
81
+ if (named) return named.value
82
+ if (!text || f.choices.length) return undefined
83
+ if (f.type === 'number' || f.type === 'integer') {
84
+ const n = Number(text)
85
+ return Number.isFinite(n) && (f.type === 'number' || Number.isInteger(n)) ? n : undefined
86
+ }
87
+ return text
88
+ }
89
+
90
+ /**
91
+ * The answer as MCP takes it: accept with the form filled in (an unanswered field keeps its default),
92
+ * decline when they said no or left a required field empty, cancel when nobody answered in time.
93
+ */
94
+ export function formResponse(params, request, resolved) {
95
+ if (!request) return { action: 'decline' }
96
+ if (resolved?.by === 'timeout') return { action: 'cancel' }
97
+ const answer = resolved?.answer ?? {}
98
+ if (!request.questions) return { action: (answer.optionIds ?? []).includes('accept') ? 'accept' : 'decline' }
99
+ const content = {}
100
+ for (const f of fieldsOf(params.requestedSchema)) {
101
+ const a = answer.answers?.[f.id] ?? (f.id === 'q1' ? answer : {})
102
+ const value = valueOf(f, a) ?? f.default
103
+ if (value !== undefined) content[f.key] = value
104
+ else if (f.required) return { action: 'decline' }
105
+ }
106
+ return Object.keys(content).length ? { action: 'accept', content } : { action: 'decline' }
107
+ }
@@ -0,0 +1,24 @@
1
+ // What Codex is given for a turn: turn/start's `input` (UserInput in its app-server protocol). The
2
+ // instruction is the text; what came with it from Kira is already on this Mac (the API's stage_files
3
+ // copied it under .kira/attachments/, and the text ends with each one's path, as Claude Code is told).
4
+ // A picture also goes in as `localImage`, so Codex sees it with the message instead of having to open
5
+ // it, and nothing leaves the Mac but what Codex itself sends; anything else it reads by its path.
6
+ // Pure: tested in test/codex-input.test.mjs.
7
+
8
+ /** The pictures Codex reads from a path (codex-rs encodes these; an SVG is text, read by path). */
9
+ const IMAGES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
10
+
11
+ /** An attachment Codex can be shown as a picture: one of IMAGES, at an absolute path. */
12
+ const isPicture = a => typeof a?.path === 'string' && a.path.startsWith('/') && IMAGES.has(String(a.mime))
13
+
14
+ /**
15
+ * turn/start's input for `prompt` and the files staged with it (`attachments`: { path, mime }, as the
16
+ * API's StageService gave them): the text first, then each picture, in the order they were attached.
17
+ */
18
+ export function codexInput(prompt, attachments = []) {
19
+ const pictures = (Array.isArray(attachments) ? attachments : []).filter(isPicture)
20
+ return [
21
+ { type: 'text', text: prompt, text_elements: [] },
22
+ ...pictures.map(a => ({ type: 'localImage', path: a.path })),
23
+ ]
24
+ }
@@ -80,6 +80,8 @@ export function itemAction(item, cwd) {
80
80
  return act('plan', 'Plan', short(item.text, 160), { tool: 'Plan' })
81
81
  case 'collabAgentToolCall':
82
82
  return act('delegate', 'Subtask', short(item.prompt ?? '', 160), { tool: 'Subtask' })
83
+ case 'enteredReviewMode':
84
+ return act('read', 'Review', short(item.review ?? '', 160), { tool: 'Review' })
83
85
  case 'imageGeneration':
84
86
  return act('other', 'Make an image', short(item.revisedPrompt ?? '', 160), { tool: 'ImageGeneration' })
85
87
  default:
@@ -0,0 +1,107 @@
1
+ // What Codex streams while a turn runs, as the agent protocol's live events (live.mjs): its reply as
2
+ // it's written, its reasoning summaries, a running command's output, its plan and the turn's whole
3
+ // diff. Pure: it keeps what has come so far of each and says what to send.
4
+ import { TEXT_CAP, short } from './claude-calls.mjs'
5
+
6
+ /** How much of a running command's output is shown: its last lines. */
7
+ const OUTPUT_TAIL = 4000
8
+ /** How much of the turn's diff is kept with the job; its files and counts are always whole. */
9
+ export const DIFF_CAP = 40_000
10
+ const STEP_CAP = 300
11
+
12
+ /** A unified diff's files, each with the lines it adds and removes. */
13
+ export function diffFiles(diff) {
14
+ const files = []
15
+ let cur = null
16
+ for (const line of String(diff ?? '').split('\n')) {
17
+ if (line.startsWith('diff --git ')) {
18
+ cur = { path: / b\/(.+)$/.exec(line)?.[1] ?? line.slice(11), adds: 0, dels: 0 }
19
+ files.push(cur)
20
+ } else if (!cur || line.startsWith('+++ ') || line.startsWith('--- ')) continue
21
+ else if (line.startsWith('+')) cur.adds++
22
+ else if (line.startsWith('-')) cur.dels++
23
+ }
24
+ return files
25
+ }
26
+
27
+ /** turn/plan/updated as a plan event: its steps, each pending, in progress or completed. */
28
+ function planEvent(p) {
29
+ return {
30
+ kind: 'plan',
31
+ id: `${p.turnId}:plan`,
32
+ ...(p.explanation?.trim() ? { explanation: short(p.explanation.trim(), 600) } : {}),
33
+ steps: (p.plan ?? []).map(s => ({ text: short(s.step, STEP_CAP), status: s.status })),
34
+ }
35
+ }
36
+
37
+ /** turn/diff/updated as a changes event: the files with their counts, and the diff (cut when long). */
38
+ function changesEvent(p) {
39
+ const diff = String(p.diff ?? '')
40
+ const truncated = diff.length > DIFF_CAP
41
+ return {
42
+ kind: 'changes',
43
+ id: `${p.turnId}:changes`,
44
+ files: diffFiles(diff),
45
+ diff: truncated ? diff.slice(0, DIFF_CAP) : diff,
46
+ ...(truncated ? { truncated } : {}),
47
+ }
48
+ }
49
+
50
+ /** One turn's streams: `update` maps a notification to a live event, `done` and `ends` say what to keep. */
51
+ export function codexStream() {
52
+ /** itemId -> what it has written so far (a message, a command's output). */
53
+ const texts = new Map()
54
+ /** itemId -> its reasoning summary, part by part. */
55
+ const thoughts = new Map()
56
+ let plan = null
57
+ let changes = null
58
+ const grow = (id, delta, keep) => {
59
+ const text = keep((texts.get(id) ?? '') + delta)
60
+ texts.set(id, text)
61
+ return text
62
+ }
63
+ const thinking = id => {
64
+ const text = (thoughts.get(id) ?? []).filter(Boolean).join('\n\n').trim()
65
+ return { kind: 'thinking', id, text: short(text, TEXT_CAP) }
66
+ }
67
+
68
+ return {
69
+ /** The live event a notification makes, or null when it makes none. */
70
+ update(method, p) {
71
+ switch (method) {
72
+ case 'item/agentMessage/delta': {
73
+ const text = grow(p.itemId, p.delta, t => t.slice(0, TEXT_CAP + 1))
74
+ return { kind: 'message', id: p.itemId, text: short(text, TEXT_CAP) }
75
+ }
76
+ case 'item/reasoning/summaryPartAdded':
77
+ case 'item/reasoning/summaryTextDelta': {
78
+ const parts = thoughts.get(p.itemId) ?? []
79
+ parts[p.summaryIndex] = (parts[p.summaryIndex] ?? '') + (p.delta ?? '')
80
+ thoughts.set(p.itemId, parts)
81
+ return p.delta ? thinking(p.itemId) : null
82
+ }
83
+ case 'item/commandExecution/outputDelta':
84
+ return { kind: 'output', id: p.itemId, text: grow(p.itemId, p.delta, t => t.slice(-OUTPUT_TAIL)) }
85
+ case 'turn/plan/updated':
86
+ return (plan = planEvent(p))
87
+ case 'turn/diff/updated':
88
+ return (changes = changesEvent(p))
89
+ default:
90
+ return null
91
+ }
92
+ },
93
+ /** A finished item: its reasoning summary to keep (null for anything else, or nothing said); forgets it. */
94
+ done(item) {
95
+ texts.delete(item.id)
96
+ if (item.type !== 'reasoning') return null
97
+ if (item.summary?.length) thoughts.set(item.id, item.summary)
98
+ const kept = thinking(item.id)
99
+ thoughts.delete(item.id)
100
+ return kept.text ? kept : null
101
+ },
102
+ /** What the turn ends with, to keep: its last plan and its changes (when it changed anything). */
103
+ ends() {
104
+ return [plan, changes?.files.length ? changes : null].filter(Boolean)
105
+ },
106
+ }
107
+ }
@@ -1,7 +1,9 @@
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).
1
+ // Codex on this Mac, as the person signed in to it: whether it's there and signed in, the models it
2
+ // offers them (its own list, so the composer never names a model Codex wouldn't take), and their
3
+ // ChatGPT usage limits (none with an API key).
3
4
  import { homedir } from 'node:os'
4
5
  import { codexBin, openCodex } from './codex-rpc.mjs'
6
+ import { limitsOf, snapshotOf } from './codex-usage.mjs'
5
7
 
6
8
  const ASK_MS = 15_000
7
9
 
@@ -19,7 +21,7 @@ export function modelsOf(list) {
19
21
  return [...models.filter(m => m.default), ...models.filter(m => !m.default)]
20
22
  }
21
23
 
22
- /** Whether Codex is here and signed in, and its models: `{ installed, signedIn, models }` or `{ error }`. */
24
+ /** Whether Codex is here and signed in, its models and their limits: `{ installed, signedIn, models, limits }`. */
23
25
  async function codexModels() {
24
26
  const bin = codexBin()
25
27
  if (!bin) return { installed: false, signedIn: false, models: [] }
@@ -30,7 +32,11 @@ async function codexModels() {
30
32
  codex.notify('initialized')
31
33
  const account = await codex.request('account/read', {})
32
34
  if (!account?.account) return { installed: true, signedIn: false, models: [] }
33
- return { installed: true, signedIn: true, models: modelsOf(await codex.request('model/list', {})) }
35
+ const [list, read] = await Promise.all([
36
+ codex.request('model/list', {}),
37
+ codex.request('account/rateLimits/read', { excludeResetCreditDetails: true }).catch(() => null),
38
+ ])
39
+ return { installed: true, signedIn: true, models: modelsOf(list), limits: limitsOf(snapshotOf(read)) }
34
40
  } catch (err) {
35
41
  return { error: `Codex didn't answer: ${err.message}` }
36
42
  } finally {
@@ -0,0 +1,114 @@
1
+ // What Codex asks the person besides approving a command or a file change, in Kira's agent protocol
2
+ // (protocol.mjs): its questions (item/tool/requestUserInput), more access (item/permissions/requestApproval)
3
+ // and an MCP server's form (mcpServer/elicitation/request), each as a request card; and the person's
4
+ // answer back in Codex's own response shape (an MCP server's form: codex-forms.mjs). Pure; codex-ask.mjs asks.
5
+ import { displayPath, short } from './claude-calls.mjs'
6
+ import { permissionOptions, requestId } from './protocol.mjs'
7
+
8
+ const options = list => list.map((o, k) => ({ id: `o${k + 1}`, ...o }))
9
+ /** A question's answer: per question, else (the first one) the answer's own options and words. */
10
+ const answerTo = (answer, id, first) => answer?.answers?.[id] ?? (first ? answer ?? {} : {})
11
+ const picked = (q, a) => (a.optionIds ?? []).map(id => q.options.find(o => o.id === id)).filter(Boolean)
12
+ const typed = a => (typeof a.text === 'string' && a.text.trim() ? a.text.trim() : '')
13
+
14
+ /** Codex's questions as a choice, one question each; a secret one isn't carried through Kira. */
15
+ export function userInputRequest(params) {
16
+ const asked = (params.questions ?? []).filter(q => !q.isSecret)
17
+ const questions = asked.map((q, n) => ({
18
+ id: `q${n + 1}`,
19
+ question: q.question,
20
+ header: q.header,
21
+ multi: false,
22
+ options: options((q.options ?? []).map(o => ({ label: o.label, description: o.description || undefined }))),
23
+ }))
24
+ const secret = (params.questions ?? []).length - asked.length
25
+ if (!questions.length) return null
26
+ return {
27
+ id: requestId(),
28
+ type: 'choice',
29
+ title: questions.length === 1 ? questions[0].question : `${questions.length} questions`,
30
+ ...(secret ? { detail: 'Codex also asked for something secret: answer that in Codex on the Mac.' } : {}),
31
+ options: questions[0].options,
32
+ allowText: true,
33
+ questions,
34
+ action: { tool: 'AskUserQuestion', label: 'Question', target: short(questions.map(q => q.question).join(' / ')) },
35
+ }
36
+ }
37
+
38
+ /** The answers as Codex takes them, by its question ids; unanswered (timed out, secret) ones are empty. */
39
+ export function userInputResponse(params, request, resolved) {
40
+ const answer = resolved?.by === 'timeout' ? undefined : resolved?.answer
41
+ const asked = (params.questions ?? []).filter(q => !q.isSecret)
42
+ const answers = Object.fromEntries((params.questions ?? []).map(q => [q.id, { answers: [] }]))
43
+ asked.forEach((codexQ, n) => {
44
+ const q = request?.questions?.[n]
45
+ if (!q) return
46
+ const a = answerTo(answer, q.id, n === 0)
47
+ const words = [...picked(q, a).map(o => o.label), typed(a)].filter(Boolean)
48
+ answers[codexQ.id] = { answers: words }
49
+ })
50
+ return { answers }
51
+ }
52
+
53
+ /** A folder Codex asks for, as Kira shows and checks it; null for what no path names. */
54
+ function folderOf(path, cwd) {
55
+ if (path?.type === 'path') return path.path
56
+ if (path?.type === 'glob_pattern') return path.pattern
57
+ const v = path?.value ?? {}
58
+ if (v.kind === 'slash_tmp') return '/tmp'
59
+ if (v.kind === 'project_roots') return v.subpath ? `${cwd}/${v.subpath}` : cwd
60
+ return null
61
+ }
62
+ const SPECIAL = { root: 'the whole disk', minimal: 'system basics', tmpdir: 'the temporary folder' }
63
+
64
+ /** What more access Codex asks for: each folder as the call Kira's rules check, and what no rule can. */
65
+ export function permissionsAsked(params, cwd) {
66
+ const fs = params.permissions?.fileSystem ?? {}
67
+ const entries = [
68
+ ...(fs.entries ?? []),
69
+ ...(fs.read ?? []).map(path => ({ access: 'read', path: { type: 'path', path } })),
70
+ ...(fs.write ?? []).map(path => ({ access: 'write', path: { type: 'path', path } })),
71
+ ].filter(e => e.access !== 'deny')
72
+ const calls = []
73
+ const unchecked = []
74
+ const parts = []
75
+ for (const e of entries) {
76
+ const folder = folderOf(e.path, cwd)
77
+ const verb = e.access === 'write' ? 'write in' : 'read'
78
+ if (!folder) {
79
+ const v = e.path?.value ?? {}
80
+ unchecked.push(e)
81
+ parts.push(`${verb} ${SPECIAL[v.kind] ?? v.path ?? 'a folder'}`)
82
+ continue
83
+ }
84
+ const tool = e.access === 'write' ? 'Edit' : 'Read'
85
+ calls.push({ tool, input: { file_path: folder }, cwd })
86
+ parts.push(`${verb} ${displayPath(folder, cwd)}`)
87
+ }
88
+ // The network isn't a call Kira's rules name: the person decides it.
89
+ if (params.permissions?.network?.enabled) {
90
+ unchecked.push('network')
91
+ parts.unshift('use the network')
92
+ }
93
+ return { calls, unchecked: unchecked.length > 0, summary: parts.join(', ') }
94
+ }
95
+
96
+ /** More access as a permission; "don't ask again" only when one folder is all it asks. */
97
+ export function permissionsRequest(params, cwd, verdict) {
98
+ const { calls, unchecked, summary } = permissionsAsked(params, cwd)
99
+ const rule = !unchecked && calls.length === 1 ? verdict?.suggest : ''
100
+ return {
101
+ id: requestId(),
102
+ type: 'permission',
103
+ title: `Codex wants to ${summary || 'get more access'}`,
104
+ detail: params.reason ? short(params.reason, 2000) : undefined,
105
+ options: permissionOptions(rule),
106
+ action: { tool: 'Permissions', label: 'Get access', target: short(summary, 200) },
107
+ ...(rule ? { rule } : {}),
108
+ ...(verdict?.why ? { why: verdict.why } : {}),
109
+ }
110
+ }
111
+
112
+ /** Granted (all it asked, for this turn) or not (nothing). */
113
+ export const permissionsResponse = (params, yes) =>
114
+ yes ? { permissions: params.permissions ?? {}, scope: 'turn' } : { permissions: {} }
@@ -0,0 +1,89 @@
1
+ // Codex's code review (review/start) in Kira's agent protocol. Pure. What to review comes as Kira's
2
+ // target (genui review.ts) and goes to Codex as its ReviewTarget; the review comes back in Codex's
3
+ // exitedReviewMode item as the text codex-rs renders (review_format.rs), read here into the findings
4
+ // card's event:
5
+ //
6
+ // <overall explanation>
7
+ //
8
+ // Full review comments: ("Review comment:" for one)
9
+ //
10
+ // - [P1] <title> — /abs/path/file.ts:3-3
11
+ // <body, indented two spaces>
12
+ import { displayPath, short } from './claude-calls.mjs'
13
+
14
+ const MAX_FINDINGS = 50
15
+ const SHA = /^[0-9a-f]{4,40}$/i
16
+ const REF = /^(?!-)[\w./-]{1,200}$/
17
+ const HEADER = /(?:^|\n)(?:Review comment|Full review comments):\n/
18
+ /** "- [x] title — path:3-9": the selection box only in Codex's own TUI; the location after the last " — ". */
19
+ const LOCATED = /^- (?:\[[ x]\] )?(.*) — (.+?):(\d+)-(\d+)$/
20
+ const UNPLACED = /^- (?:\[[ x]\] )?(.*)$/
21
+ const PRIORITY = /^\[P([0-3])\]\s*/
22
+
23
+ /** Kira's review target as Codex's review/start takes it; a branch without a base asks `base()` for one. */
24
+ export async function codexTarget(review, base) {
25
+ switch (review?.type) {
26
+ case 'baseBranch': {
27
+ const branch = review.branch || (await base())
28
+ if (!REF.test(branch)) throw new Error(`“${branch}” isn't a branch Codex can review against`)
29
+ return { type: 'baseBranch', branch }
30
+ }
31
+ case 'commit':
32
+ if (!SHA.test(String(review.sha))) throw new Error(`“${review.sha}” isn't a commit`)
33
+ return { type: 'commit', sha: review.sha }
34
+ case 'custom':
35
+ return { type: 'custom', instructions: short(review.instructions, 4000) }
36
+ default:
37
+ return { type: 'uncommittedChanges' }
38
+ }
39
+ }
40
+
41
+ /** One finding's head line and the body lines under it. */
42
+ function findingOf(head, body, cwd) {
43
+ const [, said, path, start, end] = head
44
+ const priority = PRIORITY.exec(said)
45
+ return {
46
+ title: short(said.replace(PRIORITY, '').trim(), 300),
47
+ priority: priority ? Number(priority[1]) : null,
48
+ path: path ? displayPath(path, cwd) : '',
49
+ line: start ? Number(start) : null,
50
+ endLine: end ? Number(end) : null,
51
+ body: short(body.join('\n').trim(), 2000),
52
+ }
53
+ }
54
+
55
+ /** Codex's rendered review: its overall explanation, and each finding. */
56
+ export function readReview(text, cwd) {
57
+ const all = String(text ?? '').replace(/\r\n/g, '\n')
58
+ const header = HEADER.exec(all)
59
+ if (!header) return { summary: all.trim(), findings: [] }
60
+ const found = []
61
+ for (const line of all.slice(header.index + header[0].length).split('\n')) {
62
+ const head = line.startsWith('- ') ? (LOCATED.exec(line) ?? UNPLACED.exec(line)) : null
63
+ if (head) found.push({ head, body: [] })
64
+ else if (found.length && (line.startsWith(' ') || !line.trim())) found.at(-1).body.push(line.slice(2))
65
+ }
66
+ const findings = found.slice(0, MAX_FINDINGS).map(f => findingOf(f.head, f.body, cwd))
67
+ return { summary: all.slice(0, header.index).trim(), findings }
68
+ }
69
+
70
+ /**
71
+ * A review as it goes by: what it looks at (enteredReviewMode, in Codex's words) and, when it ends
72
+ * (exitedReviewMode), the findings card's event. Codex then repeats the review as its message; the
73
+ * card says it once.
74
+ */
75
+ export function reviewWatch(cwd) {
76
+ let target = ''
77
+ let said = null
78
+ return {
79
+ /** A finished item: the review's event when it's the review's end, else null. */
80
+ completed(item) {
81
+ if (item.type === 'enteredReviewMode') target = String(item.review ?? '')
82
+ if (item.type !== 'exitedReviewMode') return null
83
+ said = String(item.review ?? '')
84
+ const { summary, findings } = readReview(said, cwd)
85
+ return { kind: 'review', id: item.id, target: short(target, 200), summary: short(summary, 4000), findings }
86
+ },
87
+ repeats: text => said !== null && text.trim() === said.trim(),
88
+ }
89
+ }
@@ -0,0 +1,31 @@
1
+ // Steering a Codex turn while it runs: what a person says to Codex at work goes into the turn it's on
2
+ // (the app server's turn/steer, held to the turn it expects) instead of waiting for it to end. Codex
3
+ // says so in its capabilities (`steer: true`); the API sends such a message through `steer_job`
4
+ // (job-tools.mjs), and when it isn't taken (no turn at work, or Codex refused) it waits on the job.
5
+ import { codexInput } from './codex-input.mjs'
6
+
7
+ /** A run's way into its turn at work. `request` is the app server's (codex-rpc.mjs). */
8
+ export function turnSteering(request) {
9
+ let turn = null
10
+ return {
11
+ /** A turn started on the thread: what's said now goes into it. */
12
+ started(threadId, turnId) {
13
+ turn = { threadId, turnId }
14
+ },
15
+ /** The turn ended: what's said now waits for the job's end. */
16
+ ended() {
17
+ turn = null
18
+ },
19
+ /** Add the person's words to the turn at work: { steered: true }, or why not. */
20
+ async steer(text) {
21
+ if (!turn) return { steered: false, note: 'Codex has no turn at work' }
22
+ const { threadId, turnId } = turn
23
+ try {
24
+ await request('turn/steer', { threadId, expectedTurnId: turnId, input: codexInput(text) })
25
+ return { steered: true }
26
+ } catch (err) {
27
+ return { steered: false, note: String(err?.message ?? err) }
28
+ }
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,101 @@
1
+ // How full a Codex thread is and how much of the person's ChatGPT plan is left, from Codex's app
2
+ // server: `thread/tokenUsage/updated` (the context), `account/rateLimits/read` and
3
+ // `account/rateLimits/updated` (the limits). Becomes the protocol's `usage` event, and the words a run
4
+ // fails with when a limit stops it: which limit, and when it resets.
5
+
6
+ /** A window as Kira sends it: { usedPercent, resetsAt (Unix seconds), minutes }. */
7
+ const windowOf = w =>
8
+ w && typeof w.usedPercent === 'number'
9
+ ? { usedPercent: w.usedPercent, resetsAt: w.resetsAt ?? null, minutes: w.windowDurationMins ?? null }
10
+ : null
11
+
12
+ /** The context a thread fills: its last request's tokens out of the model's window; null when unknown. */
13
+ export function contextOf(tokenUsage) {
14
+ const window = tokenUsage?.modelContextWindow
15
+ const used = tokenUsage?.last?.totalTokens
16
+ return window > 0 && typeof used === 'number' ? { used, window } : null
17
+ }
18
+
19
+ /** The Codex limits in a `rateLimits/read` answer: its `codex` bucket, else the single one. */
20
+ export const snapshotOf = read => read?.rateLimitsByLimitId?.codex ?? read?.rateLimits ?? null
21
+
22
+ /**
23
+ * A rate-limit snapshot as Kira's limits, merged over what was known: an update is sparse, and what
24
+ * it leaves out (null) keeps its last value. Another bucket's update (not Codex's) changes nothing.
25
+ */
26
+ export function limitsOf(snapshot, prev = null) {
27
+ if (!snapshot || (snapshot.limitId && snapshot.limitId !== 'codex')) return prev
28
+ const c = snapshot.credits
29
+ const credits = c ? { has: !!c.hasCredits, unlimited: !!c.unlimited, balance: c.balance ?? null } : null
30
+ return {
31
+ primary: windowOf(snapshot.primary) ?? prev?.primary ?? null,
32
+ secondary: windowOf(snapshot.secondary) ?? prev?.secondary ?? null,
33
+ credits: credits ?? prev?.credits ?? null,
34
+ plan: snapshot.planType ?? prev?.plan ?? null,
35
+ }
36
+ }
37
+
38
+ /** A window's name: "5-hour", "weekly". */
39
+ function windowName(minutes) {
40
+ if (minutes === 7 * 24 * 60) return 'weekly'
41
+ if (minutes && minutes < 24 * 60) return `${Math.round(minutes / 60)}-hour`
42
+ return minutes ? `${Math.round(minutes / (24 * 60))}-day` : 'usage'
43
+ }
44
+
45
+ const LIMIT_ERRORS = new Set(['usageLimitExceeded', 'rateLimitExceeded'])
46
+
47
+ /** What a run says when it stops: which limit stopped it and when it resets, else Codex's own words. */
48
+ export function stoppedBy(turnError, limits, now = Date.now()) {
49
+ const said = `Codex stopped: ${turnError?.message ?? 'error'}`
50
+ if (!LIMIT_ERRORS.has(turnError?.codexErrorInfo)) return said
51
+ const full = [limits?.primary, limits?.secondary].filter(Boolean).sort((a, b) => b.usedPercent - a.usedPercent)[0]
52
+ if (!full) return `Codex stopped: you've reached your ChatGPT usage limit. ${turnError.message ?? ''}`.trim()
53
+ const when = full.resetsAt ? resetWords(full.resetsAt, now) : 'later'
54
+ const name = windowName(full.minutes)
55
+ return `Codex stopped: your ${name} ChatGPT limit is used up (${full.usedPercent}%). It resets ${when}.`
56
+ }
57
+
58
+ /** "at 3:10 PM" today, "on Tue at 9:05 AM" otherwise, in this Mac's own way of saying times. */
59
+ function resetWords(resetsAt, now) {
60
+ const at = new Date(resetsAt * 1000)
61
+ const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
62
+ if (at.toDateString() === new Date(now).toDateString()) return `at ${time}`
63
+ return `on ${at.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} at ${time}`
64
+ }
65
+
66
+ const pctOf = c => (c ? Math.round((c.used / c.window) * 100) : null)
67
+
68
+ /**
69
+ * A run's usage: fed Codex's notifications, it emits a `usage` event with the latest of both, only
70
+ * when what a person sees changes (a whole percent of context, a limit), not on every token count.
71
+ */
72
+ export function usageFeed(emit) {
73
+ let context = null
74
+ let limits = null
75
+ let sent = ''
76
+ const send = () => {
77
+ if (!context && !limits) return
78
+ const key = JSON.stringify([pctOf(context), limits])
79
+ if (key === sent) return
80
+ sent = key
81
+ emit({ kind: 'usage', context, limits })
82
+ }
83
+ return {
84
+ onTokens(tokenUsage) {
85
+ context = contextOf(tokenUsage) ?? context
86
+ send()
87
+ },
88
+ onLimits(snapshot) {
89
+ limits = limitsOf(snapshot, limits)
90
+ if (limits) send()
91
+ },
92
+ /** The limits known so far, for the words a run fails with. */
93
+ get limits() {
94
+ return limits
95
+ },
96
+ /** What the run ends with, for its stats. */
97
+ get stats() {
98
+ return { ...(context ? { context } : {}), ...(limits ? { limits } : {}) }
99
+ },
100
+ }
101
+ }
@@ -1,12 +1,21 @@
1
1
  // Codex as an agent, through its app server (codex-rpc.mjs). Runs on the person's Mac with their own
2
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.
3
+ // change Codex asks about is decided by Kira's rules (io.check), and by the person when they ask; its
4
+ // questions, more access and MCP servers' forms are asked as cards too (codex-ask.mjs).
4
5
  import { permissionOptions, requestId } from './protocol.mjs'
5
6
  import { TEXT_CAP, short } from './claude-calls.mjs'
6
7
  import { callsOf, codexOptionsFor, imageOf, itemAction, itemResult, strictest } from './codex-items.mjs'
7
8
  import { codexBin, openCodex } from './codex-rpc.mjs'
9
+ import { codexInput } from './codex-input.mjs'
10
+ import { turnSteering } from './codex-steer.mjs'
11
+ import { codexStream } from './codex-live.mjs'
12
+ import { liveEmitter } from './live.mjs'
8
13
  import { kiraDecides } from './checks.mjs'
14
+ import { CODEX_ASKS, askPerson, refusedByPolicy, said } from './codex-ask.mjs'
9
15
  import { codexAgentOf, developerInstructions } from './codex-agent.mjs'
16
+ import { snapshotOf, stoppedBy, usageFeed } from './codex-usage.mjs'
17
+ import { codexTarget, reviewWatch } from './codex-review.mjs'
18
+ import { defaultBranch } from '../refs.mjs'
10
19
  import { kiraSkillRoots } from '../kira-plugin.mjs'
11
20
 
12
21
  const COMMAND = 'item/commandExecution/requestApproval'
@@ -20,8 +29,9 @@ const kiraServer = ({ url }) => ({ 'mcp_servers.kira.url': url, 'mcp_servers.kir
20
29
  /**
21
30
  * Run one instruction in a Codex thread. Same contract as claude.mjs:
22
31
  * io.emit(event), io.ask(request, signal), io.check(call); returns { ok, text, error, sessionId, stats }.
32
+ * io.steerable(steer) is handed the way into the turn at work (codex-steer.mjs).
23
33
  */
24
- async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
34
+ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abort }, io) {
25
35
  const bin = codexBin()
26
36
  if (!bin) {
27
37
  io.emit({ kind: 'result', ok: false, error: NOT_INSTALLED, sessionId })
@@ -34,8 +44,18 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
34
44
  let error = null
35
45
  /** The files each change will touch, from when it started: what its approval is checked on. */
36
46
  const changes = new Map()
47
+ /** How full the thread is and the person's limits, as they change (codex-usage.mjs). */
48
+ const usage = usageFeed(e => io.emit(e))
49
+ /** A review asked for ("@codex /review"): Codex's review/start instead of a turn, its findings a card. */
50
+ const review = reviewWatch(cwd)
51
+ /** What is still being written (live.mjs): sent as it comes, the final form of each kept. */
52
+ const live = liveEmitter(io.emit)
53
+ const stream = codexStream()
37
54
  let finished
38
55
  const ended = new Promise(resolve => (finished = resolve))
56
+ // What the person says while a turn runs goes into it (`codex` is the app server, opened below).
57
+ const steering = turnSteering((method, params) => codex.request(method, params))
58
+ io.steerable?.(steering.steer)
39
59
 
40
60
  const decide = async (method, params) => {
41
61
  const calls = callsOf(method, params, changes.get(params.itemId)).map(c => ({ ...c, cwd: c.cwd ?? cwd }))
@@ -48,10 +68,7 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
48
68
  cwd,
49
69
  )
50
70
  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 })
71
+ refusedByPolicy(io, action.tool, verdict)
55
72
  return { decision: 'decline' }
56
73
  }
57
74
  const request = {
@@ -64,44 +81,48 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
64
81
  ...(verdict.suggest ? { rule: verdict.suggest } : {}),
65
82
  ...(verdict.why ? { why: verdict.why } : {}),
66
83
  }
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' }
84
+ const resolved = await askPerson(io, request, abort?.signal)
85
+ return { decision: said(resolved, 'allow', 'always') ? 'accept' : 'decline' }
77
86
  }
78
87
 
79
88
  const onNotify = (method, p) => {
89
+ const update = stream.update(method, p)
90
+ if (update) return live.update(update)
80
91
  if (method === 'item/started') {
81
92
  if (p.item.type === 'fileChange') changes.set(p.item.id, p.item.changes ?? [])
82
93
  const action = itemAction(p.item, cwd)
83
94
  if (action) io.emit({ kind: 'action', id: p.item.id, ...action })
84
95
  } else if (method === 'item/completed') {
96
+ const reviewed = review.completed(p.item)
97
+ if (reviewed) io.emit(reviewed)
85
98
  if (p.item.type === 'agentMessage' && p.item.text?.trim()) {
86
99
  text = p.item.text
87
- io.emit({ kind: 'message', text: short(p.item.text, TEXT_CAP) })
100
+ if (!review.repeats(text)) live.final({ kind: 'message', id: p.item.id, text: short(p.item.text, TEXT_CAP) })
88
101
  } else if (itemAction(p.item, cwd)) {
89
- io.emit({ kind: 'action_result', id: p.item.id, ...itemResult(p.item) })
102
+ live.final({ kind: 'action_result', id: p.item.id, ...itemResult(p.item) })
90
103
  const image = imageOf(p.item)
91
104
  if (image) io.emit(image)
92
105
  }
106
+ const thought = stream.done(p.item)
107
+ if (thought) live.final(thought)
93
108
  changes.delete(p.item.id)
109
+ } else if (method === 'thread/tokenUsage/updated' && p.threadId === thread) {
110
+ usage.onTokens(p.tokenUsage)
111
+ } else if (method === 'account/rateLimits/updated') {
112
+ usage.onLimits(p.rateLimits)
94
113
  } else if (method === 'error' && !p.willRetry) {
95
- error = `Codex stopped: ${p.error?.message ?? 'error'}`
114
+ error = stoppedBy(p.error, usage.limits)
96
115
  } else if (method === 'turn/completed' && p.turn?.id === turnId) {
97
116
  const t = p.turn
98
- if (t.status === 'failed') error = `Codex stopped: ${t.error?.message ?? 'failed'}`
117
+ steering.ended()
118
+ if (t.status === 'failed') error = stoppedBy(t.error ?? { message: 'failed' }, usage.limits)
99
119
  if (t.status === 'interrupted') error = 'Stopped by the user.'
100
120
  finished({ durationMs: t.durationMs ?? undefined })
101
121
  }
102
122
  }
103
123
  const onRequest = async (method, params) => {
104
124
  if (APPROVALS.has(method)) return decide(method, params)
125
+ if (Object.hasOwn(CODEX_ASKS, method)) return CODEX_ASKS[method](params, { io, cwd, signal: abort?.signal })
105
126
  throw new Error(`Kira does not answer ${method}`)
106
127
  }
107
128
 
@@ -115,6 +136,11 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
115
136
  try {
116
137
  await codex.request('initialize', { clientInfo: { name: 'kira', title: 'Kira', version: '1' }, capabilities: null })
117
138
  codex.notify('initialized')
139
+ // Their limits as the run starts (not waited on): a ChatGPT sign-in has them, a key doesn't.
140
+ void codex.request('account/rateLimits/read', { excludeResetCreditDetails: true }).then(
141
+ read => usage.onLimits(snapshotOf(read)),
142
+ () => {},
143
+ )
118
144
  // A Kira agent on Codex: its agent file, applied as Codex applies a role (codex-agent.mjs).
119
145
  const role = await codexAgentOf(persona)
120
146
  // Kira's built-in skills, as Codex's own for this app server: the folder Claude Code's plugin uses.
@@ -127,12 +153,17 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
127
153
  : await codex.request('thread/start', { cwd, ...options, ...instructions, config })
128
154
  thread = opened.thread.id
129
155
  io.emit({ kind: 'session', sessionId: thread, model: opened.model ?? null })
130
- const input = [{ type: 'text', text: prompt, text_elements: [] }]
131
- const started = await codex.request('turn/start', { threadId: thread, input, ...(effort ? { effort } : {}) })
156
+ const input = codexInput(prompt, attachments)
157
+ const target = settings?.review ? await codexTarget(settings.review, () => defaultBranch(cwd)) : null
158
+ const started = target
159
+ ? await codex.request('review/start', { threadId: thread, target })
160
+ : await codex.request('turn/start', { threadId: thread, input, ...(effort ? { effort } : {}) })
132
161
  turnId = started.turn.id
162
+ steering.started(thread, turnId)
133
163
  const end = await ended
134
164
  if (end.crashed && !error) error = abort?.signal.aborted ? 'Stopped by the user.' : `Codex stopped: ${end.crashed}`
135
- const stats = end.durationMs ? { durationMs: end.durationMs } : undefined
165
+ const stats = { ...(end.durationMs ? { durationMs: end.durationMs } : {}), ...usage.stats }
166
+ for (const kept of stream.ends()) live.final(kept)
136
167
  io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: thread, stats })
137
168
  return { ok: !error, text, error, sessionId: thread, stats }
138
169
  } catch (err) {
@@ -140,9 +171,12 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
140
171
  io.emit({ kind: 'result', ok: false, error: message, sessionId: thread })
141
172
  return { ok: false, error: message, sessionId: thread }
142
173
  } finally {
174
+ live.close()
143
175
  abort?.signal.removeEventListener('abort', stop)
144
176
  codex.close()
145
177
  }
146
178
  }
147
179
 
148
- export const codex = { id: 'codex', name: 'Codex', run, capabilities: { resume: true, requests: ['permission'] } }
180
+ /** steer: what a person says while it works goes into the turn at work (codex-steer.mjs). */
181
+ const capabilities = { resume: true, requests: ['permission', 'choice'], steer: true }
182
+ export const codex = { id: 'codex', name: 'Codex', run, capabilities }
@@ -0,0 +1,52 @@
1
+ // What an agent is still in the middle of (its reply as it's written, its thinking, a running
2
+ // command's output, its plan, the turn's changes) goes out as it happens, as transient events
3
+ // (protocol.mjs): the API forwards them to the screens following the job and never stores them.
4
+ // Each id is sent at most once every `every` ms, the latest update winning; the event that settles
5
+ // an id (its final form, stored as usual) cancels what's still pending for it, so it is never
6
+ // overtaken by a stale update.
7
+
8
+ const LIVE_EVERY = 250
9
+
10
+ /** A throttled emitter over io.emit: `update` for transient events, `final` for the ones that settle them. */
11
+ export function liveEmitter(emit, every = LIVE_EVERY) {
12
+ /** id -> the update waiting for its turn, and its timer. */
13
+ const pending = new Map()
14
+ /** id -> when an update for it last went out. */
15
+ const sent = new Map()
16
+ const cancel = id => {
17
+ clearTimeout(pending.get(id)?.timer)
18
+ pending.delete(id)
19
+ }
20
+ const send = event => {
21
+ sent.set(event.id, Date.now())
22
+ emit({ ...event, transient: true })
23
+ }
24
+ return {
25
+ update(event) {
26
+ const waiting = pending.get(event.id)
27
+ if (waiting) {
28
+ waiting.event = event
29
+ return
30
+ }
31
+ const wait = (sent.get(event.id) ?? -Infinity) + every - Date.now()
32
+ if (wait <= 0) return send(event)
33
+ const entry = { event, timer: null }
34
+ entry.timer = setTimeout(() => {
35
+ pending.delete(event.id)
36
+ send(entry.event)
37
+ }, wait)
38
+ pending.set(event.id, entry)
39
+ },
40
+ final(event) {
41
+ if (event.id !== undefined) {
42
+ cancel(event.id)
43
+ sent.delete(event.id)
44
+ }
45
+ emit(event)
46
+ },
47
+ /** The run is over: nothing pending goes out after its end. */
48
+ close() {
49
+ for (const id of [...pending.keys()]) cancel(id)
50
+ },
51
+ }
52
+ }
@@ -2,15 +2,26 @@
2
2
  // agent. The API, the portal and the glasses only ever see these shapes.
3
3
  //
4
4
  // session { kind, sessionId, model? }
5
- // message { kind, text } the agent said something
5
+ // message { kind, id?, text } the agent said something
6
+ // thinking { kind, id, text } its reasoning, as the agent summarizes it
7
+ // output { kind, id, text } a running action's output so far (always transient)
8
+ // plan { kind, id, explanation?, steps: [{ text, status: pending | inProgress | completed }] }
9
+ // changes { kind, id, files: [{ path, adds, dels }], diff, truncated? } the turn's whole diff
6
10
  // action { kind, id, type, tool, label, target, note?, detail? }
7
11
  // type: read | search | edit | write | run | fetch | delegate | plan | other
8
12
  // detail: { edit: {old,new,all} | edits: [...] | content | command }
9
13
  // action_result { kind, id, ok, preview? }
10
14
  // request { kind, request: AgentRequest } needs a person; the job waits
11
15
  // request_resolved { kind, id, answer, by } by: voice | lens | phone | portal | timeout | policy
16
+ // usage { kind, context: {used,window} | null, limits: CodexLimits | null } Codex: how full its
17
+ // thread is and the person's ChatGPT limits ({primary,secondary: {usedPercent,resetsAt,minutes},
18
+ // credits, plan}), the latest of both each time one changes (codex-usage.mjs)
12
19
  // result { kind, ok, text?, error?, sessionId?, stats? }
13
20
  //
21
+ // Any event with an id may come first as `transient: true` (live.mjs): what is still being written,
22
+ // sent live as it changes and never stored. The next event with the same id that isn't transient is
23
+ // its final form (an output's is its action_result), stored as usual.
24
+ //
14
25
  // AgentRequest:
15
26
  // { id, type: permission | choice | text | plan, title, detail?, options: [{id,label,description?}],
16
27
  // multi?, allowText?, action?: {tool,label,target}, questions?: [{id, question, header, options, multi}] }
package/src/job-tools.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // The bridge's coding-agent jobs: starting one (acknowledged at once, streamed as it runs,
2
- // reported when done) and stopping one.
2
+ // reported when done), stopping one, and steering one (words into its turn at work).
3
3
  import { agentById } from './agents/index.mjs'
4
4
  import { awaitAnswer, forgetJob } from './agents/answers.mjs'
5
5
  import { resolveCwd, folderExists } from './claude-run.mjs'
@@ -10,7 +10,7 @@ import { sendIfLive, sendLive } from './connection.mjs'
10
10
  import { log } from './host.mjs'
11
11
  import { jobEndLine, jobStartLine, promptOf } from './job-format.mjs'
12
12
 
13
- /** Running jobs, by id, so a person can stop one. */
13
+ /** Running jobs, by id, so a person can stop one (abort) or, for an agent that can, steer it. */
14
14
  const running = new Map()
15
15
 
16
16
  /** The ids of the jobs running here now. */
@@ -41,30 +41,42 @@ export const jobTools = {
41
41
  }
42
42
  const sessionId = args.session_id
43
43
  log(jobStartLine({ agentId: agent.id, jobId, persona: args.persona, sessionId, dir: display(dir), prompt }))
44
+ const abort = new AbortController()
45
+ const live = { abort, steer: null }
44
46
  let seq = 0
45
47
  const io = {
46
48
  emit: event => sendLive({ type: 'job.event', jobId, seq: ++seq, at: new Date().toISOString(), event }),
47
49
  ask: (request, signal) => awaitAnswer(jobId, request, signal),
48
50
  check: call => checkCall(jobId, call, sendIfLive),
51
+ steerable: steer => (live.steer = steer),
49
52
  }
50
- const abort = new AbortController()
51
- running.set(jobId, abort)
53
+ running.set(jobId, live)
52
54
  // Started is said at once. The git snapshot (what changed, reported at the end) is taken first but
53
55
  // in the background: in a big repository it takes longer than the API waits for this answer.
54
56
  const before = snapshot(dir).catch(() => null)
55
57
  const persona = args.persona ?? null
56
58
  const settings = args.settings ?? null
57
- const job = { prompt, sessionId, cwd: dir, persona, settings, abort }
59
+ // What came with it from Kira, already copied here (the prompt names their paths): codex-input.mjs.
60
+ const attachments = Array.isArray(args.attachments) ? args.attachments : []
61
+ const job = { prompt, attachments, sessionId, cwd: dir, persona, settings, abort }
58
62
  void before.then(() => agent.run(job, io)).then(outcome => finish({ jobId, agent, dir, before, outcome }))
59
63
  return { started: true, agent: agent.id, cwd: display(dir) }
60
64
  },
61
65
  /** Stop a running job: the agent is cut off; whatever it committed stays. */
62
66
  stop_job(args) {
63
- const abort = running.get(String(args.job_id ?? ''))
64
- if (!abort) return { stopped: false, note: 'no such job running here' }
65
- abort.abort()
67
+ const job = running.get(String(args.job_id ?? ''))
68
+ if (!job) return { stopped: false, note: 'no such job running here' }
69
+ job.abort.abort()
66
70
  return { stopped: true }
67
71
  },
72
+ /** A person's words into a running job's turn, for an agent that takes them mid-turn (codex-steer.mjs). */
73
+ async steer_job(args) {
74
+ const job = running.get(String(args.job_id ?? ''))
75
+ const text = String(args.text ?? '').trim()
76
+ if (!job || !text) return { steered: false, note: job ? 'nothing to say' : 'no such job running here' }
77
+ if (!job.steer) return { steered: false, note: 'this agent takes nothing mid-turn' }
78
+ return job.steer(text)
79
+ },
68
80
  /** Older API builds still call ask_claude. */
69
81
  ask_claude(args) {
70
82
  return jobTools.run_agent({ ...args, agent: 'claude' })
package/src/refs.mjs CHANGED
@@ -58,3 +58,9 @@ export async function gitRefs(dir) {
58
58
  ])
59
59
  return { cwd: display(main), branches: joinRefs(parseBranches(branches), parseWorktrees(worktrees), main) }
60
60
  }
61
+
62
+ /** The branch a folder's work goes back into: its remote's default (origin/HEAD), else main. */
63
+ export async function defaultBranch(dir) {
64
+ const head = (await git(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']))?.trim()
65
+ return head ? head.replace(/^origin\//, '') : 'main'
66
+ }