SLOPSHOPPER

delegate

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.

newpanebandrowsguardcommand
★ 3v0.1.0MITupdated 2026-10-26wdsewdser-bit/claude-code-mods/delegate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate
│ ┃ 委派:供应商与模型位 ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ 模型位 ▾ │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ flash 模型: 未设置 ▾ │ ⏺ Bash(bun test) │ ┃ │ 推理强度: 默认 ▾ │ ⎿ 3 pass, 1 fail │ ┃ │ pro 模型: 未设置 ▾ │ │ ┃ │ 推理强度: 默认 ▾ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 供应商 · CPA ▸ │ › /delegate │ ┃ ╰──────────────────────────────────────────╯ ⎿ delegate: 委派面板已打开。 │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ GPT · ChatGPT 订阅 ▸ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ 供应商密钥仅保存在本机 CPA 配置中。 │ delegate-open-live ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
delegate-open-live ⟨Claude Code's own drawing⟩
Pane · 委派:供应商与模型位
╭──────────────────────────────────────────────────────────╮ │ 模型位 ▾ │ │ │ │ flash 模型: 未设置 ▾ │ │ 推理强度: 默认 ▾ │ │ pro 模型: 未设置 ▾ │ │ 推理强度: 默认 ▾ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 供应商 · CPA ▸ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ GPT · ChatGPT 订阅 ▸ │ ╰──────────────────────────────────────────────────────────╯ 供应商密钥仅保存在本机 CPA 配置中。
Pane · delegate-live
╭──────────────────────────────────────────────────────────╮ │ │ │ GPT 额度 ↻ 刷新 │ │ 额度脚本没有输出 │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ 本会话还没有委派任务 │ │ 派出 delegate_flash / pro / gpt │ │ 后,这里会实时显示它们在做什么。 │ │ 本会话 │ │ │ ╰──────────────────────────────────────────────────────────╯
README

delegate

把任务交给更便宜的模型执行: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 小时额度)。

依赖

  • DS:本地 a2o(ant2oai)代理,scripts/run_proxy.py 在它外面加了 system 消息展开和 --effort。
  • GPT:本地 CLIProxyAPI,默认 http://127.0.0.1:8317;启动脚本默认在 ~/tools/cliproxyapi/ensure_cpa.py,可用 DELEGATE_CPA_ENSURE、DELEGATE_CPA_BASE 覆盖。
  • 子代理用 ptc-run 的提示词(scripts/ptc-*-system.md)。

安全

  • key 只经 stdin 传给 scripts/cpa-admin.mjs,输出一律脱敏;作业记录不保存供应商 key。
  • work 模式的子代理可以直接改文件和执行命令。

开发

claude plugin validate delegate
claude plugin test delegate
Source 6 files
hooks/register.tsx 1989 lines
1import { 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 lines
1export 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}
216
hooks/lib/gpt.ts 37 lines
1/** 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')
37
hooks/lib/jobs.ts 452 lines
1/** 作业记录与 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}
452
hooks/lib/quota.ts 50 lines
1// 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}
50
types/index.d.ts 20 lines
1export 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