Run worker jobs on Codex in the background, with completion notifications and a jobs pane

A Claude Code mod that runs worker jobs on Codex in the background, the way a background Agent subagent runs on Claude.
dispatch: the model hands Codex a self-contained brief and gets a job id back at once. The job runs detached (setsid codex exec), so reloading the mod or restarting Claude Code doesn't kill it.result / cancel: read a job's full report, or kill its whole process tree./codex: a pane listing this session's Codex jobs next to its Claude subagents. The status line shows codex ▸ N running.Each job's files are in ~/.cache/codex-lane/<id>/: brief.md, meta.json, events.jsonl (Codex's --json stream), stderr.log, last.md (the final report) and exit.
/plugin install codex-lane --marketplace wryskware/claude-mods
For one session from a clone: claude --plugin-dir <clone>/mods/codex-lane.
It needs the codex CLI on PATH, logged in, and Linux or macOS (setsid, bash).
| Field | |
|---|---|
brief | The self-contained task. Codex sees none of the conversation. |
description | A 3–5 word label for the pane and the notification. |
effort | Codex reasoning effort: low, medium, high, xhigh. |
cwd | Working directory; defaults to the session's. |
model | Codex model; defaults to your ~/.codex/config.toml. |
worktree | true runs it in a separate git worktree (codex exec --worktree). |
Which work goes to Codex, and at what effort, is up to you. Put your routing rules in your CLAUDE.md.
If ai-usagebar is installed, the mod adds one line of Claude and Codex usage that the model reads with each prompt and after each Agent or dispatch call:
[quota, 2m old] claude: 5h 5%, week 30% (+3 vs pace, resets in 5d3h) | codex: week 8% (+5 vs pace, resets in 6d18h) | spawned since: claude 1, codex 2
"vs pace" is percentage points above or below an even burn of the week. The usage is sampled at most every 5 minutes, so the line also counts the spawns made since the sample was taken. The mod reports the numbers and the model chooses the lane, by your rules. Without ai-usagebar the line is left out.
Jobs run under Codex's workspace-write sandbox. A workspace can change that with .claude/codex-lane.json. The mod uses the nearest one at or above the job's cwd:
{ "network": true, "addDirs": ["/home/me/.ssh"] }
| Key | Effect |
|---|---|
sandbox | Codex sandbox mode (read-only, workspace-write, danger-full-access). |
network | true allows network access under workspace-write. |
addDirs | Extra writable directories (--add-dir). |
hooks/register.tsx 382 lines1// codex-lane: run worker jobs on Codex in the background.
2//
3// The model calls `dispatch` with a self-contained brief, as it would spawn a
4// background Agent. The job runs detached (`setsid`), outside this module, so a
5// reload or a restart never kills it; everything it writes lands in
6// ~/.cache/codex-lane/<id>/. A 5s timer watches for the DONE marker and, when it
7// appears, submits the final report as a prompt, which wakes an idle session the
8// way a finished background agent's notification does. The disk is the source of
9// truth: after a reload the timer picks the jobs up again from there.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { AgentRow, DispatchInput, JobView } from '../types'
15import { applyQuotaReport, countSpawn, endQuotaRefresh, quotaLine, startQuotaRefresh } from './quota'
16
17const PLUGIN = 'codex-lane'
18const PANE = 'codex-lane'
19const TICK_MS = 5000
20const REPORT_LIMIT = 20000
21
22const jobsAtom = atom({ plugin: 'codex-lane', key: 'jobs' } as const, [] as JobView[])
23const agentsAtom = atom({ plugin: 'codex-lane', key: 'agents' } as const, [] as AgentRow[])
24const nowAtom = atom({ plugin: 'codex-lane', key: 'now' } as const, 0)
25
26/** Codex reasoning efforts, passed through as `model_reasoning_effort`. */
27const EFFORTS = ['low', 'medium', 'high', 'xhigh']
28
29// One job, detached. Its stdio is closed first so `$.process.run` returns at
30// once; `setsid` makes it a process group of its own, so cancel kills the tree.
31const RUNNER = `exec </dev/null >/dev/null 2>&1
32dir=$1; shift
33echo $$ > "$dir/pid"
34codex exec "$@" - < "$dir/brief.md" > "$dir/events.jsonl" 2> "$dir/stderr.log"
35code=$?
36[ -e "$dir/exit" ] || echo "$code" > "$dir/exit"
37touch "$dir/DONE"`
38
39type Meta = {
40 id: string
41 session: string
42 description: string
43 effort: string
44 model?: string
45 cwd: string
46 startedAt: number
47 args: string[]
48}
49
50/** Optional per-workspace sandbox exceptions, read from <dir>/.claude/codex-lane.json. */
51type WorkspaceConfig = { sandbox?: string; network?: boolean; addDirs?: string[] }
52
53let root: string | undefined
54let ticking = false
55/** Finished jobs never change again; keep them out of the per-tick reads. */
56const finished = new Map<string, JobView>()
57
58async function jobsRoot($: EngineInterface): Promise<string> {
59 if (!root) {
60 const home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
61 root = `${home}/.cache/codex-lane`
62 }
63 return root
64}
65
66async function readText($: EngineInterface, path: string): Promise<string | undefined> {
67 return $.fs.read(path).catch(() => undefined)
68}
69
70async function workspaceConfig($: EngineInterface, cwd: string): Promise<WorkspaceConfig> {
71 for (let dir = cwd.replace(/\/+$/, ''); ; dir = dir.slice(0, dir.lastIndexOf('/'))) {
72 const text = await readText($, `${dir}/.claude/codex-lane.json`)
73 if (text !== undefined) return JSON.parse(text) as WorkspaceConfig
74 if (!dir) return {}
75 }
76}
77
78function duration(ms: number): string {
79 const s = Math.max(0, Math.round(ms / 1000))
80 if (s < 60) return `${s}s`
81 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
82 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
83}
84
85function statusOf(exit: string): JobView['status'] {
86 if (exit === 'cancelled') return 'cancelled'
87 return exit === '0' ? 'done' : 'failed'
88}
89
90async function notify($: EngineInterface, meta: Meta, dir: string, view: JobView) {
91 const report = (await readText($, `${dir}/last.md`))?.trim() ?? ''
92 const parts = [
93 `[codex-lane] Codex job ${meta.id} (${meta.description}) ${view.status} after ${duration(
94 (view.endedAt ?? meta.startedAt) - meta.startedAt,
95 )}.`,
96 ]
97 if (report) {
98 parts.push(report.length > REPORT_LIMIT
99 ? `${report.slice(0, REPORT_LIMIT)}\n\n…(report cut; call result("${meta.id}") for the rest)`
100 : report)
101 } else {
102 const stderr = (await readText($, `${dir}/stderr.log`))?.trim() ?? ''
103 parts.push(`No final report. stderr tail:\n${stderr.slice(-4000) || '(empty)'}`)
104 }
105 parts.push(`Logs: ${dir}/ (events.jsonl, stderr.log, last.md)`)
106 void $.prompt.submit({ text: parts.join('\n\n') })
107}
108
109/** Asks ai-usagebar for a new quota sample when the last one is stale. */
110async function refreshQuota($: EngineInterface) {
111 if (!startQuotaRefresh()) return
112 try {
113 const ran = await $.process.run(['ai-usagebar', 'usage', '--json'], { timeoutMs: 60_000 })
114 if (ran.exitCode === 0) applyQuotaReport(ran.stdout)
115 } catch {
116 // Not installed or timed out: keep the last sample and try again later.
117 } finally {
118 endQuotaRefresh()
119 }
120}
121
122async function tick($: EngineInterface) {
123 void refreshQuota($)
124 if (ticking) return
125 ticking = true
126 try {
127 const base = await jobsRoot($)
128 const session = await $.session.id()
129 const now = Date.now()
130 const views: JobView[] = []
131
132 for (const entry of await $.fs.list(base).catch(() => [])) {
133 if (entry.kind !== 'dir') continue
134 const known = finished.get(entry.name)
135 if (known) {
136 views.push(known)
137 continue
138 }
139 const dir = `${base}/${entry.name}`
140 const metaText = await readText($, `${dir}/meta.json`)
141 if (!metaText) continue
142 const meta = JSON.parse(metaText) as Meta
143 if (meta.session !== session) continue
144
145 let isDone = await $.fs.exists(`${dir}/DONE`)
146 if (!isDone) {
147 // A runner that died without its marker (a reboot, a kill -9) is lost.
148 const pid = (await readText($, `${dir}/pid`))?.trim()
149 const alive = pid ? (await $.process.run(['kill', '-0', pid])).exitCode === 0 : true
150 if (!alive && !(await $.fs.exists(`${dir}/DONE`))) {
151 await $.fs.write(`${dir}/exit`, 'lost\n')
152 await $.fs.write(`${dir}/DONE`, '')
153 }
154 isDone = await $.fs.exists(`${dir}/DONE`)
155 }
156
157 const view: JobView = {
158 id: meta.id,
159 description: meta.description,
160 effort: meta.effort,
161 cwd: meta.cwd,
162 startedAt: meta.startedAt,
163 status: 'running',
164 }
165 if (isDone) {
166 view.status = statusOf(((await readText($, `${dir}/exit`)) ?? '').trim())
167 view.endedAt = (await $.fs.stat(`${dir}/DONE`)).mtimeMs
168 finished.set(meta.id, view)
169 if (!(await $.fs.exists(`${dir}/notified`))) {
170 await $.fs.write(`${dir}/notified`, '')
171 await notify($, meta, dir, view)
172 }
173 }
174 views.push(view)
175 }
176
177 views.sort((a, b) => a.startedAt - b.startedAt)
178 const running = views.filter(v => v.status === 'running').length
179 $.ui.status(running ? `codex ▸ ${running} running` : undefined)
180
181 const agents = (await $.agent.list()).map(a => ({
182 id: a.id,
183 description: a.description,
184 type: a.type,
185 status: a.status,
186 }))
187 await update($, jobsAtom, () => views)
188 await update($, agentsAtom, () => agents)
189 await update($, nowAtom, () => now)
190 } finally {
191 ticking = false
192 }
193}
194
195const DISPATCH_DESCRIPTION = `Start a worker job on Codex (OpenAI; the model in ~/.codex/config.toml unless \`model\` is given) in the background: the Codex equivalent of spawning a background Agent worker.
196
197Returns a job id at once. When the job finishes, its final report arrives as a new message, as a finished background agent's does: do not poll, sleep or wait for it.
198
199\`brief\` must be self-contained, exactly as for an Agent worker: objective, deliverable, relevant files, constraints, decision authority, verification method, return format. Codex sees none of this conversation.
200
201\`effort\` is Codex's reasoning effort (low, medium, high, xhigh). It runs in \`cwd\` (default: the session's) under the workspace-write sandbox, unless that workspace's .claude/codex-lane.json says otherwise. Set \`worktree: true\` to run it in a separate git worktree instead of the working tree.`
202
203export const register: Register = on => {
204 on('session.start', async ($, e, next) => {
205 await $.tool.register({
206 name: 'dispatch',
207 description: DISPATCH_DESCRIPTION,
208 inputSchema: {
209 type: 'object',
210 properties: {
211 brief: { type: 'string', description: 'The self-contained task brief.' },
212 description: { type: 'string', description: 'A short (3-5 word) label for the job.' },
213 effort: { type: 'string', enum: EFFORTS },
214 cwd: { type: 'string', description: 'Absolute working directory; default the session cwd.' },
215 model: { type: 'string', description: 'Codex model override; omit for the configured default.' },
216 worktree: { type: 'boolean', description: 'Run in a separate git worktree.' },
217 },
218 required: ['brief', 'description', 'effort'],
219 },
220 })
221 await $.tool.register({
222 name: 'result',
223 description:
224 'Read a Codex job\'s status and its full final report. Only needed when the completion message cut the report short, or to check a job on request; never to poll.',
225 inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
226 })
227 await $.tool.register({
228 name: 'cancel',
229 description: 'Cancel a running Codex job (kills its whole process tree).',
230 inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
231 })
232 await $.command.register({ name: 'codex', description: 'Show Codex jobs and Claude subagents in a pane' })
233
234 $.clock.every(TICK_MS, () => tick($))
235 void tick($)
236 return next(e)
237 })
238
239 on('tool.call', { tool: 'mcp__codex-lane__dispatch' }, async ($, e) => {
240 // The matcher does not narrow `e` to this tool's input; the schema above holds it.
241 const input = e as unknown as DispatchInput
242 const { effort } = input
243 if (!EFFORTS.includes(effort)) return { deny: `Unknown effort "${effort}"; use one of ${EFFORTS.join(', ')}.` }
244
245 const cwd = input.cwd ?? (await $.process.run(['pwd'])).stdout.trim()
246 if (!(await $.fs.exists(cwd))) return { deny: `cwd ${cwd} does not exist.` }
247 const config = await workspaceConfig($, cwd)
248
249 const base = await jobsRoot($)
250 const id = `cx-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
251 const dir = `${base}/${id}`
252
253 const args = [
254 '--json',
255 '--skip-git-repo-check',
256 '-C', cwd,
257 '-o', `${dir}/last.md`,
258 '-s', config.sandbox ?? 'workspace-write',
259 '-c', `model_reasoning_effort="${effort}"`,
260 ]
261 if (input.model) args.push('-m', input.model)
262 if (config.network) args.push('-c', 'sandbox_workspace_write.network_access=true')
263 for (const extra of config.addDirs ?? []) args.push('--add-dir', extra)
264 if (input.worktree) args.push('--worktree')
265
266 const meta: Meta = {
267 id,
268 session: await $.session.id(),
269 description: input.description,
270 effort,
271 model: input.model,
272 cwd,
273 startedAt: Date.now(),
274 args,
275 }
276 await $.fs.write(`${dir}/brief.md`, input.brief)
277 await $.fs.write(`${dir}/meta.json`, JSON.stringify(meta, null, 2))
278
279 const started = await $.process.run(['setsid', '-f', 'bash', '-c', RUNNER, PLUGIN, dir, ...args])
280 if (started.exitCode !== 0) {
281 return { deny: `Could not start Codex: ${started.stderr.trim() || `exit ${started.exitCode}`}` }
282 }
283 void tick($)
284
285 const sandbox = [config.sandbox ?? 'workspace-write', config.network && 'network on'].filter(Boolean).join(', ')
286 countSpawn('codex')
287 const quota = quotaLine()
288 return {
289 ...(quota ? { context: [quota] } : {}),
290 result: `Started Codex job ${id} (effort ${effort}${input.model ? `, ${input.model}` : ''}; ${sandbox}${input.worktree ? ', own worktree' : ''}) in ${cwd}. Its report arrives as a message when it finishes; don't poll. Logs: ${dir}/`,
291 }
292 })
293
294 on('tool.call', { tool: 'mcp__codex-lane__result' }, async ($, e) => {
295 const { id } = e as unknown as { id: string }
296 const dir = `${await jobsRoot($)}/${id}`
297 if (!(await $.fs.exists(`${dir}/meta.json`))) return { deny: `No Codex job ${id}.` }
298 if (!(await $.fs.exists(`${dir}/DONE`))) return { result: `Codex job ${id} is still running. Logs: ${dir}/` }
299 const exit = ((await readText($, `${dir}/exit`)) ?? '').trim()
300 const report = (await readText($, `${dir}/last.md`))?.trim()
301 const stderr = report ? '' : `\n\nstderr tail:\n${((await readText($, `${dir}/stderr.log`)) ?? '').slice(-4000)}`
302 return { result: `Codex job ${id}: ${statusOf(exit)} (exit ${exit}).\n\n${report || '(no final report)'}${stderr}` }
303 })
304
305 on('tool.call', { tool: 'mcp__codex-lane__cancel' }, async ($, e) => {
306 const { id } = e as unknown as { id: string }
307 const dir = `${await jobsRoot($)}/${id}`
308 if (!(await $.fs.exists(`${dir}/meta.json`))) return { deny: `No Codex job ${id}.` }
309 if (await $.fs.exists(`${dir}/DONE`)) return { result: `Codex job ${id} already finished.` }
310 // The cancel is its own notice: mark it notified so no completion prompt follows.
311 await $.fs.write(`${dir}/notified`, '')
312 await $.fs.write(`${dir}/exit`, 'cancelled\n')
313 const pid = (await readText($, `${dir}/pid`))?.trim()
314 if (pid) await $.process.run(['kill', '-s', 'TERM', '--', `-${pid}`])
315 await $.fs.write(`${dir}/DONE`, '')
316 void tick($)
317 return { result: `Cancelled Codex job ${id}.` }
318 })
319
320 // The quota line rides beside each prompt and after each spawn, so the model
321 // can choose a lane with no call of its own.
322 on('prompt.submit', ($, e, next) => {
323 const quota = quotaLine()
324 return next(quota ? { ...e, context: [...(e.context ?? []), quota] } : e)
325 })
326
327 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
328 const ran = await next(e)
329 if (ran.deny !== undefined) return ran
330 countSpawn('claude')
331 const quota = quotaLine()
332 return quota ? { ...ran, context: [...(ran.context ?? []), quota] } : ran
333 })
334
335 on('command.run', { command: 'codex' }, async $ => {
336 await $.ui.open({ id: PANE, title: 'Workers' })
337 void tick($)
338 return { text: 'Workers pane opened.' }
339 })
340
341 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
342 const { Box, Text } = $.ui.resolve(e)
343 const jobs = await read($, jobsAtom)
344 const agents = await read($, agentsAtom)
345 const now = await read($, nowAtom)
346 const COLOR: Record<JobView['status'], string> = {
347 running: 'warning',
348 done: 'success',
349 failed: 'error',
350 cancelled: 'inactive',
351 }
352
353 return (
354 <Box flexDirection="column">
355 <Text bold>Codex</Text>
356 {jobs.length === 0 && <Text dimColor> No Codex jobs this session.</Text>}
357 {jobs.map(job => (
358 <Text wrap="truncate-end">
359 <Text color={COLOR[job.status]}>{job.status === 'running' ? '▸' : job.status === 'done' ? '✓' : '✗'}</Text>
360 {` ${job.description} `}
361 <Text dimColor>
362 {`${job.effort} · ${duration((job.endedAt ?? now) - job.startedAt)} · ${job.id}`}
363 </Text>
364 </Text>
365 ))}
366 <Text> </Text>
367 <Text bold>Claude</Text>
368 {agents.length === 0 && <Text dimColor> No subagents this session.</Text>}
369 {agents.map(agent => (
370 <Text wrap="truncate-end">
371 <Text color={agent.status === 'running' ? 'warning' : agent.status === 'completed' ? 'success' : 'inactive'}>
372 {agent.status === 'running' ? '▸' : agent.status === 'completed' ? '✓' : '·'}
373 </Text>
374 {` ${agent.description} `}
375 <Text dimColor>{`${agent.type} · ${agent.status}`}</Text>
376 </Text>
377 ))}
378 </Box>
379 )
380 })
381}
382hooks/quota.ts 116 lines1// Quota lane: one line of Claude/Codex usage the model reads beside each prompt
2// and after each Agent or dispatch call, so it can pick a lane without asking.
3//
4// The numbers come from `ai-usagebar usage --json`, which owns the credentials
5// and the provider rate limits. It is asked at most every 5 minutes, from the
6// mod's timer (register.tsx runs it), never from a hook a prompt waits on; without it the line is
7// simply left out. Spawns counted since the last sample cover a burst of
8// dispatches the sample cannot see yet.
9
10const WEEK = 604800
11const FIVE_HOUR = 18000
12const MAX_AGE_MS = 300_000
13const RETRY_MS = 60_000
14
15type Metric = { label: string; percent?: number; window_secs?: number; reset_at?: string }
16type Entry = { id: string; metrics?: Metric[]; error?: string | null }
17type Window = { name: string; pct: number; resetsAt?: number; windowSecs?: number }
18type Lane = { windows: Window[]; error?: string }
19
20export type LaneName = 'claude' | 'codex'
21
22const VENDORS: Record<string, LaneName> = { anthropic: 'claude', openai: 'codex' }
23
24let sample: { at: number; lanes: Partial<Record<LaneName, Lane>> } | undefined
25let lastTry = 0
26let refreshing = false
27const spawned: Record<LaneName, number> = { claude: 0, codex: 0 }
28
29/** Counts a spawn on a lane until the next sample shows its usage. */
30export function countSpawn(lane: LaneName) {
31 spawned[lane]++
32}
33
34/** Claims a refresh when the sample is stale and none is running; pair with endQuotaRefresh. */
35export function startQuotaRefresh(): boolean {
36 const now = Date.now()
37 if (refreshing || now - lastTry < RETRY_MS) return false
38 if (sample && now - sample.at < MAX_AGE_MS) return false
39 refreshing = true
40 lastTry = now
41 return true
42}
43
44export function endQuotaRefresh() {
45 refreshing = false
46}
47
48/** Takes `ai-usagebar usage --json` output as the new sample; bad JSON keeps the old one. */
49export function applyQuotaReport(stdout: string) {
50 let report: { entries?: Entry[] }
51 try {
52 report = JSON.parse(stdout) as { entries?: Entry[] }
53 } catch {
54 return
55 }
56 const lanes: Partial<Record<LaneName, Lane>> = {}
57 for (const entry of report.entries ?? []) {
58 const lane = VENDORS[entry.id]
59 if (!lane) continue
60 lanes[lane] = {
61 error: entry.error ?? undefined,
62 windows: (entry.metrics ?? [])
63 .filter(m => typeof m.percent === 'number')
64 .map(m => ({
65 name: m.label.replace(/\s*\([^)]*\)\s*$/, '').trim(),
66 pct: m.percent as number,
67 resetsAt: m.reset_at ? Date.parse(m.reset_at) : undefined,
68 windowSecs: m.window_secs,
69 })),
70 }
71 }
72 sample = { at: Date.now(), lanes }
73 spawned.claude = 0
74 spawned.codex = 0
75}
76
77function span(ms: number): string {
78 const m = Math.max(0, Math.round(ms / 60000))
79 if (m < 60) return `${m}m`
80 const h = Math.floor(m / 60)
81 return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d${h % 24}h`
82}
83
84/** Points above (+) or below (−) an even burn of a weekly window. */
85function pace(w: Window, now: number): number | undefined {
86 if (w.windowSecs !== WEEK || w.resetsAt === undefined) return undefined
87 const remaining = w.resetsAt - now
88 if (remaining <= 0 || remaining > WEEK * 1000) return undefined
89 return Math.round(w.pct - 100 * (1 - remaining / (WEEK * 1000)))
90}
91
92function describe(lane: Lane | undefined, now: number): string {
93 if (!lane) return 'no data'
94 if (lane.windows.length === 0) return lane.error ? 'unavailable' : 'no data'
95 return lane.windows
96 .map(w => {
97 if (w.windowSecs === FIVE_HOUR) return `5h ${w.pct}%`
98 const p = pace(w, now)
99 const label = /weekly$/i.test(w.name) ? 'week' : `${w.name} week`
100 const parts = [p === undefined ? undefined : `${p >= 0 ? '+' : '−'}${Math.abs(p)} vs pace`,
101 w.resetsAt ? `resets in ${span(w.resetsAt - now)}` : undefined].filter(Boolean)
102 return `${label} ${w.pct}%${parts.length ? ` (${parts.join(', ')})` : ''}`
103 })
104 .join(', ')
105}
106
107/** The line the model reads, or undefined while there is no sample. */
108export function quotaLine(): string | undefined {
109 if (!sample) return undefined
110 const now = Date.now()
111 const counts = spawned.claude || spawned.codex
112 ? ` | spawned since: claude ${spawned.claude}, codex ${spawned.codex}`
113 : ''
114 return `[quota, ${span(now - sample.at)} old] claude: ${describe(sample.lanes.claude, now)} | codex: ${describe(sample.lanes.codex, now)}${counts}`
115}
116types/index.d.ts 32 lines1export type JobView = {
2 id: string
3 description: string
4 effort: string
5 cwd: string
6 startedAt: number
7 endedAt?: number
8 status: 'running' | 'done' | 'failed' | 'cancelled'
9}
10
11export type DispatchInput = {
12 brief: string
13 description: string
14 effort: string
15 cwd?: string
16 model?: string
17 worktree?: boolean
18}
19
20export type AgentRow = { id: string; description: string; type: string; status: string }
21
22declare module 'claude-code' {
23 interface McpToolInputs {
24 'mcp__codex-lane__dispatch': DispatchInput
25 'mcp__codex-lane__result': { id: string }
26 'mcp__codex-lane__cancel': { id: string }
27 }
28 interface PluginState {
29 'codex-lane': { jobs: JobView[]; agents: AgentRow[]; now: number }
30 }
31}
32