Hand a task to a cheaper model (any OpenAI chat-protocol provider) through a local Anthropic-to-OpenAI proxy; /delegate manages providers, keys and models.

把任务交给更便宜的模型执行:DS(任意 OpenAI chat 协议供应商,经本地 Anthropic→OpenAI 代理)和 GPT(经本地 CLIProxyAPI,走 ChatGPT 订阅)。
delegate_flash / delegate_pro:DS 快档 / 强档。mode 为 read(只读)或 work(可改文件),支持 background、resume(续用同一子代理,沿用上次的 mode/ptc/模型/档位)、effort。delegate_gpt:GPT 执行,默认 gpt-6.1-sol,可选 gpt-6-astra;档位最低 medium。delegate_send / delegate_status / delegate_list / delegate_cancel:后台任务插话、查看、列表、取消。任务状态落盘在 ~/.claude/delegate/jobs/,重启后仍能找回并续用。/delegate:管理供应商、协议(chat / responses / anthropic)、key 和模型列表。界面上有实时面板(事件流、轮数、上下文)和按钮栏(运行数、GPT 5 小时额度)。
a2o(ant2oai)代理,scripts/run_proxy.py 在它外面加了 system 消息展开和 --effort。http://127.0.0.1:8317;启动脚本默认在 ~/tools/cliproxyapi/ensure_cpa.py,可用 DELEGATE_CPA_ENSURE、DELEGATE_CPA_BASE 覆盖。scripts/ptc-*-system.md)。scripts/cpa-admin.mjs,输出一律脱敏;作业记录不保存供应商 key。work 模式的子代理可以直接改文件和执行命令。claude plugin validate delegate
claude plugin test delegatehooks/register.tsx 1989 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Draft } from '../types'
5import {
6 idOf,
7 isEffort,
8 migrationPlan,
9 clearSlot,
10 normalize,
11 parseTier,
12 removeProvider,
13 resolveTier,
14 setSlot,
15 TIERS,
16 callLabel,
17 toolDescription,
18} from './lib/config.ts'
19import type { Config, Result, Tier } from './lib/config.ts'
20import { GPT_DEFAULT_MODEL, GPT_MODELS, gptLabel, gptModelOf, gptToolDescription } from './lib/gpt.ts'
21import {
22 eventLine,
23 formatList,
24 formatStatus,
25 isJobKey,
26 isStatus,
27 jobKey,
28 JOB_PREFIX,
29 jobsRootOf,
30 MAX_JOBS,
31 newJobId,
32 normalizeRecord,
33 normalizeStatus,
34 pidMatches,
35 recordFromNote,
36 statusText,
37 STATUSES,
38 summarize,
39 uuid,
40} from './lib/jobs.ts'
41import { liveEvents, spanOf, tokensOf, usageOf } from './lib/jobs.ts'
42import type { Agent, JobRecord, JobStatusFile, LiveEvent, Mode, Ptc } from './lib/jobs.ts'
43import { barOf, normalizeQuota, resetIn, usedColor } from './lib/quota.ts'
44import type { GptQuota } from './lib/quota.ts'
45
46const COMMAND = 'delegate'
47const PANE = 'delegate'
48const toolOf = (tier: Tier): string => `mcp__delegate__delegate_${tier}`
49// gpt is a target like a tier, but it has no slot: the model is chosen per call and CPA supplies the endpoint.
50const GPT_TOOL = 'mcp__delegate__delegate_gpt'
51const STORE_KEY = 'cfg'
52// 旧版把全部作业挤在这一个 key 里;现在每条作业单独 job:<id>,这张表只当镜像和迁移用。
53const JOBS_KEY = 'jobs'
54// resume 用的是 agent id(= session id),作业 id 是 bg-/fg-;这一条索引把两者连起来。
55const AGENT_PREFIX = 'agent:'
56const MAX_REPLY = 20_000
57// A finished background job speaks to the model through a prompt: keep it short, the rest is in a file.
58const BG_INLINE = 6_000
59
60const EMPTY: Draft = {
61 pName: '',
62 pUrl: '',
63 pKey: '',
64 keyFor: '',
65 keyValue: '',
66 flashProvider: '',
67 flashModel: '',
68 proProvider: '',
69 proModel: '',
70 flashEffort: '',
71 proEffort: '',
72}
73
74const draftAtom = atom({ plugin: 'delegate', key: 'draft' } as const, EMPTY)
75const flashAtom = atom({ plugin: 'delegate', key: 'flash' } as const, '')
76const versionAtom = atom({ plugin: 'delegate', key: 'version' } as const, 0)
77
78// Calls in flight, so the status line is cleared only when the last one ends.
79let running = 0
80
81const messageOf = (error: unknown): string =>
82 error instanceof Error ? error.message : String(error)
83
84type CpaProvider = { id: string; name: string; prefix: string; protocol: string; baseUrl: string; keyMask: string; models: string[] }
85type CpaAccount = { name: string; email?: string; disabled?: boolean; status?: string }
86type AdminReply = { ok: boolean; error?: string; providers?: CpaProvider[]; models?: string[]; accounts?: CpaAccount[]; url?: string; state?: string; status?: string }
87const admin = async ($: EngineInterface, command: string, input: Record<string, unknown> = {}): Promise<AdminReply> => {
88 const ran = await $.process.run(['node', `${$.plugin.root}/scripts/cpa-admin.mjs`, command], { stdin: JSON.stringify(input), timeoutMs: 120_000 })
89 const result = JSON.parse(ran.stdout.trim().split('\n').at(-1) || '{}') as AdminReply
90 if (ran.exitCode !== 0 || !result.ok) throw new Error(result.error || 'CPA 操作失败')
91 return result
92}
93let migrationPending: Promise<Config> | undefined
94const loadConfig = async ($: EngineInterface): Promise<Config> => {
95 if (migrationPending) return migrationPending
96 let raw: unknown
97 try { raw = await $.store.get(STORE_KEY) } catch { return normalize(undefined) }
98 const plan = migrationPlan(raw)
99 if (!plan.providers.length) return plan.config
100 migrationPending = (async () => {
101 const existing = (await admin($, 'providers')).providers || []
102 for (const p of plan.providers) await admin($, 'save', { ...p, models: [...new Set([...(existing.find(x => x.id === p.id)?.models || []), ...p.models])] })
103 // No store write on failure: legacy secrets stay available for a safe retry.
104 await $.store.set(STORE_KEY, plan.config)
105 return plan.config
106 })()
107 try { return await migrationPending } finally { migrationPending = undefined }
108}
109
110// One tool per tier, so the person's tool row already reads delegate_flash / delegate_pro.
111const describeTool = async ($: EngineInterface, config: Config): Promise<void> => {
112 for (const tier of TIERS) await describeOne($, config, tier)
113}
114
115const describeOne = async ($: EngineInterface, config: Config, tier: Tier): Promise<void> => {
116 try {
117 await $.tool.register({
118 name: `delegate_${tier}`,
119 description: `【${tier} 档】${toolDescription(config)}`,
120 inputSchema: {
121 type: 'object',
122 properties: {
123 task: { type: 'string', description: '自包含的任务说明' },
124 mode: {
125 type: 'string',
126 enum: ['read', 'work'],
127 description: 'read 只读(默认);work 可改文件和跑命令',
128 },
129 ptc: {
130 type: 'string',
131 enum: ['only', 'off'],
132 description: "仅 read 模式:默认 'only',子代理只用一个只读的 run_code,在程序里批量 read/glob/grep 再汇总(只能读 cwd,不能写文件、跑命令,另带 WebFetch 读网页);'off' 改用原生 Read/Grep/Glob/WebFetch(要读 cwd 以外的文件时用)",
133 },
134 cwd: { type: 'string', description: '工作目录;不填用当前会话的' },
135 maxTurns: { type: 'number', description: '最多轮数,默认不限(1000)' },
136 effort: {
137 type: 'string',
138 enum: ['low', 'medium', 'high', 'xhigh', 'max', 'off'],
139 description: '只对这一次调用覆盖思考强度;不填用该档位的设置。疑难根因、复杂设计才用 max',
140 },
141 background: {
142 type: 'boolean',
143 description: 'true:立即返回,完成后自动通知你(你有别的事可做、任务较久时用);默认 false:等它做完再继续',
144 },
145 resume: {
146 type: 'string',
147 description: '上次返回的 agent ID:接着那个代理的上下文继续,不新建(沿用它的档位、mode、ptc 和目录;显式传 mode/ptc 则覆盖)',
148 },
149 },
150 required: ['task'],
151 },
152 })
153 } catch (error) {
154 $.ui.log(`delegate: tool not registered: ${messageOf(error)}`, { to: 'debug' })
155 }
156}
157
158// delegate_gpt: GPT through the local CPA proxy (ChatGPT subscription). No provider, no key, no slot to set up.
159const describeGpt = async ($: EngineInterface): Promise<void> => {
160 try {
161 await $.tool.register({
162 name: 'delegate_gpt',
163 description: `【gpt 档】${gptToolDescription()}`,
164 inputSchema: {
165 type: 'object',
166 properties: {
167 task: { type: 'string', description: '自包含的任务说明' },
168 model: {
169 type: 'string',
170 enum: [...GPT_MODELS],
171 description: "默认 gpt-6.1-sol,日常都用它;只有真正高难的问题才传 'gpt-6-astra'(更慢,别当默认)",
172 },
173 mode: {
174 type: 'string',
175 enum: ['read', 'work'],
176 description: 'read 只读(默认);work 可改文件和跑命令',
177 },
178 ptc: {
179 type: 'string',
180 enum: ['only', 'off'],
181 description: "仅 read 模式:默认 'only',子代理只用一个只读的 run_code,在程序里批量 read/glob/grep 再汇总(只能读 cwd,不能写文件、跑命令,另带 WebFetch 读网页);'off' 改用原生 Read/Grep/Glob/WebFetch(要读 cwd 以外的文件时用)",
182 },
183 cwd: { type: 'string', description: '工作目录;不填用当前会话的' },
184 maxTurns: { type: 'number', description: '最多轮数,默认不限(1000)' },
185 effort: {
186 type: 'string',
187 enum: ['medium', 'high', 'xhigh', 'max'],
188 description: '思考强度(映射到 Claude Code 的 --effort),不填就是 medium。medium 是最低档;判断量较大、跨模块的改动或审查用 high;真正高难的问题用 xhigh,一般就够了;max 只在 xhigh 仍不稳时用',
189 },
190 background: {
191 type: 'boolean',
192 description: 'true:立即返回,完成后自动通知你(你有别的事可做、任务较久时用);默认 false:等它做完再继续',
193 },
194 resume: {
195 type: 'string',
196 description: '上次返回的 agent ID:接着那个代理的上下文继续,不新建(沿用它的 mode、ptc 和目录;显式传 mode/ptc 则覆盖)',
197 },
198 },
199 required: ['task'],
200 },
201 })
202 } catch (error) {
203 $.ui.log(`delegate: gpt tool not registered: ${messageOf(error)}`, { to: 'debug' })
204 }
205}
206
207const saveConfig = async ($: EngineInterface, config: Config): Promise<void> => {
208 await $.store.set(STORE_KEY, config)
209 await describeTool($, config)
210 await update($, versionAtom, n => n + 1)
211}
212
213type JobReply = {
214 ok: boolean
215 text?: string
216 error?: string
217 sessionId?: string | null
218 turns?: number | null
219 ms?: number
220 // 实际作答的模型(按出现顺序去重);一个都没看到就是 null
221 models?: string[] | null
222 // 任务自己写的 TASK_TERMINAL(completed / blocked / failed);没写就是 null
223 terminal?: string | null
224}
225
226// 进程成功(ok)不等于任务完成:把任务终态和实际模型单独说一行,附在返回文本末尾。
227const runnerNote = (reply: JobReply): string => {
228 const models = Array.isArray(reply.models)
229 ? reply.models.filter(m => typeof m === 'string' && m !== '')
230 : []
231 const terminal = typeof reply.terminal === 'string' && reply.terminal !== '' ? reply.terminal : null
232 // 完成且只有一个模型:只说实际模型,不用再报终态
233 if (terminal === 'completed' && models.length === 1) return `实际模型=${models[0]}`
234 const parts: string[] = []
235 if (terminal !== 'completed') parts.push(`TASK_TERMINAL=${terminal ?? 'none'}`)
236 if (models.length > 0) parts.push(`实际模型=${models.join(', ')}`)
237 return parts.length === 0 ? '' : `runner:${parts.join(';')}`
238}
239
240const runJob = async (
241 $: EngineInterface,
242 job: Record<string, unknown>,
243): Promise<JobReply> => {
244 const script = `${$.plugin.root}/scripts/run-delegate.mjs`
245 const input = JSON.stringify(job)
246 try {
247 let stdout = ''
248 let stderr = ''
249 let code: number | null = null
250 try {
251 // process.spawn, not process.run: run caps a child at ten minutes; the runner enforces its own (2 h) limit
252 const child = $.process.spawn({ argv: ['node', script], input })
253 let step = await child.next()
254 while (!step.done) {
255 if (step.value.stream === 'stdout') stdout += step.value.text
256 else stderr += step.value.text
257 step = await child.next()
258 }
259 code = step.value?.code ?? null
260 } catch (error) {
261 // a host without process.spawn (the test kit) falls back to run and its ten-minute cap
262 if (!messageOf(error).includes('no implementation for process.spawn')) throw error
263 const ran = await $.process.run(['node', script], { stdin: input, timeoutMs: 600_000 })
264 stdout = ran.stdout
265 stderr = ran.stderr
266 code = ran.exitCode
267 }
268 const line = stdout.trim().split('\n').at(-1) ?? ''
269 try {
270 return JSON.parse(line) as JobReply
271 } catch {
272 return {
273 ok: false,
274 error: `runner gave no result (exit ${code}): ${stderr.slice(-300)}`,
275 }
276 }
277 } catch (error) {
278 return { ok: false, error: messageOf(error) }
279 }
280}
281
282const setFlash = async ($: EngineInterface, text: string): Promise<void> => {
283 await update($, flashAtom, () => text)
284}
285
286const setDraftField = async (
287 $: EngineInterface,
288 field: keyof Draft,
289 value: string,
290): Promise<void> => {
291 await update($, draftAtom, d => ({ ...d, [field]: value }))
292}
293
294const applyResult = async (
295 $: EngineInterface,
296 result: Result,
297 ok: string,
298 clear: Partial<Draft>,
299): Promise<void> => {
300 if (result.error !== undefined) {
301 await setFlash($, result.error)
302
303 return
304 }
305 await saveConfig($, result.config)
306 await update($, draftAtom, d => ({ ...d, ...clear }))
307 await setFlash($, ok)
308}
309
310const EFFORT_CHOICES = ['default', 'low', 'medium', 'high', 'xhigh', 'max', 'off'] as const
311let delArmed: string | undefined
312let cpaProviders: CpaProvider[] = []
313let cpaAccounts: CpaAccount[] = []
314let paneLoaded = false
315let paneTab = 'slots'
316let editId: string | undefined
317let editProtocol = 'chat'
318let fetched: string[] = []
319let selected: string[] = []
320let modelPage = 0
321let providerPage = 0
322let addedPage = 0
323let loginUrl = ''
324let loginState = ''
325let loginNext = 0
326let loginUntil = 0
327let loginPending = false
328const redraw = async ($: EngineInterface): Promise<void> => { await update($, versionAtom, n => n + 1) }
329const paneAction = async ($: EngineInterface, fn: () => Promise<void>): Promise<void> => {
330 try { await fn() } catch { await setFlash($, 'CPA 操作失败,请检查供应商设置或本机服务后重试') }
331 await redraw($)
332}
333const refreshProviders = async ($: EngineInterface): Promise<void> => {
334 const result = await admin($, 'providers')
335 cpaProviders = result.providers || []
336 const config = await loadConfig($)
337 const providers = cpaProviders.map(p => ({ id: p.id, name: config.providers.find(x => x.id === p.id)?.name || p.name }))
338 await saveConfig($, normalize({ ...config, providers }))
339 paneLoaded = true
340}
341const refreshAccounts = async ($: EngineInterface): Promise<void> => { cpaAccounts = (await admin($, 'gpt-accounts')).accounts || [] }
342const editProvider = async ($: EngineInterface, p?: CpaProvider): Promise<void> => {
343 editId = p?.id || ''; editProtocol = p?.protocol || 'chat'; fetched = []; selected = []; modelPage = 0; addedPage = 0
344 const c = await loadConfig($)
345 await update($, draftAtom, d => ({ ...d, pName: c.providers.find(x => x.id === p?.id)?.name || p?.name || '', pUrl: p?.baseUrl || '', pKey: '' }))
346 await redraw($)
347}
348const saveProvider = async ($: EngineInterface, models?: string[]): Promise<void> => {
349 const d = await read($, draftAtom)
350 const existing = cpaProviders.find(p => p.id === editId)
351 const id = editId || idOf(d.pName, cpaProviders.map(p => p.id))
352 if (!d.pName.trim()) { await setFlash($, '名称不能为空'); return }
353 await admin($, 'save', { id, name: d.pName, protocol: editProtocol, baseUrl: d.pUrl, ...(d.pKey.trim() ? { key: d.pKey.trim() } : {}), models: models || existing?.models || [] })
354 const config = await loadConfig($)
355 await saveConfig($, { ...config, providers: [...config.providers.filter(p => p.id !== id), { id, name: d.pName.trim() }] })
356 editId = id
357 await update($, draftAtom, d => ({ ...d, pKey: '' }))
358 await refreshProviders($)
359 await setFlash($, '供应商已保存到 CPA')
360}
361const startLogin = async ($: EngineInterface): Promise<void> => {
362 if (loginState) await admin($, 'gpt-login-cancel', { state: loginState })
363 const r = await admin($, 'gpt-login-start')
364 loginUrl = r.url || ''; loginState = r.state || ''
365 if (!/^https:\/\//.test(loginUrl) || !loginState) throw new Error('登录接口未返回 URL / state')
366 loginNext = 0; loginUntil = Date.now() + 300_000
367 try { await $.process.run(['cmd', '/c', 'start', '', loginUrl]) } catch { /* link remains visible */ }
368}
369// Called by the existing session-scoped clock, never a short-lived button handler's clock.
370const pollLogin = async ($: EngineInterface): Promise<void> => {
371 if (!loginState || loginPending || Date.now() < loginNext) return
372 loginPending = true
373 const state = loginState
374 try {
375 const status = await admin($, 'gpt-login-status', { state })
376 if (state !== loginState) return
377 if (status.status === 'ok') {
378 loginState = ''; loginUrl = ''
379 await refreshAccounts($); await refreshQuota($, true); await setFlash($, 'Codex 登录成功')
380 } else if (status.status === 'error' || Date.now() > loginUntil) {
381 await admin($, 'gpt-login-cancel', { state }); loginState = ''; await setFlash($, '登录失败或超时,请重试')
382 }
383 } catch { if (state === loginState) { loginState = ''; await setFlash($, '登录轮询失败,请刷新后重试') } }
384 finally { loginPending = false; loginNext = Date.now() + 2000; await redraw($) }
385}
386
387const effortAction = async ($: EngineInterface, tier: Tier, effort: string): Promise<void> => {
388 const config = await loadConfig($)
389 await applyResult(
390 $,
391 setSlot(config, tier, { provider: '', modelId: '', effort }),
392 `${tier} 推理强度:${effort === 'default' ? '默认' : effort}`,
393 {},
394 )
395}
396
397const removeProviderAction = async ($: EngineInterface, id: string, name: string): Promise<void> => {
398 if (delArmed !== id) {
399 delArmed = id
400 await setFlash($, `再点一次「确认删除?」删掉「${name}」,用到它的模型位会被清空`)
401
402 return
403 }
404 delArmed = undefined
405 await admin($, 'delete', { id })
406 await refreshProviders($)
407 await changeAction($, c => removeProvider(c, id), `已删除供应商「${name}」,用到它的模型位已清空`)
408}
409
410const changeAction = async (
411 $: EngineInterface,
412 make: (config: Config) => Result,
413 ok: string,
414): Promise<void> => {
415 await applyResult($, make(await loadConfig($)), ok, {})
416}
417
418const testAction = async ($: EngineInterface, target: Agent): Promise<void> => {
419 const job: Record<string, unknown> = {
420 task: 'Reply with the single word OK and call no tools.',
421 mode: 'read',
422 maxTurns: 3,
423 timeoutMs: 90_000,
424 }
425 if (target === 'gpt') {
426 Object.assign(job, { gpt: true, model: GPT_DEFAULT_MODEL })
427 } else {
428 const resolved = resolveTier(await loadConfig($), target)
429 if ('error' in resolved) {
430 await setFlash($, resolved.error)
431
432 return
433 }
434 Object.assign(job, {
435 model: resolved.modelId,
436 effort: resolved.effort,
437 })
438 }
439 await setFlash($, `测试 ${target} 中…`)
440 const reply = await runJob($, job)
441 await setFlash(
442 $,
443 reply.ok
444 ? `测试 ${target} 通过:${(reply.text ?? '').slice(0, 60)}(${Math.round((reply.ms ?? 0) / 1000)} 秒)`
445 : `测试 ${target} 失败:${reply.error ?? reply.text ?? '未知原因'}`,
446 )
447}
448
449type LegacyNote = Record<string, unknown>
450
451
452const loadLegacyJobs = async ($: EngineInterface): Promise<Record<string, LegacyNote>> => {
453 try {
454 const raw = await $.store.get(JOBS_KEY)
455
456 return typeof raw === 'object' && raw !== null ? (raw as Record<string, LegacyNote>) : {}
457 } catch {
458 return {}
459 }
460}
461
462// 旧版整表照写(老版本和外部脚本可能还在读它),失败不算数。
463const mirrorLegacy = async ($: EngineInterface, rec: JobRecord): Promise<void> => {
464 try {
465 const table = await loadLegacyJobs($)
466 // 旧表的键一直是 agent id(resume 就是拿它查的),没有 id 才退回作业 id。
467 table[rec.sessionId ?? rec.jobId] = {
468 jobId: rec.jobId,
469 tier: rec.tier,
470 cwd: rec.cwd,
471 mode: rec.mode,
472 ptc: rec.ptc,
473 at: rec.at,
474 status: rec.status,
475 model: rec.model,
476 background: rec.background,
477 summary: rec.summary,
478 sessionId: rec.sessionId,
479 ...(rec.endedAt === undefined ? {} : { endedAt: rec.endedAt }),
480 ...(rec.notified === true ? { notified: true } : {}),
481 }
482 const kept = Object.entries(table)
483 .sort((a, b) => Number(b[1].at ?? 0) - Number(a[1].at ?? 0))
484 .slice(0, MAX_JOBS)
485 await $.store.set(JOBS_KEY, Object.fromEntries(kept))
486 } catch {}
487}
488
489/** 一条作业一个 key:两个会话同时派活也不会互相覆盖。 */
490const saveJob = async ($: EngineInterface, rec: JobRecord): Promise<void> => {
491 try {
492 await $.store.set(jobKey(rec.jobId), rec)
493 } catch (error) {
494 $.ui.log(`delegate: 作业 ${rec.jobId} 没写进 store:${messageOf(error)}`, { to: 'debug' })
495 }
496 if (rec.sessionId !== null && rec.sessionId !== '') {
497 try {
498 await $.store.set(`${AGENT_PREFIX}${rec.sessionId}`, rec.jobId)
499 } catch {}
500 }
501 await mirrorLegacy($, rec)
502 await pruneJobs($)
503}
504
505const loadJob = async ($: EngineInterface, id: string): Promise<JobRecord | undefined> => {
506 try {
507 const rec = normalizeRecord(await $.store.get(jobKey(id)), id)
508 if (rec !== undefined) return rec
509 } catch {}
510 // resume 传的是 agent id:顺着索引找回那条作业。
511 try {
512 const jobId = await $.store.get(`${AGENT_PREFIX}${id}`)
513 if (typeof jobId === 'string' && jobId !== '') {
514 const rec = normalizeRecord(await $.store.get(jobKey(jobId)), jobId)
515 if (rec !== undefined) return rec
516 }
517 } catch {}
518 const note = (await loadLegacyJobs($))[id]
519
520 return note === undefined ? undefined : recordFromNote(id, note)
521}
522
523const listJobs = async ($: EngineInterface): Promise<JobRecord[]> => {
524 const found = new Map<string, JobRecord>()
525 let keys: string[] = []
526 try {
527 keys = await $.store.keys()
528 } catch {
529 keys = []
530 }
531 for (const key of keys.filter(isJobKey)) {
532 const id = key.slice(JOB_PREFIX.length)
533 try {
534 const rec = normalizeRecord(await $.store.get(key), id)
535 if (rec !== undefined) found.set(rec.jobId, rec)
536 } catch {}
537 }
538 const known = new Set<string>()
539 for (const rec of found.values()) {
540 known.add(rec.jobId)
541 if (rec.sessionId !== null) known.add(rec.sessionId)
542 }
543 for (const [id, note] of Object.entries(await loadLegacyJobs($))) {
544 if (known.has(id)) continue
545 // 同一条作业在旧表里可能有两条键(计划里的 id 和跑完后的 agent id),只认一条。
546 if (typeof note.jobId === 'string' && known.has(note.jobId)) continue
547 const rec = recordFromNote(id, note)
548 if (rec !== undefined) found.set(rec.jobId, rec)
549 }
550
551 return [...found.values()].sort((a, b) => b.at - a.at)
552}
553
554/** 按开始时间留最近 MAX_JOBS 条;多出 10 条以上才动手,免得每次写都扫全表。 */
555const pruneJobs = async ($: EngineInterface): Promise<void> => {
556 let keys: string[] = []
557 try {
558 keys = (await $.store.keys()).filter(isJobKey)
559 } catch {
560 return
561 }
562 if (keys.length <= MAX_JOBS + 10) return
563 const records: JobRecord[] = []
564 for (const key of keys) {
565 try {
566 const rec = normalizeRecord(await $.store.get(key), key.slice(JOB_PREFIX.length))
567 if (rec !== undefined) records.push(rec)
568 } catch {}
569 }
570 records.sort((a, b) => b.at - a.at)
571 for (const rec of records.slice(MAX_JOBS)) {
572 try {
573 await $.store.delete(jobKey(rec.jobId))
574 if (rec.sessionId !== null && rec.sessionId !== '') await $.store.delete(`${AGENT_PREFIX}${rec.sessionId}`)
575 } catch {}
576 }
577}
578
579const homeOf = async ($: EngineInterface): Promise<string | undefined> => {
580 try {
581 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
582 } catch {
583 return undefined
584 }
585}
586
587const rootOf = async ($: EngineInterface): Promise<string | undefined> => jobsRootOf(await homeOf($))
588
589// 第一条消息之前 $.session.id() 可能迟迟不返回;按钮栏的绘制等它就会整条不出来,所以限时 300ms。
590// 每次都重新问:模块级的 id 缓存会在桌面端多会话共用进程时一直返回上一个会话的 id(owner 全串)。
591// 宿主没有 setTimeout(hooks 模块只有 $.clock),限时用 $.clock.after 做。
592const SESSION_ID_WAIT_MS = 300
593// 宿主不许把 $(或 $.session)当值传、也不许读,所以做不到「按 $ 实例」的 WeakMap。
594// 折中:模块级只记「上一次成功拿到的 id」,且只在本次 300ms 超时时兜底;拿到新 id 立刻覆盖。
595// 代价:桌面端多会话共用一个进程时,超时窗口内可能返回另一个会话的 id(最多 300ms,且下一次调用就纠正)。
596const SESSION_FALLBACK_MS = 5_000
597let lastSessionId: { id: string; at: number } | undefined
598const sessionOf = async ($: EngineInterface): Promise<string | undefined> => {
599 const id = await new Promise<string | undefined>(resolve => {
600 let settled = false
601 let timer: { cancel: () => void } | undefined
602 const settle = (value: string | undefined): void => {
603 if (settled) return
604 settled = true
605 try {
606 timer?.cancel()
607 } catch {}
608 resolve(value)
609 }
610 try {
611 timer = $.clock.after(SESSION_ID_WAIT_MS, () => settle(undefined))
612 } catch {
613 settle(undefined)
614 }
615 void Promise.resolve()
616 .then(() => $.session.id())
617 .then(
618 value => settle(typeof value === 'string' && value !== '' ? value : undefined),
619 () => settle(undefined),
620 )
621 })
622 if (id !== undefined) {
623 lastSessionId = { id, at: Date.now() }
624
625 return id
626 }
627
628 // 超时兜底:只用刚刚拿到的值,太旧就当拿不到(宁可当作「无会话」,也不把别的会话的 id 当成自己的)。
629 const last = lastSessionId
630
631 return last !== undefined && Date.now() - last.at < SESSION_FALLBACK_MS ? last.id : undefined
632}
633
634/** 属于本会话的作业:旧记录没有 owner,只在「全部会话」里出现。 */
635const mine = (r: JobRecord, me: string | undefined): boolean => me !== undefined && r.owner === me
636
637// 面板的「看全部会话」开关按会话存。StateAddress 只有 plugin/key/id,liveAll 不是 StateFamily,
638// 类型上不许填 id,所以会话维度放在 store 的键里:liveAll:<sessionId>。
639// 共用 atom 只当重绘信号:别的会话在里面写的值不会被当成自己的。
640const liveAllKey = (sessionId: string): string => `liveAll:${sessionId}`
641const readLiveAll = async ($: EngineInterface): Promise<boolean> => {
642 await read($, liveAllAtom) // 只为订阅重绘,值不用
643 const me = await sessionOf($)
644 // 拿不到会话 id 就按「只看本会话」:不读共用槽,免得显示别的会话的选择。
645 if (me === undefined) return false
646 try {
647 return (await $.store.get(liveAllKey(me))) === true
648 } catch {
649 return false
650 }
651}
652const writeLiveAll = async ($: EngineInterface, isAll: boolean): Promise<void> => {
653 const me = await sessionOf($)
654 if (me !== undefined) {
655 try {
656 await $.store.set(liveAllKey(me), isAll)
657 } catch {}
658 }
659 // 只当重绘信号:每次都翻转,保证值一定变化(别的会话写过同样的值也会触发重绘)。
660 await update($, liveAllAtom, v => !v)
661}
662
663const jobDir = (root: string, id: string): string => `${root}/${id}`
664
665// runner 写的 status.json 不是原子写,读到一半就重读一次,还是不行就当没有。
666const readStatus = async ($: EngineInterface, dir: string): Promise<JobStatusFile | undefined> => {
667 try {
668 return normalizeStatus(JSON.parse(await $.fs.read(`${dir}/status.json`)))
669 } catch {
670 return undefined
671 }
672}
673
674const readResult = async ($: EngineInterface, dir: string): Promise<JobReply | undefined> => {
675 try {
676 const raw = JSON.parse(await $.fs.read(`${dir}/result.json`)) as unknown
677
678 return typeof raw === 'object' && raw !== null ? (raw as JobReply) : undefined
679 } catch {
680 return undefined
681 }
682}
683
684/** 事件日志只取尾巴几条,别把整个文件塞进上下文。 */
685const tailEvents = async ($: EngineInterface, dir: string, count: number): Promise<string[]> => {
686 let text = ''
687 try {
688 text = await $.fs.read(`${dir}/events.jsonl`)
689 } catch {
690 return []
691 }
692
693 return text
694 .split('\n')
695 .filter(line => line.trim() !== '')
696 .slice(-count)
697 .map(line => eventLine(line))
698}
699
700// 按 pid 问一句,不按进程名或命令行搜(那会杀到别人的 claude)。
701const isAlive = async ($: EngineInterface, pid: number): Promise<boolean> => {
702 try {
703 const run = await $.process.run(['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
704 timeoutMs: 10_000,
705 })
706
707 return new RegExp(`"${pid}"`).test(run.stdout)
708 } catch {
709 return false
710 }
711}
712
713const sessionCwd = async ($: EngineInterface): Promise<string | undefined> => {
714 try {
715 const cwd = await $.session.cwd()
716
717 return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
718 } catch {
719 return undefined
720 }
721}
722
723// 测试环境可能没有真时钟;真机上是 $.clock.sleep。
724const nap = async ($: EngineInterface, ms: number): Promise<void> => {
725 try {
726 await $.clock.sleep(ms)
727 } catch {
728 await new Promise(resolve => setTimeout(resolve, ms))
729 }
730}
731
732let jobSeq = 0
733const nextJobId = (background: boolean): string => {
734 jobSeq = (jobSeq + 1) % 1296
735
736 return newJobId(background, Date.now(), jobSeq)
737}
738
739// Checked before the runner starts: spawn's own failure for a bad cwd says nothing useful.
740const cwdProblem = async (
741 $: EngineInterface,
742 cwd: string | undefined,
743): Promise<string | undefined> => {
744 if (cwd === undefined || cwd === '') return undefined
745 const stat = await $.fs.stat(cwd).catch(() => undefined)
746 if (stat === undefined) return `cwd 不存在:${cwd}`
747 if (stat.kind !== 'dir') return `cwd 不是目录:${cwd}`
748
749 return undefined
750}
751
752const callTool = async (
753 $: EngineInterface,
754 target: Agent,
755 input: Record<string, unknown>,
756): Promise<{ result: string; isError?: true }> => {
757 const task = typeof input.task === 'string' ? input.task.trim() : ''
758 if (task === '') return { result: 'delegate: task 不能为空', isError: true }
759 const resume = typeof input.resume === 'string' ? input.resume.trim() : ''
760 const old = resume === '' ? undefined : await loadJob($, resume)
761 if (resume !== '' && old === undefined) {
762 return {
763 result: `delegate: 没有 ID 为 ${resume} 的代理(delegate_list 能列出最近的作业,包括重启前的)。去掉 resume 新建一个。`,
764 isError: true,
765 }
766 }
767 const given = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : undefined
768 let cwd = given ?? old?.cwd
769 let cwdNote = ''
770 const badCwd = await cwdProblem($, cwd)
771 if (badCwd !== undefined) {
772 // 显式传的目录错了就报错;沿用旧作业的目录没了,退回本会话的目录(CLI 按 ID 全机找 transcript)。
773 if (given !== undefined) return { result: `delegate: ${badCwd}`, isError: true }
774 const fallback = await sessionCwd($)
775 if (fallback === undefined) return { result: `delegate: ${badCwd}`, isError: true }
776 cwd = fallback
777 cwdNote = `(原目录 ${old?.cwd ?? '?'} 已不存在,改用 ${fallback})`
778 }
779 const config = await loadConfig($)
780 // gpt has no slot: this call picks the model (default gpt-6.1-sol) and the runner gets the endpoint from CPA.
781 // resume 没传 model 时沿用原作业的 GPT 模型,免得 Astra 续用时悄悄换成默认的 Sol
782 const inheritedModel =
783 input.model === undefined && old?.tier === 'gpt' && typeof gptModelOf(old.model) === 'string' ? old.model : input.model
784 const gptModel = target === 'gpt' ? gptModelOf(inheritedModel) : undefined
785 if (gptModel !== undefined && typeof gptModel !== 'string') {
786 return { result: `delegate: ${gptModel.error}`, isError: true }
787 }
788 const resolved = target === 'gpt' ? undefined : resolveTier(config, target)
789 if (resolved !== undefined && 'error' in resolved) {
790 return { result: `delegate: ${resolved.error}`, isError: true }
791 }
792 // A resume keeps what that agent last ran as, unless this call says otherwise.
793 const mode: Mode =
794 input.mode === 'read' || input.mode === 'work' ? input.mode : (old?.mode ?? 'read')
795 const ptc: Ptc =
796 input.ptc === 'only' || input.ptc === 'off' ? input.ptc : (old?.ptc ?? 'only')
797 const background = input.background === true
798 const label =
799 target === 'gpt'
800 ? gptLabel(gptModel as string, { background, work: mode === 'work' })
801 : callLabel(config, target, { background, work: mode === 'work' })
802 const effort =
803 target === 'gpt'
804 ? // GPT 最低 medium:不填或传 low 都按 medium 跑
805 input.effort === 'high' || input.effort === 'xhigh' || input.effort === 'max'
806 ? input.effort
807 : input.effort === undefined && (old?.effort === 'high' || old?.effort === 'xhigh' || old?.effort === 'max')
808 ? old.effort // resume 没传 effort:沿用原作业的档位
809 : 'medium'
810 : isEffort(input.effort)
811 ? input.effort
812 : input.effort === undefined && isEffort(old?.effort)
813 ? old?.effort
814 : undefined
815 const recordedCwd = cwd ?? (await sessionCwd($)) ?? ''
816 const common = {
817 task,
818 mode,
819 ...(mode === 'read' ? { ptc: ptc === 'off' ? false : 'only' } : {}),
820 cwd: cwd ?? (recordedCwd === '' ? undefined : recordedCwd),
821 maxTurns: typeof input.maxTurns === 'number' ? input.maxTurns : undefined,
822 resume: resume === '' ? undefined : resume,
823 }
824 // gpt carries no url/key: the runner asks ensure_cpa.py for them (and starts CPA if it is down).
825 const job =
826 resolved === undefined
827 ? { ...common, gpt: true, model: gptModel, effort }
828 : { ...common, model: resolved.modelId, effort: effort ?? resolved.effort }
829 const modelId = resolved === undefined ? String(gptModel) : resolved.modelId
830 // 开跑前就记下来(status=running),宿主重启后也还能按 agent ID 找回这个代理。
831 // 新建任务给一个 UUID 让 runner 用 --session-id 传下去;resume 不传,沿用旧 id。
832 const at = Date.now()
833 const jobId = nextJobId(background)
834 const sessionId = resume === '' ? uuid() : undefined
835 const record: JobRecord = {
836 jobId,
837 sessionId: sessionId ?? resume,
838 tier: target,
839 model: modelId,
840 ...(typeof job.effort === 'string' ? { effort: job.effort } : {}),
841 cwd: recordedCwd,
842 mode,
843 ptc,
844 background,
845 status: 'running',
846 at,
847 summary: summarize(task),
848 }
849 const owner = await sessionOf($)
850 if (owner !== undefined) record.owner = owner
851 await saveJob($, record)
852 await showLive($, jobId)
853 const wire: Record<string, unknown> = {
854 ...job,
855 jobId,
856 tier: target,
857 // 流式输入:任务作为第一条 user 消息写进去,stdin 保持打开,才能中途插话。
858 stream: true,
859 ...(sessionId === undefined ? {} : { sessionId }),
860 }
861 if (background) {
862 void runBackground($, record, wire)
863
864 return {
865 result: `[delegate ${target} · ${mode} · 后台任务 ${jobId}] 已启动(agent ${record.sessionId ?? '?'})。完成后会自动通知你(结果和 agent ID 都在通知里)。期间继续做别的,不要轮询,也不要为同一件事再派一个;要看进度用 delegate_status("${jobId}"),要停用 delegate_cancel("${jobId}"),要补充要求用 delegate_send("${jobId}", "…")。${cwdNote}`,
866 }
867 }
868
869 running += 1
870 $.ui.status(`◆ 委派 ${label} · 运行中`)
871 try {
872 return await settle($, record, await runJob($, wire))
873 } finally {
874 running -= 1
875 if (running === 0) $.ui.status(undefined)
876 }
877}
878
879const settle = async (
880 $: EngineInterface,
881 record: JobRecord,
882 reply: JobReply,
883): Promise<{ result: string; isError?: true }> => {
884 // 成功分支的 sessionId 可能是流里收集的(runner 会回退到 sid),所以以回复为准,其次才用计划里的 id。
885 const sessionId =
886 typeof reply.sessionId === 'string' && reply.sessionId !== '' ? reply.sessionId : (record.sessionId ?? '')
887 record.sessionId = sessionId === '' ? null : sessionId
888 record.status = reply.ok ? 'done' : reply.error === 'cancelled' ? 'cancelled' : 'failed'
889 record.endedAt = Date.now()
890 if (typeof reply.turns === 'number') record.turns = reply.turns
891 if (!reply.ok) record.error = (reply.error ?? reply.text ?? '').slice(0, 300)
892 await saveJob($, record)
893 const id = record.sessionId ?? ''
894 const head = `[delegate ${record.tier} · ${record.mode} · agent ${id === '' ? '?' : id} · ${reply.turns ?? '?'} 轮 · ${Math.round((reply.ms ?? 0) / 1000)} 秒 · ${record.jobId}]`
895 const note = runnerNote(reply)
896 const tail = note === '' ? '' : `\n${note}`
897 if (!reply.ok) {
898 return { result: `${head}\n失败:${reply.error ?? reply.text ?? '未知原因'}${tail}`, isError: true }
899 }
900 const text = reply.text ?? ''
901
902 return {
903 result: `${head}\n${text.length > MAX_REPLY ? `${text.slice(0, MAX_REPLY)}\n…(已截断)` : text}${tail}`,
904 }
905}
906
907// A job started with background: true: it runs on, and says so through a prompt when it ends.
908const runBackground = async (
909 $: EngineInterface,
910 record: JobRecord,
911 job: Record<string, unknown>,
912): Promise<void> => {
913 try {
914 const done = await settle($, record, await runJob($, job))
915 let where = ''
916 try {
917 const home = await homeOf($)
918 if (home !== undefined) {
919 const file = `${home.replace(/\\/g, '/')}/.claude/delegate/results/${record.jobId}.md`
920 await $.fs.write(file, done.result)
921 where = `\n完整结果:${file}`
922 }
923 } catch {
924 where = ''
925 }
926 const body =
927 done.result.length > BG_INLINE
928 ? `${done.result.slice(0, BG_INLINE)}\n…(已截断)`
929 : done.result
930 await $.prompt.submit({
931 text: `【delegate 后台任务 ${record.jobId} ${done.isError === true ? '失败' : '完成'}】\n${body}${where}`,
932 })
933 // 通知发出去才算数:没发出去的,session.start 会在重启后补发一次。
934 record.notified = true
935 await saveJob($, record)
936 } catch (error) {
937 $.ui.log(`delegate: background job ${record.jobId} failed: ${messageOf(error)}`, { to: 'debug' })
938 }
939}
940
941// ---- 作业管理:列出、看进度、取消、插话 -------------------------------------
942
943const jobTools = (): { name: string; description: string; inputSchema: Record<string, unknown> }[] => [
944 {
945 name: 'delegate_list',
946 description:
947 '列出最近的 delegate 作业(包括 Claude Code 重启之前的):作业 id、agent/session id、档位与模型、状态、cwd、开始/结束时间、任务首行摘要。重启后想接着用之前的子代理,先用它找到 agent id,再传给 delegate_flash / delegate_pro / delegate_gpt 的 resume。',
948 inputSchema: {
949 type: 'object',
950 properties: {
951 status: {
952 type: 'string',
953 enum: [...STATUSES, 'all'],
954 description:
955 '只看某个状态:running 运行中 / done 完成 / failed 失败 / cancelled 已取消 / orphaned 宿主重启后中断(可 resume);不填就是全部',
956 },
957 limit: { type: 'number', description: '最多列几条,默认 20' },
958 },
959 },
960 },
961 {
962 name: 'delegate_status',
963 description:
964 '看一条作业现在在做什么:状态、轮数、最后用的工具、最后输出、最近的流事件。运行中的作业每处理一条事件就更新一次状态文件,所以随时问都能看到它做到哪了。',
965 inputSchema: {
966 type: 'object',
967 properties: { id: { type: 'string', description: '作业 id(delegate_list 里的那个,如 bg-xxx)' } },
968 required: ['id'],
969 },
970 },
971 {
972 name: 'delegate_cancel',
973 description:
974 '取消一条还在运行的作业:runner 看到标记就杀掉子进程树(含本地代理),作业以 ok:false / error:cancelled 结束并留下 agent id,之后可以用那个 id resume 接着做。只按这条作业自己记录的 pid 杀进程。',
975 inputSchema: {
976 type: 'object',
977 properties: { id: { type: 'string', description: '作业 id' } },
978 required: ['id'],
979 },
980 },
981 {
982 name: 'delegate_send',
983 description:
984 '给正在跑的作业追加一条消息:不打断正在跑的工具,当前轮结束后它会把这条当作新一轮接着做。用来补要求、纠偏、让它顺带检查别的东西。已经结束的作业不能插话,改用 resume 把要求写进 task。',
985 inputSchema: {
986 type: 'object',
987 properties: {
988 id: { type: 'string', description: '作业 id' },
989 message: { type: 'string', description: '要它知道的话(自包含,它看不到你的对话)' },
990 },
991 required: ['id', 'message'],
992 },
993 },
994]
995
996const describeJobTools = async ($: EngineInterface): Promise<void> => {
997 for (const tool of jobTools()) {
998 try {
999 await $.tool.register(tool)
1000 } catch (error) {
1001 $.ui.log(`delegate: tool ${tool.name} not registered: ${messageOf(error)}`, { to: 'debug' })
1002 }
1003 }
1004}
1005
1006const idOfInput = (input: Record<string, unknown>): string =>
1007 typeof input.id === 'string' ? input.id.trim() : ''
1008
1009const noSuchJob = (id: string): { result: string; isError: true } => ({
1010 result: `delegate: 没有作业 ${id}(delegate_list 看有哪些)。`,
1011 isError: true,
1012})
1013
1014const listAction = async (
1015 $: EngineInterface,
1016 input: Record<string, unknown>,
1017): Promise<{ result: string; isError?: true }> => {
1018 const filter = typeof input.status === 'string' ? input.status.trim() : ''
1019 if (filter !== '' && filter !== 'all' && !isStatus(filter)) {
1020 return { result: `delegate_list: status 只能是 ${STATUSES.join(' / ')} 或 all`, isError: true }
1021 }
1022 const limit = typeof input.limit === 'number' ? Math.min(Math.max(Math.trunc(input.limit), 1), 50) : 20
1023 const all = await listJobs($)
1024 const root = await rootOf($)
1025 const live: Record<string, JobStatusFile> = {}
1026 for (const rec of all) {
1027 if (rec.status !== 'running' || root === undefined) continue
1028 const status = await readStatus($, jobDir(root, rec.jobId))
1029 if (status !== undefined) live[rec.jobId] = status
1030 }
1031 const shown = filter === '' || filter === 'all' ? all : all.filter(rec => rec.status === filter)
1032
1033 return { result: formatList(shown, { now: Date.now(), limit, filter: filter === 'all' ? '' : filter, live }) }
1034}
1035
1036const statusAction = async (
1037 $: EngineInterface,
1038 input: Record<string, unknown>,
1039): Promise<{ result: string; isError?: true }> => {
1040 const id = idOfInput(input)
1041 if (id === '') return { result: 'delegate_status 需要 id(delegate_list 里那个作业 id)', isError: true }
1042 const rec = await loadJob($, id)
1043 const root = await rootOf($)
1044 const dir = root === undefined ? undefined : jobDir(root, id)
1045 const status = dir === undefined ? undefined : await readStatus($, dir)
1046 if (rec === undefined && status === undefined) return noSuchJob(id)
1047 const result = dir === undefined ? undefined : await readResult($, dir)
1048 const events = dir === undefined ? [] : await tailEvents($, dir, 8)
1049
1050 return { result: formatStatus(rec, status, events, result, Date.now()) }
1051}
1052
1053const cancelAction = async (
1054 $: EngineInterface,
1055 input: Record<string, unknown>,
1056): Promise<{ result: string; isError?: true }> => {
1057 const id = idOfInput(input)
1058 if (id === '') return { result: 'delegate_cancel 需要 id(delegate_list 里那个作业 id)', isError: true }
1059 const rec = await loadJob($, id)
1060 if (rec === undefined) return noSuchJob(id)
1061 if (rec.status !== 'running') {
1062 const hint = rec.sessionId === null ? '' : `;要接着做就用 resume ${rec.sessionId}`
1063
1064 return { result: `delegate: ${id} 已经结束了(${statusText(rec.status)})${hint}`, isError: true }
1065 }
1066 const root = await rootOf($)
1067 if (root === undefined) return { result: 'delegate: 找不到家目录,没法取消', isError: true }
1068 const dir = jobDir(root, id)
1069 try {
1070 await $.fs.write(`${dir}/cancel`, JSON.stringify({ at: Date.now(), by: 'delegate_cancel' }))
1071 } catch (error) {
1072 return { result: `delegate: 写不了取消标记:${messageOf(error)}`, isError: true }
1073 }
1074 // runner 每 0.5 秒看一次取消标记;等它自己收尾,等不到再按 status.json 的 pid 兜底。
1075 let status = await readStatus($, dir)
1076 const end = Date.now() + 4000
1077 while (Date.now() < end) {
1078 if (status !== undefined && (status.phase === 'cancelled' || status.phase === 'done' || status.phase === 'failed')) break
1079 if (status?.pid !== undefined && !(await isAlive($, status.pid))) break
1080 await nap($, 400)
1081 status = await readStatus($, dir)
1082 }
1083 const stillAlive = status?.pid !== undefined && (await isAlive($, status.pid))
1084 let forced = false
1085 if (stillAlive && pidMatches(status, rec) && status?.pid !== undefined) {
1086 // 兜底:只杀 status.json 里记的 pid,且开始时间对得上才算这条作业的进程。
1087 try {
1088 await $.process.run(['taskkill', '/PID', String(status.pid), '/T', '/F'], { timeoutMs: 20_000 })
1089 forced = true
1090 } catch {}
1091 }
1092 const sessionId = status?.sessionId ?? rec.sessionId
1093 if (forced || !stillAlive) {
1094 rec.status = 'cancelled'
1095 rec.endedAt = Date.now()
1096 rec.error = 'cancelled'
1097 await saveJob($, rec)
1098 }
1099 const how = forced ? '(runner 没来得及收尾,已按它记录的 pid 强制结束)' : stillAlive ? '(取消标记已写入,但进程还在,pid 校验不符所以没有强杀)' : ''
1100 const next =
1101 sessionId === null
1102 ? '它没留下 agent id,只能重新派。'
1103 : `agent ${sessionId} 还在,要接着做就 resume 它(delegate_flash / delegate_pro / delegate_gpt 传 resume: "${sessionId}"),它带着已经做过的内容继续。`
1104
1105 return {
1106 result: `[delegate ${id} 已取消${how}]\n${next}`,
1107 ...(stillAlive && !forced ? { isError: true as const } : {}),
1108 }
1109}
1110
1111const sendAction = async (
1112 $: EngineInterface,
1113 input: Record<string, unknown>,
1114): Promise<{ result: string; isError?: true }> => {
1115 const id = idOfInput(input)
1116 const message = typeof input.message === 'string' ? input.message.trim() : ''
1117 if (id === '') return { result: 'delegate_send 需要 id(delegate_list 里那个作业 id)', isError: true }
1118 if (message === '') return { result: 'delegate_send: message 不能为空', isError: true }
1119 const rec = await loadJob($, id)
1120 if (rec === undefined) return noSuchJob(id)
1121 if (rec.status !== 'running') {
1122 const hint = rec.sessionId === null ? '' : `;要接着做就用 resume ${rec.sessionId},把要求写进 task`
1123
1124 return { result: `delegate: ${id} 已经结束了(${statusText(rec.status)}),插不了话${hint}`, isError: true }
1125 }
1126 const root = await rootOf($)
1127 if (root === undefined) return { result: 'delegate: 找不到家目录,没法送消息', isError: true }
1128 const dir = jobDir(root, id)
1129 const status = await readStatus($, dir)
1130 if (status !== undefined && status.stream !== true) {
1131 return {
1132 result: 'delegate: 这个作业不是流式输入启动的(旧版本),插不了话;用 delegate_cancel 结束它再 resume 把要求写进 task。',
1133 isError: true,
1134 }
1135 }
1136 const file = `${dir}/inbox/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
1137 try {
1138 await $.fs.write(file, JSON.stringify({ text: message, at: Date.now() }))
1139 } catch (error) {
1140 return { result: `delegate: 写不了消息文件:${messageOf(error)}`, isError: true }
1141 }
1142
1143 return {
1144 result: `[delegate ${id}] 消息已送到 runner,它会在当前轮结束后把这条当作新一轮处理(不打断正在跑的工具)。用 delegate_status("${id}") 看它有没有接上。`,
1145 }
1146}
1147
1148// 重启后补一件事:把“已完成但没通知”的后台作业补发一条,把“还在 running 但进程已经没了”的标成 orphaned。
1149const notifyDone = async ($: EngineInterface, rec: JobRecord): Promise<void> => {
1150 const root = await rootOf($)
1151 const dir = root === undefined ? undefined : jobDir(root, rec.jobId)
1152 const result = dir === undefined ? undefined : await readResult($, dir)
1153 const status = dir === undefined ? undefined : await readStatus($, dir)
1154 const text = result?.text ?? status?.lastText ?? result?.error ?? ''
1155 const agent = result?.sessionId ?? rec.sessionId
1156 const body = text.length > BG_INLINE ? `${text.slice(0, BG_INLINE)}\n…(已截断)` : text
1157 const state = rec.status === 'done' ? '完成' : rec.status === 'cancelled' ? '已取消' : '失败'
1158 const head = `【delegate 后台任务 ${rec.jobId} ${state}(重启后补发)】\n[agent ${agent ?? '?'} · ${rec.turns ?? '?'} 轮 · ${rec.tier} / ${rec.model === '' ? '?' : rec.model} · ${rec.mode}]`
1159 const next = agent === null || agent === undefined ? '' : `\n要接着做就用 resume ${agent}`
1160 await $.prompt.submit({ text: `${head}\n${body}${next}` })
1161}
1162
1163const recoverJobs = async ($: EngineInterface): Promise<void> => {
1164 const records = await listJobs($)
1165 if (records.length === 0) return
1166 const root = await rootOf($)
1167 const me = await sessionOf($)
1168 const pending: JobRecord[] = []
1169 for (const rec of records) {
1170 if (rec.status === 'running') {
1171 const status = root === undefined ? undefined : await readStatus($, jobDir(root, rec.jobId))
1172 const alive = status?.pid === undefined ? false : await isAlive($, status.pid)
1173 if (alive) continue
1174 const result = root === undefined ? undefined : await readResult($, jobDir(root, rec.jobId))
1175 if (result !== undefined) {
1176 // runner 在宿主死后自己跑完了:按它留下的 result.json 收尾,再补一条通知。
1177 rec.status = result.ok ? 'done' : result.error === 'cancelled' ? 'cancelled' : 'failed'
1178 rec.endedAt = status?.endedAt ?? status?.updatedAt ?? Date.now()
1179 if (typeof result.turns === 'number') rec.turns = result.turns
1180 if (typeof result.sessionId === 'string' && result.sessionId !== '') rec.sessionId = result.sessionId
1181 if (!result.ok) rec.error = (result.error ?? '').slice(0, 300)
1182 await saveJob($, rec)
1183 if (rec.background) pending.push(rec)
1184 } else {
1185 // 宿主重启把 runner 带走了(或者它自己崩了):还能 resume,标出来别当成还在跑。
1186 rec.status = 'orphaned'
1187 rec.endedAt = status?.updatedAt ?? status?.endedAt ?? Date.now()
1188 await saveJob($, rec)
1189 }
1190 } else if (
1191 rec.background &&
1192 rec.notified !== true &&
1193 (rec.status === 'done' || rec.status === 'failed' || rec.status === 'cancelled')
1194 ) {
1195 pending.push(rec)
1196 }
1197 }
1198 // 别的会话派的作业不在这里补发:那边恢复时自己会补。
1199 for (const rec of pending.filter(r => r.owner === undefined || r.owner === me).slice(0, 5)) {
1200 try {hooks/lib/config.ts 216 lines1export type Provider = { id: string; name: string }
2export type Tier = 'flash' | 'pro'
3export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'off'
4export type Slot = { providerId: string; modelId: string; effort?: Effort }
5export type Config = {
6 providers: Provider[]
7 slots: Record<Tier, Slot | null>
8}
9
10export const TIERS: readonly Tier[] = ['flash', 'pro']
11
12export const SEED: Config = {
13 providers: [
14 {
15 id: 'command',
16 name: 'Command Code',
17 },
18 ],
19 slots: {
20 flash: { providerId: 'command', modelId: 'deepseek/deepseek-v4.1-flash' },
21 pro: { providerId: 'command', modelId: 'deepseek/deepseek-v4-pro' },
22 },
23}
24
25const isRecord = (v: unknown): v is Record<string, unknown> =>
26 typeof v === 'object' && v !== null && !Array.isArray(v)
27
28const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
29
30export const isEffort = (v: unknown): v is Effort => v === 'low' || v === 'medium' || v === 'xhigh' || v === 'high' || v === 'max' || v === 'off'
31
32const slotOf = (raw: unknown, providers: readonly Provider[]): Slot | null => {
33 if (!isRecord(raw)) return null
34 const providerId = text(raw.providerId)
35 const modelId = text(raw.modelId)
36 if (modelId === '' || !providers.some(p => p.id === providerId)) return null
37
38 return isEffort(raw.effort) ? { providerId, modelId, effort: raw.effort } : { providerId, modelId }
39}
40
41/** Reads whatever the store held back into a valid Config, seeding when empty. */
42export const normalize = (raw: unknown): Config => {
43 if (!isRecord(raw)) return structuredClone(SEED)
44 const providers: Provider[] = []
45 for (const one of Array.isArray(raw.providers) ? raw.providers : []) {
46 if (!isRecord(one)) continue
47 const id = text(one.id)
48 if (id === '' || providers.some(p => p.id === id)) continue
49 providers.push({ id, name: text(one.name) || id })
50 }
51 const slots = isRecord(raw.slots) ? raw.slots : {}
52
53 return {
54 providers,
55 slots: { flash: slotOf(slots.flash, providers), pro: slotOf(slots.pro, providers) },
56 }
57}
58
59export const idOf = (name: string, taken: readonly string[]): string => {
60 const base =
61 name
62 .toLowerCase()
63 .replace(/[^a-z0-9]+/g, '-')
64 .replace(/^-+|-+$/g, '') || 'provider'
65 let id = base
66 for (let n = 2; taken.includes(id); n += 1) id = `${base}-${n}`
67
68 return id
69}
70
71export type Result = { config: Config; error?: string }
72
73export const addProvider = (config: Config, input: { name: string }): Result => {
74 const name = input.name.trim()
75 if (!name) return { config, error: '名称不能为空' }
76 return { config: { ...config, providers: [...config.providers, { id: idOf(name, config.providers.map(p => p.id)), name }] } }
77}
78
79/** Build a migration without mutating the original: the caller persists only after all CPA saves succeed. */
80export const migrationPlan = (raw: unknown): { config: Config; providers: { id: string; name: string; protocol: 'chat'; baseUrl: string; key: string; models: string[] }[] } => {
81 const config = normalize(raw)
82 const providers = []
83 if (isRecord(raw) && Array.isArray(raw.providers)) for (const p of raw.providers) {
84 if (!isRecord(p) || !text(p.url) || !text(p.key) || !config.providers.some(x => x.id === text(p.id))) continue
85 providers.push({ id: text(p.id), name: text(p.name) || text(p.id), protocol: 'chat' as const,
86 baseUrl: text(p.url).replace(/\/chat\/completions\/?$/, ''), key: text(p.key),
87 models: [...new Set(TIERS.map(t => config.slots[t]).filter(s => s?.providerId === text(p.id)).map(s => s!.modelId))] })
88 }
89 return { config, providers }
90}
91
92// A slot that pointed at a removed provider becomes empty: normalize drops it.
93export const removeProvider = (config: Config, providerId: string): Result => ({
94 config: normalize({
95 ...config,
96 providers: config.providers.filter(p => p.id !== providerId),
97 }),
98})
99
100const findProvider = (config: Config, wanted: string): Provider | undefined => {
101 const w = wanted.trim().toLowerCase()
102
103 return config.providers.find(p => p.id === w || p.name.toLowerCase() === w)
104}
105
106/**
107 * Points a tier at a provider and a model id. An empty field keeps what the
108 * slot already has, so changing only the model id (or only the provider) works.
109 */
110export const setSlot = (
111 config: Config,
112 tier: Tier,
113 input: { provider: string; modelId: string; effort?: string },
114): Result => {
115 const current = config.slots[tier]
116 const provider =
117 input.provider.trim() === ''
118 ? config.providers.find(p => p.id === current?.providerId)
119 : findProvider(config, input.provider)
120 const modelId = input.modelId.trim() || current?.modelId || ''
121 if (provider === undefined) {
122 return {
123 config,
124 error:
125 input.provider.trim() === ''
126 ? '这个位置还没有供应商,请填供应商名称'
127 : `没有叫「${input.provider.trim()}」的供应商,先添加供应商`,
128 }
129 }
130 if (modelId === '') return { config, error: '请填模型 ID' }
131 // Empty keeps what the slot has, like the other fields; 'default' clears it.
132 const wanted = input.effort?.trim().toLowerCase() ?? ''
133 if (wanted !== '' && wanted !== 'default' && !isEffort(wanted)) {
134 return { config, error: '推理强度只能是 low、medium、high、xhigh、max、off 或 default(留空不改)' }
135 }
136 const effort = wanted === '' ? current?.effort : wanted === 'default' ? undefined : (wanted as Effort)
137
138 return {
139 config: {
140 ...config,
141 slots: {
142 ...config.slots,
143 [tier]: effort === undefined ? { providerId: provider.id, modelId } : { providerId: provider.id, modelId, effort },
144 },
145 },
146 }
147}
148
149export const clearSlot = (config: Config, tier: Tier): Result => ({
150 config: { ...config, slots: { ...config.slots, [tier]: null } },
151})
152
153export const maskKey = (key: string): string =>
154 key === '' ? '未设置' : `已设置(…${key.slice(-4)})`
155
156export const parseTier = (v: unknown): Tier | undefined =>
157 v === 'flash' || v === 'pro' ? v : undefined
158
159/** The line the person sees for a call: tier, provider / model, how it runs. Never sent to the model. */
160export const callLabel = (
161 config: Config,
162 tier: Tier,
163 flags: { background: boolean; work: boolean },
164): string => {
165 const slot = config.slots[tier]
166 const provider = config.providers.find(p => p.id === slot?.providerId)
167 const where = slot === null || provider === undefined ? '未设置' : `${provider.name} / ${slot.modelId}${slot.effort === undefined ? '' : ` · ${slot.effort}`}`
168
169 return `${tier} · ${where}${flags.background ? ' · 后台' : ' · 前台'}${flags.work ? ' · 可改文件' : ' · 只读'}`
170}
171
172export type Resolved = {
173 tier: Tier
174 modelId: string
175 providerName: string
176 effort?: Effort
177}
178
179export const resolveTier = (
180 config: Config,
181 tier: Tier,
182): Resolved | { error: string } => {
183 const slot = config.slots[tier]
184 if (slot === null) return { error: `${tier} 位还没有设置模型。在 /delegate 面板里设置。` }
185 const provider = config.providers.find(p => p.id === slot.providerId)
186 if (provider === undefined) return { error: `${tier} 位的供应商已被删除` }
187
188 return {
189 tier,
190 modelId: `${provider.id}/${slot.modelId}`,
191 providerName: provider.name,
192 ...(slot.effort === undefined ? {} : { effort: slot.effort }),
193 }
194}
195
196/** What the model reads: tiers, never model names, so swapping a backend needs no re-briefing. */
197export const toolDescription = (config: Config): string => {
198 const ready = TIERS.filter(t => config.slots[t] !== null)
199
200 return [
201 '把一个自包含的任务交给外部模型去做,它在独立进程里有自己的上下文和读文件、搜索(可选改文件、跑命令)的工具,只把最终文本返回给你。不占 Claude 额度。',
202 '两个工具对应两个档位(不是模型名):delegate_flash 与 delegate_pro。',
203 '- flash:快而便宜。检索定位、读代码归纳、批量机械改写、跑固定命令、格式化、整理清单。大多数派发用它。',
204 '- pro:更强更贵。需要更多判断的多文件分析、较复杂的改动、flash 做不好时升级。',
205 '任务说明要自包含:写清目标、允许读写的路径、已知事实、期望的输出格式。它看不到你的对话。',
206 '返回里的 agent ID 可以填进 resume 继续同一个代理(它记得之前读过的内容和 mode,比新建便宜、不用重讲背景);resume 没传 mode 就沿用该代理上次的 mode,显式传则以传入的为准;换任务或上下文已很长时才新建。',
207 'background=true 立即返回,任务做完后会自动通知你(结果在通知里):你手头有别的事可做、任务较久、或要同时派几个互不依赖的包时用;你下一步就依赖它的结果时用默认的前台,别用后台再干等。',
208 '轮数和时长基本不设限(只有 2 小时的防卡死兜底),不用为了怕超时把任务拆碎;预计超过几分钟的任务用 background=true。万一超时,返回里会带 agent ID 和它做到哪:先核对文件,没做完就 resume 续做,不要原样重试。汇报要求它精简:结论、改了哪些文件、测试结果。',
209 'mode=read(默认)只读;mode=work 可改文件和跑命令(它只用一个 run_code 工具,在程序里批量调用 read/edit/bash 等),仅在任务明确需要改动时用。',
210 "read 模式默认也只用一个只读的 run_code(只能读 cwd 内的文件,不能写文件、跑命令),在程序里批量读取、检索后汇总,另外带一个 WebFetch 可以读网页;要读 cwd 以外的文件时传 ptc:'off',改用原生 Read/Grep/Glob/WebFetch。",
211 ready.length === 2 ? '' : `当前已配置的档位:${ready.join('、') || '无(先用 /delegate 设置)'}`,
212 ]
213 .filter(Boolean)
214 .join('\n')
215}
216hooks/lib/gpt.ts 37 lines1/** delegate_gpt: GPT through the local CPA (CLIProxyAPI) proxy, billed to the ChatGPT subscription.
2 * Nothing here touches the flash/pro slots: the runner asks ensure_cpa.py for the endpoint and key. */
3
4export const GPT_MODELS = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-sol'] as const
5export type GptModel = (typeof GPT_MODELS)[number]
6
7export const GPT_DEFAULT_MODEL: GptModel = 'gpt-6.1-sol'
8
9export const isGptModel = (v: unknown): v is GptModel =>
10 typeof v === 'string' && (GPT_MODELS as readonly string[]).includes(v.trim())
11
12/** Empty means the default; anything else unknown is refused rather than silently swapped for another model. */
13export const gptModelOf = (v: unknown): GptModel | { error: string } => {
14 const name = typeof v === 'string' ? v.trim() : ''
15 if (name === '') return GPT_DEFAULT_MODEL
16 if (isGptModel(name)) return name
17
18 return { error: `未知的 GPT 模型「${name}」,可用:${GPT_MODELS.join('、')}` }
19}
20
21/** The line the person sees for a call. Never sent to the model. */
22export const gptLabel = (model: string, flags: { background: boolean; work: boolean }): string =>
23 `gpt · ChatGPT 订阅 / ${model}${flags.background ? ' · 后台' : ' · 前台'}${flags.work ? ' · 可改文件' : ' · 只读'}`
24
25export const gptToolDescription = (): string =>
26 [
27 '把一个自包含的任务交给 GPT 去做,它在独立进程里有自己的上下文和读文件、搜索(可选改文件、跑命令)的工具,只把最终文本返回给你。额度走 ChatGPT 订阅,不占 Claude 额度。',
28 '用来做较难的任务:后端实现、代码审查、需要多条线索对照的排查。它通过本机的 CPA 代理连 ChatGPT,用到时 runner 会自动把 CPA 拉起来,第一次调用可能多等几秒。',
29 "- model 默认 gpt-6.1-sol,日常都用它;只有真正高难的问题(想不透的根因、难判的设计取舍)才传 model: 'gpt-6-astra',它更慢,不要当默认。",
30 '任务说明要自包含:写清目标、允许读写的路径、已知事实、期望的输出格式。它看不到你的对话。',
31 '返回里的 agent ID 可以填进 resume 继续同一个代理(它记得之前读过的内容和 mode,比新建便宜、不用重讲背景);resume 没传 mode 就沿用该代理上次的 mode,显式传则以传入的为准;换任务或上下文已很长时才新建。',
32 'background=true 立即返回,任务做完后会自动通知你(结果在通知里):你手头有别的事可做、任务较久、或要同时派几个互不依赖的包时用;你下一步就依赖它的结果时用默认的前台,别用后台再干等。',
33 '轮数和时长基本不设限(只有 2 小时的防卡死兜底),不用为了怕超时把任务拆碎;预计超过几分钟的任务用 background=true。万一超时,返回里会带 agent ID 和它做到哪:先核对文件,没做完就 resume 续做,不要原样重试。汇报要求它精简:结论、改了哪些文件、测试结果。',
34 'mode=read(默认)只读;mode=work 可改文件和跑命令(它只用一个 run_code 工具,在程序里批量调用 read/edit/bash 等),仅在任务明确需要改动时用。',
35 "read 模式默认也只用一个只读的 run_code(只能读 cwd 内的文件,不能写文件、跑命令),在程序里批量读取、检索后汇总,另外带一个 WebFetch 可以读网页;要读 cwd 以外的文件时传 ptc:'off',改用原生 Read/Grep/Glob/WebFetch。",
36 ].join('\n')
37hooks/lib/jobs.ts 452 lines1/** 作业记录与 runner 落盘的目录布局。
2 * 每条作业单独一个 store key(job:<id>),所以并发会话不会整表覆盖;runner 在
3 * ~/.claude/delegate/jobs/<jobId>/ 里写 status.json / events.jsonl / result.json,
4 * 重启后的会话据此列出作业、看进度、取消、插话。 */
5
6export type Agent = 'flash' | 'pro' | 'gpt'
7export type Mode = 'read' | 'work'
8export type Ptc = 'only' | 'off'
9export type JobStatus = 'running' | 'done' | 'failed' | 'cancelled' | 'orphaned'
10
11export type JobRecord = {
12 jobId: string
13 /** 子代理的 session id:新建时由插件生成并 --session-id 传入,resume 时就是旧 id。 */
14 sessionId: string | null
15 tier: Agent
16 model: string
17 /** 这次实际传给 runner 的推理强度;resume 没传 effort 时沿用它。旧记录没有。 */
18 effort?: string
19 cwd: string
20 mode: Mode
21 ptc: Ptc
22 background: boolean
23 status: JobStatus
24 /** 开始时间(毫秒)。淘汰按它排序,不是完成时间。 */
25 at: number
26 endedAt?: number
27 turns?: number
28 error?: string
29 /** 任务首行摘要,列作业时给模型认人用。 */
30 summary: string
31 /** 后台任务的结果通知是否已经发出(重启后据此补发,且只补一次)。 */
32 notified?: boolean
33 /** 派出它的主会话 id:面板默认只列本会话的,补发通知也只发给它。旧记录没有。 */
34 owner?: string
35}
36
37/** runner 写的 status.json,读到什么算什么(写端非原子,字段可能缺)。 */
38export type JobStatusFile = {
39 jobId?: string
40 pid?: number
41 childPid?: number | null
42 sessionId?: string | null
43 phase?: string
44 stream?: boolean
45 turns?: number
46 lastTool?: string | null
47 lastText?: string
48 startedAt?: number
49 updatedAt?: number
50 endedAt?: number
51}
52
53export const JOB_PREFIX = 'job:'
54/** 保留最近多少条作业。 */
55export const MAX_JOBS = 200
56export const STATUSES: readonly JobStatus[] = ['running', 'done', 'failed', 'cancelled', 'orphaned']
57/** status.json 的 startedAt 与本记录的 at 相差多少以内才认作同一个进程。 */
58export const PID_WINDOW_MS = 120_000
59
60export const jobKey = (id: string): string => `${JOB_PREFIX}${id}`
61export const isJobKey = (key: string): boolean => key.startsWith(JOB_PREFIX)
62
63const HEX = '0123456789abcdef'
64
65/** --session-id 要一个 UUID;生成 v4 形状的即可。 */
66export const uuid = (): string => {
67 const chars = Array.from({ length: 32 }, () => HEX[Math.floor(Math.random() * 16)])
68 chars[12] = '4'
69 chars[16] = HEX[8 + Math.floor(Math.random() * 4)]
70 const s = chars.join('')
71
72 return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`
73}
74
75/** 作业 id:前台 fg-,后台 bg-(后台前缀保留,通知里一直是这么叫的)。 */
76export const newJobId = (background: boolean, now: number, salt: number): string =>
77 `${background ? 'bg' : 'fg'}-${now.toString(36)}${salt === 0 ? '' : `-${salt.toString(36)}`}`
78
79/** 任务说明压成一行,给列表用。 */
80export const summarize = (task: string, max = 100): string => {
81 const one = task.replace(/\s+/g, ' ').trim()
82
83 return one.length > max ? `${one.slice(0, max)}…` : one
84}
85
86/** ~/.claude/delegate/jobs,和 runner 的 os.homedir() 对齐。 */
87export const jobsRootOf = (home: string | undefined): string | undefined => {
88 if (home === undefined || home.trim() === '') return undefined
89 const clean = home.replace(/\\/g, '/').replace(/\/+$/, '')
90
91 return `${clean}/.claude/delegate/jobs`
92}
93
94export const pad2 = (n: number): string => String(n).padStart(2, '0')
95
96/** 本地时间 MM-DD HH:mm(跨年时带年份)。 */
97export const timeOf = (ms: number | undefined, now: number): string => {
98 if (ms === undefined || !Number.isFinite(ms) || ms <= 0) return '?'
99 const d = new Date(ms)
100 const sameYear = new Date(now).getFullYear() === d.getFullYear()
101
102 return `${sameYear ? '' : `${d.getFullYear()}-`}${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
103}
104
105export const agoOf = (ms: number | undefined, now: number): string => {
106 if (ms === undefined || !Number.isFinite(ms) || ms <= 0) return '?'
107 const s = Math.max(0, Math.round((now - ms) / 1000))
108 if (s < 60) return `${s} 秒前`
109 if (s < 3600) return `${Math.round(s / 60)} 分钟前`
110 if (s < 86_400) return `${Math.round(s / 3600)} 小时前`
111
112 return `${Math.round(s / 86_400)} 天前`
113}
114
115/** 跑了多久:1 分 20 秒 / 2 小时 3 分。 */
116export const spanOf = (ms: number): string => {
117 const s = Math.max(0, Math.round(ms / 1000))
118 if (s < 60) return `${s} 秒`
119 if (s < 3600) return `${Math.floor(s / 60)} 分 ${s % 60} 秒`
120
121 return `${Math.floor(s / 3600)} 小时 ${Math.floor((s % 3600) / 60)} 分`
122}
123
124const STATUS_TEXT: Record<JobStatus, string> = {
125 running: '运行中',
126 done: '完成',
127 failed: '失败',
128 cancelled: '已取消',
129 orphaned: '已中断(宿主重启,可 resume)',
130}
131
132export const statusText = (status: JobStatus): string => STATUS_TEXT[status]
133
134export const isStatus = (v: unknown): v is JobStatus =>
135 typeof v === 'string' && (STATUSES as readonly string[]).includes(v)
136
137const isRecord = (v: unknown): v is Record<string, unknown> =>
138 typeof v === 'object' && v !== null && !Array.isArray(v)
139
140const str = (v: unknown): string => (typeof v === 'string' ? v : '')
141const num = (v: unknown): number | undefined =>
142 typeof v === 'number' && Number.isFinite(v) ? v : undefined
143
144const agentOf = (v: unknown): Agent => (v === 'pro' || v === 'gpt' ? v : 'flash')
145
146/** 读回来的记录(store 里可能是旧版本或垃圾)。 */
147export const normalizeRecord = (raw: unknown, jobId: string): JobRecord | undefined => {
148 if (!isRecord(raw)) return undefined
149 const at = num(raw.at)
150
151 return {
152 jobId: str(raw.jobId) || jobId,
153 sessionId: str(raw.sessionId) === '' ? null : str(raw.sessionId),
154 tier: agentOf(raw.tier),
155 model: str(raw.model),
156 cwd: str(raw.cwd),
157 mode: raw.mode === 'work' ? 'work' : 'read',
158 ptc: raw.ptc === 'off' ? 'off' : 'only',
159 background: raw.background === true,
160 status: isStatus(raw.status) ? raw.status : 'done',
161 at: at ?? 0,
162 ...(num(raw.endedAt) === undefined ? {} : { endedAt: num(raw.endedAt) }),
163 ...(num(raw.turns) === undefined ? {} : { turns: num(raw.turns) }),
164 ...(str(raw.error) === '' ? {} : { error: str(raw.error) }),
165 summary: str(raw.summary),
166 ...(raw.notified === true ? { notified: true } : {}),
167 ...(str(raw.owner) === '' ? {} : { owner: str(raw.owner) }),
168 ...(str(raw.effort) === '' ? {} : { effort: str(raw.effort) }),
169 }
170}
171
172/** 旧版整表 jobs 里的备注(tier/cwd/mode/ptc/at)转成一条记录。 */
173export const recordFromNote = (id: string, raw: unknown): JobRecord | undefined => {
174 if (!isRecord(raw)) return undefined
175 const base = normalizeRecord(
176 {
177 ...raw,
178 jobId: id,
179 // 旧表的键就是 agent id:没有单独记 sessionId 的记录,键本身就是它。
180 sessionId: raw.sessionId ?? id,
181 status: raw.status ?? 'done',
182 background: raw.background ?? false,
183 summary: raw.summary ?? '',
184 },
185 id,
186 )
187
188 return base
189}
190
191export const normalizeStatus = (raw: unknown): JobStatusFile | undefined => {
192 if (!isRecord(raw)) return undefined
193
194 return {
195 ...(str(raw.jobId) === '' ? {} : { jobId: str(raw.jobId) }),
196 ...(num(raw.pid) === undefined ? {} : { pid: num(raw.pid) }),
197 ...(num(raw.childPid) === undefined ? {} : { childPid: num(raw.childPid) }),
198 ...(str(raw.sessionId) === '' ? {} : { sessionId: str(raw.sessionId) }),
199 ...(str(raw.phase) === '' ? {} : { phase: str(raw.phase) }),
200 stream: raw.stream === true,
201 turns: num(raw.turns),
202 lastTool: str(raw.lastTool) === '' ? null : str(raw.lastTool),
203 lastText: str(raw.lastText),
204 startedAt: num(raw.startedAt),
205 updatedAt: num(raw.updatedAt),
206 endedAt: num(raw.endedAt),
207 }
208}
209
210/** status.json 里的 pid 是不是这条作业的:id 对得上,开始时间也在窗口内。 */
211export const pidMatches = (status: JobStatusFile | undefined, rec: JobRecord): boolean => {
212 if (status === undefined || status.pid === undefined) return false
213 if (status.jobId !== undefined && status.jobId !== rec.jobId) return false
214 if (status.startedAt === undefined) return false
215
216 return Math.abs(status.startedAt - rec.at) <= PID_WINDOW_MS
217}
218
219const agentLine = (rec: JobRecord): string =>
220 `agent ${rec.sessionId ?? '?'} · ${rec.tier} / ${rec.model === '' ? '?' : rec.model} · ${rec.mode} · ${rec.background ? '后台' : '前台'}`
221
222/** delegate_list 的输出:一行一条,摘要单独一行。 */
223export const formatList = (
224 records: readonly JobRecord[],
225 opts: { now: number; limit: number; filter: string; live: Record<string, JobStatusFile> },
226): string => {
227 if (records.length === 0) {
228 return opts.filter === ''
229 ? '还没有作业记录。delegate_flash / delegate_pro / delegate_gpt 派出的每个任务都会记在这里。'
230 : `没有状态为「${opts.filter}」的作业。`
231 }
232 const shown = records.slice(0, opts.limit)
233 const head = `共 ${records.length} 个作业${opts.filter === '' ? '' : `(状态 ${opts.filter})`},显示最近 ${shown.length} 个:`
234 const rows = shown.map((rec, i) => {
235 const st = opts.live[rec.jobId]
236 const turns = st?.turns ?? rec.turns
237 const when = `${timeOf(rec.at, opts.now)}(${agoOf(rec.at, opts.now)})`
238 const took =
239 rec.endedAt === undefined
240 ? st?.updatedAt === undefined
241 ? ''
242 : ` · 已跑 ${spanOf((st.endedAt ?? st.updatedAt) - rec.at)}`
243 : ` · 结束 ${timeOf(rec.endedAt, opts.now)} · 用时 ${spanOf(rec.endedAt - rec.at)}`
244 const liveBit =
245 rec.status === 'running'
246 ? `${st?.phase === undefined ? '' : ` · ${st.phase}`}${st?.lastTool ? ` · 最后工具 ${st.lastTool}` : ''}`
247 : ''
248 const tail = [
249 rec.status === 'running' || rec.status === 'orphaned'
250 ? ` ↳ 接着做:resume "${rec.sessionId ?? rec.jobId}"(delegate_flash/pro/gpt 的 resume 参数)`
251 : '',
252 rec.status === 'running' ? ` ↳ 看进度:delegate_status "${rec.jobId}" · 取消:delegate_cancel "${rec.jobId}"` : '',
253 rec.error === undefined ? '' : ` ↳ 失败原因:${rec.error.slice(0, 200)}`,
254 ].filter(s => s !== '')
255 const summary = rec.summary === '' ? '' : ` ${rec.summary}`
256
257 return [
258 `${i + 1}. ${rec.jobId} · ${statusText(rec.status)}${turns === undefined ? '' : ` · ${turns} 轮`} · ${when}${took}${liveBit}`,
259 ` ${agentLine(rec)}${rec.cwd === '' ? '' : ` · cwd ${rec.cwd}`}`,
260 summary,
261 ...tail,
262 ]
263 .filter(s => s !== '')
264 .join('\n')
265 })
266
267 return [head, ...rows].join('\n')
268}
269
270/** 事件日志一行压成一句,给 delegate_status 的“最近事件”。 */
271export const eventLine = (raw: string, max = 140): string => {
272 let o: unknown
273 try {
274 o = JSON.parse(raw)
275 } catch {
276 return raw.replace(/\s+/g, ' ').trim().slice(0, max)
277 }
278 if (!isRecord(o)) return raw.slice(0, max)
279 const type = str(o.type)
280 if (type === 'assistant') {
281 const blocks = Array.isArray((o.message as Record<string, unknown> | undefined)?.content)
282 ? ((o.message as Record<string, unknown>).content as unknown[])
283 : []
284 const parts: string[] = []
285 for (const b of blocks) {
286 if (!isRecord(b)) continue
287 if (b.type === 'text') parts.push(`说:${str(b.text).replace(/\s+/g, ' ').trim()}`)
288 else if (b.type === 'tool_use') parts.push(`用工具 ${str(b.name)}`)
289 else if (b.type === 'thinking') parts.push('思考')
290 }
291 const line = parts.join(' · ') || '(空)'
292
293 return `assistant ${line.length > max ? `${line.slice(0, max)}…` : line}`
294 }
295 if (type === 'user') {
296 const content = (o.message as Record<string, unknown> | undefined)?.content
297 if (Array.isArray(content)) {
298 const names = content
299 .filter(c => isRecord(c) && c.type === 'tool_result')
300 .map(c => (isRecord(c) && typeof c.is_error === 'boolean' && c.is_error ? '工具结果(失败)' : '工具结果'))
301 if (names.length > 0) return `user ${names.join(' · ')}`
302 }
303
304 return `user ${typeof content === 'string' ? content.replace(/\s+/g, ' ').trim().slice(0, max) : '(消息)'}`
305 }
306 if (type === 'result') {
307 const text = str(o.result).replace(/\s+/g, ' ').trim()
308
309 return `result ${o.is_error === true ? '失败' : 'ok'}${text === '' ? '' : `:${text.slice(0, max)}`}`
310 }
311 if (type === 'system') {
312 return `system ${str(o.subtype)}`
313 }
314
315 return `${type || '事件'} ${raw.replace(/\s+/g, ' ').trim().slice(0, max)}`
316}
317
318/** delegate_status 的输出。 */
319export const formatStatus = (
320 rec: JobRecord | undefined,
321 status: JobStatusFile | undefined,
322 events: readonly string[],
323 result: { ok?: boolean; text?: string; error?: string; sessionId?: string | null } | undefined,
324 now: number,
325): string => {
326 if (rec === undefined && status === undefined) return '没有这条作业。用 delegate_list 看有哪些。'
327 const jobId = rec?.jobId ?? status?.jobId ?? '?'
328 const live = status?.phase
329 const running = rec?.status === 'running'
330 const started = status?.startedAt ?? rec?.at
331 const last = status?.updatedAt ?? rec?.endedAt
332 const lines = [
333 `作业 ${jobId}${rec === undefined ? '' : `(${rec.tier} / ${rec.model || '?'} · ${rec.mode} · ${rec.background ? '后台' : '前台'})`}`,
334 rec === undefined
335 ? `状态:${live ?? '?'}(没有作业记录,只有落盘状态)`
336 : `状态:${statusText(rec.status)}${live === undefined ? '' : `(runner 阶段 ${live})`}${running && started !== undefined && last !== undefined ? ` · 已跑 ${spanOf(last - started)}` : ''}`,
337 ]
338 const turns = status?.turns ?? rec?.turns
339 if (turns !== undefined) lines.push(`轮数:${turns}`)
340 if (status?.lastTool) lines.push(`最后工具:${status.lastTool}`)
341 const agent = status?.sessionId ?? rec?.sessionId
342 if (agent) lines.push(`agent:${agent}(resume 用这个)`)
343 if (rec?.cwd) lines.push(`cwd:${rec.cwd}`)
344 if (started !== undefined) lines.push(`开始:${timeOf(started, now)}(${agoOf(started, now)})`)
345 if (rec?.endedAt !== undefined) lines.push(`结束:${timeOf(rec.endedAt, now)} · 用时 ${spanOf(rec.endedAt - rec.at)}`)
346 if (status?.pid !== undefined) lines.push(`进程:runner pid ${status.pid}${status.childPid ? ` · claude pid ${status.childPid}` : ''}`)
347 if (rec?.summary) lines.push(`任务:${rec.summary}`)
348 const text = (result?.text ?? status?.lastText ?? '').replace(/\s+/g, ' ').trim()
349 if (text !== '') lines.push(`最后输出:${text.length > 400 ? `${text.slice(0, 400)}…` : text}`)
350 if (result?.error !== undefined && result.error !== '') lines.push(`错误:${result.error}`)
351 if (events.length > 0) {
352 lines.push('最近事件(旧→新):')
353 for (const e of events) lines.push(` ${e}`)
354 }
355
356 return lines.join('\n')
357}
358
359/** 实时面板的一行事件:工具调用、子代理说的话、你的插话、收尾。 */
360export type LiveEvent = { kind: 'tool' | 'text' | 'think' | 'you' | 'end'; title: string; detail: string }
361
362/** 面板只要这几类行:先按字面挑出来再 JSON.parse,省掉大量 system 行的解析。 */
363const WANTED = ['"type":"assistant"', '"type":"sent"', '"type":"result"']
364const wanted = (raw: string): boolean => WANTED.some(w => raw.includes(w))
365
366const oneLine = (s: string, max: number): string => {
367 const t = s.replace(/\s+/g, ' ').trim()
368
369 return t.length > max ? `${t.slice(0, max)}…` : t
370}
371
372// 工具参数里挑最能认出“它在干什么”的那个字段。
373const toolDetail = (input: unknown): string => {
374 if (!isRecord(input)) return ''
375 for (const k of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'code']) {
376 const v = input[k]
377 if (typeof v === 'string' && v.trim() !== '') {
378 return k === 'code' ? oneLine(v.split('\n').find(l => l.trim() !== '' && !l.trim().startsWith('//')) ?? v, 90) : oneLine(v, 90)
379 }
380 }
381
382 return ''
383}
384
385const toolTitle = (name: string): string => name.replace(/^mcp__[^_]+(?:-[^_]+)*__/, '')
386
387/** events.jsonl 的若干行 → 面板事件(只要工具调用、文本、插话、结果;思考和工具结果不单列)。 */
388export const liveEvents = (lines: readonly string[], max = 14): LiveEvent[] => {
389 const out: LiveEvent[] = []
390 for (const raw of lines) {
391 if (!wanted(raw)) continue
392 let o: unknown
393 try {
394 o = JSON.parse(raw)
395 } catch {
396 continue
397 }
398 if (!isRecord(o)) continue
399 const type = str(o.type)
400 if (type === 'assistant') {
401 const content = isRecord(o.message) && Array.isArray(o.message.content) ? o.message.content : []
402 for (const b of content) {
403 if (!isRecord(b)) continue
404 if (b.type === 'tool_use') out.push({ kind: 'tool', title: toolTitle(str(b.name)), detail: toolDetail(b.input) })
405 else if (b.type === 'text' && str(b.text).trim() !== '') out.push({ kind: 'text', title: '', detail: oneLine(str(b.text), 160) })
406 else if (b.type === 'thinking' && str(b.thinking).trim() !== '') out.push({ kind: 'think', title: '', detail: oneLine(str(b.thinking), 160) })
407 }
408 } else if (type === 'sent') {
409 out.push({ kind: 'you', title: '', detail: oneLine(str(o.text), 160) })
410 } else if (type === 'result') {
411 out.push({ kind: 'end', title: o.is_error === true ? '结束(出错)' : '本轮结束', detail: oneLine(str(o.result), 160) })
412 }
413 }
414
415 return out.slice(-max)
416}
417
418/** 1234567 → 1.2M,56789 → 57K。 */
419export const tokensOf = (n: number | undefined): string => {
420 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '–'
421 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
422 if (n >= 1e3) return `${Math.round(n / 1e3)}K`
423
424 return String(n)
425}
426
427/** 从事件流算用量:上下文 = 最近一次请求的输入(含缓存),输出 = 各条消息输出之和(同一消息只算一次)。 */
428export const usageOf = (lines: readonly string[]): { context: number; output: number } => {
429 const seen = new Map<string, number>()
430 let context = 0
431 for (const raw of lines) {
432 if (!raw.includes('"type":"assistant"')) continue
433 let o: unknown
434 try {
435 o = JSON.parse(raw)
436 } catch {
437 continue
438 }
439 if (!isRecord(o) || o.type !== 'assistant' || !isRecord(o.message) || !isRecord(o.message.usage)) continue
440 const u = o.message.usage
441 const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
442 const input = num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens)
443 if (input > 0) context = input
444 const id = str(o.message.id) || String(seen.size)
445 seen.set(id, Math.max(seen.get(id) ?? 0, num(u.output_tokens)))
446 }
447 let output = 0
448 for (const v of seen.values()) output += v
449
450 return { context, output }
451}
452hooks/lib/quota.ts 50 lines1// GPT(ChatGPT/Codex 订阅)额度:scripts/gpt-quota.mjs 的输出和面板上的显示。
2
3export type QuotaWindow = { label: string; used: number; resetAt?: number }
4export type GptQuota =
5 | { ok: true; source: 'live' | 'cached'; at: number; plan: string; limitReached: boolean; windows: QuotaWindow[] }
6 | { ok: false; error: string }
7
8export const normalizeQuota = (raw: unknown): GptQuota => {
9 const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
10 if (o.ok !== true) return { ok: false, error: typeof o.error === 'string' ? o.error : '额度脚本没有输出' }
11 const windows = (Array.isArray(o.windows) ? o.windows : [])
12 .map(w => (typeof w === 'object' && w !== null ? (w as Record<string, unknown>) : {}))
13 .filter(w => typeof w.label === 'string' && typeof w.used === 'number')
14 .map(w => ({
15 label: w.label as string,
16 used: Math.max(0, Math.min(100, w.used as number)),
17 ...(typeof w.resetAt === 'number' ? { resetAt: w.resetAt } : {}),
18 }))
19 if (windows.length === 0) return { ok: false, error: '额度里没有窗口数据' }
20
21 return {
22 ok: true,
23 source: o.source === 'cached' ? 'cached' : 'live',
24 at: typeof o.at === 'number' ? o.at : Date.now(),
25 plan: typeof o.plan === 'string' ? o.plan : '',
26 limitReached: o.limitReached === true,
27 windows,
28 }
29}
30
31/** 进度条拆成已用和剩余两段,好分别上色。 */
32export const barOf = (used: number, width = 12): [string, string] => {
33 const u = Math.max(0, Math.min(100, used))
34 // 用了一点就至少亮一格,免得 1% 看着像没用。
35 const n = u > 0 ? Math.max(1, Math.round((u / 100) * width)) : 0
36
37 return ['━'.repeat(n), '─'.repeat(width - n)]
38}
39
40export const usedColor = (used: number): string => (used >= 80 ? 'error' : used >= 50 ? 'warning' : 'success')
41
42export const resetIn = (resetAt: number | undefined, now: number): string => {
43 if (resetAt === undefined) return ''
44 const m = Math.max(0, Math.round((resetAt - now) / 60_000))
45 if (m >= 1440) return `${Math.floor(m / 1440)} 天 ${Math.floor((m % 1440) / 60)} 时后重置`
46 if (m >= 60) return `${Math.floor(m / 60)} 时 ${m % 60} 分后重置`
47
48 return `${m} 分后重置`
49}
50types/index.d.ts 20 lines1export type Draft = {
2 pName: string
3 pUrl: string
4 pKey: string
5 keyFor: string
6 keyValue: string
7 flashProvider: string
8 flashModel: string
9 proProvider: string
10 proModel: string
11 flashEffort: string
12 proEffort: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 delegate: { draft: Draft; flash: string; version: number; liveSel: string; liveMsg: string; liveNote: string; liveTick: number; liveMore: boolean; liveAll: boolean; livePage: number }
18 }
19}
20