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

Two Claude Code mods (plugins of function hooks) in one local marketplace, local-mods:
| Mod | Command | What it does |
|---|---|---|
agent-fleet | /fleet | A 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 | /autopilot | Runs 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 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.
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.
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 <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
90m, 2h, 1h30m, or minutes.autoCompactWindow in ~/.claude/settings.json (800000 on a 1M window gives ~66%). Raise that setting to allow a higher threshold./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 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.While it runs:
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).AUTOPILOT_DONE and autopilot wraps up.~/.claude/autopilot/<session id>.log (decisions, deferrals, restarts, limits)./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.
main takes changes through pull requests only, and a PR merges after the owner (@VladLeus) approves it:
main.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.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.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).
/reload-plugins in a session. Check with: ``bash claude plugin validate autopilot claude plugin test autopilot ``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..claude-plugin/types/ (git-ignored); grep claude-code/index.d.ts there for events and $ methods.fleet.ts, autopilot.ts); register.tsx holds the hooks.hooks/register.tsx 674 lines1import 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}
674hooks/autopilot.ts 578 lines1import 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.'
578types/index.d.ts 65 lines1/**
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