SLOPSHOPPER

self-drive

/self-drive: hand Claude a task and walk away; it decides alone, logs every call it would have asked you, holds risky actions, and keeps going

newpaneguardcommandstatustool
v0.1.1MITupdated 2026-10-09sruthik27/claude-foundry/mods/self-drive
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · self-drive
│ ┃ self-drive ✕ › fix the failing auth test and add an audit log call │ ┃ No self-drive yet. Start one with │ ┃ /self-drive 2h <task> ⏺ 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 │ │ › /self-drive │ ⎿ self-drive: Usage: /self-drive [30m|3h] <task>. Then walk away. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · self-drive
No self-drive yet. Start one with /self-drive 2h <task>
README

self-drive

Experimental. Starts autonomous continuation only when invoked. Decisions and held actions are journaled. Recognized risky commands and messaging tools are held. Deadlines gate new tool calls; they cannot terminate running commands or agents. After a limit, stop releases the gate. Pattern checks are not a security sandbox.

Install

claude plugin install self-drive --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

/self-drive 30m finish the test suite
/self-drive
/self-drive stop

Details

MIT licensed. Maintained by Sruthik Issac.

Source 3 files
hooks/register.tsx 307 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Drive, Entry, EntryKind } from '../types'
5import { CONTINUE, MAX_CONTINUES, MAX_IDLE, brief, busyAgents, ended, fmtDur, logged, parseArgs, report, riskOf, riskyTool } from './drive.js'
6
7const PANE = 'self-drive'
8const drive = atom({ plugin: 'self-drive', key: 'drive' } as const, null)
9const LOG_TOOL = 'mcp__self-drive__self_drive_log'
10
11const KIND_STYLE: Record<EntryKind, { icon: string; color: string }> = {
12  decision: { icon: '◆', color: '#38bdf8' },
13  'needs-you': { icon: '✋', color: '#fbbf24' },
14  held: { icon: '⛔', color: '#f87171' },
15  note: { icon: '·', color: '#a3a3a3' },
16  finished: { icon: '✔', color: '#34d399' },
17}
18
19export const register: Register = on => {
20  /** Tool calls and log entries in the turn now running; drives the idle counter. */
21  let activity = 0
22  let briefTimer: { cancel: () => void } | undefined
23
24  on('session.start', async ($, e, next) => {
25    const r = await next(e)
26    await $.command.register({
27      name: 'self-drive',
28      description: 'Hand over a task and walk away: /self-drive [3h] <task> · /self-drive (dashboard) · /self-drive stop',
29      argumentHint: '[duration] <task> | stop',
30    })
31    await $.tool.register({
32      name: 'self_drive_log',
33      description: 'Self-drive journal. Record a decision you made on the user\'s behalf (kind "decision": title, alternatives, why), something only the user can do (kind "needs-you"), a milestone (kind "note"), or that all work is done (kind "finished": detail = summary). Only use while self-drive mode is on.',
34      inputSchema: {
35        type: 'object',
36        properties: {
37          kind: { type: 'string', enum: ['decision', 'needs-you', 'note', 'finished'] },
38          title: { type: 'string', description: 'One line: what was decided / what the user must do / the milestone.' },
39          alternatives: { type: 'string', description: 'decision: the options you did not pick.' },
40          why: { type: 'string', description: 'decision: why this one.' },
41          detail: { type: 'string', description: 'Anything else; for finished, the summary.' },
42        },
43        required: ['kind', 'title'],
44      },
45    })
46    // Restore an interrupted drive on resume.
47    const saved = (await $.store.get(`drive:${await $.session.id()}`)) as Drive | undefined
48    if (saved && (await read($, drive)) === null) {
49      await update($, drive, () => saved.status === 'driving' ? ended(saved, 'stopped', Date.now(), 'paused by restart') : saved)
50      if (saved.status === 'driving') $.ui.status('▶ self-drive paused by restart · /self-drive to view')
51    }
52    return r
53  })
54
55  on('classic.SessionStart', async ($, e, next) => {
56    const r = await next(e)
57    if (!['clear', 'resume', 'fork'].includes(e.source)) return r
58    activity = 0
59    briefTimer?.cancel(); briefTimer = undefined
60    const saved = e.source === 'clear' ? undefined : await $.store.get(`drive:${await $.session.id()}`) as Drive | undefined
61    await update($, drive, () => saved?.status === 'driving' ? ended(saved, 'stopped', Date.now(), 'paused by session switch') : saved ?? null)
62    $.ui.status(undefined)
63    return r
64  })
65
66  // ---- the command ----
67  on('command.run', { command: 'self-drive' }, async ($, e) => {
68    const args = e.args.trim()
69    const now = await $.clock.now()
70    const cur = await read($, drive)
71
72    if (args === '' ) {
73      if (!cur) return { text: 'Usage: /self-drive [30m|3h] <task>. Then walk away.' }
74      await $.ui.open({ id: PANE, title: 'Self-drive', focus: true })
75      return {}
76    }
77    if (args === 'stop') {
78      briefTimer?.cancel(); briefTimer = undefined
79      if (!cur || !['driving', 'limit'].includes(cur.status)) return { text: 'Self-drive is not running.' }
80      const d = await update($, drive, x => ended(x, 'stopped', now, 'stopped by you'))
81      if (d) await persist($, d, now)
82      $.ui.status(undefined)
83      return { text: 'Self-drive stopped. The dashboard keeps the log: /self-drive' }
84    }
85
86    // A slash command goes to command.run, never prompt.submit, so the drive starts here.
87    const { ms, task } = parseArgs(args)
88    if (!task) return { text: 'Give it a task: /self-drive 2h finish the upload feature' }
89    const d: Drive = {
90      task, startedAt: now, deadline: ms ? now + ms : undefined, status: 'driving',
91      entries: [], continues: 0, idle: 0,
92    }
93    await update($, drive, () => d)
94    await persist($, d, now)
95    activity = 0
96    void $.ui.open({ id: PANE, title: 'Self-drive' })
97    $.ui.status(`▶ self-drive on${d.deadline ? ` · until ${new Date(d.deadline).toTimeString().slice(0, 5)}` : ''}`)
98    // A command.run hook may not submit (the prompt would wait on the turn this hook holds),
99    // so the brief goes out on a timer, once the command has finished.
100    const text = brief(task, d.deadline, now)
101    briefTimer?.cancel()
102    briefTimer = $.clock.after(50, () => { briefTimer = undefined; void $.prompt.submit({ text, asUser: true }) })
103    return { text: `▶ Self-drive started${d.deadline ? ` for ${fmtDur(d.deadline - now)}` : ''}: ${task}` }
104  })
105
106  // ---- the journal tool ----
107  on('tool.call', { tool: LOG_TOOL }, async ($, e) => {
108    const input = (e as unknown as { input?: Record<string, string> }).input ?? (e as unknown as Record<string, string>)
109    const cur = await read($, drive)
110    if (!cur || cur.status !== 'driving') return { result: 'Not logged: self-drive is not on.' }
111    const now = await $.clock.now()
112    const kind = (['decision', 'needs-you', 'note', 'finished'].includes(input.kind ?? '') ? input.kind : 'note') as EntryKind
113    const entry: Entry = { at: now, kind, title: String(input.title ?? '').slice(0, 200) }
114    if (input.alternatives) entry.alternatives = String(input.alternatives).slice(0, 400)
115    if (input.why) entry.why = String(input.why).slice(0, 400)
116    if (input.detail) entry.detail = String(input.detail).slice(0, 4000)
117    activity += 1
118    const expired = cur.deadline !== undefined && now >= cur.deadline
119    const d = await update($, drive, x => x && {
120      ...x, entries: [...x.entries, entry],
121      ...(kind === 'finished' ? { status: expired ? 'limit' as const : 'finished' as const, endedAt: now, endReason: expired ? 'deadline reached' : 'work complete' } : {}),
122    })
123    if (d) await persist($, d, now)
124    if (kind === 'finished') $.ui.status('✔ self-drive finished · /self-drive for the report')
125    return { result: `Logged (${kind}).` }
126  })
127
128  // ---- guards while driving ----
129  on('tool.call', { tool: ['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode'] }, async ($, e, next) => {
130    const cur = await read($, drive)
131    if (cur?.status !== 'driving') return next(e)
132    return { deny: 'Self-drive is on: the user is away and cannot answer. Decide it yourself, record the choice with self_drive_log (kind "decision"), and keep going.' }
133  })
134
135  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
136    const cur = await read($, drive)
137    if (await deadlineBlocks($, cur)) return { deny: 'Self-drive budget reached. Stop work and write the final report. Use /self-drive stop to release this gate.' }
138    if (cur?.status !== 'driving') return next(e)
139    activity += 1
140    const why = riskOf(e.command)
141    if (!why) {
142      void update($, drive, x => x && { ...x, lastActivity: `$ ${e.command.split('\n')[0]?.slice(0, 80)}` })
143      return next(e)
144    }
145    const now = await $.clock.now()
146    const d = await update($, drive, x => logged(x, { at: now, kind: 'held', title: why, detail: '`' + e.command.slice(0, 300) + '`' }))
147    if (d) await persist($, d, now)
148    return { deny: `Held by self-drive (${why}): this is risky or outward-facing and the user is away. It has been added to the user's needs-you list automatically. Continue with everything else.` }
149  })
150
151  on('tool.call', async ($, e, next) => {
152    const cur = await read($, drive)
153    if (e.tool !== LOG_TOOL && await deadlineBlocks($, cur)) return { deny: 'Self-drive budget reached. Stop work and write the final report. Use /self-drive stop to release this gate.' }
154    if (cur?.status !== 'driving' || e.tool === LOG_TOOL || e.tool === 'Bash') return next(e)
155    activity += 1
156    const why = riskyTool(e.tool)
157    if (!why) {
158      const t = e.tool
159      void update($, drive, x => x && { ...x, lastActivity: t })
160      return next(e)
161    }
162    const now = await $.clock.now()
163    const d = await update($, drive, x => logged(x, { at: now, kind: 'held', title: why, detail: String(e.tool) }))
164    if (d) await persist($, d, now)
165    return { deny: `Held by self-drive (${why}): the user is away. Added to their needs-you list; continue with everything else.` }
166  })
167
168  // ---- keep going ----
169  on('turn.complete', async ($, e, next) => {
170    const r = await next(e)
171    if (e.agentId) return r
172    const cur = await read($, drive)
173    if (!cur || cur.status !== 'driving') return r
174    const now = await $.clock.now()
175    if (e.isAborted) {
176      // The person pressed Esc: they are back. Stop driving, keep the log.
177      const d = await update($, drive, x => ended(x, 'stopped', now, 'interrupted by you'))
178      if (d) await persist($, d, now)
179      $.ui.status(undefined)
180      return r
181    }
182    // Background agents still running: their completion wakes the main loop with a
183    // notification turn, so don't count this turn as idle and don't push a "continue"
184    // (it would queue behind them and could make Claude poll). Just wait for them.
185    const busy = busyAgents(await $.agent.list())
186    const waiting = busy.length > 0 && !(cur.deadline && now >= cur.deadline)
187    if (waiting) {
188      activity = 0
189      const d = await update($, drive, x => x && { ...x, lastActivity: `waiting on ${busy.length} agent${busy.length > 1 ? 's' : ''}: ${busy.slice(0, 2).join(', ')}` })
190      if (d) await persist($, d, now)
191      return r
192    }
193    const idle = activity === 0 ? cur.idle + 1 : 0
194    activity = 0
195    const limit =
196      cur.deadline && now >= cur.deadline ? 'deadline reached' :
197      cur.continues >= MAX_CONTINUES ? `${MAX_CONTINUES} auto-continues used` :
198      idle >= MAX_IDLE ? `${MAX_IDLE} turns in a row without progress` : undefined
199    if (limit) {
200      const d = await update($, drive, x => ended(x, 'limit', now, limit, { idle }))
201      if (d) await persist($, d, now)
202      $.ui.status(`■ self-drive stopped: ${limit} · /self-drive`)
203      void $.prompt.submit({ text: `Self-drive has stopped (${limit}). Stop working now. Write the final report for me: what was done, the decisions to review, and the needs-you list.`, asUser: true })
204      return r
205    }
206    const d = await update($, drive, x => x && { ...x, continues: x.continues + 1, idle })
207    if (d) await persist($, d, now)
208    void $.prompt.submit({ text: CONTINUE, asUser: true })
209    return r
210  })
211
212  // ---- the dashboard ----
213  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
214    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
215    const d = await read($, drive)
216    if (!d) return <Text dimColor>No self-drive yet. Start one with /self-drive 2h &lt;task&gt;</Text>
217    const now = Date.now()
218    const w = Math.max(20, e.props.bodyColumns - 2)
219    const running = d.status === 'driving'
220    const head = running ? { t: '▶ DRIVING', c: '#38bdf8' } : d.status === 'finished' ? { t: '✔ FINISHED', c: '#34d399' } : { t: '■ STOPPED', c: '#fbbf24' }
221    const ran = fmtDur((d.endedAt ?? now) - d.startedAt)
222    const budget = d.deadline ? ` of ${fmtDur(d.deadline - d.startedAt)}` : ''
223    const decisions = d.entries.filter(x => x.kind === 'decision')
224    const needs = d.entries.filter(x => x.kind === 'needs-you' || x.kind === 'held')
225    const fin = d.entries.filter(x => x.kind === 'finished').at(-1)
226    const notes = d.entries.filter(x => x.kind === 'note').slice(-3)
227
228    const row = (x: Entry, i: number, n?: number) => (
229      <Box key={`${x.kind}${i}`} flexDirection="column">
230        <Box flexDirection="row" columnGap={1}>
231          <Text color={KIND_STYLE[x.kind].color}>{n !== undefined ? `${n}.` : KIND_STYLE[x.kind].icon}</Text>
232          <Text bold wrap="wrap">{x.title}</Text>
233        </Box>
234        {x.why && <Text dimColor wrap="wrap">   why: {x.why}</Text>}
235        {x.alternatives && <Text dimColor wrap="wrap">   instead of: {x.alternatives}</Text>}
236        {x.kind !== 'decision' && x.detail && <Text dimColor wrap="wrap">   {x.detail.slice(0, w * 2)}</Text>}
237      </Box>
238    )
239
240    return (
241      <Box flexDirection="column" gap={1}>
242        <Box flexDirection="column">
243          <Box flexDirection="row" columnGap={2}>
244            <Text color={head.c} bold>{head.t}</Text>
245            <Text dimColor>⏱ {ran}{budget}</Text>
246            <Text dimColor>↻ {d.continues}</Text>
247          </Box>
248          <Text wrap="wrap">{d.task}</Text>
249          {d.endReason && !running && <Text dimColor>ended: {d.endReason}</Text>}
250          {running && d.lastActivity && <Text dimColor>now: {d.lastActivity.slice(0, w)}</Text>}
251        </Box>
252
253        {fin?.detail && (
254          <Box flexDirection="column">
255            <Text color="#34d399" bold>Summary</Text>
256            <Markdown text={fin.detail.slice(0, 3000)} />
257          </Box>
258        )}
259
260        <Box flexDirection="column">
261          <Text color="#38bdf8" bold>◆ Decisions made for you ({decisions.length})</Text>
262          {decisions.length === 0 ? <Text dimColor>none yet</Text> : decisions.map((x, i) => row(x, i, i + 1))}
263        </Box>
264
265        <Box flexDirection="column">
266          <Text color="#fbbf24" bold>✋ Needs you ({needs.length})</Text>
267          {needs.length === 0 ? <Text dimColor>nothing so far</Text> : needs.map((x, i) => row(x, i))}
268        </Box>
269
270        {notes.length > 0 && (
271          <Box flexDirection="column">
272            <Text dimColor bold>Milestones</Text>
273            {notes.map((x, i) => row(x, i))}
274          </Box>
275        )}
276
277        {running && (
278          <Button key="stop" label="Stop self-drive" onPress={async () => {
279            const t = Date.now()
280            const s = await update($, drive, x => ended(x, 'stopped', t, 'stopped by you'))
281            if (s) await persist($, s, t)
282            $.ui.status(undefined)
283          }} />
284        )}
285      </Box>
286    )
287  })
288}
289
290/** Saves the drive for resume and writes the readable report next to the other notes. */
291async function persist($: import('claude-code').EngineInterface, d: Drive, now: number): Promise<void> {
292  const sid = await $.session.id()
293  await $.store.set(`drive:${sid}`, d)
294  try {
295    const home = await $.env.get('HOME')
296    if (home) {
297      const day = new Date(d.startedAt).toISOString().slice(0, 10)
298      await $.fs.write(`${home}/.claude/self-drive/${day}-${sid.slice(0, 8)}.md`, report(d, now))
299    }
300  } catch { /* the report file is a convenience */ }
301}
302
303/** A tool already running cannot be cancelled here; no new work starts after the budget. */
304async function deadlineBlocks($: import('claude-code').EngineInterface, d: Drive | null): Promise<boolean> {
305  return d?.status === 'limit' || !!(d?.status === 'driving' && d.deadline !== undefined && await $.clock.now() >= d.deadline)
306}
307
hooks/drive.ts 117 lines
1import type { Drive, DriveStatus, Entry } from '../types'
2
3export const MAX_CONTINUES = 40
4export const MAX_IDLE = 3
5
6/** "/self-drive 3h task..." -> duration in ms (optional) and the task. */
7export function parseArgs(args: string): { ms?: number; task: string } {
8  const m = /^\s*(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)\b[\s,;:.-]*/i.exec(args)
9  if (!m) return { task: args.trim() }
10  const n = Number(m[1])
11  const ms = /^h/i.test(m[2] ?? '') ? n * 3600000 : n * 60000
12  return { ms, task: args.slice(m[0].length).trim() }
13}
14
15export function brief(task: string, deadline: number | undefined, now: number): string {
16  const until = deadline ? `You have about ${fmtDur(deadline - now)} (until ${new Date(deadline).toTimeString().slice(0, 5)} local).` : 'Work until the goal is fully done.'
17  return `SELF-DRIVE MODE. I am stepping away and cannot answer anything until I return. ${until}
18
19Goal: ${task}
20
21How to work while I am away:
22- Never ask me anything and never wait for my acknowledgement. Make the best decision a careful senior engineer would, and keep going.
23- Every time you decide something you would normally have asked me about (a design choice, scope, a trade-off, an assumption, a default), record it with the self_drive_log tool, kind "decision": what you chose, the alternatives, and why. Be honest; these are what I will review first.
24- Anything that truly needs me (credentials, approvals, merges, production or shared-system actions, purchases, messages to people) — log it with kind "needs-you" and work around it; leave those for the end.
25- Risky, hard-to-undo or outward-facing actions are held by a guard while I am away. If one is refused, log it as needs-you and continue with everything else.
26- Plan the work, then execute it fully: implement, test, fix, verify. Prefer reversible steps. Commit locally in small steps where the repo allows it; do not push.
27- Use kind "note" sparingly for milestones.
28- When everything you can do is done, call self_drive_log with kind "finished" and a summary of what was done. Then write the final report for me: what was done, the decisions to review, and the needs-you list.`
29}
30
31export const CONTINUE = `Self-drive is still on and I am still away. Continue with the next step toward the goal. If everything you can do is done, call self_drive_log with kind "finished" and write the final report.`
32
33// ---- guard: actions held while away ----
34
35const RISKY: readonly [RegExp, string][] = [
36  [/\bgit\s+push\b[^\n]*(\s--force\b|\s-f\b|\s--force-with-lease\b)/, 'force push'],
37  [/\bgit\s+push\b[^\n]*\b(main|master)\b/, 'push to main/master'],
38  [/\bgit\s+push\b/, 'git push'],
39  [/\bgit\s+(branch\s+-D|push\s+\S+\s+--delete|push\s+\S+\s+:)/, 'delete a branch'],
40  [/\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-z]*f/, 'discard work (reset --hard / clean -f)'],
41  [/\bkubectl\b[^\n]*\b(delete|apply|replace|patch|scale|rollout\s+restart|drain|cordon)\b/, 'change a cluster (kubectl)'],
42  [/\bhelm\s+(install|upgrade|uninstall|delete|rollback)\b/, 'change a cluster (helm)'],
43  [/\bterraform\s+(apply|destroy)\b|\bpulumi\s+(up|destroy)\b/, 'apply infrastructure'],
44  [/\bgh\s+(pr\s+(merge|close)|release\s+create|repo\s+delete)\b/, 'merge/close/release on GitHub'],
45  [/\b(npm|pnpm|yarn)\s+publish\b|\btwine\s+upload\b|\bdocker\s+push\b/, 'publish a package or image'],
46  [/\brm\s+-[a-z]*r[a-z]*f?\s+(\/(?!tmp\b)|~|\$HOME)/, 'recursive delete outside the project'],
47  [/\b(mgc|outlook)\b[^\n]*\b(send|reply|forward)\b|\bsendmail\b/, 'send email'],
48]
49
50/** The reason a Bash command is held, or undefined to let it run. */
51export function riskOf(command: string): string | undefined {
52  const token = String.raw`(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s;&|]+)`
53  const value = String.raw`(?:-C|-c|--git-dir|--work-tree|--namespace|--config-env)\s+${token}`
54  const attached = String.raw`(?:-[Cc][^\s;&|]+|--(?:git-dir|work-tree|namespace|config-env)=${token}|--(?:no-pager|paginate|bare|no-optional-locks|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs))`
55  const normalized = command.replace(new RegExp(String.raw`\bgit\s+(?:(?:${value}|${attached})\s+)+`, 'g'), 'git ')
56  for (const [re, why] of RISKY) if (re.test(normalized)) return why
57  return undefined
58}
59
60/** Tool names (MCP and skills) that reach people or shared systems. */
61export function riskyTool(tool: string): string | undefined {
62  if (/^mcp__.*(slack|teams|outlook|mail|send|post|jira.*(create|transition|comment|update)|confluence.*(create|update))/i.test(tool)) return 'post to a shared system or message people'
63  return undefined
64}
65
66// ---- report ----
67
68export function fmtDur(ms: number): string {
69  const m = Math.max(0, Math.round(ms / 60000))
70  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
71}
72
73const of = (d: Drive, k: Entry['kind']) => d.entries.filter(e => e.kind === k)
74
75export function report(d: Drive, now: number): string {
76  const end = d.endedAt ?? now
77  const lines = [
78    `# Self-drive report`,
79    ``,
80    `- Goal: ${d.task}`,
81    `- Status: ${d.status}${d.endReason ? ` (${d.endReason})` : ''}`,
82    `- Ran: ${fmtDur(end - d.startedAt)} · started ${new Date(d.startedAt).toLocaleString()}`,
83    `- Auto-continues: ${d.continues}`,
84  ]
85  const fin = of(d, 'finished').at(-1)
86  if (fin) lines.push('', '## Summary', '', fin.detail ?? fin.title)
87  const dec = of(d, 'decision')
88  lines.push('', `## Decisions made on your behalf (${dec.length})`, '')
89  dec.forEach((e, i) => {
90    lines.push(`${i + 1}. **${e.title}**`)
91    if (e.why) lines.push(`   - Why: ${e.why}`)
92    if (e.alternatives) lines.push(`   - Alternatives: ${e.alternatives}`)
93    if (e.detail) lines.push(`   - ${e.detail}`)
94  })
95  const need = [...of(d, 'needs-you'), ...of(d, 'held')]
96  lines.push('', `## Needs you (${need.length})`, '')
97  need.forEach(e => lines.push(`- [ ] ${e.kind === 'held' ? '(held) ' : ''}${e.title}${e.detail ? ` — ${e.detail}` : ''}`))
98  const notes = of(d, 'note')
99  if (notes.length) { lines.push('', '## Milestones', ''); notes.forEach(e => lines.push(`- ${e.title}`)) }
100  return lines.join('\n') + '\n'
101}
102
103/** Ends a drive (typed, so callers can't widen the status). */
104export function ended(d: Drive | null, status: Exclude<DriveStatus, 'driving'>, at: number, reason: string, extra: Partial<Drive> = {}): Drive | null {
105  return d && { ...d, ...extra, status, endedAt: at, endReason: reason }
106}
107
108/** Appends one journal entry. */
109export function logged(d: Drive | null, e: Entry): Drive | null {
110  return d && { ...d, entries: [...d.entries, e] }
111}
112
113/** Agents still doing work: their completion will wake the main loop by itself. */
114export function busyAgents(list: readonly { status: string; description: string }[]): string[] {
115  return list.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting').map(a => a.description)
116}
117
types/index.d.ts 23 lines
1export type EntryKind = 'decision' | 'needs-you' | 'held' | 'note' | 'finished'
2export type Entry = { at: number; kind: EntryKind; title: string; detail?: string; alternatives?: string; why?: string }
3export type DriveStatus = 'driving' | 'finished' | 'stopped' | 'limit'
4export type Drive = {
5  task: string
6  startedAt: number
7  deadline?: number
8  status: DriveStatus
9  endedAt?: number
10  endReason?: string
11  entries: Entry[]
12  continues: number
13  /** Consecutive auto-continued turns with no tool call and no log entry. */
14  idle: number
15  lastActivity?: string
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'self-drive': { drive: Drive | null }
21  }
22}
23