kira-glasses-bridge 2.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,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, the permission
2
- // rules in their own settings, and one piece of text written with that sign-in (complete.mjs).
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
+ }
@@ -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
- if (m.subtype === 'success') text = String(m.result ?? '')
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
- const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd }
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
  }
@@ -1,7 +1,8 @@
1
1
  // How full a Codex thread is and how much of the person's ChatGPT plan is left, from Codex's app
2
2
  // server: `thread/tokenUsage/updated` (the context), `account/rateLimits/read` and
3
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.
4
+ // fails with when a limit stops it: which limit, and when it resets (agent-usage.mjs).
5
+ import { fullestOf, usageFeed as agentUsageFeed, usedUpWords } from './agent-usage.mjs'
5
6
 
6
7
  /** A window as Kira sends it: { usedPercent, resetsAt (Unix seconds), minutes }. */
7
8
  const windowOf = w =>
@@ -28,6 +29,7 @@ export function limitsOf(snapshot, prev = null) {
28
29
  const c = snapshot.credits
29
30
  const credits = c ? { has: !!c.hasCredits, unlimited: !!c.unlimited, balance: c.balance ?? null } : null
30
31
  return {
32
+ account: 'chatgpt',
31
33
  primary: windowOf(snapshot.primary) ?? prev?.primary ?? null,
32
34
  secondary: windowOf(snapshot.secondary) ?? prev?.secondary ?? null,
33
35
  credits: credits ?? prev?.credits ?? null,
@@ -35,67 +37,35 @@ export function limitsOf(snapshot, prev = null) {
35
37
  }
36
38
  }
37
39
 
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
40
  const LIMIT_ERRORS = new Set(['usageLimitExceeded', 'rateLimitExceeded'])
46
41
 
47
- /** What a run says when it stops: which limit stopped it and when it resets, else Codex's own words. */
42
+ /**
43
+ * What a run says when it stops (`error`): which limit stopped it and when it resets, else Codex's own
44
+ * words; and the window that stopped it (`hit`), when a limit did and it's known.
45
+ */
48
46
  export function stoppedBy(turnError, limits, now = Date.now()) {
49
47
  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}`
48
+ if (!LIMIT_ERRORS.has(turnError?.codexErrorInfo)) return { error: said, hit: null }
49
+ const full = fullestOf(limits)
50
+ const reached = `Codex stopped: you've reached your ChatGPT usage limit. ${turnError.message ?? ''}`
51
+ if (!full) return { error: reached.trim(), hit: null }
52
+ return { error: `Codex stopped: ${usedUpWords(full, 'ChatGPT', now)}`, hit: full }
64
53
  }
65
54
 
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
- */
55
+ /** A run's usage, fed Codex's notifications (agent-usage.mjs sends it). */
72
56
  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
- }
57
+ const feed = agentUsageFeed(emit)
83
58
  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
- },
59
+ onTokens: tokenUsage => feed.context(contextOf(tokenUsage)),
60
+ onLimits: snapshot => feed.limits(limitsOf(snapshot, feed.known)),
92
61
  /** The limits known so far, for the words a run fails with. */
93
62
  get limits() {
94
- return limits
63
+ return feed.known
95
64
  },
65
+ stopped: feed.stopped,
96
66
  /** What the run ends with, for its stats. */
97
67
  get stats() {
98
- return { ...(context ? { context } : {}), ...(limits ? { limits } : {}) }
68
+ return feed.stats
99
69
  },
100
70
  }
101
71
  }
@@ -111,11 +111,11 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
111
111
  } else if (method === 'account/rateLimits/updated') {
112
112
  usage.onLimits(p.rateLimits)
113
113
  } else if (method === 'error' && !p.willRetry) {
114
- error = stoppedBy(p.error, usage.limits)
114
+ error = usage.stopped(stoppedBy(p.error, usage.limits))
115
115
  } else if (method === 'turn/completed' && p.turn?.id === turnId) {
116
116
  const t = p.turn
117
117
  steering.ended()
118
- if (t.status === 'failed') error = stoppedBy(t.error ?? { message: 'failed' }, usage.limits)
118
+ if (t.status === 'failed') error = usage.stopped(stoppedBy(t.error ?? { message: 'failed' }, usage.limits))
119
119
  if (t.status === 'interrupted') error = 'Stopped by the user.'
120
120
  finished({ durationMs: t.durationMs ?? undefined })
121
121
  }
@@ -13,10 +13,12 @@
13
13
  // action_result { kind, id, ok, preview? }
14
14
  // request { kind, request: AgentRequest } needs a person; the job waits
15
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)
19
- // result { kind, ok, text?, error?, sessionId?, stats? }
16
+ // usage { kind, context: {used,window} | null, limits: AgentLimits | null } how full the thread
17
+ // is and the person's plan limits ({account, primary, secondary, models: [...], credits,
18
+ // extra, plan}; each window {usedPercent,resetsAt,minutes,model?}), the latest of both each
19
+ // time one changes (agent-usage.mjs; Claude Code: claude-usage.mjs, Codex: codex-usage.mjs)
20
+ // result { kind, ok, text?, error?, sessionId?, stats? } stats: the agent's own, and its last
21
+ // context and limits, and limitHit (the window, as above) when a usage limit stopped it
20
22
  //
21
23
  // Any event with an id may come first as `transient: true` (live.mjs): what is still being written,
22
24
  // sent live as it changes and never stored. The next event with the same id that isn't transient is