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 +1 -1
- package/src/agents/codex-ask.mjs +66 -0
- package/src/agents/codex-forms.mjs +107 -0
- package/src/agents/codex-input.mjs +24 -0
- package/src/agents/codex-items.mjs +2 -0
- package/src/agents/codex-live.mjs +107 -0
- package/src/agents/codex-local.mjs +10 -4
- package/src/agents/codex-requests.mjs +114 -0
- package/src/agents/codex-review.mjs +89 -0
- package/src/agents/codex-steer.mjs +31 -0
- package/src/agents/codex-usage.mjs +101 -0
- package/src/agents/codex.mjs +58 -24
- package/src/agents/live.mjs +52 -0
- package/src/agents/protocol.mjs +12 -1
- package/src/job-tools.mjs +20 -8
- package/src/refs.mjs +6 -0
package/package.json
CHANGED
|
@@ -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,
|
|
2
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/agents/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
131
|
-
const
|
|
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 } :
|
|
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
|
-
|
|
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
|
+
}
|
package/src/agents/protocol.mjs
CHANGED
|
@@ -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 }
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
if (!
|
|
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
|
+
}
|