SLOPSHOPPER

handoff

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…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-12daviddecorso/claude-mods/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
│ ┃ handoff ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing handed off yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /handoff │ ⎿ handoff: Writing the plan with /handoff:plan-handoff; pick how t │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · handoff
Nothing handed off yet.
Pane · jules
No Jules jobs tracked. /handoff jules sends the current plan.
README

claude-mods

Function-hook plugins for Claude Code. Each one installs on its own.

Install

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.

shell-edit-guard

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

dev-servers

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

handoff

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

aq

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

Developing

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.

License

MIT

Source 4 files
hooks/register.tsx 933 lines
1import { 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}
933
hooks/agy.ts 93 lines
1import 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}
93
hooks/jules.ts 107 lines
1import 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}
107
types/index.d.ts 84 lines
1export 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