kira-glasses-bridge 2.30.0 → 2.32.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/agent-usage.mjs +87 -0
- package/src/agents/claude-local.mjs +44 -2
- package/src/agents/claude-usage.mjs +116 -0
- package/src/agents/claude.mjs +25 -3
- 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 +71 -0
- package/src/agents/codex.mjs +58 -24
- package/src/agents/live.mjs +52 -0
- package/src/agents/protocol.mjs +15 -2
- package/src/job-tools.mjs +20 -8
- package/src/refs.mjs +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A run's usage, whichever agent it is: how full its thread's context is ({ used, window }) and the
|
|
2
|
+
// person's plan limits (AgentLimits, packages/genui/src/wire/agentUsage.ts), as the protocol's `usage`
|
|
3
|
+
// event, and the words a run fails with when a limit stops it: which limit, and when it resets (and that
|
|
4
|
+
// window, as the run's stats.limitHit, for the card that offers the other agent). Each
|
|
5
|
+
// agent reads its own reports into these shapes (claude-usage.mjs, codex-usage.mjs).
|
|
6
|
+
|
|
7
|
+
/** A window's name: "5-hour", "weekly", "weekly Opus". */
|
|
8
|
+
function windowName({ minutes, model }) {
|
|
9
|
+
const length = lengthName(minutes)
|
|
10
|
+
return model ? `${length} ${model}` : length
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function lengthName(minutes) {
|
|
14
|
+
if (minutes === 7 * 24 * 60) return 'weekly'
|
|
15
|
+
if (minutes && minutes < 24 * 60) return `${Math.round(minutes / 60)}-hour`
|
|
16
|
+
return minutes ? `${Math.round(minutes / (24 * 60))}-day` : 'usage'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Every window the limits have: the plan's two, then each model's own. */
|
|
20
|
+
const windowsOf = limits => [limits?.primary, limits?.secondary, ...(limits?.models ?? [])].filter(Boolean)
|
|
21
|
+
|
|
22
|
+
/** The fullest window, the one that most likely stopped a run. */
|
|
23
|
+
export const fullestOf = limits => windowsOf(limits).sort((a, b) => b.usedPercent - a.usedPercent)[0] ?? null
|
|
24
|
+
|
|
25
|
+
/** "at 3:10 PM" today, "on Tue at 9:05 AM" otherwise, in this Mac's own way of saying times. */
|
|
26
|
+
function resetWords(resetsAt, now) {
|
|
27
|
+
const at = new Date(resetsAt * 1000)
|
|
28
|
+
const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
29
|
+
if (at.toDateString() === new Date(now).toDateString()) return `at ${time}`
|
|
30
|
+
return `on ${at.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} at ${time}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which limit is used up and when it resets: "your 5-hour ChatGPT limit is used up (100%). It resets
|
|
35
|
+
* at 3:10 PM." (`plan` names whose: "ChatGPT", "Claude"; a model's own window names the model instead.)
|
|
36
|
+
*/
|
|
37
|
+
export function usedUpWords(window, plan, now = Date.now()) {
|
|
38
|
+
const when = window.resetsAt ? resetWords(window.resetsAt, now) : 'later'
|
|
39
|
+
const whose = window.model ? windowName(window) : `${windowName(window)} ${plan}`
|
|
40
|
+
return `your ${whose} limit is used up (${Math.round(window.usedPercent)}%). It resets ${when}.`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pctOf = c => (c ? Math.round((c.used / c.window) * 100) : null)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A run's usage feed: given the context and limits as they change, it emits a `usage` event with the
|
|
47
|
+
* latest of both, only when what a person sees changes (a whole percent of context, a limit), not on
|
|
48
|
+
* every token count.
|
|
49
|
+
*/
|
|
50
|
+
export function usageFeed(emit) {
|
|
51
|
+
let context = null
|
|
52
|
+
let limits = null
|
|
53
|
+
let limitHit = null
|
|
54
|
+
let sent = ''
|
|
55
|
+
const send = () => {
|
|
56
|
+
if (!context && !limits) return
|
|
57
|
+
const key = JSON.stringify([pctOf(context), limits])
|
|
58
|
+
if (key === sent) return
|
|
59
|
+
sent = key
|
|
60
|
+
emit({ kind: 'usage', context, limits })
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
/** The context now (null: not known, the last one stands). */
|
|
64
|
+
context(next) {
|
|
65
|
+
context = next ?? context
|
|
66
|
+
send()
|
|
67
|
+
},
|
|
68
|
+
/** The limits now, whole (null: not known, the last ones stand). */
|
|
69
|
+
limits(next) {
|
|
70
|
+
limits = next ?? limits
|
|
71
|
+
if (limits) send()
|
|
72
|
+
},
|
|
73
|
+
/** The limits known so far, to merge an update over and for the words a run fails with. */
|
|
74
|
+
get known() {
|
|
75
|
+
return limits
|
|
76
|
+
},
|
|
77
|
+
/** A run stopped ({ error, hit } from an agent's stoppedBy): keeps the window that stopped it. */
|
|
78
|
+
stopped({ error, hit }) {
|
|
79
|
+
limitHit = hit ?? limitHit
|
|
80
|
+
return error
|
|
81
|
+
},
|
|
82
|
+
/** What the run ends with, for its stats: `limitHit` is the window that stopped it, if one did. */
|
|
83
|
+
get stats() {
|
|
84
|
+
return { ...(context ? { context } : {}), ...(limits ? { limits } : {}), ...(limitHit ? { limitHit } : {}) }
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// Claude Code on this Mac, as the person signed in to it: which account it uses,
|
|
2
|
-
// rules in their own settings, and one piece of text written with that sign-in
|
|
1
|
+
// Claude Code on this Mac, as the person signed in to it: which account it uses, its plan's usage
|
|
2
|
+
// limits, the permission rules in their own settings, and one piece of text written with that sign-in
|
|
3
|
+
// (complete.mjs).
|
|
3
4
|
import { execFile } from 'node:child_process'
|
|
4
5
|
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
8
|
+
import { limitsFromUsage } from './claude-usage.mjs'
|
|
5
9
|
import { FOLDERS } from '../config-folders.mjs'
|
|
6
10
|
import { completeText } from './complete.mjs'
|
|
7
11
|
|
|
@@ -28,6 +32,43 @@ async function claudeAccount() {
|
|
|
28
32
|
)
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The person's plan limits as a Claude Code session reads them: the data behind its /usage (the SDK's
|
|
37
|
+
* experimental structured answer; an older SDK or CLI without it gives none). No model call.
|
|
38
|
+
*/
|
|
39
|
+
export async function planLimits(q) {
|
|
40
|
+
const ask = q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET
|
|
41
|
+
if (typeof ask !== 'function') return null
|
|
42
|
+
return limitsFromUsage(await ask.call(q, { skipBehaviors: true }).catch(() => null))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LIMITS_MS = 15_000
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Their plan limits outside a run, for the composer: a session opened only to ask (its prompt never
|
|
49
|
+
* comes, so nothing is sent to a model), closed once it has answered.
|
|
50
|
+
*/
|
|
51
|
+
async function claudeLimits() {
|
|
52
|
+
let release
|
|
53
|
+
const never = (async function* () {
|
|
54
|
+
await new Promise(resolve => (release = resolve))
|
|
55
|
+
})()
|
|
56
|
+
const abortController = new AbortController()
|
|
57
|
+
const timer = setTimeout(() => abortController.abort(), LIMITS_MS)
|
|
58
|
+
const env = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: 'false' }
|
|
59
|
+
const bin = process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}
|
|
60
|
+
// None of their settings or MCP servers: it only asks (a second, not three).
|
|
61
|
+
const quiet = { settingSources: [], strictMcpConfig: true }
|
|
62
|
+
const q = query({ prompt: never, options: { cwd: homedir(), env, abortController, ...quiet, ...bin } })
|
|
63
|
+
try {
|
|
64
|
+
return { limits: await planLimits(q) }
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
release?.()
|
|
68
|
+
q.close()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
/** The permissions block of a Claude Code settings file, as written there (nothing when it has none). */
|
|
32
73
|
export function permissionsIn(settings) {
|
|
33
74
|
const p = settings?.permissions ?? {}
|
|
@@ -48,6 +89,7 @@ async function claudePermissions() {
|
|
|
48
89
|
|
|
49
90
|
export const claudeTools = {
|
|
50
91
|
claude_account: claudeAccount,
|
|
92
|
+
claude_limits: claudeLimits,
|
|
51
93
|
claude_permissions: claudePermissions,
|
|
52
94
|
complete_text: completeText,
|
|
53
95
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// How full a Claude Code session is and how much of the person's Claude plan is left, from the Agent
|
|
2
|
+
// SDK: `getContextUsage()` (the context, as /context counts it), the structured /usage answer (the
|
|
3
|
+
// plan, its 5-hour and weekly windows, each model's own weekly window, extra usage) and
|
|
4
|
+
// `rate_limit_event` messages as a run goes. Read into the shapes every agent sends (agent-usage.mjs),
|
|
5
|
+
// and the words a run fails with when a limit stops it. Pure.
|
|
6
|
+
import { fullestOf, usedUpWords } from './agent-usage.mjs'
|
|
7
|
+
|
|
8
|
+
const FIVE_HOURS = 300
|
|
9
|
+
const WEEK = 7 * 24 * 60
|
|
10
|
+
|
|
11
|
+
/** The context the session fills: its tokens out of the window Claude Code measures against. */
|
|
12
|
+
export function contextFrom(usage) {
|
|
13
|
+
const window = usage?.rawMaxTokens || usage?.maxTokens
|
|
14
|
+
return window > 0 && typeof usage.totalTokens === 'number' ? { used: usage.totalTokens, window } : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A /usage window ({ utilization: 0-100, resets_at: ISO }) as Kira sends it. */
|
|
18
|
+
function windowOf(w, minutes, model = null) {
|
|
19
|
+
if (typeof w?.utilization !== 'number') return null
|
|
20
|
+
const at = w.resets_at ? Date.parse(w.resets_at) : NaN
|
|
21
|
+
const resetsAt = Number.isNaN(at) ? null : Math.round(at / 1000)
|
|
22
|
+
return { usedPercent: w.utilization, resetsAt, minutes, ...(model ? { model } : {}) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Each model's own weekly window: the server's list, else the older Opus and Sonnet fields. */
|
|
26
|
+
function modelWindows(r) {
|
|
27
|
+
const listed = Array.isArray(r.model_scoped) ? r.model_scoped : null
|
|
28
|
+
const named = listed
|
|
29
|
+
? listed.map(w => windowOf(w, WEEK, w.display_name || 'Model'))
|
|
30
|
+
: [windowOf(r.seven_day_opus, WEEK, 'Opus'), windowOf(r.seven_day_sonnet, WEEK, 'Sonnet')]
|
|
31
|
+
return named.filter(Boolean)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Extra usage, from minor units (cents) to the currency's own. */
|
|
35
|
+
function extraOf(x) {
|
|
36
|
+
if (!x) return null
|
|
37
|
+
const unit = 10 ** (Number.isInteger(x.decimal_places) ? x.decimal_places : 2)
|
|
38
|
+
const major = n => (typeof n === 'number' ? n / unit : null)
|
|
39
|
+
const currency = x.currency ?? null
|
|
40
|
+
return { enabled: !!x.is_enabled, used: major(x.used_credits), limit: major(x.monthly_limit), currency }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The /usage answer (`usage_EXPERIMENTAL…()`) as Kira's limits; null when no plan limits apply (an API key). */
|
|
44
|
+
export function limitsFromUsage(answer) {
|
|
45
|
+
const r = answer?.rate_limits
|
|
46
|
+
if (!answer?.rate_limits_available || !r) return null
|
|
47
|
+
return {
|
|
48
|
+
account: 'claude',
|
|
49
|
+
primary: windowOf(r.five_hour, FIVE_HOURS),
|
|
50
|
+
secondary: windowOf(r.seven_day, WEEK),
|
|
51
|
+
models: modelWindows(r),
|
|
52
|
+
credits: null,
|
|
53
|
+
extra: extraOf(r.extra_usage),
|
|
54
|
+
plan: answer.subscription_type ?? null,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where each `rateLimitType` of an event goes in Kira's limits. */
|
|
59
|
+
const SLOTS = {
|
|
60
|
+
five_hour: { slot: 'primary', minutes: FIVE_HOURS },
|
|
61
|
+
seven_day: { slot: 'secondary', minutes: WEEK },
|
|
62
|
+
seven_day_opus: { slot: 'models', minutes: WEEK, model: 'Opus' },
|
|
63
|
+
seven_day_sonnet: { slot: 'models', minutes: WEEK, model: 'Sonnet' },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const EMPTY = { account: 'claude', primary: null, secondary: null, models: [], credits: null, extra: null, plan: null }
|
|
67
|
+
|
|
68
|
+
/** One window of an event (utilization 0-1, resetsAt in seconds) set into the limits. */
|
|
69
|
+
function setWindow(limits, type, { utilization, resetsAt, rejected }) {
|
|
70
|
+
const at = SLOTS[type]
|
|
71
|
+
if (!at) return limits
|
|
72
|
+
const prev = at.slot === 'models' ? limits.models.find(w => w.model === at.model) : limits[at.slot]
|
|
73
|
+
const said = typeof utilization === 'number' ? Math.round(utilization * 100) : rejected ? 100 : null
|
|
74
|
+
// An event's fraction is coarser than /usage's percent: within a point of the same window, what's known stands.
|
|
75
|
+
const same = prev && (resetsAt ?? prev.resetsAt) === prev.resetsAt
|
|
76
|
+
const usedPercent = said === null || (same && Math.abs(said - prev.usedPercent) <= 1) ? prev?.usedPercent : said
|
|
77
|
+
if (typeof usedPercent !== 'number') return limits
|
|
78
|
+
const window = { usedPercent, resetsAt: resetsAt ?? prev?.resetsAt ?? null, minutes: at.minutes }
|
|
79
|
+
if (at.slot !== 'models') return { ...limits, [at.slot]: window }
|
|
80
|
+
return { ...limits, models: [...limits.models.filter(w => w.model !== at.model), { ...window, model: at.model }] }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A `rate_limit_event` merged over what was known: the windows it carries (its `unifiedWindows` when
|
|
85
|
+
* the CLI sends them, and the one its `rateLimitType` names), the rest as they were.
|
|
86
|
+
*/
|
|
87
|
+
export function limitsFromEvent(info, prev = null) {
|
|
88
|
+
if (!info) return prev
|
|
89
|
+
let limits = { ...EMPTY, ...prev, models: prev?.models ?? [] }
|
|
90
|
+
for (const [type, w] of Object.entries(info.unifiedWindows ?? {})) limits = setWindow(limits, type, w ?? {})
|
|
91
|
+
if (info.rateLimitType) {
|
|
92
|
+
const rejected = info.status === 'rejected'
|
|
93
|
+
limits = setWindow(limits, info.rateLimitType, { ...info, rejected })
|
|
94
|
+
}
|
|
95
|
+
return limits
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The window an event says is used up, to name it when the run stops. */
|
|
99
|
+
export function rejectedWindow(info, limits) {
|
|
100
|
+
if (info?.status !== 'rejected' || !SLOTS[info.rateLimitType]) return null
|
|
101
|
+
const at = SLOTS[info.rateLimitType]
|
|
102
|
+
return at.slot === 'models' ? limits?.models?.find(w => w.model === at.model) : limits?.[at.slot]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a run says when it ends on an error (`error`): for a usage limit (Claude Code's words start with
|
|
107
|
+
* one of `limitPrefixes`), which one and when it resets; else Claude Code's own words. `hit`: the
|
|
108
|
+
* window that stopped it, when a limit did and it's known.
|
|
109
|
+
*/
|
|
110
|
+
export function stoppedBy(said, { limits, rejected, limitPrefixes = [] }, now = Date.now()) {
|
|
111
|
+
const text = String(said ?? 'error').trim()
|
|
112
|
+
if (!limitPrefixes.some(p => text.startsWith(p))) return { error: `Claude stopped: ${text.slice(0, 300)}`, hit: null }
|
|
113
|
+
const full = rejected ?? fullestOf(limits)
|
|
114
|
+
if (!full) return { error: `Claude stopped: ${text}`, hit: null }
|
|
115
|
+
return { error: `Claude stopped: ${usedUpWords(full, 'Claude', now)}`, hit: full }
|
|
116
|
+
}
|
package/src/agents/claude.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Claude Code as an agent, through the Agent SDK. Runs in this process on the
|
|
2
2
|
// user's Mac with their own Claude login; files never leave the machine.
|
|
3
|
-
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
3
|
+
import { USAGE_LIMIT_ERROR_PREFIXES, query } from '@anthropic-ai/claude-agent-sdk'
|
|
4
|
+
import { usageFeed } from './agent-usage.mjs'
|
|
4
5
|
import {
|
|
5
6
|
NOT_ANOTHER_WAY,
|
|
6
7
|
TEXT_CAP,
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
toRequest,
|
|
11
12
|
} from './claude-calls.mjs'
|
|
12
13
|
import { kiraDecides } from './checks.mjs'
|
|
14
|
+
import { planLimits } from './claude-local.mjs'
|
|
15
|
+
import { contextFrom, limitsFromEvent, rejectedWindow, stoppedBy } from './claude-usage.mjs'
|
|
13
16
|
import { sdkOptionsFor } from './settings.mjs'
|
|
14
17
|
import { kiraPluginOptions } from '../kira-plugin.mjs'
|
|
15
18
|
|
|
@@ -42,6 +45,11 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
42
45
|
let error = null
|
|
43
46
|
/** What Kira's rules said about each tool call, by its id, for the question if Claude Code asks. */
|
|
44
47
|
const verdicts = new Map()
|
|
48
|
+
/** How full the session is and the person's limits, as they change (claude-usage.mjs). */
|
|
49
|
+
const usage = usageFeed(e => io.emit(e))
|
|
50
|
+
/** The limit a rate_limit_event said is used up, and the last turn whose context was read. */
|
|
51
|
+
let rejected = null
|
|
52
|
+
let turn = null
|
|
45
53
|
|
|
46
54
|
/** Refused before it runs: the job shows why, and Claude is told (`told`, when it needs more). */
|
|
47
55
|
const refuse = (tool, reason, told = reason) => {
|
|
@@ -127,11 +135,21 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
127
135
|
if (m.type === 'system' && m.subtype === 'init') {
|
|
128
136
|
session = m.session_id ?? session
|
|
129
137
|
io.emit({ kind: 'session', sessionId: session, model: m.model ?? null })
|
|
138
|
+
// The plan's windows, each model's own and extra usage (events carry only some of them).
|
|
139
|
+
void planLimits(q).then(limits => usage.limits(limits))
|
|
140
|
+
} else if (m.type === 'rate_limit_event') {
|
|
141
|
+
usage.limits(limitsFromEvent(m.rate_limit_info, usage.known))
|
|
142
|
+
rejected = rejectedWindow(m.rate_limit_info, usage.known) ?? rejected
|
|
130
143
|
} else if (m.type === 'system' && m.subtype === 'task_started') {
|
|
131
144
|
if (m.is_backgrounded && m.tool_use_id) orphans.set(m.tool_use_id, m.description ?? m.subagent_type ?? 'task')
|
|
132
145
|
} else if (m.type === 'system' && m.subtype === 'task_notification') {
|
|
133
146
|
if (m.tool_use_id) orphans.delete(m.tool_use_id)
|
|
134
147
|
} else if (m.type === 'assistant') {
|
|
148
|
+
// A new turn of the main thread: how full the context is now, as /context counts it (no model call).
|
|
149
|
+
if (!nested && m.message?.id && m.message.id !== turn) {
|
|
150
|
+
turn = m.message.id
|
|
151
|
+
usage.context(contextFrom(await q.getContextUsage({ detail: 'summary' }).catch(() => null)))
|
|
152
|
+
}
|
|
135
153
|
for (const b of m.message?.content ?? []) {
|
|
136
154
|
// A subagent's narration is for its parent, not the user; its actions still show what is going on while the Subtask runs.
|
|
137
155
|
if (b.type === 'text' && b.text?.trim() && !nested) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
|
|
@@ -147,14 +165,18 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
147
165
|
}
|
|
148
166
|
} else if (m.type === 'result') {
|
|
149
167
|
session = m.session_id ?? session
|
|
150
|
-
|
|
168
|
+
// A turn that ended on an API error (a usage limit among them) is a success carrying the error.
|
|
169
|
+
const limit = { limits: usage.known, rejected, limitPrefixes: USAGE_LIMIT_ERROR_PREFIXES }
|
|
170
|
+
if (m.subtype === 'success' && !m.is_error) text = String(m.result ?? '')
|
|
171
|
+
else if (m.subtype === 'success') error = usage.stopped(stoppedBy(m.result, limit))
|
|
151
172
|
else error = `Claude stopped: ${m.subtype}${Array.isArray(m.errors) && m.errors.length ? ` (${m.errors.join('; ').slice(0, 300)})` : ''}`
|
|
152
173
|
// Work left running in the background dies with this process; say so rather than let "still running" stand as the outcome.
|
|
153
174
|
if (orphans.size) {
|
|
154
175
|
const note = `${orphans.size} background ${orphans.size === 1 ? 'task was' : 'tasks were'} still running when Claude ended its turn (${[...orphans.values()].slice(0, 5).join(', ')}); they were stopped with the session and their findings are not in this report. Send a follow-up to have that work done inline.`
|
|
155
176
|
text = text ? `${text}\n\n(${note})` : `(${note})`
|
|
156
177
|
}
|
|
157
|
-
|
|
178
|
+
// How full the session ended and the limits it last saw (claude-usage.mjs), beside the run's own.
|
|
179
|
+
const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd, ...usage.stats }
|
|
158
180
|
io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: session, stats })
|
|
159
181
|
return { ok: !error, text, error, sessionId: session, stats }
|
|
160
182
|
}
|
|
@@ -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
|
+
}
|