kira-glasses-bridge 2.31.0 → 2.34.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-calls.mjs +1 -0
- package/src/agents/claude-local.mjs +44 -2
- package/src/agents/claude-usage.mjs +116 -0
- package/src/agents/claude.mjs +42 -4
- package/src/agents/codex-items.mjs +2 -0
- package/src/agents/codex-subagents.mjs +60 -0
- package/src/agents/codex-usage.mjs +19 -49
- package/src/agents/codex.mjs +15 -5
- package/src/agents/protocol.mjs +12 -4
- package/src/agents/subagents.mjs +121 -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
|
+
}
|
|
@@ -58,6 +58,7 @@ export function describeAction(name, input, cwd) {
|
|
|
58
58
|
return act('fetch', 'Fetch', short(i.url, 200))
|
|
59
59
|
case 'WebSearch':
|
|
60
60
|
return act('fetch', 'Search the web', short(i.query, 160))
|
|
61
|
+
case 'Agent':
|
|
61
62
|
case 'Task':
|
|
62
63
|
return act('delegate', 'Subtask', short(i.description ?? i.prompt, 160))
|
|
63
64
|
case 'TodoWrite':
|
|
@@ -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,8 +11,12 @@ 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'
|
|
18
|
+
import { git } from '../git.mjs'
|
|
19
|
+
import { isAgentCall, subagentFeed } from './subagents.mjs'
|
|
15
20
|
|
|
16
21
|
/** Kira's MCP server as Claude Code connects to it: Streamable HTTP, the token as the bearer. */
|
|
17
22
|
const kiraServer = ({ url, token }) => ({ type: 'http', url, headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -42,6 +47,11 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
42
47
|
let error = null
|
|
43
48
|
/** What Kira's rules said about each tool call, by its id, for the question if Claude Code asks. */
|
|
44
49
|
const verdicts = new Map()
|
|
50
|
+
/** How full the session is and the person's limits, as they change (claude-usage.mjs). */
|
|
51
|
+
const usage = usageFeed(e => io.emit(e))
|
|
52
|
+
/** The limit a rate_limit_event said is used up, and the last turn whose context was read. */
|
|
53
|
+
let rejected = null
|
|
54
|
+
let turn = null
|
|
45
55
|
|
|
46
56
|
/** Refused before it runs: the job shows why, and Claude is told (`told`, when it needs more). */
|
|
47
57
|
const refuse = (tool, reason, told = reason) => {
|
|
@@ -92,6 +102,9 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
92
102
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1', ENABLE_CLAUDEAI_MCP_SERVERS: 'false' }
|
|
93
103
|
/** Background tasks the model still managed to start (an older CLI ignoring the flag): tool_use id -> description. */
|
|
94
104
|
const orphans = new Map()
|
|
105
|
+
/** The agents it starts, each with where it works (subagents.mjs). */
|
|
106
|
+
const branchOf = async path => (await git(path, ['branch', '--show-current']))?.trim() || null
|
|
107
|
+
const agents = subagentFeed(e => io.emit(e), branchOf)
|
|
95
108
|
|
|
96
109
|
try {
|
|
97
110
|
const q = query({
|
|
@@ -127,15 +140,34 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
127
140
|
if (m.type === 'system' && m.subtype === 'init') {
|
|
128
141
|
session = m.session_id ?? session
|
|
129
142
|
io.emit({ kind: 'session', sessionId: session, model: m.model ?? null })
|
|
143
|
+
// The plan's windows, each model's own and extra usage (events carry only some of them).
|
|
144
|
+
void planLimits(q).then(limits => usage.limits(limits))
|
|
145
|
+
} else if (m.type === 'rate_limit_event') {
|
|
146
|
+
usage.limits(limitsFromEvent(m.rate_limit_info, usage.known))
|
|
147
|
+
rejected = rejectedWindow(m.rate_limit_info, usage.known) ?? rejected
|
|
130
148
|
} else if (m.type === 'system' && m.subtype === 'task_started') {
|
|
131
149
|
if (m.is_backgrounded && m.tool_use_id) orphans.set(m.tool_use_id, m.description ?? m.subagent_type ?? 'task')
|
|
132
150
|
} else if (m.type === 'system' && m.subtype === 'task_notification') {
|
|
133
151
|
if (m.tool_use_id) orphans.delete(m.tool_use_id)
|
|
152
|
+
if (m.tool_use_id) agents.ended(m.tool_use_id, m.status, m.summary)
|
|
153
|
+
} else if (m.type === 'system' && m.subtype === 'task_progress') {
|
|
154
|
+
if (m.tool_use_id) agents.progress(m.tool_use_id, m.summary)
|
|
134
155
|
} else if (m.type === 'assistant') {
|
|
156
|
+
// A new turn of the main thread: how full the context is now, as /context counts it (no model call).
|
|
157
|
+
if (!nested && m.message?.id && m.message.id !== turn) {
|
|
158
|
+
turn = m.message.id
|
|
159
|
+
usage.context(contextFrom(await q.getContextUsage({ detail: 'summary' }).catch(() => null)))
|
|
160
|
+
}
|
|
135
161
|
for (const b of m.message?.content ?? []) {
|
|
136
162
|
// A subagent's narration is for its parent, not the user; its actions still show what is going on while the Subtask runs.
|
|
137
163
|
if (b.type === 'text' && b.text?.trim() && !nested) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
|
|
138
|
-
else if (b.type === 'tool_use')
|
|
164
|
+
else if (b.type === 'tool_use') {
|
|
165
|
+
// A step inside an agent says whose it is.
|
|
166
|
+
const whose = nested ? { agent: m.parent_tool_use_id } : {}
|
|
167
|
+
io.emit({ kind: 'action', id: b.id, tool: b.name, ...describeAction(b.name, b.input, cwd), ...whose })
|
|
168
|
+
if (isAgentCall(b.name)) agents.called(b.id, b.input, m.parent_tool_use_id)
|
|
169
|
+
if (nested) agents.touched(m.parent_tool_use_id, b.input)
|
|
170
|
+
}
|
|
139
171
|
}
|
|
140
172
|
} else if (m.type === 'user') {
|
|
141
173
|
for (const b of m.message?.content ?? []) {
|
|
@@ -143,18 +175,24 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
143
175
|
const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map(c => (c.type === 'text' ? c.text : '')).join('\n') : ''
|
|
144
176
|
if (/^Async agent launched successfully/.test(t.trim()) && !orphans.has(b.tool_use_id)) orphans.set(b.tool_use_id, 'subagent')
|
|
145
177
|
io.emit({ kind: 'action_result', id: b.tool_use_id, ok: !b.is_error, preview: short(t.trim(), 240) })
|
|
178
|
+
if (agents.has(b.tool_use_id)) agents.returned(b.tool_use_id, m.tool_use_result, b.is_error)
|
|
146
179
|
}
|
|
147
180
|
}
|
|
148
181
|
} else if (m.type === 'result') {
|
|
149
182
|
session = m.session_id ?? session
|
|
150
|
-
|
|
183
|
+
agents.stopAll()
|
|
184
|
+
// A turn that ended on an API error (a usage limit among them) is a success carrying the error.
|
|
185
|
+
const limit = { limits: usage.known, rejected, limitPrefixes: USAGE_LIMIT_ERROR_PREFIXES }
|
|
186
|
+
if (m.subtype === 'success' && !m.is_error) text = String(m.result ?? '')
|
|
187
|
+
else if (m.subtype === 'success') error = usage.stopped(stoppedBy(m.result, limit))
|
|
151
188
|
else error = `Claude stopped: ${m.subtype}${Array.isArray(m.errors) && m.errors.length ? ` (${m.errors.join('; ').slice(0, 300)})` : ''}`
|
|
152
189
|
// Work left running in the background dies with this process; say so rather than let "still running" stand as the outcome.
|
|
153
190
|
if (orphans.size) {
|
|
154
191
|
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
192
|
text = text ? `${text}\n\n(${note})` : `(${note})`
|
|
156
193
|
}
|
|
157
|
-
|
|
194
|
+
// How full the session ended and the limits it last saw (claude-usage.mjs), beside the run's own.
|
|
195
|
+
const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd, ...usage.stats }
|
|
158
196
|
io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: session, stats })
|
|
159
197
|
return { ok: !error, text, error, sessionId: session, stats }
|
|
160
198
|
}
|
|
@@ -79,6 +79,8 @@ export function itemAction(item, cwd) {
|
|
|
79
79
|
case 'plan':
|
|
80
80
|
return act('plan', 'Plan', short(item.text, 160), { tool: 'Plan' })
|
|
81
81
|
case 'collabAgentToolCall':
|
|
82
|
+
// Spawning one is a step; waiting on or messaging its agents moves the agent's row (codex-subagents.mjs).
|
|
83
|
+
if (item.tool !== 'spawnAgent') return null
|
|
82
84
|
return act('delegate', 'Subtask', short(item.prompt ?? '', 160), { tool: 'Subtask' })
|
|
83
85
|
case 'enteredReviewMode':
|
|
84
86
|
return act('read', 'Review', short(item.review ?? '', 160), { tool: 'Review' })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The agents Codex spawns inside a turn (its collab tools), as protocol `agent` events, the way
|
|
2
|
+
// subagents.mjs does Claude Code's. A spawn is a collabAgentToolCall naming the new agent's thread;
|
|
3
|
+
// that thread's items then arrive on the same app server, so each one says whose it is (`whose`), and
|
|
4
|
+
// later collab calls and subAgentActivity items say how each agent stands. Codex's agents work in the
|
|
5
|
+
// turn's own folder.
|
|
6
|
+
import { short } from './claude-calls.mjs'
|
|
7
|
+
|
|
8
|
+
const STATES = {
|
|
9
|
+
completed: 'done',
|
|
10
|
+
errored: 'failed',
|
|
11
|
+
interrupted: 'stopped',
|
|
12
|
+
shutdown: 'stopped',
|
|
13
|
+
notFound: 'stopped',
|
|
14
|
+
}
|
|
15
|
+
const ACTIVITY = { completed: 'done', interrupted: 'stopped' }
|
|
16
|
+
|
|
17
|
+
/** The last part of an agent's path ("/root/explorer" → "explorer"): the name Codex gave it. */
|
|
18
|
+
const nameOf = path => String(path ?? '').split('/').filter(Boolean).pop() || null
|
|
19
|
+
|
|
20
|
+
export function codexAgentFeed(emit) {
|
|
21
|
+
const agents = new Map()
|
|
22
|
+
/** Each agent's thread, to the spawn call that made it. */
|
|
23
|
+
const threads = new Map()
|
|
24
|
+
const send = a => {
|
|
25
|
+
agents.set(a.id, a)
|
|
26
|
+
emit(a)
|
|
27
|
+
}
|
|
28
|
+
const settle = (threadId, status, name) => {
|
|
29
|
+
const a = agents.get(threads.get(threadId))
|
|
30
|
+
if (!a) return
|
|
31
|
+
const named = name && !a.name ? { name } : {}
|
|
32
|
+
if (status && a.status === 'running') send({ ...a, status, ...named })
|
|
33
|
+
else if (named.name) send({ ...a, ...named })
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
/** The agent a thread's items belong to, if the thread is one Codex spawned. */
|
|
37
|
+
whose: threadId => threads.get(threadId) ?? null,
|
|
38
|
+
started(item) {
|
|
39
|
+
if (item.type !== 'collabAgentToolCall' || item.tool !== 'spawnAgent') return
|
|
40
|
+
const description = short(item.prompt ?? '', 200)
|
|
41
|
+
send({ kind: 'agent', id: item.id, type: 'agent', description, status: 'running' })
|
|
42
|
+
},
|
|
43
|
+
completed(item) {
|
|
44
|
+
if (item.type === 'subAgentActivity') {
|
|
45
|
+
return settle(item.agentThreadId, ACTIVITY[item.kind], nameOf(item.agentPath))
|
|
46
|
+
}
|
|
47
|
+
if (item.type !== 'collabAgentToolCall') return
|
|
48
|
+
if (item.tool === 'spawnAgent') {
|
|
49
|
+
for (const t of item.receiverThreadIds ?? []) threads.set(t, item.id)
|
|
50
|
+
const a = agents.get(item.id)
|
|
51
|
+
if (a && item.status === 'failed') send({ ...a, status: 'failed' })
|
|
52
|
+
}
|
|
53
|
+
for (const [t, state] of Object.entries(item.agentsStates ?? {})) settle(t, STATES[state?.status])
|
|
54
|
+
},
|
|
55
|
+
/** The turn ended: what still runs ended with it (`status`: done, or stopped when it was cut short). */
|
|
56
|
+
endAll(status) {
|
|
57
|
+
for (const a of agents.values()) if (a.status === 'running') send({ ...a, status })
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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
|
-
/**
|
|
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 =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
+
return feed.stats
|
|
99
69
|
},
|
|
100
70
|
}
|
|
101
71
|
}
|
package/src/agents/codex.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { codexBin, openCodex } from './codex-rpc.mjs'
|
|
|
9
9
|
import { codexInput } from './codex-input.mjs'
|
|
10
10
|
import { turnSteering } from './codex-steer.mjs'
|
|
11
11
|
import { codexStream } from './codex-live.mjs'
|
|
12
|
+
import { codexAgentFeed } from './codex-subagents.mjs'
|
|
12
13
|
import { liveEmitter } from './live.mjs'
|
|
13
14
|
import { kiraDecides } from './checks.mjs'
|
|
14
15
|
import { CODEX_ASKS, askPerson, refusedByPolicy, said } from './codex-ask.mjs'
|
|
@@ -51,6 +52,8 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
51
52
|
/** What is still being written (live.mjs): sent as it comes, the final form of each kept. */
|
|
52
53
|
const live = liveEmitter(io.emit)
|
|
53
54
|
const stream = codexStream()
|
|
55
|
+
/** The agents it spawns, and which thread is whose (codex-subagents.mjs). */
|
|
56
|
+
const agents = codexAgentFeed(e => io.emit(e))
|
|
54
57
|
let finished
|
|
55
58
|
const ended = new Promise(resolve => (finished = resolve))
|
|
56
59
|
// What the person says while a turn runs goes into it (`codex` is the app server, opened below).
|
|
@@ -86,16 +89,22 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
const onNotify = (method, p) => {
|
|
92
|
+
// An item from a thread Codex spawned is its agent's: its steps say so, and what it says is for Codex.
|
|
93
|
+
const whose = p?.threadId ? agents.whose(p.threadId) : null
|
|
89
94
|
const update = stream.update(method, p)
|
|
90
|
-
if (update) return live.update(update)
|
|
95
|
+
if (update) return whose ? undefined : live.update(update)
|
|
91
96
|
if (method === 'item/started') {
|
|
92
97
|
if (p.item.type === 'fileChange') changes.set(p.item.id, p.item.changes ?? [])
|
|
98
|
+
agents.started(p.item)
|
|
93
99
|
const action = itemAction(p.item, cwd)
|
|
94
|
-
if (action) io.emit({ kind: 'action', id: p.item.id, ...action })
|
|
100
|
+
if (action) io.emit({ kind: 'action', id: p.item.id, ...action, ...(whose ? { agent: whose } : {}) })
|
|
95
101
|
} else if (method === 'item/completed') {
|
|
102
|
+
agents.completed(p.item)
|
|
96
103
|
const reviewed = review.completed(p.item)
|
|
97
104
|
if (reviewed) io.emit(reviewed)
|
|
98
|
-
if (p.item.type === 'agentMessage'
|
|
105
|
+
if (whose && p.item.type === 'agentMessage') {
|
|
106
|
+
// Its agent's answer goes to Codex, not the person.
|
|
107
|
+
} else if (p.item.type === 'agentMessage' && p.item.text?.trim()) {
|
|
99
108
|
text = p.item.text
|
|
100
109
|
if (!review.repeats(text)) live.final({ kind: 'message', id: p.item.id, text: short(p.item.text, TEXT_CAP) })
|
|
101
110
|
} else if (itemAction(p.item, cwd)) {
|
|
@@ -111,12 +120,13 @@ async function run({ prompt, attachments, sessionId, cwd, persona, settings, abo
|
|
|
111
120
|
} else if (method === 'account/rateLimits/updated') {
|
|
112
121
|
usage.onLimits(p.rateLimits)
|
|
113
122
|
} else if (method === 'error' && !p.willRetry) {
|
|
114
|
-
error = stoppedBy(p.error, usage.limits)
|
|
123
|
+
error = usage.stopped(stoppedBy(p.error, usage.limits))
|
|
115
124
|
} else if (method === 'turn/completed' && p.turn?.id === turnId) {
|
|
116
125
|
const t = p.turn
|
|
117
126
|
steering.ended()
|
|
118
|
-
if (t.status === 'failed') error = stoppedBy(t.error ?? { message: 'failed' }, usage.limits)
|
|
127
|
+
if (t.status === 'failed') error = usage.stopped(stoppedBy(t.error ?? { message: 'failed' }, usage.limits))
|
|
119
128
|
if (t.status === 'interrupted') error = 'Stopped by the user.'
|
|
129
|
+
agents.endAll(t.status === 'completed' ? 'done' : 'stopped')
|
|
120
130
|
finished({ durationMs: t.durationMs ?? undefined })
|
|
121
131
|
}
|
|
122
132
|
}
|
package/src/agents/protocol.mjs
CHANGED
|
@@ -10,13 +10,21 @@
|
|
|
10
10
|
// action { kind, id, type, tool, label, target, note?, detail? }
|
|
11
11
|
// type: read | search | edit | write | run | fetch | delegate | plan | other
|
|
12
12
|
// detail: { edit: {old,new,all} | edits: [...] | content | command }
|
|
13
|
+
// agent: the id of the agent (below) whose step it is, when an agent the turn started took it
|
|
13
14
|
// action_result { kind, id, ok, preview? }
|
|
15
|
+
// agent { kind, id, type, description, status, name?, isolated?, background?, summary?,
|
|
16
|
+
// worktree?: { path, branch? }, stats?: { tools?, ms?, adds?, dels? }, agent? }
|
|
17
|
+
// an agent the turn started (Claude Code's Agent tool, Codex's spawnAgent), sent again whole
|
|
18
|
+
// each time it changes; status: running | done | failed | stopped; worktree: its own, when it
|
|
19
|
+
// has one (subagents.mjs, codex-subagents.mjs)
|
|
14
20
|
// request { kind, request: AgentRequest } needs a person; the job waits
|
|
15
21
|
// request_resolved { kind, id, answer, by } by: voice | lens | phone | portal | timeout | policy
|
|
16
|
-
// usage { kind, context: {used,window} | null, limits:
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
22
|
+
// usage { kind, context: {used,window} | null, limits: AgentLimits | null } how full the thread
|
|
23
|
+
// is and the person's plan limits ({account, primary, secondary, models: [...], credits,
|
|
24
|
+
// extra, plan}; each window {usedPercent,resetsAt,minutes,model?}), the latest of both each
|
|
25
|
+
// time one changes (agent-usage.mjs; Claude Code: claude-usage.mjs, Codex: codex-usage.mjs)
|
|
26
|
+
// result { kind, ok, text?, error?, sessionId?, stats? } stats: the agent's own, and its last
|
|
27
|
+
// context and limits, and limitHit (the window, as above) when a usage limit stopped it
|
|
20
28
|
//
|
|
21
29
|
// Any event with an id may come first as `transient: true` (live.mjs): what is still being written,
|
|
22
30
|
// sent live as it changes and never stored. The next event with the same id that isn't transient is
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The agents Claude Code starts inside a turn (its Agent tool, once called Task), as protocol `agent`
|
|
2
|
+
// events: what kind each is, what it was asked, how it's going, and where it works: the job's folder,
|
|
3
|
+
// or a worktree of its own (`isolation: "worktree"`, under <repo>/.claude/worktrees/<name>). Its own
|
|
4
|
+
// steps carry its id as `agent` (claude.mjs); the worktree shows up in the first path one of them
|
|
5
|
+
// touches, and the finished call names it too. Each change of an agent is the whole event again, so
|
|
6
|
+
// the last one with an id is where it stands.
|
|
7
|
+
import { display } from '../git.mjs'
|
|
8
|
+
|
|
9
|
+
/** Claude Code's tool for starting an agent, by its current name and its old one. */
|
|
10
|
+
export const isAgentCall = name => name === 'Agent' || name === 'Task'
|
|
11
|
+
|
|
12
|
+
const WORKTREE = /^(.*\/\.claude\/worktrees\/[^/]+)(?:\/|$)/
|
|
13
|
+
|
|
14
|
+
/** The agent worktree a path is inside, if any. */
|
|
15
|
+
export function worktreeOf(path) {
|
|
16
|
+
const m = WORKTREE.exec(String(path ?? ''))
|
|
17
|
+
return m ? m[1] : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every absolute path a tool call names, for finding where an agent works. */
|
|
21
|
+
export function pathsIn(input) {
|
|
22
|
+
const i = input ?? {}
|
|
23
|
+
const named = [i.file_path, i.notebook_path, i.path].filter(p => typeof p === 'string')
|
|
24
|
+
const cd = /(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|\S+)/.exec(String(i.command ?? ''))
|
|
25
|
+
if (cd) named.push(cd[1].replace(/^["']|["']$/g, ''))
|
|
26
|
+
return named.filter(p => p.startsWith('/'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An Agent call as the agent it starts. */
|
|
30
|
+
export function agentFrom(id, input, parent) {
|
|
31
|
+
const i = input ?? {}
|
|
32
|
+
return {
|
|
33
|
+
kind: 'agent',
|
|
34
|
+
id,
|
|
35
|
+
type: String(i.subagent_type || 'general-purpose'),
|
|
36
|
+
description: String(i.description || i.prompt || '').slice(0, 200),
|
|
37
|
+
status: 'running',
|
|
38
|
+
...(i.name ? { name: String(i.name) } : {}),
|
|
39
|
+
...(i.isolation === 'worktree' ? { isolated: true } : {}),
|
|
40
|
+
...(parent ? { agent: parent } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A finished Agent call's result (the SDK's tool_use_result): how it went, where, and what it did. */
|
|
45
|
+
export function finished(agent, result, failed) {
|
|
46
|
+
const r = result && typeof result === 'object' ? result : {}
|
|
47
|
+
if (r.status === 'async_launched') return { ...agent, background: true }
|
|
48
|
+
const s = r.toolStats
|
|
49
|
+
const stats = {
|
|
50
|
+
...(typeof r.totalToolUseCount === 'number' ? { tools: r.totalToolUseCount } : {}),
|
|
51
|
+
...(typeof r.totalDurationMs === 'number' ? { ms: r.totalDurationMs } : {}),
|
|
52
|
+
...(s ? { adds: s.linesAdded ?? 0, dels: s.linesRemoved ?? 0 } : {}),
|
|
53
|
+
}
|
|
54
|
+
// Claude Code removes an agent's worktree when it changed nothing, and then doesn't name it.
|
|
55
|
+
const removed = r.status === 'completed' && agent.isolated
|
|
56
|
+
const worktree = r.worktreePath
|
|
57
|
+
? { path: display(r.worktreePath), ...(r.worktreeBranch ? { branch: r.worktreeBranch } : {}) }
|
|
58
|
+
: removed
|
|
59
|
+
? null
|
|
60
|
+
: agent.worktree
|
|
61
|
+
const { worktree: _, ...rest } = agent
|
|
62
|
+
return {
|
|
63
|
+
...rest,
|
|
64
|
+
status: failed ? 'failed' : 'done',
|
|
65
|
+
...(worktree ? { worktree } : {}),
|
|
66
|
+
...(Object.keys(stats).length ? { stats } : {}),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ENDED = { completed: 'done', failed: 'failed', stopped: 'stopped' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A turn's agents as they go. `emit` sends each change; `branchOf(path)` reads a worktree's branch
|
|
74
|
+
* (git, or a stand-in in tests).
|
|
75
|
+
*/
|
|
76
|
+
export function subagentFeed(emit, branchOf) {
|
|
77
|
+
const agents = new Map()
|
|
78
|
+
const send = (a, more = {}) => {
|
|
79
|
+
agents.set(a.id, a)
|
|
80
|
+
emit({ ...a, ...more })
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
has: id => agents.has(id),
|
|
84
|
+
/** The Agent call: it starts. */
|
|
85
|
+
called(id, input, parent) {
|
|
86
|
+
send(agentFrom(id, input, parent))
|
|
87
|
+
},
|
|
88
|
+
/** One of its own calls: the first path inside a worktree says which one it works in. */
|
|
89
|
+
touched(id, input) {
|
|
90
|
+
const a = agents.get(id)
|
|
91
|
+
if (!a || a.worktree) return
|
|
92
|
+
const path = pathsIn(input).map(worktreeOf).find(Boolean)
|
|
93
|
+
if (!path) return
|
|
94
|
+
send({ ...a, worktree: { path: display(path) } })
|
|
95
|
+
void Promise.resolve(branchOf(path)).then(branch => {
|
|
96
|
+
const now = agents.get(id)
|
|
97
|
+
if (branch && now?.worktree && !now.worktree.branch) send({ ...now, worktree: { ...now.worktree, branch } })
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
/** A one-line summary of where it's at, while it works: shown live, never stored. */
|
|
101
|
+
progress(id, summary) {
|
|
102
|
+
const a = agents.get(id)
|
|
103
|
+
if (a && summary) emit({ ...a, summary: String(summary).slice(0, 200), transient: true })
|
|
104
|
+
},
|
|
105
|
+
/** Its call's result: done, or still going in the background. */
|
|
106
|
+
returned(id, result, failed) {
|
|
107
|
+
const a = agents.get(id)
|
|
108
|
+
if (a) send(finished(a, result, failed))
|
|
109
|
+
},
|
|
110
|
+
/** A background agent ended (task_notification). */
|
|
111
|
+
ended(id, status, summary) {
|
|
112
|
+
const a = agents.get(id)
|
|
113
|
+
if (a?.status !== 'running') return
|
|
114
|
+
send({ ...a, status: ENDED[status] ?? 'done', ...(summary ? { summary: String(summary).slice(0, 200) } : {}) })
|
|
115
|
+
},
|
|
116
|
+
/** The turn ended: whatever still runs stops with it. */
|
|
117
|
+
stopAll() {
|
|
118
|
+
for (const a of agents.values()) if (a.status === 'running') send({ ...a, status: 'stopped' })
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|