SLOPSHOPPER

autopilot

Run a session autonomously for a set time with a fixed role; hand off and restart itself at a context threshold

newguardcommandtoaststatusprompt
v0.2.0no licenseupdated 2026-10-09VladLeus/claude-mods/autopilot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autopilot
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /autopilot ⎿ autopilot: Autopilot is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Two Claude Code mods (plugins of function hooks) in one local marketplace, local-mods:

ModCommandWhat it does
agent-fleet/fleetA pane with every running session that has the mod: context fill, working/idle, subagents and workflow agents, who messages whom, cost, and an autopilot badge. /fleet toggles it.
autopilot/autopilotRuns a session on its own for a set time with a fixed role. At a context threshold it writes a handoff, clears itself and resumes from the handoff, instead of auto-compacting. Respects the permission mode, defers blocked actions, watches token limits, works with /goal.

Function-hook mods are early access: you need a recent Claude Code (built and tested on 2.1.295).

Install

Just use them

Install a signed release tag, not main: pick the latest tag from the releases and put it after #.

claude plugin marketplace add VladLeus/claude-mods#v0.2.0
claude plugin install autopilot@local-mods
claude plugin install agent-fleet@local-mods

Restart Claude Code. The marketplace stays on that tag, and third-party marketplaces do not auto-update unless you turn it on in /plugin → Marketplaces. To move to a newer release, check its notes and diff first, then remove the marketplace and add it again with the new tag.

Develop them (clone + local marketplace)

This mode is for developing the mods, not for everyday use. The marketplace is read live from your clone, so any commit you pull or check out runs with full access to your machine on the next reload. Review untrusted PRs in a separate worktree or clone that no marketplace points at. Never run claude plugin test (or reload plugins) on an untrusted PR checkout locally: spec files and hooks are code that runs with your full user access, so let the specs CI run them.

git clone https://github.com/VladLeus/claude-mods.git ~/code/claude-mods
claude plugin marketplace add ~/code/claude-mods
claude plugin install autopilot@local-mods --scope user
claude plugin install agent-fleet@local-mods --scope user

A marketplace added from a folder is read from that folder: edit the files, then run /reload-plugins in a session.

Skills autopilot needs

Autopilot hands off with two skills. Copy them unless you already have skills with these names:

cp -R extras/skills/create-handoff-doc extras/skills/resume-handoff-doc ~/.claude/skills/

Handoffs are written to thoughts/shared/handoffs/ of the project.

autopilot

/autopilot <time> <threshold%> [max restarts] "<role>" [--goal "<condition>"] [--5h 95] [--week 80]
/autopilot                 # status
/autopilot 0 0 stop        # hand the wheel back (also clears the goal)

Example, a night run:

/autopilot 10h 65 "You are the docs writer, responsible for … Use /explore for the map." --goal "issue #42 is closed and its docs are merged" --5h 95 --week 80
  • time: 90m, 2h, 1h30m, or minutes.
  • threshold: the context % at which it hands off and restarts. Under 50%, every handoff after the first fires 10 points later (a resume alone loads ~5–15%). It never waits past auto-compact minus 10 points; a higher value is lowered to that ceiling and the command says so. The ceiling depends on autoCompactWindow in ~/.claude/settings.json (800000 on a 1M window gives ~66%). Raise that setting to allow a higher threshold.
  • max restarts: leave out for as many as the time allows.
  • role: who the session is and what it is responsible for. It is pinned in the system prompt, survives /clear, and goes into every handoff with the session's /rename name and /color (the color is set again after each /clear). /skills named in the role are recognised and announced to the model as skills.
  • --goal: a /goal completion condition, set again after every /clear. When the evaluator marks it met, autopilot asks for a final handoff and stops. Phrase it in terms of files or repo state, not "in the chat": after /clear the chat is empty.
  • --5h: at this % of the 5-hour token window it parks and resumes 2 minutes after the window resets.
  • --week: at this % of the weekly window it stops for good: final handoff, summary, a phone notification from the session, autopilot off.

While it runs:

  • Ordinary questions (AskUserQuestion) are answered with "decide yourself within your role"; the decision goes into the handoff. Only a question that truly cannot wait is sent to your phone (PushNotification).
  • Permissions stay as they are. Run the session in auto mode or with allow rules. An action the permission mode refuses (a push, a commit) is never retried another way: it is logged as deferred and left for you.
  • The session never ends or hands off on its own; autopilot decides. If the work is done it answers AUTOPILOT_DONE and autopilot wraps up.
  • Log: ~/.claude/autopilot/<session id>.log (decisions, deferrals, restarts, limits).

agent-fleet

/fleet toggles the pane (/fleet open, /fleet close). Every session with the mod writes a heartbeat to ~/.claude/fleet/<session id>.json every 15 s and on every turn, spawn and message; the pane reads that folder. Ended and cleared sessions are hidden at once (a resume brings them back), a silent one shows "no signal" after 3 minutes and is dropped after an hour, and files older than a day are deleted.

Contributing

main takes changes through pull requests only, and a PR merges after the owner (@VladLeus) approves it:

  • Work on a branch, open a PR against main.
  • A PR is required, with 1 code-owner approval (CODEOWNERS + the branch ruleset); new commits dismiss it.
  • Required checks: owner-approval, specs (each mod and the marketplace) and signed-commits. Commits must be signed.
  • protected-paths is a warning, not a requirement: it turns red when a PR touches .github/, CODEOWNERS or removes or shrinks tests, so the owner reviews those changes with extra care.
  • The owner-approval status is success only when the owner's latest review is an approval of the PR's current head; the owner-approved label mirrors it and a label set by hand is overwritten.
  • No direct pushes and no force pushes to main, not even for admins: an admin can bypass the rules only by merging a PR.

Collaborators push branches to this repository. A PR from a fork works too, but its label and status are then set by the owner by hand (a fork's workflow token cannot write to this repository).

Developing

  • Edit, then /reload-plugins in a session. Check with: ``bash claude plugin validate autopilot claude plugin test autopilot ``
  • Bump version in .claude-plugin/plugin.json and run claude plugin update <mod>@local-mods after a change. /reload-plugins reads the folder, but a restarted Claude Code (the desktop app especially) loads the copy cached at the last install or update.
  • The engine writes the API's types beside each mod at .claude-plugin/types/ (git-ignored); grep claude-code/index.d.ts there for events and $ methods.
  • Logic worth testing lives in a plain module (fleet.ts, autopilot.ts); register.tsx holds the hooks.
Source 3 files
hooks/register.tsx 674 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { FinalReason, Run, TokenWindow } from '../types'
4import {
5  ASK_ANSWER,
6  CONTINUE_PROMPT,
7  DEFAULT_CEILING,
8  DEFERRED_NOTE,
9  RESTART_TOOL,
10  RESUME_AFTER_WAIT_PROMPT,
11  afterTurn,
12  autoAnswersQuestions,
13  effectiveTrigger,
14  finalPrompt,
15  followsNewSessionId,
16  formatLeft,
17  goalState,
18  goalVerdictsFromRows,
19  identityFromRows,
20  isDoneAnswer,
21  isValidHandoffPath,
22  kickoffPrompt,
23  limitAction,
24  namedSkills,
25  parseCommand,
26  resolveHandoffPath,
27  restartsText,
28  roleSection,
29  shouldDropStopBlock,
30  triggerCeiling,
31  waitPrompt,
32  wrapMidTurn,
33  wrapupPrompt,
34} from './autopilot'
35
36const FULL_RESTART_TOOL = 'mcp__autopilot__restart_session'
37/** With a goal, the goal loop starts the next turn; if none starts within this, autopilot looks why. */
38const GOAL_IDLE_MS = 20_000
39
40let run: Run | null = null
41let sessionId = ''
42// Empty when HOME is unset: then no state or log file is written at all.
43let home = ''
44let isTurnRunning = false
45let toolsThisTurn = 0
46// Counts main turns, so a delayed check can tell whether a new turn started meanwhile.
47let turnSeq = 0
48// Whether this run's /goal is set right now (/clear removes it; autopilot sets it again).
49let isGoalSet = false
50// The highest context % a handoff may wait for, below auto-compact.
51let ceiling = DEFAULT_CEILING
52
53/**
54 * Reads the session's auto-compact threshold and sets the ceiling below it.
55 * The trigger compares against `context.percent`, which is over the model's
56 * window, so the threshold is measured on that window too, never on the
57 * smaller compaction window (`autoCompactWindow`) the breakdown counts in.
58 */
59async function readCeiling($: EngineInterface): Promise<number> {
60  const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => null)
61  const breakdown = usage?.context.breakdown
62  ceiling = triggerCeiling(
63    breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
64    usage?.context.window,
65  )
66
67  return ceiling
68}
69
70const storeKey = (id: string) => `run:${id}`
71const autopilotDir = () => `${home}/.claude/autopilot`
72const clockTime = (ms: number) => new Date(ms).toISOString().slice(11, 16)
73
74async function save($: EngineInterface): Promise<void> {
75  if (!sessionId) return
76  if (!run) {
77    await $.store.delete(storeKey(sessionId))
78    if (home) await $.fs.write(`${autopilotDir()}/${sessionId}.json`, JSON.stringify({ isOn: false }))
79    return
80  }
81  await $.store.set(storeKey(sessionId), run)
82  if (!home) return
83  // Read by the fleet dashboard for its badge.
84  await $.fs.write(
85    `${autopilotDir()}/${sessionId}.json`,
86    JSON.stringify({
87      isOn: true,
88      phase: run.phase,
89      until: run.until,
90      threshold: run.threshold,
91      restarts: run.restarts,
92      maxRestarts: run.maxRestarts,
93      waitUntil: run.waitUntil,
94      hasGoal: run.goal !== null,
95    }),
96  )
97}
98
99/** Appends one line to this session's autopilot log: decisions, deferrals, restarts. */
100async function log($: EngineInterface, line: string): Promise<void> {
101  if (!home) return
102  const path = `${autopilotDir()}/${sessionId}.log`
103  // Free text may carry line breaks; flattened, it cannot forge a log line.
104  // Every other control, format and separator character goes too (same class as autopilot.ts).
105  const flat = line
106    .replace(/[\r\n\u{85}\u{2028}\u{2029}]/gu, ' ')
107    .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
108  const stamp = new Date(await $.clock.now()).toISOString()
109  let before = ''
110  try {
111    before = await $.fs.read(path)
112  } catch {
113    before = ''
114  }
115  await $.fs.write(path, `${before}${stamp}  ${flat}\n`).catch(() => undefined)
116}
117
118/**
119 * Submits a prompt once the session is idle, outside the hook that decided
120 * it. A submit lands as an interruption when a turn runs, so when one has
121 * started meanwhile (the goal loop, the person), something else is driving:
122 * the prompt is dropped.
123 */
124function queue($: EngineInterface, text: string): void {
125  $.clock.after(300, () => {
126    if (isTurnRunning) return
127    void $.prompt.submit({ text }).catch(() => undefined)
128  })
129}
130
131async function setGoal($: EngineInterface): Promise<void> {
132  if (!run?.goal) return
133  const goal = run.goal
134  await $.command
135    .run({ command: 'goal', args: goal })
136    .then(() => {
137      isGoalSet = true
138    })
139    .catch(async error => log($, `could not set the goal: ${String(error)}`))
140}
141
142async function clearGoal($: EngineInterface): Promise<void> {
143  if (!isGoalSet) return
144  isGoalSet = false
145  await $.command
146    .run({ command: 'goal', args: 'clear' })
147    .catch(async error => log($, `could not clear the goal: ${String(error)}`).catch(() => undefined))
148}
149
150/**
151 * The transcript lives under the project root's folder. Never the current
152 * directory: a shell `cd` in the session moves that, not the root.
153 */
154async function transcriptOf($: EngineInterface, id: string): Promise<string> {
155  const root = await $.session.root()
156
157  return `${home}/.claude/projects/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
158}
159
160/**
161 * The whole transcript lines grep matches; empty when nothing or on failure.
162 * Whole lines, so each is parsed as a row and judged by where a value sits.
163 */
164async function grepTranscript($: EngineInterface, pattern: string): Promise<string[]> {
165  if (!home) return []
166  let out = ''
167  try {
168    const grep = $.process.spawn({ argv: ['/usr/bin/grep', '-E', '-e', pattern, '--', await transcriptOf($, sessionId)] })
169    for await (const chunk of grep) if (chunk.stream === 'stdout') out += chunk.text
170  } catch {
171    return []
172  }
173
174  return out.split('\n').filter(Boolean)
175}
176
177/** Reads the session's /rename name and /color from its own transcript's top-level identity rows. */
178async function readIdentity($: EngineInterface): Promise<{ name: string | null; color: string | null }> {
179  return identityFromRows(await grepTranscript($, '^\\{"type":"(custom-title|agent-name|agent-color)"'))
180}
181
182/**
183 * Whether the goal evaluator has judged this run's goal met, from the
184 * `goal_status` verdicts the engine keeps as top-level attachment rows.
185 */
186async function isGoalMet($: EngineInterface): Promise<boolean> {
187  if (!run?.goal) return false
188  const rows = await grepTranscript($, '"goal_status"')
189
190  return goalState(goalVerdictsFromRows(rows), run.goal) === 'achieved'
191}
192
193/**
194 * Keeps the run's name and color current. A transcript after /clear carries
195 * the name but no color, so a value missing there never erases a known one.
196 */
197async function refreshIdentity($: EngineInterface): Promise<void> {
198  if (!run) return
199  const identity = await readIdentity($)
200  const name = identity.name ?? run.name
201  const color = identity.color ?? run.color
202  if (name === run.name && color === run.color) return
203  run.name = name
204  run.color = color
205  await save($)
206}
207
208/**
209 * /clear and /resume change the session id under a running module. Only the
210 * run's own /clear (phase restarting) moves it to the new id; any other new
211 * id is another conversation, which the run does not drive: it stays stored
212 * under its old id, and the new id's own run, if any, is loaded.
213 */
214async function syncId($: EngineInterface): Promise<void> {
215  const id = await $.session.id()
216  if (id === sessionId) return
217  const previous = sessionId
218  sessionId = id
219  if (run && followsNewSessionId(run.phase)) {
220    if (previous) await $.store.delete(storeKey(previous))
221    await save($)
222    return
223  }
224  run = ((await $.store.get(storeKey(id))) as Run | undefined) ?? null
225}
226
227/** Moves the run to its final phase; the goal loop stops so it cannot outrun the handoff. */
228async function enterFinal($: EngineInterface, reason: FinalReason): Promise<string | null> {
229  if (!run) return null
230  run.phase = 'final'
231  run.finalReason = reason
232  await clearGoal($)
233  await save($)
234  await log($, `final handoff requested (${reason})`)
235  if (reason === 'week') {
236    $.ui.toast(`Autopilot: ABSOLUTE STOP, the weekly token limit is at ${run.weekStop}%. Writing a handoff, then waiting for you.`)
237  }
238
239  return finalPrompt(run, reason)
240}
241
242/** Ends a 5-hour wait: back to work, the goal set again. */
243async function wake($: EngineInterface): Promise<void> {
244  if (!run || run.phase !== 'waiting' || isTurnRunning) return
245  run.phase = 'running'
246  run.waitUntil = null
247  run.idleTurns = 0
248  await save($)
249  await log($, 'the 5-hour token window reset: resuming')
250  // Setting the goal again starts the next turn itself.
251  if (run.goal) await setGoal($)
252  else queue($, RESUME_AFTER_WAIT_PROMPT)
253}
254
255function scheduleWake($: EngineInterface, now: number): void {
256  if (!run?.waitUntil) return
257  $.clock.after(Math.max(1_000, run.waitUntil - now), () => void wake($))
258}
259
260/**
261 * Applies the token windows: the weekly stop ends the run, the 5-hour stop
262 * parks it until the reset. Returns the instruction for the model, if any.
263 * The phase flips before the first await, so parallel calls act once.
264 */
265async function applyLimits($: EngineInterface, windows: readonly TokenWindow[], now: number): Promise<string | null> {
266  if (!run || (run.phase !== 'running' && run.phase !== 'resuming' && run.phase !== 'waiting')) return null
267  const action = limitAction(run, windows, now)
268  if (!action) return null
269
270  if (action.kind === 'week') return enterFinal($, 'week')
271  if (run.phase === 'waiting') return null
272
273  run.phase = 'waiting'
274  run.waitUntil = action.waitUntil
275  await clearGoal($)
276  await save($)
277  await log($, `5-hour token window at ${run.fiveHourStop}%+: parked until ${clockTime(action.waitUntil)} UTC`)
278  scheduleWake($, now)
279
280  return waitPrompt(run, action.waitUntil)
281}
282
283/** The goal met: the last handoff, then the wheel goes back to the person. */
284async function finishOnGoal($: EngineInterface): Promise<void> {
285  await log($, 'goal met (evaluator verdict in the transcript)')
286  // The evaluator already cleared the goal; nothing to clear.
287  isGoalSet = false
288  const instruction = await enterFinal($, 'goal')
289  if (instruction) queue($, instruction)
290}
291
292/**
293 * With a goal, no new turn after GOAL_IDLE_MS means the goal loop stopped.
294 * Met ends the run; otherwise the goal is gone (cleared, impossible) and the
295 * continuing goes back to autopilot.
296 */
297function watchGoal($: EngineInterface): void {
298  const seq = turnSeq
299  $.clock.after(GOAL_IDLE_MS, () => {
300    void (async () => {
301      if (!run || run.phase !== 'running' || isTurnRunning || turnSeq !== seq) return
302      if (await isGoalMet($)) {
303        await finishOnGoal($)
304        return
305      }
306      await log($, 'goal loop stopped without a met verdict: autopilot continues the work')
307      isGoalSet = false
308      queue($, CONTINUE_PROMPT)
309    })()
310  })
311}
312
313function statusText(now: number): string {
314  if (!run) return 'Autopilot is off.'
315
316  return [
317    `Autopilot: ${run.phase}${run.waitUntil ? ` until ${clockTime(run.waitUntil)} UTC` : ''} · ${formatLeft(run.until - now)} left · handoff at ${effectiveTrigger(run, ceiling)}% (asked ${run.threshold}%, ceiling ${ceiling}% below auto-compact) · restarts ${restartsText(run)}`,
318    `Token limits: park at ${run.fiveHourStop}% of the 5-hour window, stop at ${run.weekStop}% of the week`,
319    `Role: ${run.role}`,
320    ...(run.skills.length ? [`Skills recognised in the role: ${run.skills.map(name => `/${name}`).join(', ')}`] : []),
321    ...(run.goal ? [`Goal: ${run.goal}${isGoalSet ? '' : ' (not set right now)'}`] : []),
322    home ? `Log: ${autopilotDir()}/${sessionId}.log` : 'Log: none (HOME is not set)',
323  ].join('\n')
324}
325
326export const register: Register = on => {
327  on('session.start', async ($, e, next) => {
328    const rawHome = (await $.env.get('HOME')) ?? ''
329    home = rawHome.startsWith('/') ? rawHome : ''
330    // State and logs are the person's alone: the folder is closed to other users.
331    if (home) {
332      await $.process.run(['/bin/mkdir', '-p', autopilotDir()]).catch(() => undefined)
333      await $.process.run(['/bin/chmod', '700', autopilotDir()]).catch(() => undefined)
334    }
335    sessionId = await $.session.id()
336    run = ((await $.store.get(storeKey(sessionId))) as Run | undefined) ?? null
337    await readCeiling($)
338
339    await $.command.register({
340      name: 'autopilot',
341      description: 'Run this session on autopilot with a fixed role; no args shows status, "0 0 stop" hands back',
342      argumentHint: '<time> <threshold%> [max restarts] "<role>" [--goal "…"] [--5h 95] [--week 80] | 0 0 stop',
343    })
344    await $.tool.register({
345      name: RESTART_TOOL,
346      description:
347        'Autopilot only: after writing a handoff with /create-handoff-doc at the context threshold, call this with the handoff path. It clears the session and resumes from the handoff. Refused when autopilot is not wrapping up.',
348      inputSchema: {
349        type: 'object',
350        properties: {
351          handoffPath: {
352            type: 'string',
353            description: 'Path to the handoff .md under thoughts/shared/handoffs/ (absolute or relative to the project root)',
354          },
355        },
356        required: ['handoffPath'],
357      },
358    })
359
360    // Time and the 5-hour reset can come while the session sits idle (or after a reload lost the timers).
361    $.clock.every(30_000, () => {
362      void (async () => {
363        await syncId($)
364        if (!run || isTurnRunning) return
365        const now = await $.clock.now()
366        if ((run.phase === 'running' || run.phase === 'waiting') && now >= run.until) {
367          const instruction = await enterFinal($, 'time')
368          if (instruction) queue($, instruction)
369          return
370        }
371        if (run.phase === 'waiting' && run.waitUntil && now >= run.waitUntil) await wake($)
372      })()
373    })
374
375    return next(e)
376  })
377
378  on('command.run', { command: 'autopilot' }, async ($, e) => {
379    await syncId($)
380    const now = await $.clock.now()
381    const command = parseCommand(e.args)
382
383    if (command.kind === 'status') return { text: statusText(now) }
384    if (command.kind === 'error') return { text: command.message }
385    if (command.kind === 'stop') {
386      if (run) await log($, 'stopped by the person')
387      await clearGoal($)
388      run = null
389      await save($)
390      $.ui.status(undefined)
391
392      return { text: 'Autopilot is off. You have the wheel.' }
393    }
394
395    const limit = await readCeiling($)
396    const capNote =
397      command.threshold > limit
398        ? `\nNote: you asked for ${command.threshold}%, but auto-compact in this session leaves room for a handoff only up to ${limit}% (auto-compact minus 10 points), so ${limit}% is used. To get exactly ${command.threshold}% next time, raise autoCompactWindow in ~/.claude/settings.json.`
399        : ''
400
401    const [identity, commands] = await Promise.all([readIdentity($), $.command.list().catch(() => [])])
402    const skillNames = commands.filter(info => info.source !== 'builtin').map(info => info.name)
403    run = {
404      role: command.role,
405      name: identity.name,
406      color: identity.color,
407      startedAt: now,
408      until: now + command.durationMs,
409      threshold: command.threshold,
410      maxRestarts: command.maxRestarts,
411      restarts: 0,
412      phase: 'running',
413      handoffPath: null,
414      idleTurns: 0,
415      goal: command.goal,
416      skills: namedSkills(command.role, skillNames),
417      fiveHourStop: command.fiveHourStop,
418      weekStop: command.weekStop,
419      waitUntil: null,
420      finalReason: null,
421    }
422    await save($)
423    await log(
424      $,
425      `started: ${formatLeft(command.durationMs)}, trigger ${command.threshold}%, restarts ${restartsText(run)}, 5h ${command.fiveHourStop}%, week ${command.weekStop}%${command.goal ? `, goal: ${command.goal}` : ''}; role: ${command.role}`,
426    )
427    if (capNote) await log($, `threshold ${command.threshold}% capped to ${limit}% below auto-compact`)
428
429    // Setting a goal starts a turn of its own, which already reads the pinned
430    // role: a kickoff after it would land behind work that may be done by then.
431    const started = run
432    $.clock.after(300, () => {
433      void (async () => {
434        if (started.goal) {
435          await setGoal($)
436          return
437        }
438        await $.prompt.submit({ text: kickoffPrompt(started, now) }).catch(() => undefined)
439      })()
440    })
441
442    return { text: `${statusText(now)}${capNote}\nStop any time with /autopilot 0 0 stop.` }
443  }).catch(() => ({ text: 'Autopilot: the command failed; nothing changed. Try again.' }))
444
445  on('prompt.compose', async ($, e, next) => {
446    const composed = await next(e)
447    if (!run) return composed
448
449    return {
450      sections: [
451        ...composed.sections,
452        { id: 'autopilot:role', text: roleSection(run, await $.clock.now()), scope: 'session' as const },
453      ],
454    }
455  })
456
457  // A goal's Stop hook re-prompts while its condition is unmet, so the turn
458  // never ends and a queued /clear never runs. While a handoff, restart, final
459  // or wait is under way, its block is dropped; the other Stop hooks still run.
460  on('classic.Stop', async ($, e, next) => {
461    const result = await next(e)
462    if (!run || !shouldDropStopBlock(run.phase) || result.block === undefined) return result
463    await log($, `let the turn end during ${run.phase} (a Stop hook asked to continue: ${result.block.slice(0, 120)})`)
464
465    return { ...result, block: undefined }
466  }).catch(($, e, next) => next(e))
467
468  // The ceiling should make this never happen; if it does, it is on record.
469  on('session.compact', { trigger: 'auto' }, async ($, e, next) => {
470    if (run && !e.agentId) {
471      await log($, `WARNING: auto-compact ran before a handoff (phase ${run.phase}, ceiling ${ceiling}%)`)
472    }
473
474    return next(e)
475  }).catch(($, e, next) => next(e))
476
477  on('turn.start', async ($, e, next) => {
478    isTurnRunning = true
479    toolsThisTurn = 0
480    turnSeq += 1
481    await syncId($)
482    await refreshIdentity($)
483
484    return next(e)
485  })
486
487  on('tool.call', { tool: FULL_RESTART_TOOL }, async ($, e) => {
488    // Only the main loop restarts the session; a subagent never does.
489    if (e.agentId) {
490      await log($, `restart refused: called by a subagent (${e.agentId})`)
491      return { deny: 'Only the main session may restart under autopilot; a subagent may not.' }
492    }
493    await syncId($)
494    if (!run || run.phase !== 'wrapping') {
495      return { deny: 'Autopilot is not wrapping up: no restart. Continue your work.' }
496    }
497    const givenPath = String((e as unknown as { handoffPath?: unknown }).handoffPath ?? '')
498    const root = await $.session.root()
499    if (!isValidHandoffPath(root, givenPath)) {
500      await log($, `restart refused: handoff path ${JSON.stringify(givenPath.slice(0, 300))} is not a .md file under thoughts/shared/handoffs/`)
501      return {
502        deny: `Refused: the handoff must be a .md file under ${root}/thoughts/shared/handoffs/ (no "..", no control characters). Write it there with /create-handoff-doc, then call ${RESTART_TOOL} with its path.`,
503      }
504    }
505    const handoffPath = resolveHandoffPath(root, givenPath)
506    if (!(await $.fs.exists(handoffPath))) {
507      return { deny: `No handoff document at "${handoffPath}". Write it with /create-handoff-doc first, then call ${RESTART_TOOL} with the path to the handoff .md under thoughts/shared/handoffs/ (absolute or relative to the project root).` }
508    }
509
510    run.restarts += 1
511    run.phase = 'restarting'
512    run.handoffPath = handoffPath
513    await save($)
514    await log($, `restart ${restartsText(run)} from ${handoffPath}`)
515    $.clock.after(500, () => {
516      void (async () => {
517        await $.command.run({ command: 'clear' })
518        await syncId($)
519        // /clear keeps the session's name but drops its color and its goal. The
520        // color comes back now; the goal after the resume turn, since setting it
521        // starts a turn of its own that would run ahead of the resume.
522        isGoalSet = false
523        const color = run?.color
524        if (color) {
525          await $.command
526            .run({ command: 'color', args: color })
527            .catch(async error => log($, `could not restore the color ${color}: ${String(error)}`))
528        }
529        if (run) {
530          run.phase = 'resuming'
531          await save($)
532        }
533        await $.command.run({ command: 'resume-handoff-doc', args: handoffPath })
534      })().catch(async error => {
535        await log($, `restart failed: ${String(error)}`)
536        if (run) run.phase = 'paused'
537        await save($)
538        $.ui.toast('Autopilot: the restart failed and is paused. See the log.')
539      })
540    })
541
542    return { result: 'Restart queued: the session will be cleared and resumed from the handoff. End your turn now.' }
543  }).catch(() => ({ deny: 'Autopilot could not queue the restart. Continue your work; the person will restart the session.' }))
544
545  on('tool.call', async ($, e, next) => {
546    if (e.agentId || !run || run.phase === 'paused') return next(e)
547    toolsThisTurn += 1
548
549    // While waiting the person is in control: their questions and refusals are theirs.
550    const isAway = autoAnswersQuestions(run.phase)
551    if (isAway && e.tool === 'AskUserQuestion') {
552      await log($, `question answered by autopilot (decide yourself): ${JSON.stringify(e).slice(0, 400)}`)
553      return { deny: ASK_ANSWER }
554    }
555
556    const ran = await next(e)
557    if (ran.deny !== undefined) {
558      if (!isAway) return ran
559      await log($, `deferred (refused by the permission mode): ${e.tool}: ${ran.deny.slice(0, 300)}`)
560      return { deny: `${ran.deny}\n\n${DEFERRED_NOTE}` }
561    }
562
563    const usage = await $.session.usage().catch(() => null)
564    const percent = usage?.context.percent ?? null
565    const now = await $.clock.now()
566
567    // Token limits come before the context trigger.
568    const limitInstruction = await applyLimits($, usage?.rateLimits ?? [], now)
569    if (limitInstruction) return { ...ran, context: [...(ran.context ?? []), limitInstruction] }
570
571    // Parallel tool calls finish together: decide and flip the phase with no
572    // await in between, so only the first of them carries the instruction.
573    const action = wrapMidTurn(run, now, percent, ceiling)
574    if (!action) return ran
575    const reason = now >= run.until ? 'time' : 'restarts'
576    run.phase = action === 'wrapup' ? 'wrapping' : 'final'
577    if (action === 'final') run.finalReason = reason
578    await save($)
579    await log($, `${action} requested mid-turn at ${percent ?? '?'}% context (trigger ${effectiveTrigger(run, ceiling)}%)`)
580    if (action === 'final') await clearGoal($)
581    const instruction = action === 'wrapup' ? wrapupPrompt(run, percent) : finalPrompt(run, reason)
582
583    return { ...ran, context: [...(ran.context ?? []), instruction] }
584  }).catch(($, e, next) => next(e))
585
586  on('turn.complete', async ($, e, next) => {
587    const result = await next(e)
588    if (e.agentId) return result
589    isTurnRunning = false
590    await syncId($)
591    if (!run) return result
592    // An interrupted turn (the person, a command) decides nothing: reacting to it
593    // with a prompt would interrupt the next one in turn.
594    if (e.isAborted) return result
595
596    const now = await $.clock.now()
597    const usage = await $.session.usage().catch(() => null)
598    const percent = usage?.context.percent ?? null
599    const calledTools = toolsThisTurn > 0
600
601    // A turn with no tool calls never passed through the mid-turn check.
602    const limitInstruction = await applyLimits($, usage?.rateLimits ?? [], now)
603    if (limitInstruction) {
604      queue($, limitInstruction)
605      return result
606    }
607
608    // The evaluator writes its verdict as the turn ends: a met goal ends the run.
609    if (run.goal && isGoalSet && (run.phase === 'running' || run.phase === 'resuming') && (await isGoalMet($))) {
610      await finishOnGoal($)
611      return result
612    }
613
614    // The session says its role's work is complete: hand back rather than nudge on.
615    if ((run.phase === 'running' || run.phase === 'resuming') && isDoneAnswer(e.answer)) {
616      await log($, `the session reported its work complete: ${e.answer.replace(/\s+/g, ' ').slice(0, 160)}`)
617      const instruction = await enterFinal($, 'done')
618      if (instruction) queue($, instruction)
619      return result
620    }
621
622    const action = afterTurn(run, now, percent, calledTools, ceiling)
623
624    if (action === 'continue') {
625      run.idleTurns = calledTools ? 0 : run.idleTurns + 1
626      await save($)
627      // With a goal set, its own loop starts the next turn; autopilot only watches.
628      if (run.goal && isGoalSet) watchGoal($)
629      else queue($, CONTINUE_PROMPT)
630    }
631    if (action === 'resume-done') {
632      run.phase = 'running'
633      run.idleTurns = 0
634      await readCeiling($)
635      await save($)
636      await log($, `resumed from the handoff at ${percent ?? '?'}% context; next handoff at ${effectiveTrigger(run, ceiling)}%`)
637      // With a goal: setting it again starts the next turn, so no prompt of ours.
638      if (run.goal) await setGoal($)
639      else queue($, CONTINUE_PROMPT)
640    }
641    if (action === 'wrapup') {
642      run.phase = 'wrapping'
643      await save($)
644      await log($, `context ${percent ?? '?'}% (trigger ${effectiveTrigger(run, ceiling)}%): handoff and restart requested`)
645      queue($, wrapupPrompt(run, percent))
646    }
647    if (action === 'final') {
648      const instruction = await enterFinal($, now >= run.until ? 'time' : 'restarts')
649      if (instruction) queue($, instruction)
650    }
651    if (action === 'pause') {
652      run.phase = 'paused'
653      await clearGoal($)
654      await save($)
655      await log($, 'paused: two turns in a row without progress')
656      $.ui.toast('Autopilot paused: no progress for two turns. /autopilot to see it, or start it again.')
657    }
658    if (action === 'finish') {
659      const reason = run.finalReason
660      await clearGoal($)
661      await log($, `finished (${reason ?? 'done'}): handed back to the person`)
662      run = null
663      await save($)
664      $.ui.toast(
665        reason === 'week'
666          ? 'Autopilot STOPPED: weekly token limit. Handoff written; it is waiting for you.'
667          : 'Autopilot finished and handed back to you. The handoff and the log hold what it did.',
668      )
669    }
670
671    return result
672  })
673}
674
hooks/autopilot.ts 578 lines
1import type { Command, FinalReason, Phase, Run, TokenWindow } from '../types'
2
3export const RESTART_TOOL = 'restart_session'
4export const MAX_IDLE_TURNS = 2
5/**
6 * A threshold below this sits close to what a resume alone loads, so every
7 * handoff after the first fires RESUME_BUMP points later. At or above it the
8 * threshold stands as given.
9 */
10export const LOW_THRESHOLD = 50
11export const RESUME_BUMP = 10
12/**
13 * The trigger always fires this many points before auto-compact would: room
14 * for the handoff itself (reading state, writing the document).
15 */
16export const COMPACT_MARGIN = 10
17/** The ceiling when auto-compact is off or its threshold is unknown. */
18export const DEFAULT_CEILING = 90
19export const DEFAULT_FIVE_HOUR_STOP = 95
20export const DEFAULT_WEEK_STOP = 80
21/** Resume this long after the 5-hour window resets, and wait this long when the reset time is unknown. */
22export const RESET_MARGIN_MS = 2 * 60_000
23export const UNKNOWN_RESET_WAIT_MS = 30 * 60_000
24
25/**
26 * The highest context % a handoff may wait for: COMPACT_MARGIN below the
27 * session's auto-compact threshold, so a handoff always comes first.
28 */
29export function triggerCeiling(autoCompactTokens: number | undefined, windowTokens: number | undefined): number {
30  if (!autoCompactTokens || !windowTokens) return DEFAULT_CEILING
31
32  return Math.max(1, Math.floor((autoCompactTokens / windowTokens) * 100) - COMPACT_MARGIN)
33}
34
35/**
36 * The context % that triggers the handoff now: the person's threshold; under
37 * LOW_THRESHOLD, every handoff after the first RESUME_BUMP points later so a
38 * resumed session cannot loop; never above the ceiling below auto-compact.
39 */
40export function effectiveTrigger(run: Run, ceiling: number): number {
41  const isBumped = run.threshold < LOW_THRESHOLD && run.restarts > 0
42
43  return Math.min(isBumped ? run.threshold + RESUME_BUMP : run.threshold, ceiling)
44}
45
46/** "90m", "2h", "1h30m", "45" (minutes) → milliseconds; null when unreadable. */
47export function parseDuration(text: string): number | null {
48  if (/^\d+$/.test(text)) return Number(text) * 60_000
49  const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text)
50  if (!match || (!match[1] && !match[2])) return null
51
52  return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60_000
53}
54
55/** Takes `--name value` out of the arguments; a quoted value may hold spaces. */
56function takeFlag(text: string, name: string): { value: string | null; rest: string } {
57  const pattern = new RegExp(`\\s--${name}\\s+(?:"([^"]*)"|“([^”]*)”|«([^»]*)»|'([^']*)'|(\\S+))`)
58  const match = pattern.exec(` ${text}`)
59  if (!match) return { value: null, rest: text }
60  const value = match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? ''
61
62  return { value, rest: ` ${text}`.replace(match[0], '').trim() }
63}
64
65function percentFlag(value: string | null, fallback: number, label: string): number | string {
66  if (value === null) return fallback
67  const number = Number(value)
68  if (!Number.isFinite(number) || number < 1 || number > 100) return `${label} must be a percentage between 1 and 100.`
69
70  return number
71}
72
73const ROLE_QUOTES: Record<string, string> = { '"': '"', '“': '”', '«': '»', "'": "'" }
74
75const FLAG_NAMES = ['--goal', '--5h', '--week']
76const isSpace = (char: string | undefined): boolean => char !== undefined && /\s/.test(char)
77
78/**
79 * For each position, whether the text from there to the end is only flag
80 * syntax, the same as `^(\s+--(goal|5h|week)\s+(<quoted value>|\S+))*\s*$`
81 * with the value quoted in any supported style. Read in one pass from the end,
82 * each position's answer from one further on, so it stays linear: a regex
83 * tried at every closing quote would rescan the tail for each of them.
84 */
85function flagsOnlyFrom(text: string): Uint8Array {
86  const size = text.length
87  const isFlagsOnly = new Uint8Array(size + 1)
88  const nextSpace = new Int32Array(size + 1)
89  const nextNonSpace = new Int32Array(size + 1)
90  const nextCloser: Record<string, Int32Array> = {}
91  for (const closer of Object.values(ROLE_QUOTES)) nextCloser[closer] = new Int32Array(size + 1).fill(-1)
92  isFlagsOnly[size] = 1
93  nextSpace[size] = size
94  nextNonSpace[size] = size
95
96  for (let at = size - 1; at >= 0; at -= 1) {
97    const char = text.charAt(at)
98    const isWhitespace = isSpace(char)
99    nextSpace[at] = isWhitespace ? at : (nextSpace[at + 1] ?? size)
100    nextNonSpace[at] = isWhitespace ? (nextNonSpace[at + 1] ?? size) : at
101    for (const [closer, next] of Object.entries(nextCloser)) next[at] = char === closer ? at : (next[at + 1] ?? -1)
102    if (!isWhitespace) continue
103
104    const flagAt = nextNonSpace[at] ?? size
105    if (flagAt === size) {
106      isFlagsOnly[at] = 1
107      continue
108    }
109    const name = FLAG_NAMES.find(flag => text.startsWith(flag, flagAt))
110    if (!name || !isSpace(text[flagAt + name.length])) continue
111    const valueAt = nextNonSpace[flagAt + name.length] ?? size
112    if (valueAt === size) continue
113
114    // A bare word runs to the next space; a quoted value to its closing quote.
115    if (isFlagsOnly[nextSpace[valueAt] ?? size]) {
116      isFlagsOnly[at] = 1
117      continue
118    }
119    const closer = ROLE_QUOTES[text.charAt(valueAt)]
120    const closeAt = closer ? (nextCloser[closer]?.[valueAt + 1] ?? -1) : -1
121    if (closeAt !== -1 && isFlagsOnly[closeAt + 1]) isFlagsOnly[at] = 1
122  }
123
124  return isFlagsOnly
125}
126
127/**
128 * Splits a quoted role off the arguments: `head` runs through the role's
129 * closing quote, `rest` is what follows, the only place flags are read from.
130 * The role closes at the first closing quote after which only flags follow,
131 * so a quote or apostrophe inside the role stays in it. null when the role
132 * is not quoted.
133 */
134function splitQuotedRole(text: string): { head: string; rest: string } | null {
135  const match = /^\S+\s+\d{1,3}%?\s+(?:\d{1,2}\s+)?(["“«'])/.exec(text)
136  if (!match) return null
137  const opener = match[1] ?? ''
138  const closer = ROLE_QUOTES[opener] ?? opener
139  const isFlagsOnly = flagsOnlyFrom(text)
140  for (let closeAt = text.indexOf(closer, match[0].length); closeAt !== -1; closeAt = text.indexOf(closer, closeAt + 1)) {
141    if (isFlagsOnly[closeAt + 1]) return { head: text.slice(0, closeAt + 1), rest: text.slice(closeAt + 1) }
142  }
143
144  // Unclosed, or no closing quote is followed by flags alone: everything after the opening quote is the role.
145  return { head: text, rest: '' }
146}
147
148const USAGE =
149  'Usage: /autopilot <time> <threshold%> [max restarts] "<role>" [--goal "<condition>"] [--5h 95] [--week 80]  ·  /autopilot 0 0 stop'
150
151/**
152 * `/autopilot` arguments: nothing (status), `stop` or `0 0 stop`, or
153 * `<duration> <threshold%> [max restarts] "<role>"` with optional flags
154 * `--goal "<condition>"`, `--5h <%>`, `--week <%>`. No max means as many
155 * restarts as the time allows.
156 */
157export function parseCommand(args: string): Command {
158  let text = args.trim()
159  if (!text) return { kind: 'status' }
160  if (/^(0\s+0\s+)?stop$/i.test(text)) return { kind: 'stop' }
161
162  // Flags are read only outside a quoted role: a role may not carry its own --goal.
163  const quoted = splitQuotedRole(text)
164  let flagText = quoted ? quoted.rest : text
165  const goalFlag = takeFlag(flagText, 'goal')
166  flagText = goalFlag.rest
167  const fiveHourFlag = takeFlag(flagText, '5h')
168  flagText = fiveHourFlag.rest
169  const weekFlag = takeFlag(flagText, 'week')
170  flagText = weekFlag.rest
171  text = quoted ? `${quoted.head}${flagText}`.trim() : flagText
172
173  const fiveHourStop = percentFlag(fiveHourFlag.value, DEFAULT_FIVE_HOUR_STOP, '--5h')
174  if (typeof fiveHourStop === 'string') return { kind: 'error', message: fiveHourStop }
175  const weekStop = percentFlag(weekFlag.value, DEFAULT_WEEK_STOP, '--week')
176  if (typeof weekStop === 'string') return { kind: 'error', message: weekStop }
177  const goal = goalFlag.value?.trim() || null
178  if (goalFlag.value !== null && !goal) return { kind: 'error', message: '--goal needs a condition in quotes.' }
179
180  const match = /^(\S+)\s+(\d{1,3})%?\s+(?:(\d{1,2})\s+)?(["'“«][\s\S]+|[^\d\s][\s\S]*)$/.exec(text)
181  if (!match) return { kind: 'error', message: USAGE }
182  const [, rawDuration = '', rawThreshold = '', rawRestarts, rawRole = ''] = match
183  const durationMs = parseDuration(rawDuration)
184  if (durationMs === null || durationMs <= 0) {
185    return { kind: 'error', message: `Cannot read the time "${rawDuration}" (try 90m, 2h, 1h30m).` }
186  }
187  const threshold = Number(rawThreshold)
188  if (threshold < 1 || threshold > 95) return { kind: 'error', message: 'The context threshold must be between 1 and 95 (%).' }
189  const role = rawRole.trim().replace(/^["'“«]|["'”»]$/g, '').trim()
190  if (role.length < 10) {
191    return { kind: 'error', message: 'Give the role in a sentence or more: who the session is and what it is responsible for.' }
192  }
193
194  return {
195    kind: 'start',
196    durationMs,
197    threshold,
198    maxRestarts: rawRestarts === undefined ? null : Number(rawRestarts),
199    role,
200    goal,
201    fiveHourStop,
202    weekStop,
203  }
204}
205
206/**
207 * The skills a role names as `/name`, kept only when the session has a
208 * command of that name that is not built in (a skill, a plugin's command).
209 */
210export function namedSkills(role: string, skillNames: readonly string[]): string[] {
211  const known = new Set(skillNames)
212  const found: string[] = []
213  for (const match of role.matchAll(/(?:^|[\s("'“«`])\/([a-z0-9][\w:.-]*)/gi)) {
214    const name = trimTrailingPunctuation(match[1] ?? '')
215    if (name && known.has(name) && !found.includes(name)) found.push(name)
216  }
217
218  return found
219}
220
221const TRAILING_PUNCTUATION = '.,;:!?)'
222
223/** Drops trailing `.,;:!?)` in one linear pass (a regex here backtracks on long runs). */
224function trimTrailingPunctuation(text: string): string {
225  let end = text.length
226  while (end > 0 && TRAILING_PUNCTUATION.includes(text.charAt(end - 1))) end -= 1
227
228  return text.slice(0, end)
229}
230
231/** Whether another handoff restart is allowed; time alone limits a run with no max. */
232export function canRestart(run: Run): boolean {
233  return run.maxRestarts === null || run.restarts < run.maxRestarts
234}
235
236export function restartsText(run: Run): string {
237  return `${run.restarts}/${run.maxRestarts ?? '∞'}`
238}
239
240export type LimitAction = { kind: 'week' } | { kind: 'five-hour'; waitUntil: number } | null
241
242/**
243 * What the token windows call for: the weekly one past its stop ends the run
244 * for good; the 5-hour one past its stop parks it until the window resets.
245 */
246export function limitAction(run: Run, windows: readonly TokenWindow[], now: number): LimitAction {
247  const week = windows.find(window => window.kind === 'seven_day')
248  if (week && week.percentUsed >= run.weekStop) return { kind: 'week' }
249
250  const fiveHour = windows.find(window => window.kind === 'five_hour')
251  if (!fiveHour || fiveHour.percentUsed < run.fiveHourStop) return null
252  const resetsAt = fiveHour.resetsAt ? Date.parse(fiveHour.resetsAt) : Number.NaN
253
254  return {
255    kind: 'five-hour',
256    waitUntil: Number.isFinite(resetsAt) && resetsAt > now ? resetsAt + RESET_MARGIN_MS : now + UNKNOWN_RESET_WAIT_MS,
257  }
258}
259
260export type TurnAction = 'continue' | 'wrapup' | 'final' | 'pause' | 'finish' | 'resume-done' | 'none'
261
262/**
263 * What to do when a main-loop turn ends, from the run, the context fill, the
264 * time, whether the turn called any tool, and the ceiling below auto-compact.
265 * The token windows are judged apart (limitAction), before this.
266 */
267export function afterTurn(
268  run: Run,
269  now: number,
270  percent: number | null,
271  calledTools: boolean,
272  ceiling = DEFAULT_CEILING,
273): TurnAction {
274  // The turn that called the restart tool ends before /clear runs: queue nothing,
275  // or a prompt could reach the fresh conversation ahead of the resume.
276  if (run.phase === 'paused' || run.phase === 'restarting' || run.phase === 'waiting') return 'none'
277  // The turn /resume-handoff-doc started: the resume ran; back to work.
278  if (run.phase === 'resuming') return 'resume-done'
279  // A wrap-up turn ended; the model either called the restart tool or not.
280  if (run.phase === 'wrapping') return 'none'
281  if (run.phase === 'final') return 'finish'
282
283  if (now >= run.until) return 'final'
284  if (percent !== null && percent >= effectiveTrigger(run, ceiling)) return canRestart(run) ? 'wrapup' : 'final'
285  if (!calledTools && run.idleTurns + 1 >= MAX_IDLE_TURNS) return 'pause'
286
287  return 'continue'
288}
289
290/** Whether a tool result mid-turn should carry the wrap-up instruction now. */
291export function wrapMidTurn(
292  run: Run,
293  now: number,
294  percent: number | null,
295  ceiling = DEFAULT_CEILING,
296): 'wrapup' | 'final' | null {
297  // A resume turn often does real work right away: it is watched as well.
298  if (run.phase !== 'running' && run.phase !== 'resuming') return null
299  if (now >= run.until) return 'final'
300  if (percent !== null && percent >= effectiveTrigger(run, ceiling)) return canRestart(run) ? 'wrapup' : 'final'
301
302  return null
303}
304
305/** Phases in which the session must be able to end its turn: a goal's Stop hook may not hold it. */
306export function shouldDropStopBlock(phase: Phase): boolean {
307  return phase === 'wrapping' || phase === 'restarting' || phase === 'final'
308}
309
310/**
311 * Whether autopilot answers AskUserQuestion and marks refused actions as
312 * deferred: not while paused or waiting, when the person is in control.
313 */
314export function autoAnswersQuestions(phase: Phase): boolean {
315  return phase !== 'paused' && phase !== 'waiting'
316}
317
318function parseRow(line: string): Record<string, unknown> | null {
319  try {
320    const row: unknown = JSON.parse(line)
321    if (!row || typeof row !== 'object' || Array.isArray(row)) return null
322
323    return row as Record<string, unknown>
324  } catch {
325    // A malformed line: skip it.
326    return null
327  }
328}
329
330export type GoalVerdict = { met: boolean; condition: string }
331
332/**
333 * The goal evaluator's verdicts from whole transcript lines. The engine writes
334 * each as a top-level attachment row (`{"type":"attachment","attachment":
335 * {"type":"goal_status","met":…,"condition":…}}`); a verdict anywhere else (a
336 * tool input, message text) is not the engine's and is ignored.
337 */
338export function goalVerdictsFromRows(lines: readonly string[]): GoalVerdict[] {
339  const verdicts: GoalVerdict[] = []
340  for (const line of lines) {
341    const row = parseRow(line)
342    if (!row || row.type !== 'attachment') continue
343    const attachment = row.attachment as Record<string, unknown> | null | undefined
344    if (!attachment || typeof attachment !== 'object' || attachment.type !== 'goal_status') continue
345    if (typeof attachment.met !== 'boolean' || typeof attachment.condition !== 'string') continue
346    verdicts.push({ met: attachment.met, condition: attachment.condition })
347  }
348
349  return verdicts
350}
351
352/** The last verdict for this condition decides. None yet, or not met, is still active. */
353export function goalState(verdicts: readonly GoalVerdict[], condition: string): 'achieved' | 'active' {
354  const matching = verdicts.filter(verdict => verdict.condition === condition)
355  const last = matching[matching.length - 1]
356
357  return last?.met === true ? 'achieved' : 'active'
358}
359
360const IDENTITY_KEYS: Record<string, string> = {
361  'custom-title': 'customTitle',
362  'agent-name': 'agentName',
363  'agent-color': 'agentColor',
364}
365
366/** The color names a session can carry (the same keys as agent-fleet's SESSION_COLORS; not imported across mods). */
367const SESSION_COLOR_NAMES: Record<string, true> = {
368  red: true,
369  blue: true,
370  green: true,
371  yellow: true,
372  purple: true,
373  orange: true,
374  pink: true,
375  cyan: true,
376}
377
378/** Whether a phase is the one where the run follows the session to its new id: only its own /clear restart. */
379export function followsNewSessionId(phase: Phase): boolean {
380  return phase === 'restarting'
381}
382
383/**
384 * The session's name and color from whole transcript lines, only from
385 * top-level identity rows, the last of each kind winning: /rename's title,
386 * else the agent name.
387 */
388export function identityFromRows(lines: readonly string[]): { name: string | null; color: string | null } {
389  const last: Record<string, string> = {}
390  for (const line of lines) {
391    const row = parseRow(line)
392    const kind = typeof row?.type === 'string' ? row.type : ''
393    const key = IDENTITY_KEYS[kind]
394    if (!row || !key) continue
395    const value = row[key]
396    if (typeof value !== 'string' || !value) continue
397    if (kind === 'agent-color' && !Object.hasOwn(SESSION_COLOR_NAMES, value)) continue
398    last[kind] = value
399  }
400
401  return { name: last['custom-title'] ?? last['agent-name'] ?? null, color: last['agent-color'] ?? null }
402}
403
404const MAX_SHOWN_VALUE = 80
405
406/**
407 * Control and invisible characters: every control (Cc), format (Cf: bidi
408 * marks, zero-width, soft hyphen, tag characters) and the line and paragraph
409 * separators (Zl, Zp).
410 */
411const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u
412const INVISIBLE_ALL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu
413
414/** Trailing slashes off a path, in one linear pass (a regex backtracks on a long run). */
415function trimTrailingSlashes(path: string): string {
416  let end = path.length
417  while (end > 0 && path[end - 1] === '/') end--
418
419  return path.slice(0, end)
420}
421
422/** A transcript value shown to the model as data: control and invisible characters out, capped, JSON-quoted. */
423function quoteValue(value: string): string {
424  return JSON.stringify(value.replace(INVISIBLE_ALL, '').slice(0, MAX_SHOWN_VALUE))
425}
426
427/**
428 * Whether a handoff path the model gave is one autopilot may resume from: a
429 * `.md` file under `<root>/thoughts/shared/handoffs/`, relative paths taken
430 * against the root, no control characters, no `..` segment.
431 */
432export function isValidHandoffPath(root: string, path: string): boolean {
433  if (!path || INVISIBLE.test(path)) return false
434  if (path.split('/').includes('..')) return false
435  if (!path.endsWith('.md')) return false
436  const handoffs = `${trimTrailingSlashes(root)}/thoughts/shared/handoffs/`
437  const absolute = resolveHandoffPath(root, path)
438
439  return absolute.startsWith(handoffs) && absolute.length > handoffs.length
440}
441
442/** A handoff path as an absolute one: relative paths are taken against the root. */
443export function resolveHandoffPath(root: string, path: string): string {
444  if (path.startsWith('/')) return path
445
446  return `${trimTrailingSlashes(root)}/${path}`
447}
448
449export function formatLeft(ms: number): string {
450  const minutes = Math.max(0, Math.round(ms / 60_000))
451  if (minutes < 60) return `${minutes}m`
452
453  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
454}
455
456function skillsLine(run: Run): string[] {
457  if (run.skills.length === 0) return []
458
459  return [
460    `- Skills named in your role: ${run.skills.map(name => `/${name}`).join(', ')}. These are skills, not plain text: invoke them with the Skill tool wherever your role calls for them.`,
461  ]
462}
463
464function identityLines(run: Run): string {
465  return [
466    `- Role and responsibilities: ${run.role}`,
467    ...skillsLine(run),
468    ...(run.goal ? [`- Goal (completion condition): ${run.goal}`] : []),
469    `- Session name: ${run.name ? quoteValue(run.name) : '(none set)'}`,
470    `- Session color: ${run.color ? quoteValue(run.color) : '(none set)'}`,
471  ].join('\n')
472}
473
474const PAUSED_SECTION =
475  '# Autopilot is paused\nThe person is in control. Follow their messages as usual; the autopilot rules do not apply until it resumes.'
476
477const WAITING_SECTION =
478  '# Autopilot is waiting\nAutopilot is parked until the 5-hour token window resets, and the person is in control meanwhile. Follow their messages as usual; the autopilot rules do not apply until it resumes.'
479
480/** The system prompt section pinned while a run is on; survives /clear. */
481export function roleSection(run: Run, now: number): string {
482  // Parked or stalled: the person may be back, and the away rules must not override them.
483  if (run.phase === 'paused') return PAUSED_SECTION
484  if (run.phase === 'waiting') return WAITING_SECTION
485
486  return [
487    '# Autopilot: you are working while the person is away',
488    'The person started this run with /autopilot. These lines are their instructions about who you are.',
489    identityLines(run),
490    `- Time left: ${formatLeft(run.until - now)}; context threshold ${run.threshold}%; restarts used ${restartsText(run)}.`,
491    ...(run.handoffPath
492      ? [`- Latest handoff: ${JSON.stringify(run.handoffPath)}. If you do not know where you are, read it and continue from there; never start the work over.`]
493      : []),
494    'Rules while the person is away:',
495    '- Ordinary questions: decide yourself within your role, prefer the reversible option, and record the decision (question, options, choice, why) for your handoff.',
496    '- Permissions stay exactly as they are. If an action is denied or needs the person (a push, a commit, anything the permission mode blocks), never retry it another way to get around the denial: note it as deferred until the person returns, and continue with other work.',
497    '- Only when a question truly cannot wait for the person and blocks all useful work, call the PushNotification tool with a one-line question (under 200 characters), then continue with whatever else you can do.',
498    '- You may refine the goal of the next stage. You may never change your purpose, role or responsibilities.',
499    '- Ending this session is autopilot\'s call, not yours: never write a handoff or wind down on your own. Autopilot tells you when (context threshold, time, limits) and how. A previous handoff saying a session ended means that session, not this one: keep working.',
500  ].join('\n')
501}
502
503export function kickoffPrompt(run: Run, now: number): string {
504  return [
505    `Autopilot is on for ${formatLeft(run.until - now)}. Your role:`,
506    run.role,
507    ...skillsLine(run),
508    ...(run.goal ? [`Goal: ${run.goal}`] : []),
509    'Work toward your goal now. Start by stating in two lines what you will do first, then do it.',
510  ].join('\n')
511}
512
513/** The line a session answers with when everything its role covers is done. */
514export const DONE_MARKER = 'AUTOPILOT_DONE'
515
516export const CONTINUE_PROMPT = `Autopilot: keep going toward your goal within your role. If the current stage is done, pick the next most valuable step within your role and do it. Deferred actions wait for the person. If everything your role covers is complete and no valuable step is left, do not invent work: answer with the line ${DONE_MARKER} and a one-line reason.`
517
518/** Whether a turn's answer declares the role's work complete: the marker opens a line, never mid-sentence. */
519export function isDoneAnswer(answer: string): boolean {
520  // Spaces and tabs only: `\s` would match line breaks too and backtrack quadratically on blank lines.
521  return /^[ \t]*AUTOPILOT_DONE\b/m.test(answer)
522}
523
524export function wrapupPrompt(run: Run, percent: number | null): string {
525  return [
526    `Autopilot: the context has reached the handoff point (${percent ?? run.threshold}%). Finish this session gracefully, before auto-compact:`,
527    '1. Stop at the right moment: finish or safely park the step in progress. Start nothing new.',
528    '2. Use the /create-handoff-doc skill. Put this block at the very top of the handoff, verbatim, as the initial prompt of the next session, marked as important and not to be ignored:',
529    identityLines(run),
530    '   Then: the goal of the next stage (you may refine it), what is done, what is next, the decisions you made yourself, and the deferred actions waiting for the person.',
531    `3. Call the ${RESTART_TOOL} tool with the path of the handoff document. It clears this session and resumes from the handoff.`,
532  ].join('\n')
533}
534
535const FINAL_OPENING: Record<FinalReason, (run: Run) => string> = {
536  time: () => 'Autopilot: the autonomous time is over. Finish gracefully and hand back to the person:',
537  restarts: run => `Autopilot: the restart limit (${restartsText(run)}) is reached. Finish gracefully and hand back to the person:`,
538  goal: run => `Autopilot: the goal is achieved (${run.goal ?? ''}). Finish gracefully and hand back to the person:`,
539  done: () => 'Autopilot: you reported the work your role covers as complete. Finish gracefully and hand back to the person:',
540  week: run =>
541    `Autopilot: ABSOLUTE STOP: the weekly token limit has reached ${run.weekStop}% used. Stop all work now and hand back to the person:`,
542}
543
544export function finalPrompt(run: Run, reason: FinalReason): string {
545  return [
546    FINAL_OPENING[reason](run),
547    '1. Finish or safely park the step in progress. Start nothing new.',
548    '2. Use the /create-handoff-doc skill, with this block at the top, verbatim:',
549    identityLines(run),
550    '   Then: what is done, what is next, the decisions you made yourself, and the deferred actions waiting for the person.',
551    ...(reason === 'week'
552      ? [
553          `3. Call the PushNotification tool once: "Autopilot stopped: weekly token limit at ${run.weekStop}%. Handoff written, waiting for you."`,
554          `4. Do NOT call ${RESTART_TOOL}. End with a short summary for the person.`,
555        ]
556      : [`3. Do NOT call ${RESTART_TOOL}. End with a short summary for the person.`]),
557  ].join('\n')
558}
559
560export function waitPrompt(run: Run, waitUntil: number): string {
561  return [
562    `Autopilot: the 5-hour token window has reached ${run.fiveHourStop}% used. Park now:`,
563    '1. Finish or safely park the step in progress so nothing is left half-done. Start nothing new.',
564    `2. Write two lines on where you stopped and what comes next, then end your turn. Autopilot resumes you after the window resets (about ${new Date(waitUntil).toISOString().slice(11, 16)} UTC). No handoff, no restart.`,
565  ].join('\n')
566}
567
568export const RESUME_AFTER_WAIT_PROMPT =
569  'Autopilot: the 5-hour token window has reset. Continue from where you parked, within your role.'
570
571/** The answer an AskUserQuestion gets while the person is away. */
572export const ASK_ANSWER =
573  'The person is away (autopilot). Decide this yourself within your role, prefer the reversible option, record the decision for the handoff, and continue. If it truly cannot wait and blocks all useful work, send one PushNotification with the question and continue with other work.'
574
575/** What the model reads after an action the permission mode refused. */
576export const DEFERRED_NOTE =
577  'Autopilot: this action was refused by the permission mode. Do not try to get around it. It is logged as deferred until the person returns; put it in your handoff and continue with other work.'
578
types/index.d.ts 65 lines
1/**
2 * Where an autopilot run stands:
3 * - running: working toward the goal, nudged on after every turn
4 * - wrapping: past the context threshold, writing a handoff before a restart
5 * - restarting: the restart tool was called; /clear is queued behind the turn
6 * - resuming: /resume-handoff-doc was issued; its turn ending means resumed
7 * - waiting: the 5-hour token window is nearly used up; parked until it resets
8 * - final: time, restarts, the weekly limit or the goal: a last handoff, no restart
9 * - paused: stalled twice in a row; waits for the person
10 */
11export type Phase = 'running' | 'wrapping' | 'restarting' | 'resuming' | 'waiting' | 'final' | 'paused'
12
13export type FinalReason = 'time' | 'restarts' | 'week' | 'goal' | 'done'
14
15/** One autopilot run, kept in $.store under `run:<session id>`. */
16export type Run = {
17  role: string
18  /** The session's /rename name and /color at the start, so a handoff keeps them. */
19  name: string | null
20  color: string | null
21  startedAt: number
22  until: number
23  threshold: number
24  /** null: as many restarts as the time allows. */
25  maxRestarts: number | null
26  restarts: number
27  phase: Phase
28  /** The handoff a queued restart resumes from. */
29  handoffPath: string | null
30  /** Turns in a row that called no tool. */
31  idleTurns: number
32  /** The /goal condition the run sets and keeps set across /clear; null for none. */
33  goal: string | null
34  /** Skills the role names (`/explore`), confirmed against the session's commands. */
35  skills: string[]
36  /** Park at this % of the 5-hour token window and resume after it resets. */
37  fiveHourStop: number
38  /** Stop for good at this % of the weekly token window. */
39  weekStop: number
40  /** While waiting: when the 5-hour window resets (plus a margin). */
41  waitUntil: number | null
42  /** Why the run is in its final phase. */
43  finalReason: FinalReason | null
44}
45
46export type StartCommand = {
47  kind: 'start'
48  durationMs: number
49  threshold: number
50  maxRestarts: number | null
51  role: string
52  goal: string | null
53  fiveHourStop: number
54  weekStop: number
55}
56
57export type Command =
58  | { kind: 'status' }
59  | { kind: 'stop' }
60  | StartCommand
61  | { kind: 'error'; message: string }
62
63/** A token window as `$.session.usage().rateLimits` reports it. */
64export type TokenWindow = { kind: string; percentUsed: number; resetsAt?: string }
65