hooks/register.ts 253 lines
1type ProcessResult = { exitCode: number; stdout: string; stderr: string }
2type Model = { provider?: string; model: string }
3type CoreResponse = { protocolVersion: number; ok: boolean; data?: any; error?: { code: string; message: string; retryable?: boolean } }
4type ActiveAttempt = {
5 taskId: string
6 attemptId: string
7 token: string
8 stage: string
9 model: Model
10 turnId?: string
11 root: string
12 worktree?: string
13 sessionId: string
14}
15
16const active = new Map<string, ActiveAttempt>()
17const statusText = new Map<string, string>()
18const timers = new Map<string, { cancel(): void }>()
19
20async function corePath($: any): Promise<string> {
21 const configured = await $.env.get('TASK_WORKFLOW_CORE')
22 if (typeof configured === 'string' && configured.trim()) return configured
23 return $.plugin.root.includes('/adapters/claude') ? `${$.plugin.root}/../../dist/cli.js` : `${$.plugin.root}/dist/cli.js`
24}
25
26async function runCore($: any, command: string, payload: Record<string, unknown>): Promise<any> {
27 const result = await $.process.run(['node', await corePath($), command, '--payload', JSON.stringify(payload)], { timeoutMs: 600_000 }) as ProcessResult
28 const raw = result.stdout.trim()
29 if (!raw) throw new Error(result.stderr || `task-workflow core returned no response for ${command}`)
30 let response: CoreResponse
31 try { response = JSON.parse(raw) as CoreResponse } catch { throw new Error(`invalid task-workflow response: ${raw.slice(0, 500)}`) }
32 if (!response.ok) throw new Error(response.error?.message ?? `task-workflow ${command} failed`)
33 return response.data
34}
35
36async function sessionId($: any): Promise<string> { return String(await $.session.id()) }
37async function sessionRoot($: any): Promise<string> { return String(await $.session.cwd()) }
38async function sessionCwd($: any): Promise<string> { return String(await $.session.cwd()) }
39
40function formatSummary(summary: any): string {
41 if (!summary) return '当前会话没有任务。'
42 const error = summary.lastError ? `\n错误:${summary.lastError}` : ''
43 const next = summary.next ? `\n下一步:${summary.next}` : ''
44 return `任务:${summary.name}\n状态:${summary.status}\n阶段:${summary.stage}${summary.reviewPending ? '(收尾审查)' : ''}\n轮次:${summary.round}/${summary.totalRounds}\n模型:${summary.model}\n分支:${summary.taskBranch || '(尚未创建)'} → ${summary.targetBranch}\nworktree:${summary.worktree || '(尚未创建)'}${next}${error}`
45}
46
47function setStatus($: any, summary: any): void {
48 const id = String(summary?.sessionId ?? '')
49 if (!id) return
50 statusText.set(id, summary ? `${summary.name} · ${summary.stage} · ${summary.status}${summary.reviewPending ? ' · review' : ''}${summary.next ? ` · ${summary.next}` : ''}` : '')
51 $.ui.invalidate('ui.render')
52}
53
54function inside(value: string, root: string): boolean {
55 const normalizedValue = value.replace(/\\/g, '/').replace(/\/$/, '')
56 const normalizedRoot = root.replace(/\\/g, '/').replace(/\/$/, '')
57 return normalizedValue === normalizedRoot || normalizedValue.startsWith(`${normalizedRoot}/`)
58}
59
60function writeTool(tool: string): boolean {
61 return ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell', 'Monitor'].includes(tool)
62}
63
64function supportedModel(model: any): boolean {
65 const value = String(model?.model ?? '')
66 return value === 'codex/gpt-6.1-sol' || value === 'codex/gpt-6-luna' ||
67 value === 'gpt-6.1-sol' || value === 'gpt-6-luna' ||
68 value === 'group/auto-gpt-6-1-sol' || value === 'group/auto-gpt-6-luna' ||
69 value === 'sonnet' || value === 'opus' || value === 'haiku' || value === 'inherit' || value.startsWith('claude-')
70}
71
72function runtimeModel(model: Model): string {
73 if (model.provider === 'magpie' && model.model === 'codex/gpt-6.1-sol') return 'group/auto-gpt-6-1-sol'
74 if (model.provider === 'magpie' && model.model === 'codex/gpt-6-luna') return 'group/auto-gpt-6-luna'
75 // Keep old pinned task snapshots resumable while routing them through magpie.
76 if (model.provider === 'xrouter' && model.model === 'gpt-6.1-sol') return 'group/auto-gpt-6-1-sol'
77 if (model.provider === 'xrouter' && model.model === 'gpt-6-luna') return 'group/auto-gpt-6-luna'
78 if (model.model === 'codex/gpt-6.1-sol') return 'group/auto-gpt-6-1-sol'
79 if (model.model === 'codex/gpt-6-luna') return 'group/auto-gpt-6-luna'
80 if (model.model === 'gpt-6.1-sol') return 'group/auto-gpt-6-1-sol'
81 if (model.model === 'gpt-6-luna') return 'group/auto-gpt-6-luna'
82 return model.model
83}
84
85async function refreshStatus($: any): Promise<any> {
86 try {
87 const id = await sessionId($)
88 const summary = await runCore($, 'status', { root: await sessionRoot($), sessionId: id })
89 setStatus($, { ...summary, sessionId: id })
90 return summary
91 } catch (error) {
92 statusText.set(await sessionId($), `task-workflow 状态不可用:${error instanceof Error ? error.message : String(error)}`)
93 $.ui.invalidate('ui.render')
94 return undefined
95 }
96}
97
98function scheduleNext($: any): void {
99 void sessionId($).then(id => $.clock.after(0, () => { void startNext($, id) }))
100}
101
102async function startNext($: any, id: string): Promise<void> {
103 if (id !== await sessionId($) || active.has(id)) return
104 try {
105 const next = await runCore($, 'next', { root: await sessionRoot($), sessionId: id })
106 setStatus($, { ...next.summary, sessionId: id })
107 if (next.kind === 'done') return
108 if (next.kind === 'integration') {
109 if (!supportedModel(next.model)) throw new Error(`Claude Code cannot use configured model ${next.model?.model ?? '(empty)'}; set executor to sonnet, opus, haiku, or a claude-* model id.`)
110 const attempt: ActiveAttempt = { taskId: next.task, attemptId: next.attemptId, token: next.token, stage: 'integrate', model: next.model, root: await sessionRoot($), sessionId: id }
111 active.set(id, attempt)
112 await $.prompt.submit({ text: next.prompt })
113 return
114 }
115 if (!supportedModel(next.model)) throw new Error(`Claude Code cannot use configured model ${next.model?.model ?? '(empty)'}; set planner/executor to sonnet, opus, haiku, or a claude-* model id.`)
116 const attempt: ActiveAttempt = { taskId: next.task, attemptId: next.attemptId, token: next.token, stage: next.stage, model: next.model, root: await sessionRoot($), worktree: next.summary?.worktree, sessionId: id }
117 active.set(id, attempt)
118 await $.prompt.submit({ text: next.prompt })
119 } catch (error) {
120 active.delete(id)
121 const message = error instanceof Error ? error.message : String(error)
122 statusText.set(id, `task-workflow 暂停:${message}`)
123 $.ui.toast(`任务暂停:${message}`)
124 $.ui.invalidate('ui.render')
125 }
126}
127
128async function pauseAfterAbort($: any, attempt: ActiveAttempt, reason: string): Promise<void> {
129 try {
130 const summary = await runCore($, 'pause', { root: attempt.root, sessionId: attempt.sessionId, reason })
131 setStatus($, { ...summary, sessionId: attempt.sessionId })
132 } catch (error) { $.ui.toast(`任务暂停状态保存失败:${error instanceof Error ? error.message : String(error)}`) }
133}
134
135export function register(on: any): void {
136 on('session.start', async ($: any, e: any, next: any) => {
137 await $.command.register({ name: 'task', description: 'Start a resumable analyze -> plan -> implement -> integrate task', argumentHint: '<requirement>' })
138 await $.command.register({ name: 'task-status', description: 'Show the current task status' })
139 await $.command.register({ name: 'task-resume', description: 'Resume the current paused task', immediate: true })
140 await $.command.register({ name: 'task-cleanup', description: 'List task workflow records; use apply only for explicit maintenance cleanup', argumentHint: '[apply]' })
141 const id = await sessionId($)
142 if (!timers.has(id)) timers.set(id, $.clock.every(3000, () => { void refreshStatus($) }))
143 await refreshStatus($)
144 return next(e)
145 })
146
147 on('session.end', async ($: any, e: any, next: any) => {
148 const id = await sessionId($)
149 timers.get(id)?.cancel()
150 timers.delete(id)
151 active.delete(id)
152 statusText.delete(id)
153 return next(e)
154 })
155
156 on('command.run', { command: 'task' }, async ($: any, e: any) => {
157 if (!e.args?.trim()) return { text: '用法:/task <需求描述>' }
158 const id = await sessionId($)
159 const data = await runCore($, 'init', { root: await sessionRoot($), sessionId: id, requirement: e.args.trim(), allowCommit: true, allowMerge: true, allowCleanup: true })
160 setStatus($, { ...data.summary, sessionId: id })
161 scheduleNext($)
162 return { text: `已创建任务 ${data.summary.name},开始 ${data.summary.stage}。` }
163 })
164
165 on('command.run', { command: 'task-status' }, async ($: any) => {
166 const summary = await refreshStatus($)
167 return { text: formatSummary(summary) }
168 })
169
170 on('command.run', { command: 'task-resume' }, async ($: any) => {
171 const id = await sessionId($)
172 if (active.has(id)) return { text: '任务当前仍在执行中。' }
173 const summary = await runCore($, 'resume', { root: await sessionRoot($), sessionId: id })
174 setStatus($, { ...summary, sessionId: id })
175 scheduleNext($)
176 return { text: `任务已恢复:${summary.name},继续 ${summary.stage}。` }
177 })
178
179 on('command.run', { command: 'task-cleanup' }, async ($: any, e: any) => {
180 const apply = e.args?.trim() === 'apply'
181 if (e.args?.trim() && !apply) return { text: '用法:/task-cleanup [apply];默认只检查,不删除。' }
182 const result = await runCore($, 'cleanup', { root: await sessionRoot($), apply })
183 return { text: result.entries.length ? result.entries.map((entry: any) => `- ${entry.name ?? entry.taskId}: ${entry.reason}`).join('\n') : '没有任务记录。' }
184 })
185
186 on('turn.start', async ($: any, e: any, next: any) => {
187 const attempt = active.get(await sessionId($))
188 if (attempt && !attempt.turnId) attempt.turnId = e.turnId
189 return next(e)
190 })
191
192 on('tool.call', async ($: any, e: any, next: any) => {
193 const attempt = active.get(await sessionId($))
194 if (!attempt) return next(e)
195 const tool = String(e.tool)
196 const cwd = await sessionCwd($)
197 if (attempt.stage === 'implement') {
198 if (tool === 'EnterWorktree') {
199 if (String(e.path ?? '') !== attempt.worktree) return { deny: `EnterWorktree must use the task worktree ${attempt.worktree}.` }
200 return next(e)
201 }
202 if (tool === 'ExitWorktree') return { deny: 'Stay in the task worktree until implementation and its receipt are complete.' }
203 if (writeTool(tool) && (!attempt.worktree || !inside(cwd, attempt.worktree))) return { deny: `Task implementation is locked to ${attempt.worktree}; enter that worktree before writing or running commands.` }
204 }
205 if (attempt.stage === 'integrate' && writeTool(tool)) return { deny: 'Integration is controlled by the task workflow core after ExitWorktree; do not edit or run Git commands in this handoff.' }
206 return next(e)
207 }).catch(async ($: any, e: any, next: any) => {
208 if (next.called) return next(e)
209 return { deny: `task-workflow safety guard failed before ${String(e.tool)} could run: ${next.error?.message ?? 'unknown error'}` }
210 })
211
212 on('turn.step', async function* ($: any, e: any, next: any) {
213 const attempt = active.get(await sessionId($))
214 if (!attempt || e.agentId) return yield* next(e)
215 return yield* next({ ...e, model: runtimeModel(attempt.model) })
216 })
217
218 on('turn.complete', async ($: any, e: any, next: any) => {
219 const id = await sessionId($)
220 const attempt = active.get(id)
221 const result = await next(e)
222 if (!attempt || (attempt.turnId && attempt.turnId !== e.turnId)) return result
223 active.delete(id)
224 if (e.isAborted) {
225 await pauseAfterAbort($, attempt, '用户中断了当前阶段')
226 return result
227 }
228 try {
229 if (attempt.stage === 'integrate') {
230 const summary = await runCore($, 'integrate', { root: await sessionRoot($), cwd: await sessionCwd($), sessionId: id, attemptId: attempt.attemptId, token: attempt.token })
231 setStatus($, { ...summary, sessionId: id })
232 $.ui.toast(`任务 ${summary.name} 已完成并合并。`)
233 return result
234 }
235 const data = await runCore($, 'complete', { root: attempt.root, cwd: await sessionCwd($), sessionId: id, attemptId: attempt.attemptId, token: attempt.token, turnId: e.turnId })
236 setStatus($, { ...data.summary, sessionId: id })
237 scheduleNext($)
238 } catch (error) {
239 const message = error instanceof Error ? error.message : String(error)
240 await pauseAfterAbort($, attempt, message)
241 $.ui.toast(`任务需要恢复:${message}`)
242 }
243 return result
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
247 const text = statusText.get(await sessionId($))
248 if (!text) return next(e)
249 const { Text } = $.ui.resolve(e)
250 return Text({ children: [text], dimColor: true, wrap: 'truncate' })
251 })
252}
253