Hands a plans/<slug>.md plan to agy (headless with live progress, or copied for agy -i) or Jules (cloud PR), and starts a plan-diff-review when it's done; the…

Function-hook plugins for Claude Code. Each one installs on its own.
At a Claude Code prompt, install any of the mods below:
/plugin install <mod> --marketplace daviddecorso/claude-mods
Answer y to add the marketplace, then pick a scope.
Blocks Bash commands that write files (inline Python/Node scripts, sed -i, perl -pi, redirects, tee) and points the model at Edit/Write instead, so every file change shows up as a reviewable diff. Writes to temp and scratchpad directories are allowed.
Option extraAllowed: a regex; Bash commands matching it are never blocked (e.g. ^node scripts/gen-).
/plugin install shell-edit-guard --marketplace daviddecorso/claude-mods
Finds each repo's dev servers (dev* package scripts and wrangler dev, for Vite, Astro, Next, Wrangler and Expo), gives them fixed ports, and runs them as named services. Adds a /dev pane and dev_list / dev_start / dev_stop / dev_restart / dev_logs tools, so the model reads filtered logs instead of backgrounding servers in Bash.
/plugin install dev-servers --marketplace daviddecorso/claude-mods
Hands a plans/<slug>.md plan to agy, the Antigravity CLI (headless with live progress, or copied for agy -i), or to Google's Jules (cloud PR), then starts a plan-vs-diff review when the work lands. When an agy run's review reports findings, they can go back to agy to fix (written to plans/fixes/<slug>.md), and that run gets reviewed too. Ships two skills: plan-handoff writes the plan with a recommended implementer, and plan-diff-review checks the result against it.
Needs agy on PATH for agy handoffs, and a Jules API key for Jules handoffs, stored in the macOS Keychain:
security add-generic-password -s jules-api -a "$USER" -w
(JULES_API_KEY in the environment also works.)
/plugin install handoff --marketplace daviddecorso/claude-mods
A pane for a per-repo prompt queue kept in <repo>/.queue/ (shared across worktrees): pick queued prompts, batch them into the prompt box, queue drafts, and archive items with the x hotkey or /aq done <seq…>. Also gives the model an aq_add tool to queue follow-ups. Pairs with the aq CLI from zsh-utils, which reads and writes the same queue.
/plugin install aq --marketplace daviddecorso/claude-mods
Run a mod from a working copy with claude --plugin-dir ./<mod>. Check it with claude plugin validate ./<mod> and claude plugin test ./<mod>.
Each mod's tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates the first time it loads the mod. On a fresh clone, run claude --plugin-dir ./<mod> once before type-checking.
MIT
hooks/register.tsx 933 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
3
4import type { Finding, JulesJob, Plan, Progress, Review, Run } from '../types'
5import { applyLine, emptyProgress, splitLines } from './agy'
6import type { JulesActivity, JulesSession } from './jules'
7import {
8 JULES_API,
9 apiError,
10 foldSession,
11 isFinished,
12 julesPlanPrompt,
13 julesReviewPrompt,
14 planTarget,
15 sessionUrl,
16 sourceName,
17} from './jules'
18
19type $ = EngineInterface
20
21type Agent = {
22 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
23 pid?: number
24 // Set before the group is killed; wins over whatever outcome agy reported.
25 endReason?: 'stopped' | 'stalled'
26 hasExited: boolean
27 isStallWarned: boolean
28 watchdog?: Timer
29 abandon: () => void
30}
31
32const PANE = 'handoff'
33const JULES_PANE = 'jules'
34const PID_MARK = '__AGYPID '
35const POLL_MS = 15_000
36const WATCH_MS = 15_000
37const KILL_GRACE_MS = 5_000
38const QUIET_SHOW_MS = 60_000
39const QUIET_WARN_MS = 10 * 60_000
40const QUIET_STOP_MS = 20 * 60_000
41const JULES_POLL_MS = 60_000
42const MAX_ACTIVITY_PAGES = 5
43const KEY_HELP = 'No Jules API key. Store one with: security add-generic-password -s jules-api -a "$USER" -w'
44
45const JULES_TOOL_DESCRIPTION = [
46 "Hands a self-contained coding task to Jules, Google's async cloud agent, which works from a pushed GitHub branch and opens a PR against it. Returns the session URL; the user sees progress in the /handoff jules-status pane.",
47 'Use only for jules-shaped work: self-contained, verifiable by tests/CI or a diff small enough to read in one pass, no local-only state (simulators, local DB, secrets, dev server), no design or product judgment.',
48 'Good fits: test backfill, dependency bumps, isolated bugs, lint/type cleanups, docs, mechanical migrations. Never for work that depends on uncommitted changes or on context from this conversation that the prompt does not spell out.',
49 'Write the prompt self-contained: name the files, the expected behaviour and how to verify it. Jules sees only the pushed branch and the prompt.',
50].join(' ')
51
52const plan = atom({ plugin: 'handoff', key: 'plan' } as const, null as Plan | null)
53const run = atom({ plugin: 'handoff', key: 'run' } as const, null as Run | null)
54const reviewResult = atom({ plugin: 'handoff', key: 'review' } as const, null as Review | null)
55const isBandHidden = atom({ plugin: 'handoff', key: 'isBandHidden' } as const, false)
56const julesJobs = atom({ plugin: 'handoff', key: 'julesJobs' } as const, [] as JulesJob[])
57
58// The live agy process and the polls belong to this module: a hot reload stops them, and session.start resumes the Jules poll.
59let agent: Agent | undefined
60let poll: Timer | undefined
61let julesPoll: Timer | undefined
62let isJulesPolling = false
63let julesKey: string | undefined
64
65// Appends one line to ~/.claude/mod-logs/handoff.jsonl, which /mods-report reads; never fails the hook.
66async function logEvent($: $, event: Record<string, unknown>) {
67 try {
68 const home = await $.env.get('HOME')
69 const line = JSON.stringify({ at: new Date().toISOString(), session: await $.session.id(), ...event })
70 await $.process.run(
71 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${home}/.claude/mod-logs/handoff.jsonl`],
72 { stdin: `${line}\n` },
73 )
74 } catch {
75 // Logging is best effort.
76 }
77}
78
79function duration(ms: number): string {
80 const seconds = Math.max(0, Math.round(ms / 1000))
81 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
82}
83
84function quietNote(progress: Progress, now: number): string {
85 if (progress.outcome !== null || progress.lastEventAt === null) return ''
86 const quiet = now - progress.lastEventAt
87 return quiet >= QUIET_SHOW_MS ? ` · quiet ${duration(quiet)}` : ''
88}
89
90const COMMAND_RULE = "If a command runs longer than 5 minutes, stop it, don't retry it, and list it as a deviation."
91
92function agyPrompt(path: string): string {
93 return [
94 `Implement @${path} exactly as written. If you must deviate, do the minimum and list every deviation in your final message. Don't refactor anything the plan doesn't mention.`,
95 COMMAND_RULE,
96 ].join(' ')
97}
98
99function fixPrompt(planPath: string, fixesPath: string): string {
100 return [
101 `Fix the review findings in @${fixesPath} for the work done from @${planPath}.`,
102 "Change only what each finding needs; if you disagree with one, skip it and say why in your final message.",
103 COMMAND_RULE,
104 ].join(' ')
105}
106
107function fixesMarkdown(target: Plan, findings: Finding[]): string {
108 const items = findings.map((finding, i) => {
109 const where = finding.line === undefined ? finding.file : `${finding.file}:${finding.line}`
110 const category = finding.category ? ` (${finding.category})` : ''
111 return `${i + 1}. \`${where}\`${category}: ${finding.summary}\n ${finding.failure_scenario}`
112 })
113 return [`# Review findings for ${target.slug}`, `From reviewing the work against ${target.path}.`, ...items].join('\n\n') + '\n'
114}
115
116function reviewPrompt(target: Plan, current: Run | null): string {
117 const parts = [
118 `Use the handoff:plan-diff-review skill to review what agy implemented against ${target.path}. Report with ReportFindings.`,
119 `The diff is everything since ${target.baseRev.slice(0, 12)}: \`git diff ${target.baseRev}\` plus untracked files from \`git status\`.`,
120 ]
121 if (current?.fixesPath) {
122 parts.push(`This run, agy was fixing the findings in ${current.fixesPath}. Check each one is resolved, and report what is still open plus anything new.`)
123 }
124 if (target.dirtyAtStart.length > 0) {
125 parts.push(`These files already had uncommitted changes before the handoff, so not all of their diff is agy's: ${target.dirtyAtStart.join(', ')}.`)
126 }
127 if (current?.progress.response) parts.push(`agy's final message:\n\n${current.progress.response.trim()}`)
128 if (current?.progress.outcome === 'failed') parts.push('agy did not finish successfully; say what is missing.')
129 if (current?.progress.outcome === 'stalled') {
130 const command = current.progress.runningCommand?.command
131 const doing = command ? ` while running \`${command}\`` : ''
132 parts.push(`agy was stopped after ${QUIET_STOP_MS / 60_000} minutes with no output${doing}; treat whatever that step verifies as unverified, and say what is missing.`)
133 }
134 if (current?.progress.outcome === 'lost') parts.push('The session lost track of agy mid-run, so it may not have finished; say what is missing.')
135
136 return parts.join('\n\n')
137}
138
139async function git($: $, root: string, ...args: string[]) {
140 return $.process.run(['git', ...args], { cwd: root })
141}
142
143async function repoRoot($: $, dir: string): Promise<string | undefined> {
144 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
145 return top.exitCode === 0 ? top.stdout.trim() : undefined
146}
147
148async function capturePlan($: $, absolute: string): Promise<Plan | undefined> {
149 const dir = absolute.slice(0, absolute.lastIndexOf('/'))
150 const root = await repoRoot($, dir)
151 if (!root || dir !== `${root}/plans`) return undefined
152
153 const head = await git($, root, 'rev-parse', 'HEAD')
154 const status = await git($, root, 'status', '--porcelain', '--', '.', ':!plans')
155 const markdown = await $.fs.read(absolute).catch(() => '')
156 const captured: Plan = {
157 slug: absolute.slice(dir.length + 1, -'.md'.length),
158 path: absolute.slice(root.length + 1),
159 root,
160 baseRev: head.stdout.trim(),
161 dirtyAtStart: status.stdout.split('\n').filter(Boolean).map(line => line.slice(3)),
162 target: planTarget(markdown),
163 }
164 await update($, plan, () => captured)
165 await update($, run, () => null)
166 await update($, reviewResult, () => null)
167 await update($, isBandHidden, () => false)
168 void logEvent($, { event: 'plan', slug: captured.slug, root, dirtyAtStart: captured.dirtyAtStart.length, target: captured.target })
169
170 return captured
171}
172
173async function findPlan($: $, query: string): Promise<string> {
174 const root = await repoRoot($, await $.session.cwd())
175 if (!root) return 'Not inside a git repo.'
176 const entries = await $.fs.list(`${root}/plans`).catch(() => [])
177 const plans = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
178 const matches = plans.filter(entry => entry.name.includes(query))
179 if (matches.length === 0) return `No plan in plans/ matching "${query}".`
180 if (matches.length > 1) return `Several plans match: ${matches.map(m => m.name).join(', ')}`
181
182 const captured = await capturePlan($, `${root}/plans/${matches[0]!.name}`)
183 return captured ? `Plan ready: ${captured.path}. Pick how to hand it off above the prompt.` : 'Could not read that plan.'
184}
185
186async function setProgress($: $, change: (progress: Progress) => Progress) {
187 await update($, run, current => (current ? { ...current, progress: change(current.progress) } : current))
188}
189
190async function isAlive($: $, pid: number): Promise<boolean> {
191 const ps = await $.process.run(['ps', '-o', 'stat=', '-p', String(pid)])
192 const stat = ps.stdout.trim()
193 return ps.exitCode === 0 && stat !== '' && !stat.startsWith('Z')
194}
195
196async function killGroup($: $, pgid: number) {
197 await $.process.run(['kill', '-TERM', '--', `-${pgid}`])
198 $.clock.after(KILL_GRACE_MS, () => void $.process.run(['kill', '-KILL', '--', `-${pgid}`]))
199}
200
201async function endAgent($: $, current: Agent) {
202 if (current.pid === undefined) {
203 void current.stream.return(undefined as never)
204 current.abandon()
205 return
206 }
207 await killGroup($, current.pid)
208 // A process that left the group or survives KILL would hold the stream open forever.
209 $.clock.after(KILL_GRACE_MS * 2, () => current.abandon())
210}
211
212async function watchAgent($: $, current: Agent) {
213 if (agent !== current || current.endReason || current.hasExited) return
214 await $.ui.invalidate('ui.render')
215
216 if (current.pid !== undefined && !(await isAlive($, current.pid))) {
217 // agy exited, but anything it left running (a dev server, a test runner) holds its stdout open.
218 current.hasExited = true
219 await endAgent($, current)
220 return
221 }
222
223 const progress = (await read($, run))?.progress
224 if (!progress || progress.outcome !== null || progress.lastEventAt === null) return
225 const quiet = (await $.clock.now()) - progress.lastEventAt
226 const doing = progress.runningCommand ? ` while running ${progress.runningCommand.command}` : ''
227 if (quiet >= QUIET_STOP_MS) {
228 current.endReason = 'stalled'
229 await setProgress($, p => ({ ...p, activity: `stalled: no output for ${duration(quiet)}${doing}` }))
230 await endAgent($, current)
231 } else if (quiet >= QUIET_WARN_MS && !current.isStallWarned) {
232 current.isStallWarned = true
233 $.ui.toast(`agy has been quiet for ${duration(quiet)}${doing}; it is stopped at ${QUIET_STOP_MS / 60_000}m`)
234 }
235}
236
237async function pumpAgent($: $, current: Agent, target: Plan) {
238 let partial = ''
239 let ended: ProcessSpawnResult | undefined
240 const abandoned = new Promise<'abandoned'>(resolve => {
241 current.abandon = () => resolve('abandoned')
242 })
243 try {
244 while (true) {
245 const step = await Promise.race([current.stream.next(), abandoned])
246 if (step === 'abandoned') break
247 if (step.done) {
248 ended = step.value
249 break
250 }
251 if (step.value.stream !== 'stdout') continue
252 const { lines, rest } = splitLines(partial, step.value.text)
253 partial = rest
254 for (const line of lines) {
255 if (line.startsWith(PID_MARK)) {
256 const pid = Number(line.slice(PID_MARK.length))
257 current.pid = pid
258 await update($, run, r => (r ? { ...r, pid } : r))
259 continue
260 }
261 const now = await $.clock.now()
262 current.isStallWarned = false
263 await setProgress($, progress => applyLine(progress, line, target.root, now))
264 }
265 }
266 } catch (error) {
267 await setProgress($, progress => ({ ...progress, activity: `agy failed to run: ${String(error)}` }))
268 }
269
270 current.watchdog?.cancel()
271 agent = undefined
272 await setProgress($, progress => ({
273 ...progress,
274 outcome: current.endReason ?? progress.outcome ?? (ended?.code === 0 ? 'done' : 'failed'),
275 }))
276 const finished = await read($, run)
277 const outcome = finished?.progress.outcome
278 void logEvent($, {
279 event: 'run-end',
280 mode: 'headless',
281 slug: target.slug,
282 outcome,
283 durationMs: finished ? (await $.clock.now()) - finished.startedAt : null,
284 files: finished?.progress.files.length ?? 0,
285 steps: finished?.progress.steps ?? 0,
286 commands: finished?.progress.commands.length ?? 0,
287 exitCode: ended?.code ?? null,
288 })
289 $.ui.toast(`agy ${outcome === 'done' ? 'finished' : outcome}: ${finished?.progress.files.length ?? 0} files changed`)
290 if (outcome !== 'stopped') {
291 void logEvent($, { event: 'review', slug: target.slug, trigger: 'auto' })
292 await update($, reviewResult, () => ({ findings: null }))
293 void $.prompt.submit({ text: reviewPrompt(target, finished) })
294 }
295}
296
297async function runHeadless($: $, fixesPath: string | null = null): Promise<string> {
298 const target = await read($, plan)
299 if (!target) return 'No plan to hand off. Run /handoff first.'
300 if (agent) return 'agy is already running.'
301
302 const prompt = fixesPath ? fixPrompt(target.path, fixesPath) : agyPrompt(target.path)
303 // setpgrp puts agy at the head of its own process group, so killing the group reaches the servers and test runners it starts.
304 const stream = $.process.spawn({
305 argv: [
306 '/usr/bin/perl', '-e', `$| = 1; setpgrp(0, 0); print "${PID_MARK}$$\\n"; exec @ARGV or die "exec: $!\\n"`,
307 'agy', '-p', prompt, '--add-dir', target.root, '--mode', 'accept-edits', '--output-format', 'stream-json',
308 ],
309 cwd: target.root,
310 env: { CI: '1' },
311 })
312 const current: Agent = { stream, hasExited: false, isStallWarned: false, abandon: () => {} }
313 agent = current
314 void logEvent($, { event: 'run-start', mode: 'headless', slug: target.slug, fixes: fixesPath !== null })
315 const now = await $.clock.now()
316 await update($, run, (): Run => ({ mode: 'headless', startedAt: now, progress: emptyProgress(now), changedFiles: 0, pid: null, fixesPath }))
317 await update($, reviewResult, () => null)
318 current.watchdog = $.clock.every(WATCH_MS, () => void watchAgent($, current))
319 void pumpAgent($, current, target)
320 await $.ui.open({ id: PANE, title: `agy · ${target.slug}` })
321
322 const task = fixesPath ? `fixing the findings in ${fixesPath}` : `implementing ${target.path}`
323 return `agy is ${task} headless; the review starts when it exits.`
324}
325
326async function stopAgent($: $): Promise<string> {
327 if (agent) {
328 agent.endReason = 'stopped'
329 await endAgent($, agent)
330 return 'Stopping agy.'
331 }
332
333 const current = await read($, run)
334 if (current?.progress.outcome !== 'lost' || current.pid === null) return 'agy is not running.'
335 await killGroup($, current.pid)
336 await setProgress($, progress => ({ ...progress, outcome: 'stopped', activity: 'stopped what agy left running' }))
337 return 'Stopped what agy left running.'
338}
339
340// A reload drops the module's process handle; a headless run still marked running is no longer tracked.
341async function markLost($: $) {
342 const current = await read($, run)
343 if (agent || current?.mode !== 'headless' || current.progress.outcome !== null) return
344
345 const isRunning = current.pid !== null && (await isAlive($, current.pid))
346 await setProgress($, progress => ({
347 ...progress,
348 outcome: 'lost',
349 activity: isRunning ? `the mod reloaded; agy (pid ${current.pid}) still runs untracked` : 'the mod reloaded mid-run; agy is gone',
350 }))
351 void logEvent($, { event: 'run-end', mode: 'headless', outcome: 'lost', isRunning })
352}
353
354async function countChanged($: $, target: Plan) {
355 const status = await git($, target.root, 'status', '--porcelain', '--', '.', ':!plans')
356 const committed = await git($, target.root, 'diff', '--name-only', target.baseRev, 'HEAD')
357 const files = new Set([
358 ...status.stdout.split('\n').filter(Boolean).map(line => line.slice(3)),
359 ...committed.stdout.split('\n').filter(Boolean),
360 ])
361 await update($, run, current => (current ? { ...current, changedFiles: files.size } : current))
362 $.ui.status(`agy -i: ${files.size} files changed since handoff`)
363}
364
365type Surface = Parameters<$['ui']['copy']>[0]['surface']
366
367async function handOffInteractive($: $, surface?: Surface, fixesPath: string | null = null): Promise<string> {
368 const target = await read($, plan)
369 if (!target) return 'No plan to hand off. Run /handoff first.'
370
371 const text = fixesPath ? fixPrompt(target.path, fixesPath) : `handoff ${target.slug}`
372 const copied = await $.ui.copy({ text, surface })
373 void logEvent($, { event: 'run-start', mode: 'interactive', slug: target.slug, fixes: fixesPath !== null })
374 const now = await $.clock.now()
375 await update($, run, (): Run => ({ mode: 'interactive', startedAt: now, progress: emptyProgress(now), changedFiles: 0, pid: null, fixesPath }))
376 await update($, reviewResult, () => null)
377 poll?.cancel()
378 poll = $.clock.every(POLL_MS, () => void countChanged($, target))
379 await countChanged($, target)
380
381 if (fixesPath) {
382 return copied.isCopied
383 ? 'Copied the fix prompt. Paste it into agy -i; /handoff review when agy is done.'
384 : `Give agy -i this prompt, then /handoff review when it's done:\n\n${text}`
385 }
386 return copied.isCopied
387 ? `Copied \`${text}\`. Run it in another terminal; /handoff review when agy is done.`
388 : `Run \`${text}\` in another terminal; /handoff review when agy is done.`
389}
390
391async function sendFixes($: $, mode: 'headless' | 'interactive', surface?: Surface): Promise<string> {
392 const target = await read($, plan)
393 const findings = (await read($, reviewResult))?.findings
394 if (!target || !findings || findings.length === 0) return 'No review findings to hand back.'
395 if (agent) return 'agy is already running.'
396
397 const fixesPath = `plans/fixes/${target.slug}.md`
398 await $.process.run(['mkdir', '-p', `${target.root}/plans/fixes`])
399 await $.fs.write(`${target.root}/${fixesPath}`, fixesMarkdown(target, findings))
400 void logEvent($, { event: 'fixes', slug: target.slug, findings: findings.length, mode })
401
402 return mode === 'headless' ? runHeadless($, fixesPath) : handOffInteractive($, surface, fixesPath)
403}
404
405async function review($: $): Promise<string> {
406 const target = await read($, plan)
407 if (!target) return 'No plan has been handed off.'
408 poll?.cancel()
409 poll = undefined
410 $.ui.status(undefined)
411 const current = await read($, run)
412 await update($, isBandHidden, () => true)
413 if (current?.mode === 'interactive') {
414 void logEvent($, {
415 event: 'run-end',
416 mode: 'interactive',
417 slug: target.slug,
418 outcome: 'reviewed',
419 durationMs: (await $.clock.now()) - current.startedAt,
420 files: current.changedFiles,
421 })
422 }
423 void logEvent($, { event: 'review', slug: target.slug, trigger: 'manual' })
424 await update($, reviewResult, () => ({ findings: null }))
425 void $.prompt.submit({ text: reviewPrompt(target, current) })
426
427 return `Reviewing against ${target.path}.`
428}
429
430async function dismiss($: $) {
431 await update($, isBandHidden, () => true)
432}
433
434async function apiKey($: $): Promise<string | undefined> {
435 if (julesKey) return julesKey
436 const stored = await $.process.run(['security', 'find-generic-password', '-s', 'jules-api', '-w'])
437 const key = stored.exitCode === 0 ? stored.stdout.trim() : await $.env.get('JULES_API_KEY')
438 julesKey = key || undefined
439 return julesKey
440}
441
442async function julesFetch<T>($: $, path: string, body?: unknown): Promise<T> {
443 const key = await apiKey($)
444 if (!key) throw new Error(KEY_HELP)
445 const response = await $.http.fetch(`${JULES_API}/${path}`, {
446 method: body === undefined ? 'GET' : 'POST',
447 headers: { 'X-Goog-Api-Key': key, 'Content-Type': 'application/json' },
448 body: body === undefined ? undefined : JSON.stringify(body),
449 })
450 if (!response.ok) throw new Error(`Jules API ${apiError(response.status, response.text)}`)
451 return JSON.parse(response.text || '{}') as T
452}
453
454// Returns the GitHub source for `branch` once it's on origin with nothing unpushed, or why it can't be used.
455async function pushedBranch($: $, root: string, requested?: string): Promise<{ source: string; branch: string } | string> {
456 const current = (await git($, root, 'rev-parse', '--abbrev-ref', 'HEAD')).stdout.trim()
457 const branch = requested?.trim() || current
458 if (!branch || branch === 'HEAD') return 'Detached HEAD: check out a branch first.'
459
460 const source = sourceName((await git($, root, 'remote', 'get-url', 'origin')).stdout)
461 if (!source) return "origin isn't a GitHub remote, so Jules can't reach this repo."
462
463 const onOrigin = await git($, root, 'rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`)
464 if (onOrigin.exitCode !== 0) return `${branch} isn't on origin yet. Push it first: git push -u origin ${branch}`
465
466 if (branch === current) {
467 const ahead = Number((await git($, root, 'rev-list', '--count', `origin/${branch}..HEAD`)).stdout.trim())
468 if (ahead > 0) return `${ahead} commit${ahead === 1 ? '' : 's'} on ${branch} aren't pushed, and Jules only sees origin. Push first: git push`
469 }
470
471 return { source, branch }
472}
473
474async function startJules(
475 $: $,
476 task: { title: string; prompt: string; root: string; branch?: string; plan: Plan | null },
477): Promise<JulesJob | string> {
478 const pushed = await pushedBranch($, task.root, task.branch)
479 if (typeof pushed === 'string') return pushed
480
481 const session = await julesFetch<JulesSession>($, 'sessions', {
482 title: task.title,
483 prompt: task.prompt,
484 sourceContext: { source: pushed.source, githubRepoContext: { startingBranch: pushed.branch } },
485 automationMode: 'AUTO_CREATE_PR',
486 requirePlanApproval: false,
487 })
488 const job: JulesJob = {
489 session: session.name ?? `sessions/${session.id}`,
490 url: sessionUrl(session),
491 title: task.title,
492 prompt: task.prompt,
493 branch: pushed.branch,
494 plan: task.plan,
495 startedAt: Date.now(),
496 state: session.state ?? 'QUEUED',
497 activity: 'starting',
498 prUrl: null,
499 response: null,
500 isReviewed: false,
501 }
502 await update($, julesJobs, jobs => [...jobs, job])
503 void logEvent($, { event: 'run-start', mode: 'jules', slug: task.plan?.slug ?? null, title: task.title, branch: pushed.branch })
504 await ensureJulesPoll($)
505
506 return job
507}
508
509async function sendPlanToJules($: $): Promise<string> {
510 const target = await read($, plan)
511 if (!target) return 'No plan to hand off. Run /handoff first.'
512
513 try {
514 const markdown = await $.fs.read(`${target.root}/${target.path}`)
515 const started = await startJules($, {
516 title: target.slug,
517 prompt: julesPlanPrompt(target.path, markdown),
518 root: target.root,
519 plan: target,
520 })
521 if (typeof started === 'string') return started
522
523 await update($, isBandHidden, () => true)
524 const dirty = target.dirtyAtStart.length
525 const note = dirty > 0 ? ` ${dirty} uncommitted file${dirty === 1 ? '' : 's'} aren't part of what Jules sees.` : ''
526 return `Jules is implementing ${target.path} from origin/${started.branch}: ${started.url}. The review starts when its PR lands.${note}`
527 } catch (error) {
528 return (error as Error).message
529 }
530}
531
532async function updateJob($: $, session: string, change: (job: JulesJob) => JulesJob) {
533 await update($, julesJobs, jobs => jobs.map(job => (job.session === session ? change(job) : job)))
534}
535
536async function fetchActivities($: $, session: string): Promise<JulesActivity[]> {
537 const activities: JulesActivity[] = []
538 let pageToken = ''
539 for (let page = 0; page < MAX_ACTIVITY_PAGES; page++) {
540 const query = `pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ''}`
541 const result = await julesFetch<{ activities?: JulesActivity[]; nextPageToken?: string }>($, `${session}/activities?${query}`)
542 activities.push(...(result.activities ?? []))
543 if (!result.nextPageToken) break
544 pageToken = result.nextPageToken
545 }
546 return activities
547}
548
549async function pollJules($: $) {
550 if (isJulesPolling) return
551 isJulesPolling = true
552 try {
553 for (const job of await read($, julesJobs)) {
554 if (isFinished(job.state)) continue
555 try {
556 const session = await julesFetch<JulesSession>($, job.session)
557 const activities = await fetchActivities($, job.session)
558 const next = foldSession(job, session, activities)
559 await updateJob($, job.session, () => next)
560 if (isFinished(next.state)) await finishJules($, next)
561 } catch (error) {
562 await updateJob($, job.session, current => ({ ...current, activity: `poll failed: ${(error as Error).message}` }))
563 }
564 }
565 } finally {
566 isJulesPolling = false
567 }
568 await ensureJulesPoll($)
569}
570
571async function ensureJulesPoll($: $) {
572 const isActive = (await read($, julesJobs)).some(job => !isFinished(job.state))
573 if (isActive && !julesPoll) julesPoll = $.clock.every(JULES_POLL_MS, () => void pollJules($))
574 if (!isActive && julesPoll) {
575 julesPoll.cancel()
576 julesPoll = undefined
577 }
578}
579
580async function finishJules($: $, job: JulesJob) {
581 void logEvent($, {
582 event: 'run-end',
583 mode: 'jules',
584 slug: job.plan?.slug ?? null,
585 title: job.title,
586 outcome: job.state === 'COMPLETED' ? 'done' : 'failed',
587 durationMs: Date.now() - job.startedAt,
588 hasPr: job.prUrl !== null,
589 })
590 const pr = job.prUrl ? `: ${job.prUrl}` : ', no PR'
591 $.ui.toast(`Jules ${job.state === 'COMPLETED' ? 'finished' : 'failed'} ${job.title}${pr}`)
592 if (job.plan && job.prUrl) await reviewJules($, job.session, 'auto')
593}
594
595async function reviewJules($: $, session: string, trigger: 'auto' | 'manual'): Promise<string> {
596 const job = (await read($, julesJobs)).find(j => j.session === session)
597 if (!job) return 'That Jules job is no longer tracked.'
598 await updateJob($, session, current => ({ ...current, isReviewed: true }))
599 void logEvent($, { event: 'review', mode: 'jules', slug: job.plan?.slug ?? null, title: job.title, trigger })
600 void $.prompt.submit({ text: julesReviewPrompt(job) })
601
602 return `Reviewing Jules's ${job.title}.`
603}
604
605async function forgetJules($: $, session: string) {
606 await update($, julesJobs, jobs => jobs.filter(job => job.session !== session))
607 await ensureJulesPoll($)
608}
609
610async function openJulesPane($: $) {
611 await $.ui.open({ id: JULES_PANE, title: 'jules' })
612 void pollJules($)
613}
614
615async function delegate($: $, input: Record<string, unknown>): Promise<string> {
616 const title = String(input.title ?? '').trim()
617 const prompt = String(input.prompt ?? '').trim()
618 if (!title || !prompt) throw new Error('title and prompt are both required')
619 const root = await repoRoot($, await $.session.cwd())
620 if (!root) throw new Error('not inside a git repo')
621
622 const branch = typeof input.branch === 'string' ? input.branch : undefined
623 const started = await startJules($, { title, prompt, root, branch, plan: null })
624 if (typeof started === 'string') throw new Error(started)
625
626 return `Jules session started on origin/${started.branch}: ${started.url}. It opens a PR when done; the user tracks it in /handoff jules-status.`
627}
628
629async function isPlanFile($: $, input: unknown): Promise<boolean> {
630 const path = (input as { file_path?: unknown } | null)?.file_path
631 if (typeof path !== 'string') return false
632 const root = await repoRoot($, await $.session.cwd())
633 if (!root || !path.startsWith(`${root}/plans/`)) return false
634 const name = path.slice(`${root}/plans/`.length)
635 return /^[^/]+\.md$/.test(name) && !name.includes('..')
636}
637
638function toolText(body: string, isError = false) {
639 return isError ? { result: body, isError: true as const } : { result: body }
640}
641
642export const register: Register = on => {
643 on('session.start', async ($, e, next) => {
644 const started = await next(e)
645 await $.command.register({
646 name: 'handoff',
647 description: 'Hand the plan to agy or Jules: no args writes it with /handoff:plan-handoff; <slug>, review, fix, stop, jules, jules-status',
648 argumentHint: '[<plan slug> | review | fix | stop | jules | jules-status]',
649 })
650 await $.tool.register({
651 name: 'jules_delegate',
652 description: JULES_TOOL_DESCRIPTION,
653 inputSchema: {
654 type: 'object',
655 properties: {
656 title: { type: 'string', description: 'Short title for the Jules session and its PR.' },
657 prompt: { type: 'string', description: 'The full, self-contained task.' },
658 branch: { type: 'string', description: 'Pushed branch to start from; defaults to the current branch.' },
659 },
660 required: ['title', 'prompt'],
661 },
662 })
663 await ensureJulesPoll($)
664 await markLost($)
665 return started
666 })
667
668 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
669 const result = await next(e)
670 if (result.deny === undefined && result.isError !== true && /\/plans\/[^/]+\.md$/.test(e.file_path)) {
671 await capturePlan($, e.file_path)
672 }
673 return result
674 }).catch(($, e, next) => next(e))
675
676 // A plan copied in through the shell first shows up as an Edit; editing the plan already captured must not reset its run.
677 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
678 const result = await next(e)
679 if (result.deny === undefined && result.isError !== true && /\/plans\/[^/]+\.md$/.test(e.file_path)) {
680 const current = await read($, plan)
681 if (!current || `${current.root}/${current.path}` !== e.file_path) await capturePlan($, e.file_path)
682 }
683 return result
684 }).catch(($, e, next) => next(e))
685
686 for (const tool of ['Write', 'Edit']) {
687 on('tool.check', { tool }, async ($, e, next) => {
688 const verdict = await next(e)
689 if (verdict.decision === 'deny' || !(await isPlanFile($, e.input))) return verdict
690 return { decision: 'allow' as const, reason: 'handoff: plan file in the repo plans/ dir' }
691 }).catch(($, e, next) => next(e))
692 }
693
694 // Only a review this mod started is waiting; a ReportFindings from any other review is left alone.
695 on('tool.call', { tool: 'ReportFindings' }, async ($, e, next) => {
696 const result = await next(e)
697 if (result.deny === undefined && result.isError !== true && (await read($, reviewResult))?.findings === null) {
698 const findings: Finding[] = e.findings.map(({ file, line, summary, failure_scenario, category }) => ({
699 file,
700 line,
701 summary,
702 failure_scenario,
703 category,
704 }))
705 await update($, reviewResult, () => ({ findings }))
706 if (findings.length > 0) await update($, isBandHidden, () => false)
707 }
708 return result
709 }).catch(($, e, next) => next(e))
710
711 on('tool.call', { tool: 'mcp__handoff__jules_delegate' }, async ($, e) => {
712 try {
713 return toolText(await delegate($, e))
714 } catch (error) {
715 return toolText(`jules_delegate failed: ${(error as Error).message}`, true)
716 }
717 }).catch(() => toolText('jules_delegate failed', true))
718
719 on('command.run', { command: 'handoff' }, async ($, e) => {
720 const arg = e.args.trim()
721 if (arg === 'review') return { text: await review($) }
722 if (arg === 'stop') return { text: await stopAgent($) }
723 if (arg === 'fix') return { text: await sendFixes($, 'headless') }
724 if (arg === 'headless') return { text: await runHeadless($) }
725 if (arg === 'copy') return { text: await handOffInteractive($) }
726 if (arg === 'jules') return { text: await sendPlanToJules($) }
727 if (arg === 'jules-status') {
728 await openJulesPane($)
729 return { text: `${(await read($, julesJobs)).length} Jules jobs tracked.` }
730 }
731 if (arg !== '') return { text: await findPlan($, arg) }
732
733 void $.prompt.submit({ text: '/handoff:plan-handoff' })
734 return { text: 'Writing the plan with /handoff:plan-handoff; pick how to hand it off when it lands.' }
735 }).catch(($, e) => ({ text: `/handoff ${e.args.trim()} failed; see claude --debug for the error.` }))
736
737 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
738 if (e.props.hasSurvey) return next(e)
739 const { Box, Text, Button } = $.ui.resolve(e)
740 const target = await read($, plan)
741 const current = await read($, run)
742 const jobs = await read($, julesJobs)
743 const findings = (await read($, reviewResult))?.findings ?? []
744 const now = await $.clock.now()
745
746 let planRow = null
747 if (target && !(await read($, isBandHidden))) {
748 if (findings.length > 0) {
749 planRow = (
750 <Box>
751 <Text>Review: </Text>
752 <Text bold>
753 {findings.length} finding{findings.length === 1 ? '' : 's'}{' '}
754 </Text>
755 <Text dimColor>on {target.slug} </Text>
756 <Button
757 key="fix-headless"
758 label="Fix headless"
759 variant="primary"
760 onPress={() => void sendFixes($, 'headless').then(text => $.ui.toast(text))}
761 />
762 <Button
763 key="fix-copy"
764 label="Copy fix for agy -i"
765 onPress={press => void sendFixes($, 'interactive', press.surface).then(text => $.ui.toast(text))}
766 />
767 <Button key="dismiss" label="×" plain onPress={() => dismiss($)} />
768 </Box>
769 )
770 } else if (!current) {
771 const suggested = target.target ?? 'agy'
772 planRow = (
773 <Box>
774 <Text>Plan ready: </Text>
775 <Text bold>{target.path} </Text>
776 {target.target === 'claude' ? <Text dimColor>(targets claude) </Text> : null}
777 <Button key="headless" label="Run headless" variant={suggested === 'agy' ? 'primary' : undefined} onPress={() => void runHeadless($)} />
778 <Button key="copy" label="Copy for agy -i" onPress={press => void handOffInteractive($, press.surface)} />
779 <Button
780 key="jules"
781 label="Send to Jules"
782 variant={suggested === 'jules' ? 'primary' : undefined}
783 onPress={() => void sendPlanToJules($).then(text => $.ui.toast(text))}
784 />
785 <Button key="dismiss" label="×" plain onPress={() => dismiss($)} />
786 </Box>
787 )
788 } else if (current.mode === 'interactive') {
789 planRow = (
790 <Box>
791 <Text dimColor>agy -i on {target.slug}: {current.changedFiles} files changed · {duration(now - current.startedAt)} </Text>
792 <Button key="review" label="Review now" variant="primary" onPress={() => void review($)} />
793 <Button key="dismiss" label="×" plain onPress={() => dismiss($)} />
794 </Box>
795 )
796 } else {
797 const { progress } = current
798 const isRunning = progress.outcome === null
799 const isStoppable = isRunning || (progress.outcome === 'lost' && current.pid !== null)
800 planRow = (
801 <Box>
802 <Text color={isRunning ? 'warning' : progress.outcome === 'done' ? 'success' : 'error'}>
803 agy {isRunning ? 'running' : progress.outcome}{' '}
804 </Text>
805 <Text dimColor wrap="truncate-end">
806 {target.slug} · {progress.files.length} files · {duration(now - current.startedAt)} · {progress.activity}
807 {quietNote(progress, now)}{' '}
808 </Text>
809 <Button key="open" label="Details" onPress={() => void $.ui.open({ id: PANE, title: `agy · ${target.slug}` })} />
810 {isStoppable ? (
811 <Button key="stop" label="Stop" onPress={() => void stopAgent($)} />
812 ) : (
813 <Button key="dismiss" label="×" plain onPress={() => dismiss($)} />
814 )}
815 </Box>
816 )
817 }
818 }
819
820 let julesRow = null
821 if (jobs.length > 0) {
822 const running = jobs.filter(job => !isFinished(job.state)).length
823 const waiting = jobs.filter(job => job.state.startsWith('AWAITING')).length
824 const ready = jobs.filter(job => isFinished(job.state) && job.prUrl && !job.isReviewed).length
825 const parts = [
826 running > 0 ? `${running} running` : '',
827 waiting > 0 ? `${waiting} need${waiting === 1 ? 's' : ''} input` : '',
828 ready > 0 ? `${ready} ready for review` : '',
829 ].filter(Boolean)
830 julesRow = (
831 <Box>
832 <Text color={waiting > 0 ? 'warning' : ready > 0 ? 'success' : undefined}>jules: </Text>
833 <Text dimColor>{parts.length > 0 ? parts.join(' · ') : `${jobs.length} done`} </Text>
834 <Button key="jules-open" label="Jobs" onPress={() => void openJulesPane($)} />
835 </Box>
836 )
837 }
838
839 if (!planRow && !julesRow) return next(e)
840 return (
841 <Box flexDirection="column">
842 {planRow}
843 {julesRow}
844 </Box>
845 )
846 })
847
848 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
849 const { Box, Text, Button } = $.ui.resolve(e)
850 const target = await read($, plan)
851 const current = await read($, run)
852 if (!target || !current) return <Text dimColor>Nothing handed off yet.</Text>
853
854 const { progress } = current
855 const now = await $.clock.now()
856 const isStoppable = progress.outcome === null || (progress.outcome === 'lost' && current.pid !== null)
857 const room = Math.max(3, (e.viewport?.rows ?? 30) - progress.commands.length - 10)
858 return (
859 <Box flexDirection="column">
860 <Text bold>{target.path}</Text>
861 <Text dimColor>
862 {current.mode} · {progress.outcome ?? 'running'} · {duration(now - current.startedAt)} · {progress.steps} steps
863 {progress.conversationId ? ` · agy --conversation ${progress.conversationId}` : ''}
864 </Text>
865 <Text wrap="truncate-end">
866 ▸ {progress.activity}
867 {quietNote(progress, now)}
868 </Text>
869 <Text dimColor>Files ({progress.files.length})</Text>
870 {progress.files.slice(-room).map(file => (
871 <Text key={`file-${file}`} wrap="truncate-start">
872 {' '}
873 {file}
874 </Text>
875 ))}
876 {progress.commands.length > 0 ? <Text dimColor>Commands</Text> : null}
877 {progress.commands.map((command, i) => (
878 <Text key={`cmd-${i}`} dimColor wrap="truncate-end">
879 {' $ '}
880 {command}
881 </Text>
882 ))}
883 <Box>
884 {isStoppable ? <Button key="stop" label="Stop agy" onPress={() => void stopAgent($)} /> : null}
885 <Button key="review" label="Review against plan" onPress={() => void review($)} />
886 </Box>
887 </Box>
888 )
889 })
890
891 on('ui.render', { component: 'Pane', requestId: JULES_PANE }, async ($, e) => {
892 const { Box, Text, Button, Link } = $.ui.resolve(e)
893 const jobs = await read($, julesJobs)
894 if (jobs.length === 0) return <Text dimColor>No Jules jobs tracked. /handoff jules sends the current plan.</Text>
895 const now = await $.clock.now()
896
897 return (
898 <Box flexDirection="column">
899 {jobs.map(job => {
900 const isDone = isFinished(job.state)
901 return (
902 <Box key={`job-${job.session}`} flexDirection="column" marginBottom={1}>
903 <Text bold wrap="truncate-end">
904 {job.title}
905 {job.plan ? ` (${job.plan.path})` : ''}
906 </Text>
907 <Text dimColor>
908 {job.state} · origin/{job.branch}
909 {isDone ? '' : ` · ${duration(now - job.startedAt)}`}
910 </Text>
911 <Text wrap="truncate-end">▸ {job.activity}</Text>
912 <Link href={job.url} label="session" />
913 {job.prUrl ? <Link href={job.prUrl} /> : null}
914 <Box>
915 {isDone && job.prUrl ? (
916 <Button
917 key={`review-${job.session}`}
918 label={job.isReviewed ? 'Review again' : 'Review'}
919 variant={job.isReviewed ? undefined : 'primary'}
920 onPress={() => void reviewJules($, job.session, 'manual')}
921 />
922 ) : null}
923 <Button key={`forget-${job.session}`} label="Forget" onPress={() => void forgetJules($, job.session)} />
924 </Box>
925 </Box>
926 )
927 })}
928 <Button key="refresh" label="Refresh" onPress={() => void pollJules($)} />
929 </Box>
930 )
931 })
932}
933hooks/agy.ts 93 lines1import type { Progress } from '../types'
2
3const MAX_COMMANDS = 8
4
5export function emptyProgress(now: number): Progress {
6 return {
7 conversationId: null,
8 activity: 'starting agy',
9 files: [],
10 commands: [],
11 steps: 0,
12 outcome: null,
13 response: null,
14 lastEventAt: now,
15 runningCommand: null,
16 }
17}
18
19function relative(path: string, root: string): string {
20 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
21}
22
23function isWrite(tool: string): boolean {
24 return /write|replace|edit|create|insert|delete|move|rename/i.test(tool) && !/^(view|read|list|grep|find|search)/i.test(tool)
25}
26
27type StepUpdate = {
28 step_index?: number
29 state?: string
30 step_type?: string
31 tool_name?: string
32 tool_info?: { parameters?: Record<string, unknown> }
33}
34
35// Folds one line of `agy --output-format stream-json` into the run's progress.
36export function applyLine(previous: Progress, line: string, root: string, now: number): Progress {
37 let event: { event?: string; conversation_id?: string; step_update?: StepUpdate; result?: { status?: string; response?: string } }
38 try {
39 event = JSON.parse(line)
40 } catch {
41 return previous
42 }
43 const progress = { ...previous, lastEventAt: now }
44
45 if (event.event === 'init') return { ...progress, conversationId: event.conversation_id ?? progress.conversationId }
46
47 if (event.event === 'result') {
48 const isSuccess = event.result?.status === 'SUCCESS'
49 return {
50 ...progress,
51 outcome: isSuccess ? 'done' : 'failed',
52 activity: isSuccess ? 'finished' : `ended: ${event.result?.status ?? 'unknown'}`,
53 response: event.result?.response ?? null,
54 runningCommand: null,
55 }
56 }
57
58 const step = event.step_update
59 if (event.event !== 'step_update' || !step) return progress
60
61 const steps = Math.max(progress.steps, (step.step_index ?? 0) + 1)
62 if (step.step_type === 'agent_response') return { ...progress, steps, activity: 'thinking', runningCommand: null }
63 if (step.step_type !== 'tool' || !step.tool_name) return { ...progress, steps }
64
65 const params = step.tool_info?.parameters ?? {}
66 const target = [params.TargetFile, params.AbsolutePath, params.FilePath].find((p): p is string => typeof p === 'string')
67 const command = typeof params.CommandLine === 'string' ? params.CommandLine : undefined
68 const subject = command ?? (target ? relative(target, root) : '')
69
70 let { files, commands, runningCommand } = progress
71 if (step.state === 'DONE' && target && isWrite(step.tool_name)) {
72 const file = relative(target, root)
73 if (!files.includes(file)) files = [...files, file]
74 }
75 if (step.state === 'DONE' && command) commands = [...commands, command].slice(-MAX_COMMANDS)
76
77 const index = step.step_index ?? -1
78 if (command && step.state === 'ACTIVE') {
79 if (runningCommand?.step !== index) runningCommand = { step: index, command, since: now }
80 } else {
81 runningCommand = null
82 }
83
84 return { ...progress, steps, files, commands, runningCommand, activity: `${step.tool_name} ${subject}`.trim() }
85}
86
87export function splitLines(partial: string, text: string): { lines: string[]; rest: string } {
88 const pieces = (partial + text).split('\n')
89 const rest = pieces.pop() ?? ''
90
91 return { lines: pieces.filter(line => line.trim() !== ''), rest }
92}
93hooks/jules.ts 107 lines1import type { JulesJob, Target } from '../types'
2
3export const JULES_API = 'https://jules.googleapis.com/v1alpha'
4
5const FINISHED = new Set(['COMPLETED', 'FAILED'])
6
7export type JulesSession = {
8 name?: string
9 id?: string
10 url?: string
11 state?: string
12 outputs?: { pullRequest?: { url?: string } }[]
13}
14
15export type JulesActivity = {
16 createTime?: string
17 description?: string
18 planGenerated?: { plan?: { steps?: unknown[] } }
19 progressUpdated?: { title?: string; description?: string }
20 agentMessaged?: { agentMessage?: string }
21 sessionCompleted?: Record<string, unknown>
22 sessionFailed?: { reason?: string }
23}
24
25// `sources/github/<owner>/<repo>` for a GitHub remote in https, scp-style or ssh:// form.
26export function sourceName(remote: string): string | undefined {
27 const match = remote.trim().match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
28 return match ? `sources/github/${match[1]}/${match[2]}` : undefined
29}
30
31export function planTarget(markdown: string): Target | null {
32 const match = markdown.match(/^\**Target:?\**:?\s*`?(jules|agy|claude)\b/im)
33 return match ? (match[1]!.toLowerCase() as Target) : null
34}
35
36export function isFinished(state: string): boolean {
37 return FINISHED.has(state)
38}
39
40function activityText(activity: JulesActivity): string | undefined {
41 if (activity.sessionFailed) return `failed: ${activity.sessionFailed.reason ?? 'no reason given'}`
42 if (activity.sessionCompleted) return 'completed'
43 if (activity.progressUpdated) return activity.progressUpdated.title ?? activity.progressUpdated.description
44 if (activity.agentMessaged?.agentMessage) return activity.agentMessaged.agentMessage
45 if (activity.planGenerated) return `planned ${activity.planGenerated.plan?.steps?.length ?? 0} steps`
46 return activity.description
47}
48
49// Folds one poll of the session and its activities into the job; the API is v1alpha, so every field is optional.
50export function foldSession(job: JulesJob, session: JulesSession, activities: readonly JulesActivity[]): JulesJob {
51 const ordered = [...activities].sort((a, b) => (a.createTime ?? '').localeCompare(b.createTime ?? ''))
52 const latest = ordered.map(activityText).filter((text): text is string => Boolean(text)).at(-1)
53 const lastMessage = ordered.map(a => a.agentMessaged?.agentMessage).filter((text): text is string => Boolean(text)).at(-1)
54 const prUrl = session.outputs?.map(output => output.pullRequest?.url).find(Boolean) ?? job.prUrl
55
56 return {
57 ...job,
58 state: session.state ?? job.state,
59 url: session.url ?? job.url,
60 activity: firstLine(latest ?? job.activity),
61 prUrl,
62 response: lastMessage ?? job.response,
63 }
64}
65
66function firstLine(text: string): string {
67 return text.split('\n').find(line => line.trim() !== '')?.trim() ?? text
68}
69
70export function sessionUrl(session: JulesSession): string {
71 return session.url ?? `https://jules.google.com/session/${session.id ?? session.name?.split('/').pop() ?? ''}`
72}
73
74export function julesPlanPrompt(path: string, plan: string): string {
75 return [
76 `Implement the plan below (from ${path} in this repo) exactly as written.`,
77 "If you must deviate, do the minimum and list every deviation in the PR description. Don't refactor anything the plan doesn't mention.",
78 '',
79 plan,
80 ].join('\n')
81}
82
83export function julesReviewPrompt(job: JulesJob): string {
84 const parts = job.plan
85 ? [`Use the handoff:plan-diff-review skill to review what Jules implemented against ${job.plan.path}.`]
86 : [`Review what Jules implemented for this task:\n\n${job.prompt}`]
87 parts.push(
88 job.prUrl
89 ? `The diff is Jules's PR ${job.prUrl}: \`gh pr diff ${job.prUrl}\`. It branched from origin/${job.branch}, so local uncommitted work isn't in it.`
90 : `Jules opened no PR; its session is ${job.url}.`,
91 )
92 if (job.response) parts.push(`Jules's last message:\n\n${job.response.trim()}`)
93 if (job.state === 'FAILED') parts.push('Jules did not finish successfully; say what is missing.')
94
95 return parts.join('\n\n')
96}
97
98export function apiError(status: number, text: string): string {
99 try {
100 const message = (JSON.parse(text) as { error?: { message?: string } }).error?.message
101 if (message) return `${status}: ${message}`
102 } catch {
103 // Not JSON; fall through to the raw body.
104 }
105 return `${status}: ${text.slice(0, 200)}`
106}
107types/index.d.ts 84 lines1export type Target = 'jules' | 'agy' | 'claude'
2
3export type Plan = {
4 slug: string
5 // Relative to root, e.g. plans/nfl-pickem.md.
6 path: string
7 root: string
8 baseRev: string
9 dirtyAtStart: string[]
10 // From the plan's `Target:` line; null when it has none.
11 target: Target | null
12}
13
14export type Progress = {
15 conversationId: string | null
16 activity: string
17 files: string[]
18 commands: string[]
19 steps: number
20 // stalled: the watchdog stopped agy after too long with no output. lost: a reload dropped the process handle mid-run.
21 outcome: 'done' | 'failed' | 'stopped' | 'stalled' | 'lost' | null
22 response: string | null
23 // Milliseconds since the epoch, like every timestamp here.
24 lastEventAt: number | null
25 // A run_command step agy has started and not finished.
26 runningCommand: { step: number; command: string; since: number } | null
27}
28
29export type Run = {
30 mode: 'headless' | 'interactive'
31 startedAt: number
32 progress: Progress
33 changedFiles: number
34 // agy's pid, which is also its process group; kept here so a reload can still stop what it left running.
35 pid: number | null
36 // Relative to root; set when this run worked from a review's findings rather than the plan.
37 fixesPath: string | null
38}
39
40// The part of a ReportFindings finding the fixes file needs.
41export type Finding = {
42 file: string
43 line?: number
44 summary: string
45 failure_scenario: string
46 category?: string
47}
48
49export type Review = {
50 // Null while a handoff review is waiting for its ReportFindings call.
51 findings: Finding[] | null
52}
53
54export type JulesJob = {
55 // API resource name, `sessions/<id>`.
56 session: string
57 url: string
58 title: string
59 prompt: string
60 branch: string
61 // Null for ad-hoc jobs from the jules_delegate tool.
62 plan: Plan | null
63 // Milliseconds since the epoch.
64 startedAt: number
65 // The API's session state, e.g. IN_PROGRESS, AWAITING_USER_FEEDBACK, COMPLETED, FAILED.
66 state: string
67 activity: string
68 prUrl: string | null
69 response: string | null
70 isReviewed: boolean
71}
72
73declare module 'claude-code' {
74 interface PluginState {
75 handoff: {
76 plan: Plan | null
77 run: Run | null
78 review: Review | null
79 isBandHidden: boolean
80 julesJobs: JulesJob[]
81 }
82 }
83}
84