A prompt queue for Claude Code. Queue prompts, ideas and comments while Claude works, without interrupting it, and send them once it finishes or whenever you…

<kbd>aside</kbd> is a prompt queue for <kbd>Claude Code</kbd>. Queue prompts, ideas and comments while Claude works, without interrupting it, and send them once it finishes or whenever you are ready.
<a href="https://github.com/gravityXX/aside/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/gravityXX/aside/ci.yml?branch=main&style=flat-square&label=CI&color=555"></a> <img alt="Version" src="https://img.shields.io/github/package-json/v/gravityXX/aside?filename=.claude-plugin%2Fplugin.json&style=flat-square&label=version&color=555"> <a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-555?style=flat-square"></a>
To comment on something, copy it first (<kbd>Ctrl</kbd>+<kbd>C</kbd> or <kbd>⌘</kbd>+<kbd>C</kbd>), from anywhere: <kbd>VS Code</kbd>, a <kbd>terminal</kbd>, a <kbd>web page</kbd>. Then start a message with +, or end one with it. It goes to the queue, not to Claude. No paste needed: <kbd>aside</kbd> takes whatever is on your clipboard.
[!NOTE] What you type in a hurry is tidied as it is queued: spelling, punctuation, wording. What you copied stays exactly as copied. To keep entries as typed, set
tidytooffin/config. The other settings are under Settings.
Your reply goes to Claude. By default the queue just waits, in a line above the prompt. Its <kbd>list</kbd> button opens a pane with the queue, the tags in files and the plan, and <kbd>plan</kbd> to organize them. Beside it, <kbd>run plan</kbd> shows while the plan has steps left, <kbd>continue</kbd> while a step waits for you or a run is paused, and <kbd>stop</kbd> while a step runs. /aside opens the same pane while Claude works, and /aside g, q, find and log show their lists there too, with <kbd>queue</kbd> to go back.
Nothing reaches Claude until you say so. /aside shows the queue in a frame, and /aside pop N puts one entry in the prompt box, ready to send.
/aside plan has Claude turn the queue into steps, shown in a frame. Anything unclear goes back to the queue with Claude's question. While a step runs, it adds the queue as new steps after the plan's own and leaves those as they are: Claude organizes it once the step ends and the run waits for <kbd>continue</kbd>, or with planModel on a model, at once while the run goes on.
/aside run all sends the steps one at a time, each ending with a one-line result, and /aside run sends only the next one. A step that needs you waits for your answer: answer as usual, and Claude's reply to it ends the step and runs the next, unless Claude asks again; <kbd>continue</kbd> ends a waiting step without an answer. A step that leaves work going in the background, such as a workflow or an agent, goes on until that work is done, and the line above the prompt says running in the background; <kbd>continue</kbd> ends it sooner. After the last step, Claude sums the run up.
Every situation <kbd>aside</kbd> handles, with diagrams of how it works, is in docs/use-cases.md.
/plugin marketplace add gravityXX/aside
/plugin install aside@aside
Requires <kbd>Claude Code</kbd> 2.1.301 or newer, whose plugin API is still in early access. If /aside is an unknown command, sign in again with /login.
+ entries are queued; the list shows in the terminal./aside TEXT./aside show the queue and the plan
/aside plan have Claude turn the queue into steps, or add them to a running plan
/aside run run the next step of the plan, or the first entry of the queue
/aside run all run the plan step by step, or the queue entry by entry
/aside run 3 1 run only those steps, or those entries
/aside continue end the step that waits for you and go on
/aside stop stop after the current step
/aside log [N] the plans run to their end, with what each step did, or plan N
/aside TEXT queue TEXT, as + would
/aside pop [N] put the first entry, or entry N, in the prompt box
/aside edit N rework entry N and queue it again
/aside move N M move entry N to place M
/aside remove N ... remove one entry, or several
/aside clear empty the queue
/aside undo bring back what the last clear, or an unanswered plan or run, took
/aside find TEXT look for words in the queue, the tags, the plan and every queue
/aside import FILE queue the items of a markdown list in FILE, each an entry
/aside scan FILE read the tags of a file Claude did not write
/aside export [FILE] save the list as Markdown, to aside.md or FILE
/aside help list every command
+g TEXT put an idea on the global list, which belongs to no project
read it later +g the whole draft, onto the global list
/aside g show the global list
/aside g here N ... move ideas N of the global list to the end of this queue
/aside g add N ... move ideas N of this queue, numbered as /aside shows them, to the global list
/aside g remove N ... remove from the global list; g edit N, g pop [N] and g move N M as for the queue
/aside g clear empty the global list; /aside g undo brings back what it took
/aside g import FILE put the items of a markdown list on the global list
+@work TEXT put an idea in the queue named work, made by its first idea
read it later +@work the whole draft, into the queue work
/aside q show the global list and every named queue, with its first three ideas
/aside q work show the queue work; the commands of /aside g work on it, as in /aside q work here 1
/aside q work clear empty it, or with rename X move it all to the queue X, merged with any ideas there
/aside q work local keep it for this project alone; /aside q work global shares it again
The global list and the named queues wait in every project: the line above the prompt counts them all together, as in 3 in other queues, /aside leaves them out, and plan and run take only this queue, so /aside g here or /aside q work here brings an idea in first. The global list is the queue named global, so +@global is +g. Several sessions can add to them at once. /aside undo does not bring back what left them; a queue's own undo brings back what its last clear took. A named queue can also be this project's alone: /aside q work local keeps it here, where +@work then goes to it and other projects no longer see it, and with the newQueues setting on local a queue is this project's from its first idea.
N is a number as /aside shows it, and [N] may be left out. FILE is one word ending in .md. While Claude works, /aside, /aside g, q, find and log open their list in a pane, with <kbd>queue</kbd> to go back. Tab completes every command.
why is this so slow +? the whole draft, as a question
+? why did this fail nothing copied: the last tool output, an error first
+? @docs/report.md:42 why line 42 of that file
+! @report.md#results shorter the Results heading
+- #appendix a heading in a file Claude wrote this session
<!-- ? why --> a tag in a markdown file
In a tag, ! is a change, - a drop and + dig deeper. Tags count in markdown files Claude wrote or edited this session; /aside scan FILE adds any other. In fullscreen mode, lines you select in Claude's reply count as copied.
Change these in /config:
afterClaudeAnswers: wait (default), plan or run all. What <kbd>aside</kbd> does once Claude answers.planModel: session (default), sonnet, haiku or opus. Who organizes the queue on /aside plan: Claude in this conversation, knowing the work so far, or one call to that model outside it, which adds nothing to the conversation and runs while Claude works, but sees only the queue.newQueues: global (default) or local. Whom a named queue made by its first idea, as in +@work, is for: every project, or the one it was made in.order: oldest (default) or newest. Which entry comes first in the list and in a run.tidy: on (default) or off. Whether a small model fixes the spelling and wording of what you type, and names a step made of a long entry.[!IMPORTANT] The queue stays on your machine, one per project, beside the global list and the named queues for ideas that belong to no project. Nothing reaches Claude's conversation until you run
/aside planor/aside run, orafterClaudeAnswersdoes. Withtidyon, the text you type goes to Haiku to be corrected; what you copied never does. WithplanModelon a model,/aside plansends the queue to that model in place of the conversation.
/aside undo brings back, one set per project, and the global list and the named queues, shared by all projects, in Claude Code's store for the plugin (~/.claude/plugins/store/). It writes a file only when you run /aside export, and never over one it did not write.<!-- ? --> tags in markdown files Claude wrote or edited this session, or that you /aside scan; for a comment, the clipboard, the lines you selected in Claude's reply, the last tool output and the project's markdown files; the project's git branch.powershell.exe on WSL and Windows, pbpaste on macOS, wl-paste, xclip or xsel on Linux), git rev-parse --abbrev-ref HEAD, and grep -rlizP over the project's markdown files./aside plan, /aside run or afterClaudeAnswers, with what you copied, and to the model planModel names instead on /aside plan; while tidy is on, the text you typed goes to Haiku through Claude Code, never what you copied.<kbd>aside</kbd> opens no network connection of its own and installs nothing.
Found a bug or have an idea? Open an issue and say what you did, what you expected and what happened, with your Claude Code version (claude --version) and your system.
To change something yourself, fork the repository, work on a branch and open a pull request. CONTRIBUTING.md shows how to run and test the plugin from your copy, and docs/guidelines.md how the code is laid out, written and reviewed. A pull request needs a passing CI run and the maintainer's review before it is merged.
<kbd>aside</kbd> is free, and I put time and effort into it. If it saves you time, star the repository so others can find it, or sponsor its development and more useful skills to come.
Thank you to everyone who reports a problem, suggests an idea or sends a fix. Enjoy your queue.
hooks/register.tsx 55 lines1// One line per hook, each handing its event straight on. Nothing is decided here: a hook that
2// needs a condition puts it in the function it calls, so this file stays the list of what the
3// plugin listens to. Its matchers are literals, since the engine reads this file as written (3.5).
4
5import type { Register } from 'claude-code'
6
7import { createContext } from './context'
8import { noteBackgroundEnd, noteBackgroundWork } from './features/background'
9import { dropFailedEntry, emptyBoxAfterQueue, queueTyped, rememberMarkdownFile, rememberToolOutput } from './features/capture'
10import { asideFailed, completeAside, runAside, startSession } from './features/commands'
11import { onTurnEnd, onTurnStart, turnEndFailed, turnStartFailed } from './features/plan'
12import { pausePlanFromTool, planToolFailed, savePlanFromTool, waitToolFailed } from './features/plantool'
13import { endSessionSteps, noteStepAnswer, stepAnswerFailed } from './features/steps'
14import { drawBand } from './ui/band'
15import { drawListingFrame, drawPlanTool, hidePlanToolResult } from './ui/output'
16import { drawPane } from './ui/pane'
17
18// Every hook the plugin has, each handing its event to the module that does the work.
19export const register: Register = (on, options) => {
20 const ctx = createContext(options)
21
22 on('session.start', ($, e, next) => startSession(ctx, $, e, next))
23
24 on('prompt.submit', ($, e, next) => queueTyped(ctx, $, e, next)).catch(dropFailedEntry)
25 // Beneath the one above, which drops a queued entry before it comes here: what the person
26 // typed, at this terminal or through Remote Control.
27 on('prompt.submit', { origin: { kind: /^(composer|bridge)$/ } }, ($, e, next) => noteStepAnswer(ctx, $, e, next)).catch(stepAnswerFailed)
28 // A background task's notification: the step that started the work it names goes on.
29 on('prompt.submit', { origin: { kind: 'task-notification' } }, ($, e, next) => noteBackgroundEnd(ctx, $, e, next)).catch(stepAnswerFailed)
30 on('prompt.change', ($, e, next) => emptyBoxAfterQueue(ctx, $, e, next))
31 on('prompt.autocomplete', ($, e, next) => completeAside(ctx, $, e, next))
32
33 on('tool.call', ($, e, next) => rememberToolOutput(ctx, e, next))
34 on('tool.call', { tool: /^(Workflow|Agent|Bash|TaskStop)$/ }, ($, e, next) => noteBackgroundWork(ctx, $, e, next))
35 on('tool.call', { tool: 'Write' }, async ($, e, next) => rememberMarkdownFile($, e.file_path, await next(e)))
36 on('tool.call', { tool: 'Edit' }, async ($, e, next) => rememberMarkdownFile($, e.file_path, await next(e)))
37 // A RegExp: the laid types know only the tools connected when they were written, and the
38 // plan tools' full names are known only once they are registered.
39 on('tool.call', { tool: /__plan$/ }, ($, e, next) => savePlanFromTool(ctx, $, e, next)).catch(planToolFailed)
40 on('tool.call', { tool: /__wait$/ }, ($, e, next) => pausePlanFromTool(ctx, $, e, next)).catch(waitToolFailed)
41
42 on('turn.start', ($, e, next) => onTurnStart(ctx, $, e, next)).catch(turnStartFailed)
43 on('turn.complete', ($, e, next) => onTurnEnd(ctx, $, e, next)).catch(turnEndFailed)
44 on('session.end', ($, e, next) => endSessionSteps(ctx, $, e, next))
45
46 on('command.run', { command: 'aside' }, ($, e) => runAside(ctx, $, e)).catch(asideFailed)
47
48 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand(ctx, $, e, next))
49 // LIST_PANE in ui/pane.tsx, written out so the engine's scan of this file reads it.
50 on('ui.render', { component: 'Pane', requestId: 'aside' }, ($, e, next) => drawPane(ctx, $, e, next))
51 on('ui.render', { component: 'CommandOutput' }, drawListingFrame)
52 on('ui.render', { component: 'ToolUse' }, ($, e, next) => drawPlanTool(ctx, $, e, next))
53 on('ui.render', { component: 'ToolResult' }, ($, e, next) => hidePlanToolResult(ctx, $, e, next))
54}
55hooks/context.ts 115 lines1// Everything whose life is one load of the plugin, and nothing whose life is longer: a reload
2// starts this over (3.7), so what must outlast one belongs in `$.store` or an atom. The settings
3// are read once here and never checked again further in (4.8).
4
5import type { Register, Timer } from 'claude-code'
6
7import type { Entry } from '../types'
8import type { Order } from './core/entries'
9import type { ClipboardState } from './io/host'
10
11// What organizes the queue on /aside plan: Claude in this conversation, or one call to a
12// model of its own outside it.
13export type PlanModel = 'session' | 'sonnet' | 'haiku' | 'opus'
14
15const PLAN_MODELS: readonly PlanModel[] = ['session', 'sonnet', 'haiku', 'opus']
16
17export type Settings = {
18 readonly order: Order
19 // Whether a small model tidies each entry's own text once it is stored.
20 readonly tidy: boolean
21 // What aside does by itself when Claude finishes answering: wait for /aside, or take the
22 // queue at once, as /aside plan or /aside run all would.
23 readonly afterAnswer: 'wait' | 'plan' | 'run all'
24 readonly planModel: PlanModel
25 // Whom a named queue made by its first idea ("+@name", /aside q NAME TEXT, add or import) is
26 // for: every project, or this one alone.
27 readonly newQueues: 'global' | 'local'
28}
29
30// Who asked for a hand-over: a command answers with the whole text, while the pane's button
31// and the end of Claude's answer say one line in a toast.
32export type Asker = 'command' | 'pane' | 'answer'
33
34export type ToolOutput = { text: string; turnId: string | undefined; isError: boolean }
35
36// A view of the list pane, by what it lists, so it is listed anew each time it is drawn.
37export type PaneView = { kind: 'queue' } | { kind: 'named'; typed: string } | { kind: 'queues' } | { kind: 'find'; query: string } | { kind: 'log'; n?: number }
38
39export type Session = {
40 // Full names as the engine registered them: the prefix comes from where the plugin was
41 // loaded from, a marketplace install or a --plugin-dir folder, so it is never assumed (3.10).
42 planTool: string
43 waitTool: string
44 // The current turn, or the last one: it is not cleared when a turn ends.
45 turnId?: string
46 isOwnTurn: boolean
47 // Only an entry queued while a turn is in flight was made "while Claude worked on" it.
48 isRunning: boolean
49 lastTask?: string
50 // The last tool output of the turn: an error stays over a later success.
51 lastOutput?: ToolOutput
52 // In the turn turnId names: what the listing shows a running step as doing now.
53 lastTool?: { turnId: string | undefined; text: string }
54 // The last message queued, until the box it went back into has been emptied.
55 justQueued?: string
56 // Held until the turn it starts is bound to the step, which is what takes the answer up.
57 // isTold: it went with the step's own words, which tell Claude its reply ends the step; one
58 // typed over a running turn goes without them, so its turn leaves the step waiting.
59 answer?: { text: string; isTold: boolean }
60 // Held the same way; the turn it starts is the step's, and that turn's end ends the step.
61 notice?: string
62 // What /aside plan handed over, the items numbered as the planning prompt numbered them. A
63 // reload loses it, and the take then waits for /aside undo. isAdding: a second hand-over
64 // before this turn ends would save over the steps this one adds.
65 planning?: { items: Entry[]; turnId?: string; isSaved: boolean; isAdding: boolean }
66 // One planner at a time, so a second plan never saves over the first one's.
67 planner?: Exclude<PlanModel, 'session'>
68 // The entries being tidied now: a planner waits for them, or it would plan an entry under
69 // the text it had before.
70 tidies: Set<Promise<void>>
71 // The prompts of steps with a title an earlier release cut, sent to be named in this process.
72 namedOld: Set<string>
73 // What this process saw start and not end: how a step's turn, or its work, is known dead.
74 openTurns: Set<string>
75 ownWork: Set<string>
76 // What /aside g, q, find or log asked for while Claude worked. None of them means the queue.
77 paneView?: PaneView
78 // What draws the list pane again while a step runs or waits, so its time there moves on.
79 paneTick?: Timer
80 clipboard: ClipboardState
81}
82
83export type Context = { settings: Settings; session: Session }
84
85// Made each time the plugin loads; nothing in it has to outlast a reload.
86export const createContext = (options: Parameters<Register>[1]): Context => ({
87 settings: {
88 order: options.order === 'newest' ? 'newest' : 'oldest',
89 tidy: options.tidy !== 'off',
90 afterAnswer: options.afterClaudeAnswers === 'plan' ? 'plan' : options.afterClaudeAnswers === 'run all' ? 'run all' : 'wait',
91 planModel: PLAN_MODELS.find(model => model === options.planModel) ?? 'session',
92 newQueues: options.newQueues === 'local' ? 'local' : 'global',
93 },
94 session: {
95 planTool: 'mcp__aside__plan',
96 waitTool: 'mcp__aside__wait',
97 isOwnTurn: false,
98 isRunning: false,
99 openTurns: new Set(),
100 ownWork: new Set(),
101 tidies: new Set(),
102 namedOld: new Set(),
103 clipboard: {},
104 },
105})
106
107// The task an entry queued now belongs to: none at an idle prompt.
108export const currentTask = (ctx: Context) => (ctx.session.isRunning ? ctx.session.lastTask : undefined)
109
110export const currentOutput = (ctx: Context) => {
111 const { lastOutput, turnId } = ctx.session
112
113 return lastOutput && lastOutput.turnId === turnId ? lastOutput.text : undefined
114}
115hooks/features/background.ts 107 lines1// The work a step's turn leaves running, and the notification that ends it. register.tsx calls
2// these and nothing in the plan does, so this file is a leaf of its area.
3
4import type { EngineInterface as Engine, Hook, MatchedHook } from 'claude-code'
5
6import type { Context } from '../context'
7import { backgroundContext, isSummingUp, stepMode } from '../core/prompts'
8import { decide, writeInto } from '../core/reduce'
9import { awayWork } from '../core/step'
10import { changePlan } from '../io/plan'
11import { refreshBand } from '../io/store'
12
13type Submit = Parameters<Hook<'prompt.submit'>>
14
15// What a call left going in the background, by its result: a workflow or an agent in the
16// background, or a command; none for any other call.
17const startedWork = (e: Record<string, unknown>, result: unknown): { taskId: string; what: string } | undefined => {
18 const done = (typeof result === 'object' && result !== null ? result : {}) as Record<string, unknown>
19 if (e.tool === 'Workflow' && done.status === 'async_launched' && typeof done.taskId === 'string') {
20 return { taskId: done.taskId, what: typeof done.workflowName === 'string' ? done.workflowName : 'a workflow' }
21 }
22 if (e.tool === 'Agent' && done.status === 'async_launched' && typeof done.agentId === 'string') {
23 return { taskId: done.agentId, what: typeof done.description === 'string' ? done.description : 'an agent' }
24 }
25 if (e.tool === 'Bash' && typeof done.backgroundTaskId === 'string') {
26 return { taskId: done.backgroundTaskId, what: typeof e.description === 'string' ? e.description : 'a command' }
27 }
28
29 return undefined
30}
31
32type WorkCall = Parameters<MatchedHook<'tool.call', { readonly tool: RegExp }>>
33
34// A call of the step's own turn that leaves work going in the background makes that work the
35// step's: the step does not end before it does.
36export async function noteBackgroundWork(ctx: Context, $: Engine, e: WorkCall[1], next: WorkCall[2]) {
37 const done = await next(e)
38 if (e.tool === 'TaskStop') {
39 await dropStoppedWork(ctx, $, (done.result as { task_id?: unknown } | undefined)?.task_id)
40 return done
41 }
42 const work = e.agentId === undefined ? startedWork(e as unknown as Record<string, unknown>, done.result) : undefined
43 if (work === undefined) {
44 return done
45 }
46 const me = await $.session.id()
47 const callId = e.tool_use_id
48 // The call has run: a store that fails here costs the step its wait, never the call's result.
49 await changePlan($, plan => {
50 const made = decide(plan, { is: 'workStarted', sessionId: me, turnId: ctx.session.turnId, ...(callId ? { callId } : {}), ...work })
51 writeInto(plan, made.plan)
52 if (made.outcome === 'noted' && callId) ctx.session.ownWork.add(callId)
53 }).catch(() => undefined)
54
55 return done
56}
57
58// Work Claude stopped sends no notification: it leaves the step at once. A step in the
59// background left with none takes the turn that stopped it, whose end ends the step.
60async function dropStoppedWork(ctx: Context, $: Engine, taskId: unknown) {
61 if (typeof taskId !== 'string') {
62 return
63 }
64 const me = await $.session.id()
65 await changePlan($, plan => {
66 writeInto(plan, decide(plan, { is: 'workStopped', sessionId: me, turnId: ctx.session.turnId, taskId }).plan)
67 }).catch(() => undefined)
68 await refreshBand($)
69}
70
71// A background task's notification: the work it names is done, whatever its status, and
72// leaves the step it was part of.
73export async function noteBackgroundEnd(ctx: Context, $: Engine, e: Submit[1], next: Submit[2]) {
74 const taskId = /<task-id>([^<]+)<\/task-id>/.exec(e.text)?.[1]?.trim()
75 const callId = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(e.text)?.[1]?.trim()
76 if (taskId === undefined && callId === undefined) {
77 return next(e)
78 }
79 const me = await $.session.id()
80 const gone = await changePlan($, plan => {
81 const made = decide(plan, {
82 is: 'workEnded',
83 sessionId: me,
84 ...(e.turnId === undefined ? {} : { turnId: e.turnId }),
85 ...(taskId === undefined ? {} : { taskId }),
86 ...(callId === undefined ? {} : { callId }),
87 })
88 if (made.outcome === 'nothing') return undefined
89 writeInto(plan, made.plan)
90
91 return { active: structuredClone(made.plan.active!), steps: structuredClone(made.plan.steps), isTold: made.outcome === 'told' }
92 })
93 if (gone === undefined) {
94 return next(e)
95 }
96 await refreshBand($)
97 const { active, steps, isTold } = gone
98 if (!isTold) {
99 return next(e)
100 }
101 if (e.turnId === undefined) ctx.session.notice = e.text
102 const isLast = isSummingUp(steps, stepMode(steps, active.step, active.isChain, active.then))
103 const context = backgroundContext(steps, active.step, awayWork(active), isLast)
104
105 return next({ ...e, context: [...(e.context ?? []), context] })
106}
107hooks/features/capture.ts 423 lines1// A typed message becoming stored entries: what it holds, where each one points, and what the
2// person reads back. The slow part, the clipboard and git, runs before the queue is read and
3// never while it is held (2.9).
4
5import type { CatchHandler, EngineInterface as Engine, Hook } from 'claude-code'
6
7import type { Band, Entry } from '../../types'
8import { currentOutput } from '../context'
9import type { Context } from '../context'
10import { inOrder, isSameEntry, KINDS, parseEntries, retypedRaw } from '../core/entries'
11import type { Order } from '../core/entries'
12import { formatPlace } from '../core/listing'
13import { MARKDOWN_PATH } from '../core/markdown'
14import { pinComment } from '../core/pin'
15import type { Copied, Pin, Sources } from '../core/pin'
16import { readTidyReply, TIDY_SYSTEM, tidyPrompt } from '../core/prompts'
17import { describeError, describeTool, formatCount, newQueueNote, shortenText } from '../core/text'
18import { readClipboard, readGitBranch, resolveFile, searchMarkdown, tryReadFile } from '../io/host'
19import { changeQueue, changeQueueAfter } from '../io/queue'
20import { forgetEditing, loadEditing, projectKeys, readTrackedFiles, refreshBand, trackFile } from '../io/store'
21import { GLOBAL, addIdeas, countQueues, forgetGlobalEditing, forgetGlobalIdeas, isLocalQueue, isOut, loadGlobalEditing, loadIdeas, queueName, queueOf, resolveQueue, restoreIdea, saveGlobalIdea } from '../io/queues'
22import type { GlobalIdea } from '../io/queues'
23
24// The tools whose output a comment may be about when nothing else says so; any error counts.
25const OUTPUT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
26
27// The text selected in one transcript row: one in the prompt box or a pane is not about
28// Claude's output.
29function readReplySelection($: Engine): Promise<string> {
30 return $.ui.selection().then(one => (one?.requestId ? one.text.trim() : ''), () => '')
31}
32
33// What the pinning rules in core/pin.ts read, each a closure over `$` made here, where `$` is.
34// The clipboard and the selection are read at most once per message, and a file at most once,
35// however many comments the message holds.
36function commentSources(
37 $: Engine,
38 ctx: Context,
39 root: string,
40 seen: readonly string[],
41 output: string | undefined,
42 selection: string | undefined,
43 isRemote: boolean,
44): Sources {
45 let clipboard: Promise<Copied> | undefined
46 let selected: Promise<string> | undefined
47 const sourceCache = new Map<string, Promise<string | undefined>>()
48 const readCopied = async (): Promise<Copied> => {
49 const read = await readClipboard($, ctx.session.clipboard)
50
51 return read.hasReader ? read : { ...read, missing: 'no clipboard reader here (wl-clipboard, xclip or xsel)' }
52 }
53 const machine = {
54 read: (file: string) => {
55 if (!sourceCache.has(file)) sourceCache.set(file, tryReadFile($, file))
56
57 return sourceCache.get(file)!
58 },
59 search: (quote: string) => searchMarkdown($, quote, root),
60 resolve: (file: string) => resolveFile($, file, root, seen),
61 ...(output ? { output } : {}),
62 }
63
64 // A comment from another device: this machine's clipboard is not the one it was copied on.
65 if (isRemote) {
66 return {
67 ...machine,
68 clipboard: () => Promise.resolve({ text: '', isNew: false, missing: 'sent from another device, so no clipboard was read' }),
69 selection: () => Promise.resolve(''),
70 }
71 }
72
73 return {
74 ...machine,
75 clipboard: () => (clipboard ??= readCopied()),
76 selection: () => (selected ??= selection !== undefined ? Promise.resolve(selection) : readReplySelection($)),
77 }
78}
79
80// What was queued and where it points, as the line the dropped prompt leaves in the transcript.
81const reportQueued = (entries: readonly Entry[], notes: readonly string[], root: string, waiting: number) => {
82 const why = notes.length > 0 ? ` - ${[...new Set(notes)].join('; ')}` : ''
83 if (entries.length > 1) {
84 return `queued ${entries.length} entries${why} · ${waiting} waiting`
85 }
86 const entry = entries[0]!
87 if (entry.kind === 'idea') {
88 return `queued "${shortenText(entry.text, 30)}" · ${waiting} waiting`
89 }
90 const at = formatPlace(entry, root)
91 const quote = entry.quote ? ` "${shortenText(entry.quote, 30)}"` : ''
92 const spot = at || quote ? `${at}${quote}`.trimStart() : `"${shortenText(entry.text, 30)}"`
93
94 return `queued ${KINDS[entry.kind].mark} ${spot}${why} · ${waiting} waiting`
95}
96
97// An edited entry back at its place as /aside showed it.
98const putBack = (stored: readonly Entry[], added: readonly Entry[], index: number, order: Order): Entry[] => {
99 const shown = inOrder(stored, order)
100 shown.splice(Math.min(index, shown.length), 0, ...added)
101
102 return inOrder(shown, order)
103}
104
105// A tidying in flight, which a planner waits for.
106function whileTidying(ctx: Context, work: Promise<void>) {
107 const tidy = work.catch(() => undefined)
108 ctx.session.tidies.add(tidy)
109 void tidy.then(() => ctx.session.tidies.delete(tidy))
110}
111
112// An idea typed for a queue of no project, "+g" or "+@work", and that queue.
113type Listed = { list: string; entry: Entry }
114
115// The ideas typed for queues of no project, each onto its queue. A message that is one such
116// idea, sent while an idea is out with /aside q edit, takes that idea's place when it names the
117// same queue, and moves it to the queue it names otherwise; the record goes once it is back.
118// All of them or none: should a write fail, those already written are taken back.
119async function storeListed($: Engine, ideas: readonly Listed[], isSingle: boolean): Promise<GlobalIdea[]> {
120 if (ideas.length === 0) {
121 return []
122 }
123 const editing = isSingle ? await loadGlobalEditing($) : undefined
124 if (editing && (await queueOf($, editing)) === ideas[0]!.list) {
125 const idea = await restoreIdea($, { key: editing.key, entry: { ...editing.entry, text: ideas[0]!.entry.text, raw: ideas[0]!.entry.raw } }, editing.entry)
126 await forgetGlobalEditing($)
127
128 return [idea]
129 }
130 const added: GlobalIdea[] = []
131 try {
132 for (const list of new Set(ideas.map(one => one.list))) {
133 added.push(...(await addIdeas($, list, ideas.filter(one => one.list === list).map(one => one.entry))))
134 }
135 } catch (error) {
136 await forgetGlobalIdeas($, added.map(idea => idea.key)).catch(() => undefined)
137 throw error
138 }
139 if (editing) await forgetGlobalEditing($)
140
141 return added
142}
143
144// What a message queued for queues of no project says, one part per queue, in the order typed:
145// the global list by its count, any other queue by its name, and a queue the message made as
146// new, with whom it is for and how to make it the other.
147const reportListed = (ideas: readonly Listed[], band: Band, isNew: (list: string) => boolean): string[] =>
148 [...new Set(ideas.map(one => one.list))].map(list => {
149 const own = ideas.filter(one => one.list === list)
150 const what = own.length === 1 ? `"${shortenText(own[0]!.entry.text, 30)}"` : formatCount(own.length, 'idea')
151 if (list === GLOBAL) {
152 return `queued ${what} to the global list · ${band.global ?? 0} global`
153 }
154 const name = queueName(list)
155 const count = band.named?.find(one => one.name === name)?.count ?? own.length
156
157 return isNew(list) ? `queued ${what} to @${name}, ${newQueueNote(name, isLocalQueue(list))}` : `queued ${what} to @${name} · ${count} in @${name}`
158 })
159
160// A tidied text from the small model, or undefined when none comes in time.
161async function askTidy($: Engine, text: string): Promise<string | undefined> {
162 if (text.trim() === '') {
163 return undefined
164 }
165 const ask = { model: 'haiku', system: TIDY_SYSTEM, prompt: tidyPrompt(text), maxTokens: 1024, timeoutMs: 10_000 }
166 const reply = await $.model.complete(ask).catch(() => undefined)
167
168 return reply?.isAnswered ? readTidyReply(reply.text, text) : undefined
169}
170
171// An idea's text in a queue of no project, tidied as an entry's is, unless the idea has left
172// its queue or changed meanwhile.
173async function tidyGlobalIdea($: Engine, idea: GlobalIdea): Promise<void> {
174 const fixed = await askTidy($, idea.entry.text)
175 if (fixed === undefined) {
176 return
177 }
178 const now = (await loadIdeas($, (await queueOf($, idea)) ?? GLOBAL)).find(one => one.key === idea.key)
179 if (now === undefined || now.entry.text !== idea.entry.text) {
180 return
181 }
182 const raw = retypedRaw(now.entry, fixed)
183 await saveGlobalIdea($, { key: now.key, entry: { ...now.entry, text: fixed, ...(raw === undefined ? {} : { raw }) } })
184 // Taken out meanwhile with edit or clear, between the read above and that write: the write
185 // brought its key back, so it goes again, and the idea comes back only as edit or undo brings it.
186 if (await isOut($, now.key)) await forgetGlobalIdeas($, [now.key])
187}
188
189// An entry's own text, tidied by a small model once it is stored. It waits as typed until
190// the reply comes, and keeps that text when none comes in time or the entry has left the
191// queue by then. The edit box gets the tidied text too, with its mark and place as typed.
192// It resolves once the tidied text is stored, so a planner that waits for it reads that text.
193async function tidyEntry($: Engine, entry: Entry): Promise<void> {
194 const fixed = await askTidy($, entry.text)
195 if (fixed === undefined) {
196 return
197 }
198 const isTidied = await changeQueue($, stored => {
199 const i = stored.findIndex(one => isSameEntry(one, entry))
200 const one = stored[i]
201 if (one === undefined) return { value: false }
202 const raw = retypedRaw(one, fixed)
203 const tidied = { ...one, text: fixed, ...(raw === undefined ? {} : { raw }) }
204
205 return { list: stored.map((each, j) => (j === i ? tidied : each)), value: true }
206 })
207 if (isTidied) await refreshBand($)
208}
209
210// How addEntries reports what it queued: a line in the transcript (a typed "+", whose prompt
211// has long been answered by then), or nothing (a command, which answers with the text itself).
212type Report = 'log' | 'none'
213
214// Stores what a message (or /aside TEXT) holds and says what was queued and where it
215// points, or returns null when the text is no entry at all.
216export async function addEntries(
217 $: Engine,
218 ctx: Context,
219 text: string,
220 task: string | undefined,
221 output: string | undefined,
222 options: { images?: number; selection?: string; report?: Report; isRemote?: boolean } = {},
223): Promise<string | null> {
224 const { images = 0, report = 'none' } = options
225 const typed = parseEntries(text)
226 if (typed === null) {
227 return null
228 }
229 // The ideas for queues of no project this message stored, and whether its part of the queue
230 // is stored too: until then a failure takes those ideas back.
231 let onGlobal: GlobalIdea[] = []
232 let isStored = false
233 // One change on the queue for the whole message, so messages sent one after another are stored
234 // in that order, and an entry back from /aside edit is put back by one of them alone. The slow
235 // part (git, the clipboard, the files a comment points into) comes before the queue is read,
236 // so the list is read right before it is stored and a removal or a hand-over meanwhile is kept.
237 const prepare = async () => {
238 const keys = await projectKeys($)
239 const seen = await readTrackedFiles($)
240 const now = await $.clock.now()
241 const branch = await readGitBranch($, keys.root)
242 const sources = commentSources($, ctx, keys.root, seen, output, options.selection, options.isRemote === true)
243 // An entry taken out with /aside edit comes back where it was, keeping what it pointed at,
244 // unless the reworked text names a place of its own.
245 const editing = await loadEditing($, keys.editing)
246 const isRework =
247 editing !== undefined && typed.length === 1 && typed[0]!.list === undefined && typed[0]!.kind === editing.idea.kind && Object.keys(typed[0]!.anchor).length === 0
248 const added: Entry[] = []
249 const notes: string[] = []
250 // Each "+@name" to the queue that name means here, or a new one of the scope the setting gives.
251 const listed: Listed[] = []
252 for (const one of typed) {
253 if (one.list === undefined) continue
254 const list = await resolveQueue($, one.list, ctx.settings.newQueues === 'local')
255 listed.push({ list, entry: { text: one.text, raw: one.raw, at: now, kind: 'idea' as const } })
256 }
257 // The queues there before, so a message that makes one says so; the queue of an idea out for
258 // editing is there still, even when that idea was its only one.
259 const out = listed.length > 0 ? await loadGlobalEditing($) : undefined
260 const queues = listed.length > 0 ? new Set([...(await countQueues($)).map(one => one.name), ...(out ? [(await queueOf($, out)) ?? GLOBAL] : [])]) : new Set<string>()
261 for (const one of typed.filter(entry => entry.list === undefined)) {
262 if (isRework) {
263 const { text: _text, raw: _raw, ...kept } = editing!.idea
264 added.push({ ...kept, text: one.text, raw: one.raw })
265 continue
266 }
267 const { note, ...spot } = one.kind === 'idea' ? ({} as Pin) : await pinComment(one, keys.root, seen, sources)
268 if (note) notes.push(note)
269 added.push({ text: one.text, raw: one.raw, at: now, kind: one.kind, ...spot, ...(task ? { task } : {}), ...(branch ? { branch } : {}) })
270 }
271
272 return { keys, editing, isRework, added, notes, listed, queues }
273 }
274 const { keys, added, all, notes, listed, queues } = await changeQueueAfter($, prepare, async (stored, { keys, editing, isRework, added, notes, listed, queues }) => {
275 const all = isRework ? putBack(stored, added, editing!.index, ctx.settings.order) : [...stored, ...added]
276 // The ideas for queues of no project first, and taken back should the queue's write fail, so
277 // a message is kept whole or not at all and sent again adds nothing twice.
278 onGlobal = await storeListed($, listed, typed.length === 1)
279 // An idea taken out with /aside edit and sent back as "+g idea" or "+@work idea" has moved
280 // to that queue.
281 const isMovedOut = editing !== undefined && typed.length === 1 && typed[0]!.list !== undefined && editing.idea.kind === 'idea'
282 const forget = editing !== undefined && (added.length > 0 || isMovedOut)
283
284 return {
285 ...(added.length > 0 ? { list: all } : {}),
286 value: { keys, added, all, notes, listed, queues },
287 afterSave: async () => {
288 isStored = true
289 // Each tidying is one a planner waits for from the moment its entry can be read.
290 for (const entry of ctx.settings.tidy ? added : []) whileTidying(ctx, tidyEntry($, entry))
291 for (const idea of ctx.settings.tidy ? onGlobal : []) whileTidying(ctx, tidyGlobalIdea($, idea))
292 if (forget) await forgetEditing($, keys.editing)
293 },
294 }
295 }).catch(async (error: unknown) => {
296 if (!isStored) await forgetGlobalIdeas($, onGlobal.map(idea => idea.key)).catch(() => undefined)
297 throw error
298 })
299 const band = await refreshBand($)
300 if (images > 0) notes.push('the image was not kept')
301 const parts = [
302 ...(added.length > 0 ? [reportQueued(added, notes, keys.root, all.length)] : []),
303 ...reportListed(listed, band, list => !queues.has(list)),
304 ]
305 const said = parts.join('; ')
306 if (report === 'log') $.ui.log(said)
307
308 return said
309}
310
311// A dropped prompt goes back into the box; once queued it should not wait there to be
312// deleted by hand. The engine's put-back raises prompt.change, which empties the box at
313// once (emptyBoxAfterQueue); this timer is the fallback. Either empties it only while it
314// still holds exactly that message, so a new draft typed meanwhile is left alone.
315function emptyBoxLater($: Engine, ctx: Context, typed: string) {
316 ctx.session.justQueued = typed
317 $.clock.after(30, async () => {
318 // Emptied already, or given back because it could not be stored.
319 if (ctx.session.justQueued !== typed) return
320 const box = await $.prompt.read().catch(() => undefined)
321 if (box && box.text.trim() === typed) await $.prompt.fill({ text: '', mode: 'replace' }).catch(() => undefined)
322 })
323}
324
325// Typed by the person: at this terminal, or on a phone or the web through Remote Control.
326const isTypedMessage = (e: { origin: { kind: string }; command?: unknown }) => (e.origin.kind === 'composer' || e.origin.kind === 'bridge') && e.command === undefined
327
328// A message typed with a mark is dropped before the model and queued. Enter is answered at
329// once, as on a plain prompt: the slow part (the clipboard, the search, the store) runs right
330// after, one message at a time, and says in a transcript line where each entry points. Only
331// the selection is read now, before it goes away.
332type Submit = Parameters<Hook<'prompt.submit'>>
333
334export async function queueTyped(ctx: Context, $: Engine, e: Submit[1], next: Submit[2]) {
335 const entries = isTypedMessage(e) ? parseEntries(e.text) : null
336 if (entries === null) {
337 if (isTypedMessage(e) && e.text.trim() !== '') ctx.session.lastTask = shortenText(e.text, 200)
338 void refreshBand($)
339
340 return next(e)
341 }
342
343 const text = e.text
344 const task = e.turnId !== undefined ? ctx.session.lastTask : undefined
345 const output = currentOutput(ctx)
346 const images = e.attachments?.length ?? 0
347 // Sent from a phone or the web: this machine's clipboard, selection and prompt box are not
348 // the sender's.
349 const isRemote = e.origin.kind === 'bridge'
350 const isComment = entries.some(entry => entry.kind !== 'idea' && Object.keys(entry.anchor).length === 0)
351 const selection = isComment && !isRemote ? await readReplySelection($) : ''
352 $.clock.after(1, () =>
353 addEntries($, ctx, text, task, output, { images, selection, report: 'log', isRemote }).catch((error: unknown) =>
354 storeFailed($, ctx, text, error, isRemote).catch(() => undefined),
355 ),
356 )
357 if (!isRemote) emptyBoxLater($, ctx, e.text.trim())
358
359 return { drop: 'queued' }
360}
361
362// A typed entry that could not be stored goes back into the prompt box when the box is empty,
363// so nothing typed is lost; with a new draft there, the transcript line gives it whole.
364async function storeFailed($: Engine, ctx: Context, text: string, error: unknown, isRemote: boolean) {
365 if (ctx.session.justQueued === text.trim()) ctx.session.justQueued = undefined
366 const why = describeError(error)
367 const box = isRemote ? undefined : await $.prompt.read().catch(() => undefined)
368 const filled = box?.text.trim() === '' ? await $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined) : undefined
369 $.ui.log(
370 filled?.isFilled
371 ? `Could not queue "${shortenText(text, 40)}" (${why}); it is back in the prompt box.`
372 : `Could not queue this entry (${why}); here it is to queue again:\n${text}`,
373 )
374}
375
376type SubmitFailed = Parameters<CatchHandler<Hook<'prompt.submit'>>>
377
378// A queued entry that failed to store is dropped, not sent: it was meant to wait.
379export function dropFailedEntry($: Engine, e: SubmitFailed[1], next: SubmitFailed[2]) {
380 return next.called || !(isTypedMessage(e) && parseEntries(e.text)) ? next(e) : { drop: 'aside could not queue this entry; nothing was sent to Claude' }
381}
382
383type Change = Parameters<Hook<'prompt.change'>>
384
385export async function emptyBoxAfterQueue(ctx: Context, $: Engine, e: Change[1], next: Change[2]) {
386 if (ctx.session.justQueued !== undefined && e.origin.kind === 'engine' && e.text.trim() === ctx.session.justQueued) {
387 ctx.session.justQueued = undefined
388 await $.prompt.fill({ text: '', mode: 'replace' }).catch(() => undefined)
389 }
390
391 return next(e)
392}
393
394// What a command or an edit printed in the main loop, failed or not: a denied call, a
395// missing file on Read or a refused web fetch is not what a comment is about.
396type ToolCall = Parameters<Hook<'tool.call'>>
397
398export async function rememberToolOutput(ctx: Context, e: ToolCall[1], next: ToolCall[2]) {
399 // What the main loop does now, as the step running shows it, kept as the call starts.
400 if (e.agentId === undefined) ctx.session.lastTool = { turnId: ctx.session.turnId, text: describeTool(e.tool, e as unknown as Record<string, unknown>) }
401 const ran = await next(e)
402 const isError = ran.isError === true
403 if (e.agentId === undefined && ran.deny === undefined && OUTPUT_TOOLS.has(e.tool)) {
404 const { lastOutput, turnId } = ctx.session
405 const keepsError = lastOutput?.isError === true && lastOutput.turnId === turnId && !isError
406 if (!keepsError) {
407 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? ''))
408 ctx.session.lastOutput = { text: `${e.tool}${isError ? ' failed' : ''}: ${shortenText(text, 400)}`, turnId, isError }
409 }
410 }
411
412 return ran
413}
414
415// A markdown file Claude wrote or edited may hold tags: /aside reads it from now on.
416export async function rememberMarkdownFile<R extends { deny?: unknown; isError?: boolean }>($: Engine, path: string, ran: R): Promise<R> {
417 if (ran.deny === undefined && !ran.isError && MARKDOWN_PATH.test(path)) {
418 await trackFile($, path)
419 }
420
421 return ran
422}
423hooks/features/commands.ts 443 lines1// /aside itself: the table of its commands, the one switch that calls them, the help and the
2// Tab hints. The table holds data only, since `$` may not be passed to a method call (3.2), and
3// the help and the hints are built from it, so neither can drift from what runs.
4
5import type { CatchHandler, EngineInterface as Engine, Hook, MatchedHook } from 'claude-code'
6
7import { currentOutput, currentTask } from '../context'
8import type { Context } from '../context'
9import { KINDS } from '../core/entries'
10import { MARKDOWN_PATH } from '../core/markdown'
11import { describeFailure, formatCount, shortenText } from '../core/text'
12import { readQueue } from '../io/queue'
13import { loadPlan, projectKeys, refreshBand } from '../io/store'
14import { GLOBAL, countQueues, isHiddenHere, isLocalQueue, loadIdeas, queueName, resolveQueue } from '../io/queues'
15import { addEntries } from './capture'
16import { findEntries } from './find'
17import { showLog } from './history'
18import { importFile } from './importing'
19import { handOverQueue, runChosen, runEntries, runNext, runPlan, stopPlan } from './plan'
20import { nameOldSteps } from './planner'
21import { registerPlanTool, registerWaitTool } from './plantool'
22import { runGlobal, runNamed } from './global'
23import { clearQueue, editEntry, exportListing, moveEntry, popEntry, removeEntries, restoreTaken, scanFile, showListing } from './queue'
24
25type Name = 'list' | 'plan' | 'run' | 'continue' | 'stop' | 'log' | 'pop' | 'edit' | 'move' | 'remove' | 'clear' | 'undo' | 'find' | 'import' | 'scan' | 'export' | 'g' | 'q' | 'help'
26
27// A command's row: what it accepts and how help and Tab show it. runCommand does the work.
28type Command = {
29 name: Name
30 aliases?: readonly string[]
31 // Whether the words after the name have this command's shape. Anything else is text to
32 // queue, so "/aside send a summary to the team" queues that sentence.
33 takes: (args: readonly string[]) => boolean
34 // How the command is written in the argument hint, and the lines /aside help gives it.
35 usage?: string
36 help: readonly string[]
37 // What Tab shows beside the name, and the numbers it offers after it: entries, or what
38 // /aside run counts (the steps while the plan has steps to do, else the entries).
39 about: string
40 completes?: 'entries' | 'run'
41}
42
43const isNumber = (word: string | undefined) => word !== undefined && /^\d+$/.test(word)
44const none = (args: readonly string[]) => args.length === 0
45const oneNumber = (args: readonly string[]) => args.length === 1 && isNumber(args[0])
46
47const COMMANDS: readonly Command[] = [
48 {
49 name: 'list',
50 aliases: ['ls'],
51 takes: none,
52 help: ['/aside - show the queue and the plan'],
53 about: 'show the queue and the plan',
54 },
55 {
56 name: 'plan',
57 takes: none,
58 usage: 'plan',
59 help: ['/aside plan - have Claude organize the queue into steps (related entries merged); while a step runs, the new steps go after the plan\'s own'],
60 about: 'organize the queue into steps',
61 },
62 {
63 name: 'run',
64 takes: args => none(args) || (args.length === 1 && args[0] === 'all') || args.every(isNumber),
65 usage: 'run [N ... | all]',
66 help: [
67 '/aside run - run the next step of the plan; with no step left, the first entry of the queue; a step that waits for you ends and its run goes on, as with /aside continue',
68 '/aside run all - run the rest of the plan step by step; with no step left, each entry as its own step',
69 '/aside run N [M ...] - run those steps of the plan, or those entries once no step is left to do',
70 ],
71 about: 'run the next step or entry, or all of them one by one',
72 completes: 'run',
73 },
74 {
75 name: 'continue',
76 takes: none,
77 help: ['/aside continue - end the step that waits for your answer without one and go on, or go on with a paused run, as /aside run all does'],
78 about: 'go on with the plan',
79 },
80 {
81 name: 'stop',
82 takes: none,
83 usage: 'stop',
84 help: ['/aside stop - stop after the current step'],
85 about: 'stop after the current step',
86 },
87 {
88 name: 'log',
89 takes: args => none(args) || oneNumber(args),
90 usage: 'log [N]',
91 help: ['/aside log [N] - the plans run to their end, the last 5 with what each step did, or plan N of them'],
92 about: 'the plans run to their end',
93 },
94 {
95 name: 'pop',
96 takes: args => none(args) || oneNumber(args),
97 usage: 'pop [N]',
98 help: ['/aside pop [N] - put the first entry (or entry N) in the prompt box, ready to send'],
99 about: 'put an entry in the box, ready to send',
100 completes: 'entries',
101 },
102 {
103 name: 'edit',
104 takes: oneNumber,
105 usage: 'edit N',
106 help: ['/aside edit N - put entry N in the prompt box to rework it and queue it again'],
107 about: 'rework an entry and queue it again',
108 completes: 'entries',
109 },
110 {
111 name: 'move',
112 aliases: ['mv'],
113 takes: args => args.length === 2 && isNumber(args[0]) && isNumber(args[1]),
114 usage: 'move N M',
115 help: ['/aside move N M - move entry N to place M'],
116 about: 'move an entry to another place',
117 completes: 'entries',
118 },
119 {
120 name: 'remove',
121 aliases: ['rm'],
122 takes: args => args.length > 0 && args.every(isNumber),
123 usage: 'remove N [M ...]',
124 help: ['/aside remove N [M ...] - remove entry N, or several at once'],
125 about: 'remove entries',
126 completes: 'entries',
127 },
128 {
129 name: 'clear',
130 takes: none,
131 usage: 'clear',
132 help: ['/aside clear - empty the queue'],
133 about: 'empty the queue',
134 },
135 {
136 name: 'undo',
137 takes: none,
138 usage: 'undo',
139 help: ['/aside undo - bring back what the last clear, or an unanswered plan or run, took'],
140 about: 'bring back what the last clear, or an unanswered plan or run, took',
141 },
142 {
143 name: 'find',
144 takes: args => args.length > 0,
145 usage: 'find TEXT',
146 help: ['/aside find TEXT - the entries, tags, steps and ideas of every queue that hold all the words, in any case and with or without accents, each with its number'],
147 about: 'look for words in every queue and the plan',
148 },
149 {
150 name: 'import',
151 // A file name with spaces is one name, as for /aside scan.
152 takes: args => args.length > 0 && MARKDOWN_PATH.test(args.join(' ')),
153 usage: 'import FILE',
154 help: ['/aside import FILE - queue the items of a markdown list in FILE, each an entry, leaving out ticked ones and those already queued; the file stays as it is'],
155 about: 'queue the items of a markdown list',
156 },
157 {
158 name: 'scan',
159 // A file name with spaces is one name: the words are taken together.
160 takes: args => args.length > 0 && MARKDOWN_PATH.test(args.join(' ')),
161 usage: 'scan FILE',
162 help: ['/aside scan FILE - read the tags of a file Claude did not write in this session'],
163 about: 'read the tags of a markdown file',
164 },
165 {
166 name: 'export',
167 // One word: "/aside export the notes to report.md" is an idea, not a file to write.
168 takes: args => args.length === 0 || (args.length === 1 && MARKDOWN_PATH.test(args[0]!)),
169 usage: 'export [FILE]',
170 help: ['/aside export [FILE] - save what /aside shows to aside.md, or to FILE (one word, ending in .md), as Markdown'],
171 about: 'save the list to a markdown file',
172 },
173 {
174 name: 'g',
175 // Anything after it: a command on the global list, or an idea for it.
176 takes: () => true,
177 usage: 'g [TEXT | here N ... | add N ... | remove N ... | edit N | pop [N] | move N M | clear | undo | import FILE]',
178 help: [
179 '/aside g - show the global list: ideas that belong to no project',
180 '/aside g TEXT - put TEXT on the global list, as "+g TEXT" would',
181 '/aside g here N [M ...] - move ideas N of the global list to the end of this queue',
182 '/aside g add N [M ...] - move ideas N of this queue (numbers as /aside shows them) to the global list',
183 '/aside g remove N [M ...] | edit N | pop [N] | move N M | clear | undo | import FILE - the same as for the queue, on the global list',
184 ],
185 about: 'the global list: ideas for no project',
186 },
187 {
188 name: 'q',
189 // Anything after it: the queues for no project, a command on one of them, or an idea for it.
190 takes: () => true,
191 usage: 'q [NAME [TEXT | here N ... | add N ... | remove N ... | edit N | pop [N] | move N M | clear | undo | import FILE | rename NEW | local | global]]',
192 help: [
193 '/aside q - show the named queues, for every project or for this one alone, and the global list among them',
194 '/aside q NAME [TEXT] - show the queue NAME, or put TEXT in it, as "+@NAME TEXT" would; the commands of /aside g work on it too',
195 '/aside q NAME clear | undo | rename NEW - empty it, bring back what its last clear took, or move it all to the queue NEW',
196 "/aside q NAME local | global - keep the queue for this project alone, which then hides one of its name for every project here, or share it with every project",
197 ],
198 about: 'the named queues: for every project or for this one',
199 },
200 {
201 name: 'help',
202 takes: none,
203 usage: 'help',
204 help: [],
205 about: 'show every command',
206 },
207]
208
209const TEXT_HELP = '/aside TEXT - queue TEXT, as "+ TEXT" would ("/aside + run 3" for text that looks like a command)'
210
211const HELP = [
212 'Queue an idea: start a message with "+". When every line starts with "+", each line is its own entry.',
213 'Already typed it? End the message with the mark after a space ("... +", "... +?", "... +g", "... +@work") and it is queued whole.',
214 'Beside this queue: "+g idea" goes to the global list, "+@work idea" to the queue named work, made by its first idea for every project, or for this one with newQueues on local.',
215 'Comment on what the agents wrote: copy a fragment in the preview, then type',
216 ' +? question +! change +- drop ++ dig deeper',
217 ' pin it yourself with @file, @file:42, @file#heading or #heading after the mark.',
218 'In the editor, <!-- ? ... --> tags (? ! - +) in a markdown file count as comments too.',
219 "With nothing new copied, a comment takes the text selected in Claude's reply, or the last tool output.",
220 '',
221 ...COMMANDS.flatMap(command => command.help),
222 TEXT_HELP,
223].join('\n')
224
225const ARGUMENT_HINT = `[${[...COMMANDS.flatMap(command => command.usage ?? []), 'TEXT'].join(' | ')}]`
226
227const commandNamed = (word: string) => COMMANDS.find(command => command.name === word || command.aliases?.includes(word))
228
229async function runQueue($: Engine, ctx: Context, args: readonly string[]) {
230 if (args[0] === 'all') {
231 return (await runPlan($, ctx)) ?? runEntries($, ctx)
232 }
233 if (args.length > 0) {
234 return runChosen($, ctx, args.map(Number))
235 }
236
237 return runNext($, ctx)
238}
239
240async function runCommand($: Engine, ctx: Context, name: Name, args: readonly string[]): Promise<string> {
241 const [first, second] = args
242 switch (name) {
243 case 'list':
244 await nameOldSteps($, ctx)
245 return showListing($, ctx)
246 case 'plan':
247 return handOverQueue($, ctx)
248 case 'run':
249 return runQueue($, ctx, args)
250 case 'continue':
251 return runQueue($, ctx, ['all'])
252 case 'stop':
253 return stopPlan($, ctx)
254 case 'log':
255 return showLog($, ctx, first === undefined ? undefined : Number(first))
256 case 'pop':
257 return popEntry($, ctx, first === undefined ? undefined : Number(first))
258 case 'edit':
259 return editEntry($, ctx, Number(first))
260 case 'move':
261 return moveEntry($, ctx, Number(first), Number(second))
262 case 'remove':
263 return removeEntries($, ctx, args.map(Number))
264 case 'clear':
265 return clearQueue($)
266 case 'undo':
267 return restoreTaken($)
268 case 'find':
269 return findEntries($, ctx, args.join(' '))
270 case 'import':
271 return importFile($, args.join(' '))
272 case 'scan':
273 return scanFile($, args.join(' '))
274 case 'export':
275 return exportListing($, ctx, args.join(' '))
276 case 'g':
277 return runGlobal($, ctx, args)
278 case 'q':
279 return runNamed($, ctx, args)
280 case 'help':
281 return HELP
282 }
283}
284
285// Anything that is not a command is an entry to queue: "/aside add tests" as "+ add tests" would.
286async function queueText($: Engine, ctx: Context, text: string) {
287 return (await addEntries($, ctx, text.startsWith('+') ? text : `+ ${text}`, currentTask(ctx), currentOutput(ctx), { report: 'none' })) ?? HELP
288}
289
290type AsideEvent = Parameters<MatchedHook<'command.run', { readonly command: 'aside' }>>[1]
291
292export async function runAside(ctx: Context, $: Engine, e: AsideEvent) {
293 const [word = '', ...args] = e.args.trim().split(/\s+/)
294 if (word === '') {
295 // A step an earlier release titled with its text cut short gets a name, shown next time.
296 await nameOldSteps($, ctx)
297 return { text: await showListing($, ctx) }
298 }
299 const command = commandNamed(word.toLowerCase())
300
301 return { text: command?.takes(args) ? await runCommand($, ctx, command.name, args) : await queueText($, ctx, e.args.trim()) }
302}
303
304// A /aside that failed says so in a sentence of its own, not in the engine's words; what it
305// had done stays, and /aside shows it.
306type AsideFailed = Parameters<CatchHandler<MatchedHook<'command.run', { readonly command: 'aside' }>>>
307
308export function asideFailed($: Engine, e: AsideFailed[1], next: AsideFailed[2]) {
309 const typed = e.args.trim() === '' ? '/aside' : `/aside ${shortenText(e.args.trim(), 40)}`
310
311 return { text: `${typed} did not finish (${describeFailure(next.error)}). /aside shows where the queue and the plan stand.` }
312}
313
314// The numbers Tab offers after a command: the entries as /aside shows them, or for /aside run
315// while the plan has steps to do, the steps still to do.
316async function numberRows($: Engine, ctx: Context, of: 'entries' | 'ideas' | 'run' | { queue: string }, digits: string) {
317 const keys = await projectKeys($)
318 const plan = await loadPlan($, keys.plan)
319 const isSteps = of === 'run' && plan.steps.some(step => !step.isDone)
320 const listed = typeof of === 'object' ? await loadIdeas($, of.queue) : []
321 const items =
322 typeof of === 'object'
323 ? (ctx.settings.order === 'newest' ? listed.reverse() : listed).map((idea, i) => ({ n: i + 1, label: idea.entry.text, isDone: false }))
324 : isSteps
325 ? plan.steps.map((step, i) => ({ n: i + 1, label: step.title, isDone: step.isDone === true }))
326 : (await readQueue($, ctx.settings.order))
327 .map((entry, i) => ({ n: i + 1, label: entry.kind === 'idea' ? entry.text : `${KINDS[entry.kind].label}: ${entry.text}`, isDone: false, isIdea: entry.kind === 'idea' }))
328 .filter(item => of !== 'ideas' || item.isIdea)
329
330 return items
331 .filter(item => !item.isDone && String(item.n).startsWith(digits))
332 .map(item => ({ text: String(item.n), description: shortenText(item.label, 60) }))
333}
334
335// Tab after "/aside ": the commands, then the entry or step numbers.
336type Autocomplete = Parameters<Hook<'prompt.autocomplete'>>
337
338// What Tab offers after "/aside g ": the commands on the global list.
339const GLOBAL_VERBS = [
340 { text: 'here', description: 'move global ideas into this queue' },
341 { text: 'add', description: 'move ideas of this queue to the global list' },
342 { text: 'remove', description: 'remove ideas from the global list' },
343 { text: 'edit', description: 'rework a global idea' },
344 { text: 'pop', description: 'put a global idea in the box, ready to send' },
345 { text: 'move', description: 'move a global idea to another place' },
346 { text: 'clear', description: 'empty the global list' },
347 { text: 'undo', description: 'bring back what its last clear took' },
348 { text: 'import', description: 'put the items of a markdown list on it' },
349]
350
351// What Tab offers after "/aside q NAME ": the same commands on that queue, and its rename, local
352// and global.
353const NAMED_VERBS = [
354 { text: 'here', description: 'move its ideas into this queue' },
355 { text: 'add', description: 'move ideas of this queue to it' },
356 { text: 'remove', description: 'remove ideas from it' },
357 { text: 'edit', description: 'rework one of its ideas' },
358 { text: 'pop', description: 'put one of its ideas in the box, ready to send' },
359 { text: 'move', description: 'move one of its ideas to another place' },
360 { text: 'clear', description: 'empty it' },
361 { text: 'undo', description: 'bring back what its last clear took' },
362 { text: 'import', description: 'put the items of a markdown list in it' },
363 { text: 'rename', description: 'move all of it to a queue of another name' },
364 { text: 'local', description: 'keep it for this project alone' },
365 { text: 'global', description: 'share it with every project' },
366]
367
368export async function completeAside(ctx: Context, $: Engine, e: Autocomplete[1], next: Autocomplete[2]) {
369 const typed = e.text.slice(0, e.cursor)
370 // The queues by name after "q", each with how many ideas it holds.
371 const nameTyped = /^\/aside\s+q\s+@?([A-Za-z0-9_-]*)$/i.exec(typed)
372 if (nameTyped) {
373 const own = (await next(e)).suggestions
374 const word = nameTyped[1]!.toLowerCase()
375 // By the name typed: one of this project's hides one every project sees of its name.
376 const queues = []
377 for (const one of await countQueues($)) {
378 const name = queueName(one.name)
379 if (name.startsWith(word) && name !== word && !(await isHiddenHere($, one.name))) queues.push({ ...one, typed: name, isLocal: isLocalQueue(one.name) })
380 }
381
382 return {
383 suggestions: [...own, ...queues.map(one => ({ text: one.typed, description: `${formatCount(one.count, 'idea')}${one.isLocal ? ", this project's" : ''}` }))],
384 }
385 }
386 // On a queue: its commands after "g" or its name, then its numbers, as many as the command takes.
387 const queueVerb = /^\/aside\s+(?:g|q\s+@?([A-Za-z0-9_-]{1,20}))\s+(\S*)$/i.exec(typed)
388 const queueLater = /^\/aside\s+(?:g|q\s+@?([A-Za-z0-9_-]{1,20}))\s+(here|add|remove|rm|edit|pop|move|mv)\s+((?:\d+\s+)*)(\d*)$/i.exec(typed)
389 if (queueVerb || queueLater) {
390 const own = (await next(e)).suggestions
391 if (queueVerb) {
392 const word = queueVerb[2]!.toLowerCase()
393 const verbs = queueVerb[1] === undefined || queueVerb[1].toLowerCase() === GLOBAL ? GLOBAL_VERBS : NAMED_VERBS
394 return { suggestions: [...own, ...verbs.filter(row => row.text.startsWith(word) && row.text !== word)] }
395 }
396 const queue = await resolveQueue($, queueLater![1]?.toLowerCase() ?? GLOBAL, ctx.settings.newQueues === 'local')
397 const verb = queueLater![2]!.toLowerCase()
398 const count = queueLater![3]!.split(/\s+/).filter(Boolean).length
399 const room = ['here', 'add', 'remove', 'rm'].includes(verb) ? Infinity : verb === 'move' || verb === 'mv' ? 2 : 1
400 // "add" takes numbers of this project's queue, its ideas only; every other command those of the queue named.
401 const rows = count < room ? await numberRows($, ctx, verb === 'add' ? 'ideas' : { queue }, queueLater![4]!) : []
402
403 return { suggestions: [...own, ...rows] }
404 }
405 const first = /^\/aside\s+(\S*)$/.exec(typed)
406 // A number after a command, with the numbers already typed before it: offered only where
407 // one more number keeps the command's shape, so "/aside pop 2 " offers nothing.
408 const later = /^\/aside\s+(\S+)\s+((?:\d+\s+)*)(\d*)$/.exec(typed)
409 const command = later ? commandNamed(later[1]!) : undefined
410 const typedNumbers = later ? later[2]!.split(/\s+/).filter(Boolean) : []
411 const completes = command?.takes([...typedNumbers, '1']) ? command.completes : undefined
412 if (!first && completes === undefined) {
413 return next(e)
414 }
415 const own = (await next(e)).suggestions
416 if (first) {
417 const word = first[1]!.toLowerCase()
418 const rows = COMMANDS.filter(({ name }) => name.startsWith(word) && name !== word).map(({ name, about }) => ({ text: name, description: about }))
419
420 return { suggestions: [...own, ...rows] }
421 }
422
423 return { suggestions: [...own, ...(await numberRows($, ctx, completes!, later![3]!))] }
424}
425
426type SessionStart = Parameters<Hook<'session.start'>>
427
428export async function startSession(ctx: Context, $: Engine, e: SessionStart[1], next: SessionStart[2]) {
429 await $.command.register({
430 name: 'aside',
431 description: 'Show the ideas and comments queued with "+"; plan and run them',
432 argumentHint: ARGUMENT_HINT,
433 // Runs at once mid-turn, so a stop lands before the next step; the prompts it sends
434 // still wait for the turn to end.
435 immediate: true,
436 })
437 ctx.session.planTool = await registerPlanTool($)
438 ctx.session.waitTool = await registerWaitTool($)
439 await refreshBand($)
440
441 return next(e)
442}
443hooks/features/plan.ts 468 lines1// Handing the queue over, the commands that run the plan, and the hooks on a turn that drive
2// both. What the step's own life needs is in `steps`, the two tools Claude saves a plan with in
3// `plantool`, the model that organizes the queue outside the conversation in `planner`, and the
4// work a step leaves going in `background`; this file imports all four and none imports it, so
5// the area reads in one direction (2.1). It could be cut again between the hand-over and the
6// commands, which call each other nowhere, but the turn's end needs both in one breath, so they
7// stay together and the file stays a little past the 400 lines 2.4 prefers (1.2).
8
9import type { CatchHandler, EngineInterface as Engine, Hook } from 'claude-code'
10
11import type { Entry, Plan, Step } from '../../types'
12import type { Asker, Context } from '../context'
13import { inOrder, KINDS } from '../core/entries'
14import { describeCount, formatPlace } from '../core/listing'
15import { entryPrompt, isAddingPrompt, isOwnPrompt, isPlanningPrompt, planningPrompt, STEP_MARKER } from '../core/prompts'
16import { askedOf, isWaiting, turnOf } from '../core/step'
17import { askPlaces, describeError, describeFailure, formatCount, isPlace, shortenText } from '../core/text'
18import { changePlan } from '../io/plan'
19import { bringBackTake, forgetAnsweredTake, readQueue, takeChosen, takeForHandOver } from '../io/queue'
20import { loadPlan, projectKeys, refreshBand } from '../io/store'
21import type { Keys } from '../io/store'
22import { archivePlan } from './history'
23import { tagsInFiles } from './queue'
24
25import { decide, writeInto } from '../core/reduce'
26import {
27 bindAnswerTurn,
28 bindStepTurn,
29 dropDeadTurn,
30 finishStep,
31 goOnFromWaiting,
32 hasItems,
33 organizeHint,
34 pausedNext,
35 runningHere,
36 startStep,
37 submitLater,
38 runEffects,
39 takeOverNote,
40} from './steps'
41import type { TurnComplete, TurnEndReason } from './steps'
42import { replaceSteps } from './plantool'
43import { nameStepsLater, planOutside } from './planner'
44
45const EMPTY_QUEUE = 'The queue is empty. Start a message with "+" to add an idea, or "+?" to comment on a copied fragment.'
46
47// Hands the queue and the new file tags over as one planning prompt. A prompt dropped beneath
48// gives everything back. Nothing goes while a turn of a step runs here, since the prompt would
49// land in the middle of it: the plan is organized once that turn ends (planAfterStep). A step
50// that waits for the person has no turn running, so the queue is added to the plan at once,
51// the step waiting still. With planModel on a model, it goes to that model instead, at once
52// (planOutside).
53export async function handOverQueue($: Engine, ctx: Context, asker: Asker = 'command'): Promise<string> {
54 if (ctx.settings.planModel !== 'session') {
55 return planOutside($, ctx, ctx.settings.planModel, asker)
56 }
57 // A second planning prompt, sent before the turn that adds to the plan has ended, would be
58 // organized against the plan as it was, and save over the steps the first one adds.
59 if (ctx.session.planning?.isAdding) {
60 return 'Claude has yet to organize what the last /aside plan handed over; /aside plan works again once it has.'
61 }
62 const keys = await projectKeys($)
63 const plan = await loadPlan($, keys.plan)
64 const running = await runningHere($, plan)
65 if (running && isWaiting(plan.active!)) {
66 return (await handOver($, ctx, true)).text
67 }
68 if (running) {
69 return planAfterStep($, ctx, asker)
70 }
71
72 return (await handOver($, ctx, false)).text
73}
74
75// /aside plan while a step runs here, for Claude in this conversation, who cannot be asked in
76// the middle of the step: its end hands the queue over to be added to the plan, and the run
77// waits for /aside continue. In a step that waits for the person, the end of the turn running
78// now hands it over, and the step waits on. A step that ended meanwhile, or a turn of it, leaves
79// the queue to go at once.
80async function planAfterStep($: Engine, ctx: Context, asker: Asker): Promise<string> {
81 if (!(await hasItems($))) {
82 return EMPTY_QUEUE
83 }
84 const me = await $.session.id()
85 const step = await changePlan($, plan => {
86 if (plan.active?.sessionId !== me || isWaiting(plan.active)) return undefined
87 plan.active.isPlanningAfter = true
88 return { n: plan.active.step + 1, isAnswer: plan.active.is === 'answering' }
89 })
90 if (step === undefined) {
91 return handOverQueue($, ctx, asker)
92 }
93 const text = step.isAnswer
94 ? `Step ${step.n} is running on your answer; once Claude has answered, it organizes the queue into steps after the plan's own, and /aside continue goes on.`
95 : `Step ${step.n} is running; once it ends, Claude organizes the queue into steps after the plan's own, and /aside continue goes on.`
96 // While Claude works, the command's own text shows only after the turn.
97 if (asker === 'command' && ctx.session.isRunning) $.ui.log(text)
98
99 return text
100}
101
102// The hand-over itself, and whether its prompt is on its way; with isAdding, a planning prompt
103// that adds the queue to the plan as it stands, every step kept. The plan keeps that mark
104// (adding), bound to the prompt's turn by its text as it starts, so the plan tool adds the steps
105// it saves after the plan's own even when a reload has lost what this session knew.
106async function handOver($: Engine, ctx: Context, isAdding: boolean): Promise<{ text: string; isHanded: boolean }> {
107 const keys = await projectKeys($)
108 // Tags already handed over stay in their files until a step removes them; they are
109 // not handed over again. The take reads the queue itself, so nothing typed meanwhile is
110 // stored between that read and its write.
111 const tags = await tagsInFiles($, true)
112 // The entries leave the queue as they are handed over; /aside undo brings them back.
113 const { stored, giveBack } = await takeForHandOver($, tags)
114 const all = [...inOrder(stored, ctx.settings.order), ...tags]
115 if (all.length === 0) {
116 return { text: EMPTY_QUEUE, isHanded: false }
117 }
118 await refreshBand($, 0)
119 const me = await $.session.id()
120 const plan = await changePlan($, plan => {
121 if (isAdding) plan.adding = { after: plan.steps.length, sessionId: me }
122 return structuredClone(plan)
123 })
124 const text = planningPrompt(all, keys.root, plan, isAdding, ctx.session.planTool)
125 const planning = { items: all, isSaved: false, isAdding }
126 ctx.session.planning = planning
127 submitLater($, async () => text, async why => {
128 if (ctx.session.planning === planning) delete ctx.session.planning
129 await giveBack()
130 await refreshBand($, tags.length)
131 $.ui.toast(`Could not hand the queue over (${why}); the entries are back in it`)
132 })
133
134 return { text: `Organizing ${describeCount(all)}.`, isHanded: true }
135}
136
137// /aside run all and continue on the saved plan: a step that waits for the person, in any session, ends and its
138// run goes on, a paused run goes on as it was to, any other run starts from the first step not
139// done.
140// Undefined when every step is done, so the queue runs entry by entry instead.
141export async function runPlan($: Engine, ctx: Context): Promise<string | undefined> {
142 const going = await goOnFromWaiting($, ctx)
143 if (going !== undefined) {
144 return going
145 }
146 const keys = await projectKeys($)
147 const plan = await loadPlan($, keys.plan)
148 const running = await runningHere($, plan)
149 if (running) {
150 return running
151 }
152 const note = takeOverNote(plan)
153 if (plan.paused) {
154 const { isChain } = plan.paused
155 const { index, then } = pausedNext(plan)
156 if (index >= 0) {
157 await startStep($, ctx, index, isChain, then)
158 const steps = then === undefined ? '' : `: steps ${[index, ...then].map(i => i + 1).join(', ')}`
159
160 return `Going on with the plan from step ${index + 1}${steps}. /aside stop stops after the current one.${note}`
161 }
162 }
163 const first = plan.steps.findIndex(step => !step.isDone)
164 if (first < 0) {
165 return undefined
166 }
167 await startStep($, ctx, first, true)
168 const left = plan.steps.filter(step => !step.isDone).length
169
170 return `Running the plan: ${formatCount(left, 'step')}, from step ${first + 1}. /aside stop stops after the current one.${note}`
171}
172
173// An entry as a step of a plan of its own, titled with the start of its text until the small
174// model names it.
175const entryStep = (entry: Entry, root: string): Step => ({
176 title: shortenText(entry.text || formatPlace(entry, root) || KINDS[entry.kind].label, 120),
177 prompt: entryPrompt(entry, root),
178})
179
180async function runChosenSteps($: Engine, ctx: Context, plan: Plan, chosen: readonly number[]): Promise<string> {
181 if (!chosen.every(n => isPlace(n, plan.steps.length))) {
182 return askPlaces(chosen.length, plan.steps.length, 'step')
183 }
184 const note = takeOverNote(plan)
185 const [first, ...rest] = chosen.map(n => n - 1)
186 await startStep($, ctx, first!, false, rest.length > 0 ? rest : undefined)
187
188 return chosen.length === 1
189 ? `Running step ${chosen[0]}: ${plan.steps[first!]!.title}${note}`
190 : `Running steps ${chosen.join(', ')} one by one. /aside stop stops after the current one.${note}`
191}
192
193async function runChosenEntries($: Engine, ctx: Context, keys: Keys, plan: Plan, chosen: readonly number[]): Promise<string> {
194 // The numbers are read against the queue as the take finds it, so an entry typed or tidied
195 // meanwhile is neither written over nor taken in place of the one shown.
196 const taken = await takeChosen($, stored => {
197 const list = inOrder(stored, ctx.settings.order)
198 if (list.length === 0) return 'The queue is empty.'
199 if (!chosen.every(n => isPlace(n, list.length))) {
200 return askPlaces(chosen.length, list.length, 'entry')
201 }
202 return chosen.map(n => list[n - 1]!)
203 })
204 if (typeof taken === 'string') {
205 return taken
206 }
207 const note = takeOverNote(plan)
208 const { chosen: entries, giveBack } = taken
209 const steps = entries.map(entry => entryStep(entry, keys.root))
210 if (!(await replacePlan($, ctx, steps))) {
211 await giveBack()
212 return PLAN_STARTED
213 }
214 await refreshBand($)
215 await startStep($, ctx, 0, true)
216 nameStepsLater($, ctx, steps, entries)
217
218 return entries.length === 1
219 ? `Running entry ${chosen[0]}: ${steps[0]!.title}${note}`
220 : `Running entries ${chosen.join(', ')} one by one. /aside stop stops after the current one.${note}`
221}
222
223// /aside run N [M ...]: the steps with those numbers while the plan has steps to do, else the
224// entries with those numbers, one after another in the order given. A number means what
225// /aside shows.
226export async function runChosen($: Engine, ctx: Context, numbers: readonly number[]): Promise<string> {
227 const keys = await projectKeys($)
228 const plan = await loadPlan($, keys.plan)
229 const running = await runningHere($, plan)
230 if (running) {
231 return running
232 }
233 const chosen = [...new Set(numbers)]
234
235 return plan.steps.some(step => !step.isDone) ? runChosenSteps($, ctx, plan, chosen) : runChosenEntries($, ctx, keys, plan, chosen)
236}
237
238// /aside run: the next step of the plan alone, the one after a pause when the run is paused,
239// else the first entry of the queue, as /aside run 1 would. A step that waits for the person
240// ends and its run goes on, as /aside continue does.
241export async function runNext($: Engine, ctx: Context): Promise<string> {
242 const going = await goOnFromWaiting($, ctx)
243 if (going !== undefined) {
244 return going
245 }
246 const keys = await projectKeys($)
247 const plan = await loadPlan($, keys.plan)
248 const running = await runningHere($, plan)
249 if (running) {
250 return running
251 }
252 const { index: after, then } = plan.paused ? pausedNext(plan) : { index: -1, then: undefined }
253 if (after >= 0) {
254 await startStep($, ctx, after, false, undefined, { ...plan.paused!, step: after, ...(then ? { then } : {}) })
255
256 return `Running step ${after + 1}: ${plan.steps[after]!.title}; /aside continue then goes on with the paused run.${takeOverNote(plan)}`
257 }
258 const next = plan.steps.findIndex(step => !step.isDone)
259 if (next >= 0) {
260 return runChosenSteps($, ctx, plan, [next + 1])
261 }
262 if ((await readQueue($, 'oldest')).length === 0) {
263 const tags = (await tagsInFiles($, true)).length
264 return tags > 0 ? `The queue is empty; /aside run all runs the ${formatCount(tags, 'tag')} in files.` : 'Nothing to run: the queue is empty and the plan is done.'
265 }
266
267 return runChosenEntries($, ctx, keys, plan, [1])
268}
269
270const PLAN_STARTED = 'Another session started a plan meanwhile; the entries stay in the queue, and /aside shows the plan.'
271
272// The plan done made of `steps` alone, the one it was going to the log; false, and nothing
273// changed, when a plan with steps to do took its place since it was read (3.16).
274async function replacePlan($: Engine, ctx: Context, steps: readonly Step[]): Promise<boolean> {
275 return changePlan($, async plan => {
276 if (plan.steps.some(step => !step.isDone)) return false
277 await archivePlan($, plan)
278 replaceSteps(plan, steps)
279 return true
280 })
281}
282
283// /aside run all with no plan to run: every entry, and every new tag, becomes a step of its own.
284export async function runEntries($: Engine, ctx: Context): Promise<string> {
285 const keys = await projectKeys($)
286 const note = takeOverNote(await loadPlan($, keys.plan))
287 const tags = await tagsInFiles($, true)
288 // As a hand-over does, the take reads the queue itself.
289 const { stored, giveBack } = await takeForHandOver($, tags)
290 const all = [...inOrder(stored, ctx.settings.order), ...tags]
291 if (all.length === 0) {
292 return 'Nothing to run: the queue is empty and the plan is done.'
293 }
294 const steps = all.map(entry => entryStep(entry, keys.root))
295 if (!(await replacePlan($, ctx, steps))) {
296 await giveBack()
297 await refreshBand($, tags.length)
298 return PLAN_STARTED
299 }
300 await refreshBand($, 0)
301 await startStep($, ctx, 0, true)
302 nameStepsLater($, ctx, steps, all)
303
304 return `Running ${formatCount(steps.length, 'entry')} one by one. /aside stop stops after the current one.${note}`
305}
306
307// Stops the plan: a step that has not started yet does not start; a running one finishes
308// and is the last; one that waits for the person, in any session, is done as it stands, and so
309// is one that continue ended whose last turn has yet to start. A step running in another
310// session is let go here. A step that waited ends with the turn running now, whatever ends it.
311export async function stopPlan($: Engine, ctx: Context): Promise<string> {
312 const me = await $.session.id()
313 const now = await $.clock.now()
314 const made = await changePlan($, plan => {
315 const was = plan.active
316 const out = decide(plan, { is: 'stop', sessionId: me, now, openTurns: [...ctx.session.openTurns] })
317 if (out.outcome === 'nothing') return undefined
318 writeInto(plan, out.plan)
319
320 return { was: was === undefined ? undefined : structuredClone(was), plan: structuredClone(out.plan), does: out.does, outcome: out.outcome }
321 })
322 if (made === undefined) {
323 return 'Nothing is running.'
324 }
325 await runEffects($, ctx, made.plan, made.does)
326 if (made.outcome === 'dropped') {
327 return 'Dropped the paused run. /aside run all starts the plan again from its first step to do.'
328 }
329 const n = made.was!.step + 1
330 if (made.outcome === 'doneAsStands') {
331 return `Step ${n} is done as it stands, and the plan stops there.`
332 }
333 if (made.outcome === 'letGo') {
334 return `The plan stopped ${askedOf(made.was!) === undefined ? 'before' : 'at'} step ${n}${await organizeHint($, made.was)}.`
335 }
336
337 return `The plan stops after step ${n}.`
338}
339
340// The turn of this session's prompt that adds to the plan, known by its text, so a reload
341// since the hand-over still finds it.
342async function bindAddingTurn($: Engine, ctx: Context, turnId: string) {
343 const me = await $.session.id()
344 await changePlan($, plan => {
345 if (plan.adding?.sessionId === me && plan.adding.turnId === undefined) plan.adding.turnId = turnId
346 })
347}
348
349type TurnStart = Parameters<Hook<'turn.start'>>
350
351export async function onTurnStart(ctx: Context, $: Engine, e: TurnStart[1], next: TurnStart[2]) {
352 if (e.agentId === undefined) {
353 // A process runs one turn at a time: a step's turn it started, still open as a new one
354 // starts here, never ended.
355 await dropDeadTurn($, ctx, await $.session.id(), e.turnId, false)
356 ctx.session.openTurns.add(e.turnId)
357 ctx.session.turnId = e.turnId
358 ctx.session.isRunning = true
359 ctx.session.isOwnTurn = isOwnPrompt(e.text)
360 if (e.text.includes(STEP_MARKER)) await bindStepTurn($, ctx, e.turnId)
361 else if (!ctx.session.isOwnTurn) await bindAnswerTurn($, ctx, e.turnId, e.text)
362 if (isAddingPrompt(e.text)) await bindAddingTurn($, ctx, e.turnId)
363 const { planning } = ctx.session
364 if (planning && planning.turnId === undefined && isPlanningPrompt(e.text)) planning.turnId = e.turnId
365 }
366
367 return next(e)
368}
369
370// The queue a step asked /aside plan to organize, handed over once that step's turn has ended.
371// Only a hand-over that did not go out has more to say than the step's own toast.
372async function organizeAfterStep($: Engine, ctx: Context, isAdding: boolean) {
373 const said = await handOver($, ctx, isAdding).catch((error: unknown) => ({
374 text: `Could not organize the queue (${describeError(error)}).`,
375 isHanded: false,
376 }))
377 if (!said.isHanded) $.ui.toast(said.text)
378}
379
380// The turn that was to organize the queue ended with no plan saved, answered or cut short:
381// what it was handed goes back to the queue, so nothing is lost.
382async function giveBackUnplanned($: Engine) {
383 const { entries, tagIds } = await bringBackTake($)
384 if (entries.length === 0 && tagIds.length === 0) {
385 return
386 }
387 await refreshBand($, (await tagsInFiles($, true)).length)
388 const back = [entries.length > 0 ? formatCount(entries.length, 'entry') : '', tagIds.length > 0 ? formatCount(tagIds.length, 'tag') : '']
389 $.ui.toast(`No plan was saved: ${back.filter(Boolean).join(' and ')} back in the queue.`)
390}
391
392// Files the agents wrote may hold new tags; then the queue goes out by itself when the
393// setting says so, never after the plugin's own turn. A plan running elsewhere, or a saved
394// plan with steps still to do, is the person's to finish first: the queue waits.
395async function handOverAfterAnswer($: Engine, ctx: Context, plan: Plan, reason: TurnEndReason) {
396 const now = await refreshBand($, (await tagsInFiles($, true)).length)
397 const waiting = now.ideas + now.comments + now.tags
398 const isPending = plan.steps.some(step => !step.isDone)
399 const { afterAnswer } = ctx.settings
400 if (!plan.active && waiting > 0 && afterAnswer !== 'wait' && reason === 'answer' && !ctx.session.isOwnTurn && !isPending) {
401 if (afterAnswer === 'plan' && ctx.settings.planModel !== 'session') {
402 // A planner's call may take a while, and the turn's end does not wait for it: the plan's
403 // title, or why there is none, comes as a toast once it is saved.
404 $.clock.after(0, async () => {
405 $.ui.toast(await handOverQueue($, ctx, 'answer').catch((error: unknown) => `Could not organize the queue: ${describeError(error)}`))
406 })
407 return
408 }
409 $.ui.toast(afterAnswer === 'plan' ? await handOverQueue($, ctx) : await runEntries($, ctx))
410 }
411}
412
413export async function onTurnEnd(ctx: Context, $: Engine, e: TurnComplete[1], next: TurnComplete[2]) {
414 // Before anything awaits: a turn that starts while this end is handled finds this one ended.
415 if (e.agentId === undefined) ctx.session.openTurns.delete(e.turnId)
416 const done = await next(e)
417 if (e.agentId !== undefined) {
418 return done
419 }
420 ctx.session.isRunning = false
421
422 const keys = await projectKeys($)
423 const { planning } = ctx.session
424 if (planning?.turnId === e.turnId) {
425 delete ctx.session.planning
426 if (!planning.isSaved) await giveBackUnplanned($)
427 }
428 // The plan tool may save again within the turn that adds, each time in place of its last
429 // steps; once that turn is over, the steps it added are the plan's own. Read from the plan,
430 // so a reload since does not keep the mark.
431 const me = await $.session.id()
432 await changePlan($, plan => {
433 if (plan.adding?.sessionId === me && plan.adding.turnId === e.turnId) delete plan.adding
434 })
435 // Claude has answered what the plugin handed over, so undo has nothing to bring back; but
436 // a step that ran ahead of a planning prompt still out leaves that prompt's take.
437 if (ctx.session.isOwnTurn && e.reason === 'answer' && !ctx.session.planning) {
438 await forgetAnsweredTake($)
439 }
440 const plan = await loadPlan($, keys.plan)
441 const isThisStep = plan.active !== undefined && turnOf(plan.active) === e.turnId && plan.active.sessionId === (await $.session.id())
442 if (isThisStep) {
443 const organize = await finishStep($, ctx, e)
444 if (organize) await organizeAfterStep($, ctx, organize.isAdding)
445 } else {
446 await handOverAfterAnswer($, ctx, plan, e.reason)
447 }
448
449 return done
450}
451
452type TurnStartFailed = Parameters<CatchHandler<Hook<'turn.start'>>>
453
454export function turnStartFailed($: Engine, e: TurnStartFailed[1], next: TurnStartFailed[2]) {
455 $.ui.toast(`Could not note the start of this turn (${describeFailure(next.error)}); a running plan may not go on by itself`)
456
457 return next(e)
458}
459
460type TurnEndFailed = Parameters<CatchHandler<Hook<'turn.complete'>>>
461
462export async function turnEndFailed($: Engine, e: TurnEndFailed[1], next: TurnEndFailed[2]) {
463 const done = await next(e)
464 $.ui.toast(`Could not finish its work at the end of this turn (${describeFailure(next.error)}); /aside shows where the queue and the plan stand`)
465
466 return done
467}
468hooks/features/plantool.ts 238 lines1// The two tools Claude reaches the plan through: `plan`, which saves the steps it proposed for
2// the queue, and `wait`, which keeps a step open for the person's answer. Their schemas, what
3// they answer Claude, and how steps saved now meet the plan already there are one contract, and
4// it reads as one.
5
6import type { CatchHandler, EngineInterface as Engine, MatchedHook } from 'claude-code'
7
8import type { Plan, Step } from '../../types'
9import type { Context } from '../context'
10import { leftOutSection, listingText, planSection } from '../core/listing'
11import { PLAN_SAVED_NOTE } from '../core/prompts'
12import { decide, writeInto } from '../core/reduce'
13import { isAddingTurn } from '../core/step'
14import { describeFailure, shortenText } from '../core/text'
15import { changePlan } from '../io/plan'
16import { giveBackItems } from '../io/queue'
17import { loadPlan, projectKeys, refreshBand } from '../io/store'
18import { archivePlan } from './history'
19import { tagsInFiles } from './queue'
20
21
22// The plan made of `steps` alone, in place of the one it was.
23export function replaceSteps(plan: Plan, steps: readonly Step[]) {
24 plan.steps = [...steps]
25 delete plan.active
26 delete plan.paused
27 delete plan.adding
28}
29
30export const parseSteps = (value: unknown): Step[] | null => {
31 if (!Array.isArray(value) || value.length === 0) {
32 return null
33 }
34 const steps = value.map(one => {
35 const step = one as { title?: unknown; prompt?: unknown }
36
37 return typeof step.title === 'string' && typeof step.prompt === 'string' && step.prompt.trim() !== ''
38 ? { title: step.title.trim() || 'Step', prompt: step.prompt.trim() }
39 : null
40 })
41
42 return steps.every(step => step !== null) ? (steps as Step[]) : null
43}
44
45export type Unclear = { item: number; question: string }
46
47// The items Claude left out of the steps, by their number in the planning prompt (1 to
48// `count`), each with its question; an item named twice counts once. Null when malformed.
49export const parseUnclear = (value: unknown, count: number): Unclear[] | null => {
50 if (value === undefined) {
51 return []
52 }
53 if (!Array.isArray(value)) {
54 return null
55 }
56 const unclear = value.map(one => {
57 const { item, question } = one as { item?: unknown; question?: unknown }
58
59 return Number.isInteger(item) && (item as number) >= 1 && (item as number) <= count && typeof question === 'string' && question.trim() !== ''
60 ? { item: item as number, question: question.trim() }
61 : null
62 })
63 if (!unclear.every(one => one !== null)) {
64 return null
65 }
66
67 return (unclear as Unclear[]).filter((one, i, all) => all.findIndex(other => other.item === one.item) === i)
68}
69
70// The tool the model saves a plan with; returns its full name as the engine registered it.
71export async function registerPlanTool($: Engine): Promise<string> {
72 const registered = await $.tool.register({
73 name: 'plan',
74 description:
75 "Saves the step plan you just proposed for the user's queue (the aside plugin), so the user can run it with /aside run. " +
76 'Call it only when the aside plugin asked you to organize the queue. Each step: a short title and a prompt that stands on its own. ' +
77 'Items you left out of the steps go in unclear, and back to the queue.',
78 inputSchema: {
79 type: 'object',
80 properties: {
81 steps: {
82 type: 'array',
83 minItems: 1,
84 items: {
85 type: 'object',
86 properties: { title: { type: 'string' }, prompt: { type: 'string' } },
87 required: ['title', 'prompt'],
88 },
89 },
90 unclear: {
91 type: 'array',
92 description: 'the items that look done, contradictory or unclear: each by its number in the queue, with your question',
93 items: {
94 type: 'object',
95 properties: { item: { type: 'integer' }, question: { type: 'string' } },
96 required: ['item', 'question'],
97 },
98 },
99 },
100 required: ['steps'],
101 },
102 isDeferred: false,
103 })
104
105 return registered.tool
106}
107
108type PlanToolCall = Parameters<MatchedHook<'tool.call', { readonly tool: RegExp }>>
109
110export async function savePlanFromTool(ctx: Context, $: Engine, e: PlanToolCall[1], next: PlanToolCall[2]) {
111 if (e.tool !== ctx.session.planTool) {
112 return next(e)
113 }
114 const input = e as unknown as { steps?: unknown; unclear?: unknown }
115 const steps = parseSteps(input.steps)
116 if (steps === null) {
117 return { deny: 'steps must be a non-empty list of { title, prompt }, each prompt non-empty' }
118 }
119 // The items are known by number only in the turn that organizes them; a plan saved
120 // in any other turn has none to leave out.
121 const planning = ctx.session.planning?.turnId === ctx.session.turnId ? ctx.session.planning : undefined
122 const items = planning?.items ?? []
123 const unclear = planning ? parseUnclear(input.unclear, items.length) : []
124 if (unclear === null) {
125 return { deny: `unclear must list items by their number in the queue, from 1 to ${items.length}, each with a question` }
126 }
127 const keys = await projectKeys($)
128 const old = await loadPlan($, keys.plan)
129 const me = await $.session.id()
130 // Steps added after the plan's own leave a step that runs as it is.
131 if (old.active && old.active.sessionId === me && !isAddingTurn(old, me, ctx.session.turnId)) {
132 return { deny: `step ${old.active.step + 1} of the current plan is running; the user stops it with /aside stop first` }
133 }
134 // The items left out go back before the plan is saved, so a plan is never saved with items
135 // still missing; a call made again after a failure gives back none twice.
136 const left = unclear.map(({ item, question }) => ({ entry: items[item - 1]!, question }))
137 await giveBackItems($, left.map(one => one.entry))
138 // Given no plan to check against, the steps are always saved.
139 const plan = (await savePlanSteps($, ctx, steps, isAddingTurn(old, me, ctx.session.turnId) ? 'tool' : 'replan'))!
140 if (planning) planning.isSaved = true
141 await refreshBand($, left.some(one => one.entry.isTag) ? (await tagsInFiles($, true)).length : undefined)
142 const sections = [planSection(plan, await $.clock.now())!, ...(left.length > 0 ? [leftOutSection(left, keys.root)] : [])]
143
144 return { result: listingText(sections), context: [PLAN_SAVED_NOTE] }
145}
146
147// Whether `now` is still the plan `given` was, only further on: the same steps, while what is
148// done, running or paused may have moved.
149const isSamePlan = (given: Plan, now: Plan) =>
150 now.steps.length === given.steps.length && now.steps.every((step, i) => step.title === given.steps[i]!.title && step.prompt === given.steps[i]!.prompt)
151
152// How steps saved now meet the plan there: 'add', a planner's answer while a step ran, goes
153// after every step; 'tool', the plan tool's save in the turn that adds, goes after the steps
154// its hand-over marked (adding.after); 'replan' merges with the steps to do.
155type SaveMode = 'add' | 'tool' | 'replan'
156
157// Saves `steps` as the plan. Added, they go after the plan's own steps, which stay as they
158// stood, a run going on or paused included; saved again in the same planning turn, they take
159// the place of those the first save added. A replan keeps the steps done, first, as the planning
160// prompt said. Either way, a plan run to its end makes way for the new one. With `given`, the
161// plan a planner was given, nothing is saved, and undefined comes back, unless the plan is still
162// that one: only further on while adding to it, else just as it was.
163export async function savePlanSteps($: Engine, ctx: Context, steps: readonly Step[], mode: SaveMode, given?: Plan): Promise<Plan | undefined> {
164 return changePlan($, async plan => {
165 if (given && !(mode === 'add' ? isSamePlan(given, plan) : JSON.stringify(plan) === JSON.stringify(given))) {
166 return undefined
167 }
168 const base = mode === 'add' ? plan.steps.length : mode === 'tool' ? plan.adding?.after : undefined
169 const kept = plan.steps.slice(0, base ?? plan.steps.length)
170 const isLeft = plan.active !== undefined || kept.some(step => !step.isDone)
171 if (base !== undefined && isLeft) {
172 plan.steps = [...kept, ...steps]
173 } else {
174 // A plan run to its end goes to the log as the new one takes its place.
175 if (!isLeft) await archivePlan($, plan)
176 replaceSteps(plan, [...(isLeft ? kept.filter(step => step.isDone) : []), ...steps])
177 }
178
179 return structuredClone(plan)
180 })
181}
182
183// The tool a step waits for the person with when it needs their answer before the next one.
184export async function registerWaitTool($: Engine): Promise<string> {
185 const registered = await $.tool.register({
186 name: 'wait',
187 description:
188 "Keeps the step of the user's aside plan you are working on open for their answer: their next message comes to this step, which ends with your reply to it unless you call this tool again. " +
189 'Call it only from a step of the aside plan that needs the user\'s answer or decision, then ask your question and end your reply with the "Result: " line.',
190 inputSchema: {
191 type: 'object',
192 properties: { reason: { type: 'string', description: 'what you need from the user, in a few words' } },
193 required: ['reason'],
194 },
195 isDeferred: false,
196 })
197
198 return registered.tool
199}
200
201// The step stays open once this turn ends: the person's answer comes to it, and Claude's reply
202// to that answer ends it, or /aside continue does. A new question since an answer was taken up makes the step wait for that one.
203export async function pausePlanFromTool(ctx: Context, $: Engine, e: PlanToolCall[1], next: PlanToolCall[2]) {
204 if (e.tool !== ctx.session.waitTool) {
205 return next(e)
206 }
207 const me = await $.session.id()
208 const reason = (e as unknown as { reason?: unknown }).reason
209 const pausedFor = typeof reason === 'string' && reason.trim() !== '' ? shortenText(reason, 120) : 'it needs your answer'
210 const said = await changePlan($, plan => {
211 const made = decide(plan, { is: 'ask', sessionId: me, pausedFor })
212 writeInto(plan, made.plan)
213
214 return made.outcome
215 })
216 if (said === 'nothing') {
217 return { deny: 'no step of the aside plan is running; call this tool only from a step of the plan' }
218 }
219 if (said === 'ending') {
220 return { result: 'The user has ended this step, so it ends with this reply and nothing waits for their answer. Ask your question all the same, then end your reply with the "Result: " line.' }
221 }
222 const summary = said === 'last' ? ' Write no summary of the run yet: it comes once the step ends.' : ''
223
224 return { result: `The step waits for the user's answer. Ask your question, then end your reply with the "Result: " line; their answer comes to this step, which ends with your reply to it unless you call this tool again.${summary}` }
225}
226
227// What the person hears when one of the plan's hooks fails, in place of the engine's own
228// words: the reason, and what still stands.
229type PlanToolFailed = Parameters<CatchHandler<MatchedHook<'tool.call', { readonly tool: RegExp }>>>
230
231export function planToolFailed($: Engine, e: PlanToolFailed[1], next: PlanToolFailed[2]) {
232 return next.called ? next(e) : { deny: `aside could not save the plan (${describeFailure(next.error)}); the plan was not saved, so call the tool again` }
233}
234
235export function waitToolFailed($: Engine, e: PlanToolFailed[1], next: PlanToolFailed[2]) {
236 return next.called ? next(e) : { deny: `aside could not pause the plan (${describeFailure(next.error)}); call the tool again` }
237}
238hooks/features/steps.ts 387 lines1// Nothing here reaches for the hand-over or for the commands, which is what keeps the imports of
2// this area going one way (2.1): a step that asked for the queue to be organized says so to
3// whoever ended its turn, rather than organizing it itself. The queries the rest of the plan
4// shares, such as why nothing new may start while a step runs, live here beside the state they
5// read. It is a little past the 400 lines 2.4 prefers and stays one file on purpose (1.2): a
6// step's prompt going out, the turns bound to it and its end are one chain of events, and a cut
7// anywhere along it would put two halves of the same walk in two places.
8
9import type { Args, CatchHandler, EngineInterface as Engine, Hook } from 'claude-code'
10
11import type { Plan, Step } from '../../types'
12import type { Context } from '../context'
13import { answerContext, closingPrompt, isSummingUp, stepMode, stepPrompt } from '../core/prompts'
14import { PLANNING_PAUSE, decide, writeInto } from '../core/reduce'
15import type { Effect } from '../core/reduce'
16import { askedOf, awayWork, isAway, isWaiting, turnOf } from '../core/step'
17import { describeError, formatCount, formatDuration } from '../core/text'
18import { changePlan } from '../io/plan'
19import { readQueue } from '../io/queue'
20import { loadPlan, projectKeys, refreshBand } from '../io/store'
21import { tagsInFiles } from './queue'
22
23// From a timer, since neither a command's hook nor a turn's end may submit a prompt while
24// it holds the turn; `onFail` hears why, once, when a hook beneath drops it, the engine
25// refuses it or composing it fails. Nothing here is left to throw unheard in the timer.
26export function submitLater($: Engine, compose: () => Promise<string | undefined>, onFail: (why: string) => Promise<void>) {
27 $.clock.after(50, async () => {
28 let why: string | undefined
29 try {
30 const prompt = await compose()
31 if (prompt === undefined) {
32 return
33 }
34 const sent = await $.prompt.submit({ text: prompt, asUser: true })
35 if ('drop' in sent && sent.drop !== undefined) why = sent.drop
36 } catch (error) {
37 why = describeError(error)
38 }
39 if (why !== undefined) {
40 await onFail(why).catch(error => $.ui.toast(`Could not send its prompt (${why}), nor set things back: ${describeError(error)}`))
41 }
42 })
43}
44
45// Whether there is anything to hand over: an entry, or a tag not handed over yet.
46export async function hasItems($: Engine): Promise<boolean> {
47 return (await readQueue($, 'oldest')).length > 0 || (await tagsInFiles($, true)).length > 0
48}
49
50// Lets go of the step `index` that never started (its prompt was dropped or refused),
51// unless something else took its place meanwhile. A step that continue ended, whose prompt
52// was only to sum the run up, is done as it stands.
53async function releaseStep($: Engine, ctx: Context, index: number, why: string) {
54 const now = await $.clock.now()
55 const made = await changePlan($, plan => {
56 const was = plan.active
57 const out = decide(plan, { is: 'promptDropped', index, now })
58 writeInto(plan, out.plan)
59
60 // The step as it was: its own sentence says whether /aside plan still has anything to do.
61 return { outcome: out.outcome, does: out.does, plan: structuredClone(out.plan), was: out.outcome === 'nothing' || was === undefined ? undefined : structuredClone(was) }
62 })
63 await runEffects($, ctx, made.plan, made.does)
64 $.ui.toast(
65 made.outcome === 'ending'
66 ? `Could not ask Claude to sum the run up (${why}); step ${index + 1} is done as it stands.`
67 : `Could not send step ${index + 1} of the plan: ${why}${await organizeHint($, made.was)}`,
68 )
69}
70
71// What a step that never ran, or was cut short, adds to its message when /aside plan was to
72// organize the queue once it ended: that /aside plan does it now, if there is anything to.
73const ORGANIZE_HINT = '; /aside plan organizes the queue'
74
75export async function organizeHint($: Engine, active: Plan['active'] | undefined): Promise<string> {
76 return active?.isPlanningAfter && (await hasItems($)) ? ORGANIZE_HINT : ''
77}
78
79// With `isChain` the next step not done follows when this one ends, with `then` those steps in
80// that order. A stop that lands before its prompt goes out keeps
81// it from going out at all. With `paused`, the step runs alone out of a paused run, which
82// stays paused after it for /aside continue.
83export async function startStep($: Engine, ctx: Context, index: number, isChain: boolean, then?: readonly number[], paused?: Plan['paused']) {
84 const sessionId = await $.session.id()
85 const made = await changePlan($, plan => {
86 const out = decide(plan, { is: 'start', sessionId, index, isChain, ...(then ? { then } : {}), ...(paused ? { paused } : {}) })
87 writeInto(plan, out.plan)
88
89 return { plan: structuredClone(out.plan), does: out.does }
90 })
91 await runEffects($, ctx, made.plan, made.does)
92}
93
94// What a move asked for, done in the order it asked for it. The sentences a move leads to stay
95// here rather than in `core/`, built from its outcome: one of them has to read the queue.
96export async function runEffects($: Engine, ctx: Context, plan: Plan, does: readonly Effect[]) {
97 for (const one of does) {
98 switch (one.does) {
99 case 'refreshBand':
100 await refreshBand($)
101 break
102 case 'sendStep':
103 sendStep($, ctx, plan.steps, one.index, one.isChain, one.then, one.isClosing)
104 break
105 // Handed on to whoever ended the turn instead: the hand-over is out of this file's
106 // reach, which is what keeps its imports going one way (2.1).
107 case 'organize':
108 break
109 }
110 }
111}
112
113// Sends the prompt of step `index`, which the plan holds as running, unless a stop or another
114// run took its place before it goes out; with isClosing, the turn that ends the last step of a
115// run that waited, and sums the run up.
116function sendStep($: Engine, ctx: Context, steps: readonly Step[], index: number, isChain: boolean, then: readonly number[] | undefined, isClosing = false) {
117 submitLater(
118 $,
119 async () => {
120 const now = await loadPlan($, (await projectKeys($)).plan)
121 // Any step with no turn of its own running is still due its prompt, the one a stop left
122 // ending, whose prompt only sums the run up, included.
123 const isStillDue = now.active?.step === index && turnOf(now.active) === undefined
124 if (!isStillDue) {
125 return undefined
126 }
127
128 return isClosing ? closingPrompt(steps, index) : stepPrompt(steps, index, stepMode(steps, index, isChain, then), ctx.session.waitTool)
129 },
130 why => releaseStep($, ctx, index, why),
131 )
132}
133
134// What /aside run adds when another session's step was running, or waiting for an answer
135// there: this one takes it over.
136export const takeOverNote = (plan: Plan) =>
137 plan.active ? ` (another session had step ${plan.active.step + 1} ${isWaiting(plan.active) ? 'waiting for an answer' : 'running'}; this one takes over)` : ''
138
139// Why nothing new starts while a step of the plan runs in this session, or waits for the
140// person in any session, if one does: a step that waits runs nowhere, so any session ends it,
141// as /clear moves on to a new session.
142export async function runningHere($: Engine, plan: Plan): Promise<string | undefined> {
143 const active = plan.active
144 if (!active) {
145 return undefined
146 }
147 const n = active.step + 1
148 if (isAway(active)) {
149 const work = awayWork(active)
150 return `Step ${n} goes on in the background${work && ` (${work})`}; /aside continue ends it and goes on, /aside stop ends it there.`
151 }
152 if (isWaiting(active)) {
153 return active.is === 'answered'
154 ? `Step ${n} waits: answer again, and Claude's reply ends it, or /aside continue ends it and goes on; /aside stop ends it there.`
155 : `Step ${n} waits for your answer; /aside continue ends it and goes on, /aside stop ends it there.`
156 }
157 if (active.sessionId !== (await $.session.id())) {
158 return undefined
159 }
160
161 return active.is === 'answering'
162 ? `Step ${n} is running on your answer, and ends once Claude has answered. /aside stop stops after it.`
163 : `Step ${n} is running. /aside stop stops after it.`
164}
165
166// /aside continue on a step that waits for the person, in this session or one before it: it
167// is done, and its run goes on here. The last step of a run of several takes one more turn
168// first, which sums the run up and then ends it, as it stands if that turn is cut short, with
169// nothing after it: steps added meanwhile wait for /aside run. Undefined when no step waits.
170export async function goOnFromWaiting($: Engine, ctx: Context): Promise<string | undefined> {
171 const me = await $.session.id()
172 const now = await $.clock.now()
173 const end = await changePlan($, plan => {
174 const was = plan.active
175 // Read before the move: the note is about the step that was running, wherever it ran.
176 const note = was === undefined || was.sessionId === me ? '' : takeOverNote(plan)
177 const out = decide(plan, { is: 'continue', sessionId: me, now, openTurns: [...ctx.session.openTurns] })
178 if (out.outcome === 'nothing') return undefined
179 writeInto(plan, out.plan)
180
181 return { was: structuredClone(was!), plan: structuredClone(out.plan), does: out.does, outcome: out.outcome, note }
182 })
183 if (end === undefined) {
184 return undefined
185 }
186 const { was, plan, does, outcome, note } = end
187 const n = was.step + 1
188 await runEffects($, ctx, plan, does)
189 if (outcome === 'closing') {
190 return `Step ${n} ends here; Claude sums the run up.${note}`
191 }
192 if (outcome === 'onward') {
193 const named = was.then === undefined ? '' : `: steps ${was.then.map(i => i + 1).join(', ')}`
194
195 return `Step ${n} done; going on with the plan from step ${plan.active!.step + 1}${named}. /aside stop stops after the current one.${note}`
196 }
197
198 return `${plan.steps.every(one => one.isDone) ? doneText(plan.steps) : `Step ${n} done.`}${note}`
199}
200
201// What the person reads once every step of the plan is done.
202const doneText = (steps: readonly Step[]) => {
203 const ms = steps.reduce((sum, one) => sum + (one.ms ?? 0), 0)
204
205 return `Plan done: ${formatCount(steps.length, 'step')}${ms >= 1000 ? ` in ${formatDuration(ms)}` : ''}`
206}
207
208// Where a paused run goes on: the next of its own steps still to do, or -1 with none left.
209export function pausedNext(plan: Plan): { index: number; then: number[] | undefined } {
210 const paused = plan.paused!
211 const then = paused.then?.filter(i => plan.steps[i] !== undefined && !plan.steps[i].isDone)
212 if (then !== undefined) {
213 return { index: then[0] ?? -1, then: then.slice(1) }
214 }
215
216 return { index: paused.isChain ? plan.steps.findIndex((step, i) => i > paused.step && !step.isDone) : -1, then: undefined }
217}
218
219// The turn of a plan step: remember which, so its end runs the next one. The step's time
220// starts with its first turn, not when it was asked for, as its prompt may wait behind a turn
221// of something else.
222export async function bindStepTurn($: Engine, ctx: Context, turnId: string) {
223 const sessionId = await $.session.id()
224 const now = await $.clock.now()
225 await changePlan($, plan => {
226 writeInto(plan, decide(plan, { is: 'bindTurn', sessionId, turnId, now }).plan)
227 })
228}
229
230// A turn of this session while its step waits for the person belongs to the step: their
231// answer, or whatever else Claude does meanwhile, such as a background task's notice, so the
232// step shows as running and keeps the time. The plugin's own prompts never come here.
233export async function bindAnswerTurn($: Engine, ctx: Context, turnId: string, text: string) {
234 const me = await $.session.id()
235 // Kept through a turn it does not start, such as a background task's notice before it.
236 const answer = ctx.session.answer?.text === text ? ctx.session.answer : undefined
237 // The turn a background task's notification starts, for the step its work was part of.
238 const isNotice = ctx.session.notice === text
239 if (isNotice) delete ctx.session.notice
240 // Only an answer sent with the step's own words ends the step with the reply to it.
241 const isAnswer = answer !== undefined
242 const isTold = answer?.isTold === true
243 if (isAnswer) delete ctx.session.answer
244 const isBound = await changePlan($, plan => {
245 const made = decide(plan, { is: 'bindAnswer', sessionId: me, turnId, isAnswer, isTold, isNotice })
246 writeInto(plan, made.plan)
247
248 return made.outcome === 'bound'
249 })
250 if (isBound) await refreshBand($)
251}
252
253type Submit = Parameters<Hook<'prompt.submit'>>
254
255// What the person types to a step that waits for them here (the hook's matcher lets only that
256// through) goes to Claude with what it most likely answers and how to go on with that step,
257// never shown to them; the turn it starts is then the step's, and takes up their answer. Typed
258// over a running turn, it may go into that turn, so it gets no context, but a turn of its own
259// later still takes up the answer.
260export async function noteStepAnswer(ctx: Context, $: Engine, e: Submit[1], next: Submit[2]) {
261 if (e.command !== undefined || e.isMeta !== undefined) {
262 return next(e)
263 }
264 const plan = await loadPlan($, (await projectKeys($)).plan)
265 const active = plan.active
266 if (!active || active.sessionId !== (await $.session.id()) || askedOf(active) === undefined) {
267 return next(e)
268 }
269 ctx.session.answer = { text: e.text, isTold: false }
270 if (e.turnId !== undefined || !isWaiting(active)) {
271 return next(e)
272 }
273 const isLast = isSummingUp(plan.steps, stepMode(plan.steps, active.step, active.isChain, active.then))
274 ctx.session.answer = { text: e.text, isTold: true }
275
276 return next({ ...e, context: [...(e.context ?? []), answerContext(plan.steps, active.step, isLast, ctx.session.waitTool)] })
277}
278
279type SessionEnd = Parameters<Hook<'session.end'>>
280
281export type TurnEndReason = Args<'turn.complete'>['reason']
282
283// The step of session `sessionId` whose turn will never end: one this process started, still
284// open, other than `turnId`; with isGone, the session itself ended, so also its work in the
285// background that calls here started, and a step whose turn never started. Where that leaves the
286// step is `turnDied` in core/reduce.ts; only a step left waiting has anything to say about it,
287// and a session that is gone says nothing at all.
288export async function dropDeadTurn($: Engine, ctx: Context, sessionId: string, turnId: string | undefined, isGone: boolean) {
289 const { openTurns, ownWork } = ctx.session
290 const now = await $.clock.now()
291 const dropped = await changePlan($, async plan => {
292 const was = plan.active
293 const out = decide(plan, { is: 'turnDied', sessionId, turnId, isGone, openTurns: [...openTurns], ownWork: [...ownWork], now })
294 if (out.outcome === 'nothing') {
295 return undefined
296 }
297 writeInto(plan, out.plan)
298
299 // Asked of the step as it was: the request to organize the queue is let go with the turn.
300 return { n: was!.step + 1, plan: structuredClone(out.plan), does: out.does, outcome: out.outcome, hint: await organizeHint($, was) }
301 })
302 if (dropped === undefined) {
303 return
304 }
305 await runEffects($, ctx, dropped.plan, dropped.does)
306 if (!isGone && dropped.outcome === 'waits') {
307 $.ui.toast(`Step ${dropped.n}'s turn never ended; it waits, and /aside continue ends it and goes on${dropped.hint}.`)
308 }
309}
310
311// The session ends: a turn of a step it runs never ends now.
312export async function endSessionSteps(ctx: Context, $: Engine, e: SessionEnd[1], next: SessionEnd[2]) {
313 await dropDeadTurn($, ctx, e.sessionId, undefined, true)
314
315 return next(e)
316}
317
318// What the turn's end hands on to whoever ended it, since only they can reach the hand-over.
319type Organize = Extract<Effect, { does: 'organize' }>
320
321// The turn of the step running here ends, whatever ended it; where that leaves the step is
322// `turnEnded` in core/reduce.ts, and what the person reads of it is here. So is the one thing
323// the decision cannot see for itself: whether there is a queue for /aside plan to organize. The
324// plan is read again as it is changed, since a planner may have added steps since the turn's end
325// read it, and the step that follows is marked running in that same change, so nothing in
326// between finds the run with no step.
327export async function finishStep($: Engine, ctx: Context, e: TurnComplete[1]): Promise<Organize | undefined> {
328 const me = await $.session.id()
329 const now = await $.clock.now()
330 const isAnswer = e.reason === 'answer'
331 const end = await changePlan($, async plan => {
332 const was = plan.active
333 // The queue is read for a step that asked for it to be organized and for no other, and
334 // before the move, which lets that request go with the turn it was asked in.
335 const canOrganize = was?.isPlanningAfter === true && (await hasItems($))
336 const out = decide(plan, { is: 'turnEnded', sessionId: me, turnId: e.turnId, isAnswer, ms: e.durationMs, answer: e.answer, now, canOrganize })
337 if (out.outcome === 'nothing') {
338 return undefined
339 }
340 writeInto(plan, out.plan)
341
342 return { was: structuredClone(was!), plan: structuredClone(out.plan), does: out.does, outcome: out.outcome, canOrganize }
343 })
344 if (end === undefined) {
345 return undefined
346 }
347 const { was, plan, does, outcome, canOrganize } = end
348 const n = was.step + 1
349 await runEffects($, ctx, plan, does)
350 // Cut short, the step organizes nothing, as any step's turn: the toast says /aside plan does.
351 const hint = !isAnswer && canOrganize ? ORGANIZE_HINT : ''
352 const active = plan.active
353 if (active !== undefined && isAway(active)) {
354 const work = awayWork(active)
355 $.ui.toast(
356 work
357 ? `Step ${n} goes on in the background (${work}); it ends once that work is done and Claude has answered it, or /aside continue ends it now.`
358 : `Step ${n} waits for /aside continue, which ends it: its work in the background is done.`,
359 )
360 } else if (active !== undefined && isWaiting(active)) {
361 // Asked of the state, not of a flag carried beside it: only a state that holds a question
362 // can be read for one, so it can never come out as "undefined".
363 $.ui.toast(
364 active.is === 'answered'
365 ? `Step ${n} waits: answer again, and Claude's reply ends it, or /aside continue ends it now${hint}.`
366 : `Step ${n} waits for your answer: ${active.pausedFor}. Answer as usual, and the step ends with Claude's reply; /aside continue ends it now${hint}.`,
367 )
368 } else if (outcome === 'stopped') {
369 $.ui.toast(`Plan stopped: step ${n} ${e.reason === 'aborted' ? 'was interrupted' : 'did not finish'}${hint}`)
370 } else if (outcome === 'pausing') {
371 $.ui.toast(`Plan paused after step ${n}: ${PLANNING_PAUSE}. /aside continue goes on.`)
372 } else if (outcome === 'done') {
373 $.ui.toast(`${plan.steps.every(one => one.isDone) ? doneText(plan.steps) : `Step ${n} done`}${hint}`)
374 }
375
376 return does.find((one): one is Organize => one.does === 'organize')
377}
378
379export type TurnComplete = Parameters<Hook<'turn.complete'>>
380
381type SubmitFailed = Parameters<CatchHandler<Hook<'prompt.submit'>>>
382
383// A message to a step that waits goes to Claude all the same when the plan cannot be read.
384export function stepAnswerFailed($: Engine, e: SubmitFailed[1], next: SubmitFailed[2]) {
385 return next(e)
386}
387hooks/ui/band.tsx 69 lines1// The line above the prompt. It reads the band atom and draws it, deciding nothing: what it
2// shows was counted in io/store.ts.
3
4import type { EngineInterface as Engine, MatchedHook } from 'claude-code'
5
6import type { Context } from '../context'
7import { describeError, formatCount, formatProgress } from '../core/text'
8import { runPlan, stopPlan } from '../features/plan'
9import { toggleListPane } from '../features/queue'
10import { readBand } from '../io/store'
11import { isDrawnOn } from './rows'
12
13type Action = 'stop' | 'continue' | 'run plan'
14
15// The press does the work itself: $.command.run from here skips this plugin's own command
16// hook, and a stop is applied at once rather than when the turn ends. Continue and run plan
17// are both /aside run all: it ends a step that waits and goes on, goes on with a paused run, or
18// starts one.
19async function pressAction($: Engine, ctx: Context, action: Action) {
20 try {
21 const said = action === 'stop' ? await stopPlan($, ctx) : await runPlan($, ctx)
22 if (said !== undefined) $.ui.toast(said)
23 } catch (error) {
24 $.ui.toast(`Could not ${action === 'stop' ? 'stop' : 'run'} the plan (${describeError(error)}).`)
25 }
26}
27
28// The line above the prompt: what waits and where the plan is, the list button, and beside it
29// the next move for the plan: stop while a step runs, continue while a step waits for the
30// person or a run is paused, run plan while steps are left.
31type BandRender = Parameters<MatchedHook<'ui.render', { readonly component: 'AbovePrompt' }>>
32
33export async function drawBand(ctx: Context, $: Engine, e: BandRender[1], next: BandRender[2]) {
34 const now = await readBand($)
35 const waiting = now.ideas + now.comments + now.tags
36 const left = now.steps - now.done
37 if (!isDrawnOn(e.surface) || e.props.hasSurvey || (waiting === 0 && left === 0 && now.running === undefined && !now.global && !now.named)) {
38 return next(e)
39 }
40
41 const parts: string[] = []
42 const plan = `plan ${formatProgress(now.done, now.steps)}`
43 if (now.running !== undefined) parts.push(`${plan} · step ${now.running} running${now.isBackground ? ' in the background' : ''}`)
44 else if (now.waiting !== undefined) parts.push(`${plan} · step ${now.waiting} waits for you`)
45 else if (now.paused !== undefined) parts.push(`${plan} · paused after step ${now.paused}`)
46 else if (left > 0) parts.push(plan)
47 if (now.ideas) parts.push(formatCount(now.ideas, 'idea'))
48 if (now.comments + now.tags) parts.push(formatCount(now.comments + now.tags, 'comment'))
49 // A reminder of the ideas in the other queues, as one count: /aside q names them.
50 const queues = (now.global ? 1 : 0) + (now.named?.length ?? 0)
51 const elsewhere = (now.global ?? 0) + (now.named ?? []).reduce((sum, one) => sum + one.count, 0)
52 if (elsewhere) parts.push(`${elsewhere} in ${queues === 1 ? 'another queue' : 'other queues'}`)
53 const action: Action | undefined =
54 now.running !== undefined ? 'stop' : now.waiting !== undefined || now.paused !== undefined ? 'continue' : left > 0 ? 'run plan' : undefined
55
56 const { Box, Button, Text } = $.ui.resolve(e)
57
58 return (
59 <Box>
60 <Text dimColor>aside: {parts.join(', ')} · </Text>
61 {/* Returned, not voided: the press lasts until the pane opens, so the engine counts the open as asked. */}
62 <Button key="list" label="list" onPress={() => toggleListPane($, ctx)} />
63 {/* Siblings, not a fragment: the engine draws <> as a column, which put the button a line lower. */}
64 {action === undefined ? null : <Text> </Text>}
65 {action === undefined ? null : <Button key="action" label={action} onPress={() => void pressAction($, ctx, action)} />}
66 </Box>
67 )
68}
69hooks/ui/output.tsx 78 lines1// What /aside printed, drawn in its frame, and the plan tool's row in the transcript.
2
3import type { EngineInterface as Engine, MatchedHook } from 'claude-code'
4
5import type { Context } from '../context'
6import { parseListingText } from '../core/listing'
7import type { ListingSection } from '../core/listing'
8import { isFramedOn, listingRows } from './rows'
9import type { Drawing } from './rows'
10
11// A listing in the rounded frame /aside output is drawn in.
12const frame = (ui: Drawing, sections: readonly ListingSection[]) => {
13 const { Box } = ui
14
15 return (
16 <Box flexDirection="column" width="100%" borderStyle="round" borderColor="promptBorder" paddingX={1}>
17 {listingRows(ui, sections)}
18 </Box>
19 )
20}
21
22// The list /aside printed, drawn in a frame with colours. It is drawn from the row's own
23// text, so an older listing keeps showing what it said; any other output is left as it is.
24type OutputRender = Parameters<MatchedHook<'ui.render', { readonly component: 'CommandOutput' }>>
25
26export async function drawListingFrame($: Engine, e: OutputRender[1], next: OutputRender[2]) {
27 const sections = e.props.command === 'aside' && !e.props.isErrored ? parseListingText(e.props.text.replace(/^aside: /, '')) : null
28 if (!isFramedOn(e.surface) || sections === null) {
29 return next(e)
30 }
31
32 return frame($.ui.resolve(e), sections)
33}
34
35// The text of a tool's result: a string, or the text blocks of one.
36const resultText = (output: unknown): string | undefined => {
37 if (typeof output === 'string') {
38 return output
39 }
40 const texts = Array.isArray(output) ? output.map(block => (block as { text?: unknown }).text).filter(text => typeof text === 'string') : []
41
42 return texts.length > 0 ? texts.join('\n') : undefined
43}
44
45// The plan the plan tool saved, read from its row's own result, which lists it as /aside
46// does; null while it runs, when it was refused, and for any other tool.
47const savedPlan = (ctx: Context, tool: string, output: unknown, isDone: boolean): ListingSection[] | null => {
48 const text = tool === ctx.session.planTool && isDone ? resultText(output) : undefined
49
50 return text === undefined ? null : parseListingText(text)
51}
52
53// The plan tool's row in the transcript: the plan it saved, in the frame /aside draws.
54type ToolRender = Parameters<MatchedHook<'ui.render', { readonly component: 'ToolUse' }>>
55
56export async function drawPlanTool(ctx: Context, $: Engine, e: ToolRender[1], next: ToolRender[2]) {
57 const { tool, output, isRunning, isErrored, isInterrupted } = e.props
58 const sections = savedPlan(ctx, tool, output, !isRunning && !isErrored && !isInterrupted)
59 if (!isFramedOn(e.surface) || sections === null) {
60 return next(e)
61 }
62
63 return frame($.ui.resolve(e), sections)
64}
65
66// Under that row the engine would print the same plan again as plain text: it draws nothing.
67type ResultRender = Parameters<MatchedHook<'ui.render', { readonly component: 'ToolResult' }>>
68
69export async function hidePlanToolResult(ctx: Context, $: Engine, e: ResultRender[1], next: ResultRender[2]) {
70 const { tool, output, isErrored } = e.props
71 if (!isFramedOn(e.surface) || savedPlan(ctx, tool, output, !isErrored) === null) {
72 return next(e)
73 }
74 const { Box } = $.ui.resolve(e)
75
76 return <Box />
77}
78hooks/ui/pane.tsx 95 lines1// The list pane. It lists anew at each draw, so what it shows follows the queue and the plan
2// without anyone telling it to.
3
4import type { EngineInterface as Engine, MatchedHook } from 'claude-code'
5
6import type { Context } from '../context'
7import type { Listed } from '../core/listing'
8import { describeError } from '../core/text'
9import { foundListed } from '../features/find'
10import { queueListed, queuesListed } from '../features/global'
11import { logListed } from '../features/history'
12import { handOverQueue } from '../features/plan'
13import { currentListing, LIST_PANE } from '../features/queue'
14import { readBand } from '../io/store'
15import { resolveQueue } from '../io/queues'
16import { isDrawnOn, listingRows } from './rows'
17
18// How often the list pane is drawn again while a step runs or waits.
19const PANE_TICK = 10_000
20
21// The list the band's button opens: drawn again whenever the queue or the plan changes.
22type PaneRender = Parameters<MatchedHook<'ui.render', { readonly component: 'Pane'; readonly requestId: 'aside' }>>
23
24// What the pane lists for the view it shows, listed anew at each draw.
25async function paneListed($: Engine, ctx: Context): Promise<Listed> {
26 const view = ctx.session.paneView ?? { kind: 'queue' }
27 switch (view.kind) {
28 case 'named':
29 // By the name typed, found again at each draw, so the pane follows the queue when it
30 // moves between this project and every project.
31 return queueListed($, ctx, await resolveQueue($, view.typed, ctx.settings.newQueues === 'local'))
32 case 'queues':
33 return queuesListed($)
34 case 'find':
35 return foundListed($, ctx, view.query)
36 case 'log':
37 return logListed($, view.n)
38 case 'queue':
39 return { sections: await currentListing($, ctx) }
40 }
41}
42
43// The pane back on the queue, from a view a command asked for.
44function showQueueInPane($: Engine, ctx: Context) {
45 ctx.session.paneView = { kind: 'queue' }
46 $.ui.invalidate('ui.render')
47}
48
49export async function drawPane(ctx: Context, $: Engine, e: PaneRender[1], next: PaneRender[2]) {
50 if (!isDrawnOn(e.surface)) {
51 return next(e)
52 }
53 const now = await readBand($)
54 const listed = await paneListed($, ctx)
55 const sections = typeof listed === 'string' ? [] : listed.sections
56 const isQueue = (ctx.session.paneView?.kind ?? 'queue') === 'queue'
57 // A step's time moves on with the clock, not with the store: while one runs or waits, the
58 // pane is drawn again a little later. Closed, it is not drawn, and the ticks stop.
59 ctx.session.paneTick?.cancel()
60 delete ctx.session.paneTick
61 if (sections.some(section => section.rows.some(row => row.word === 'running' || row.word === 'waiting'))) {
62 ctx.session.paneTick = $.clock.after(PANE_TICK, () => $.ui.invalidate('ui.render'))
63 }
64 // While a step runs, the plan button adds the queue to the plan, as /aside plan does.
65 const canPlan = now.ideas + now.comments + now.tags > 0
66 // The pane closes at once, as a planner may think a while; how the hand-over went, a
67 // failure included, is told as a toast.
68 const planFromPane = async () => {
69 await $.ui.close({ id: LIST_PANE }).catch(() => undefined)
70 $.ui.toast(await handOverQueue($, ctx, 'pane').catch(error => `Could not organize the queue (${describeError(error)}).`))
71 }
72
73 const ui = $.ui.resolve(e)
74 const { Box, Button, Text } = ui
75
76 return (
77 <Box flexDirection="column">
78 {typeof listed === 'string' ? (
79 <Text dimColor>{listed}</Text>
80 ) : sections.some(section => section.rows.length > 0) ? (
81 listingRows(ui, sections)
82 ) : (
83 <Text dimColor>The queue is empty.</Text>
84 )}
85 {typeof listed !== 'string' && listed.note ? <Text dimColor>{listed.note}</Text> : null}
86 <Box marginTop={1}>
87 {isQueue && canPlan ? <Button key="plan" label="plan" onPress={() => void planFromPane()} /> : null}
88 {isQueue ? null : <Button key="queue" label="queue" onPress={() => showQueueInPane($, ctx)} />}
89 <Text> </Text>
90 <Button key="close" label="close" onPress={() => void $.ui.close({ id: LIST_PANE })} />
91 </Box>
92 </Box>
93 )
94}
95hooks/core/entries.ts 187 lines1// What a typed line means, and what an entry is once it is stored. The marks and the words for
2// them are one table here, so the listing, the prompts and the help never spell them apart.
3
4import type { Entry, Kind } from '../../types'
5import { findFencedLines, visibleText } from './markdown'
6import { shortenText } from './text'
7
8// Each kind with the mark it is typed with and the word /aside shows for it.
9export const KINDS: Record<Kind, { mark: string; label: string }> = {
10 idea: { mark: '+', label: 'idea' },
11 question: { mark: '?', label: 'question' },
12 change: { mark: '!', label: 'change' },
13 drop: { mark: '-', label: 'drop' },
14 deeper: { mark: '++', label: 'dig deeper' },
15}
16
17// A comment's kind by the character after its "+", which is also the one inside a tag:
18// "+?" and <!-- ? -->. A plain "+" is an idea.
19const KIND_BY_MARK: Record<string, Kind> = { '?': 'question', '!': 'change', '-': 'drop', '+': 'deeper' }
20
21// Where a comment points, as typed after its kind: "@file", "@file:42", "@file#heading" or "#heading".
22export type Anchor = { file?: string; line?: number; heading?: string }
23
24// An entry as typed, before it is pinned and stored.
25// `list`: the queue for no project it is typed for, rather than this project's queue: "+g idea"
26// for the global list, "global", and "+@work idea" for the queue named "work".
27export type TypedEntry = { kind: Kind; text: string; anchor: Anchor; raw: string; list?: string }
28
29// The mark of an idea for a queue of no project, at the start of what follows the "+": "g", or
30// "@" and a name of up to 20 letters, digits, "-" and "_", in any case.
31const LIST_MARK = /^(?:g|@([A-Za-z0-9_-]{1,20}))(?=\s|$)/
32
33const ANCHOR_PATTERN = /^(?:@(?<file>(?:[A-Za-z]:(?=[\\/]))?[^\s:#]+)(?::(?<line>\d+)|#(?<fileHeading>[\p{L}\p{N}-]+))?|#(?<heading>[\p{L}\p{N}-]+))(?=\s|$)/u
34
35const parseEntry = (raw: string): TypedEntry | null => {
36 const body = raw.trim().slice(1)
37 // "+g idea" is an idea for the global list and "+@work idea" for the queue work; "+go home"
38 // is an idea that starts with "go". A comment's "@file" comes after its mark, as "+? @file".
39 const list = LIST_MARK.exec(body)
40 if (list) {
41 const text = body.slice(list[0].length).trim()
42 return text === '' ? null : { kind: 'idea', text, anchor: {}, raw: raw.trim(), list: list[1]?.toLowerCase() ?? 'global' }
43 }
44 const kind: Kind = KIND_BY_MARK[body[0] ?? ''] ?? 'idea'
45 let text = (kind === 'idea' ? body : body.slice(1)).trim()
46 const anchor: Anchor = {}
47
48 // Only comments carry an anchor: a plain idea keeps its text as typed.
49 const found = kind === 'idea' ? null : ANCHOR_PATTERN.exec(text)
50 if (found?.groups) {
51 const { file, line, fileHeading, heading } = found.groups
52 if (file) anchor.file = file
53 if (line) anchor.line = Number(line)
54 const named = fileHeading ?? heading
55 if (named) anchor.heading = named
56 text = text.slice(found[0].length).trim()
57 }
58
59 const isPinned = anchor.file !== undefined || anchor.heading !== undefined
60
61 return text === '' && (kind === 'idea' || !isPinned) ? null : { kind, text, anchor, raw: raw.trim() }
62}
63
64// A pasted diff starts with "+" too; it is a prompt, not an idea. An idea followed by
65// "- bullet" lines is not a diff: that takes a header, a removed line ("-code" or "- code")
66// or a context line (one space, then text).
67const looksLikeDiff = (text: string): boolean => {
68 const all = text.split(/\r?\n/)
69 const inFence = findFencedLines(all)
70 const lines = all.filter((line, i) => !inFence[i] && line !== '')
71 const isHeader = (line: string) =>
72 /^(diff --git |\+\+\+ (a\/|b\/|\/)|--- (a\/|b\/|\/)|@@ -\d+(,\d+)? \+\d+(,\d+)? @@)/.test(line)
73 if (lines.some(isHeader)) {
74 return true
75 }
76
77 return (
78 lines.length >= 3 &&
79 lines.every(line => /^[+\- ]/.test(line)) &&
80 lines.some(line => /^-(?! \S)/.test(line) || /^ \S/.test(line))
81 )
82}
83
84// A draft already typed goes to the queue too when it ends with a mark after a space or a
85// line break: "why is this so slow +?" is "+? why is this so slow", "read it later +g" an idea
86// for the global list, "read it later +@work" one for the queue work. "C++" is not.
87const parseTrailingMark = (text: string): TypedEntry | null => {
88 const end = /\s(\+(?:[?!+-]|g|@[A-Za-z0-9_-]{1,20})?)$/.exec(text)
89 if (!end) {
90 return null
91 }
92 const body = text.slice(0, end.index).trim()
93
94 return body === '' ? null : parseEntry(`${end[1]} ${body}`)
95}
96
97// A message that starts with "+" is queued, not sent. When every line starts with "+",
98// each line is its own entry; otherwise the whole message is one.
99export const parseEntries = (text: string): TypedEntry[] | null => {
100 const typed = text.trim()
101 const lines = typed.split(/\r?\n/).filter(line => line.trim() !== '')
102 if (lines.length === 0 || looksLikeDiff(typed)) {
103 return null
104 }
105 if (!lines[0]!.trim().startsWith('+')) {
106 const one = parseTrailingMark(typed)
107
108 return one === null ? null : [one]
109 }
110
111 const isList = lines.every(line => line.trim().startsWith('+'))
112 const entries = (isList ? lines : [typed]).map(parseEntry).filter((one): one is TypedEntry => one !== null)
113
114 return entries.length === 0 ? null : entries
115}
116
117// A comment written straight into a markdown file, with the line it sits on and the text
118// above it: <!-- ? question -->, <!-- ! change -->, <!-- - drop -->, <!-- + dig deeper -->.
119export type FileTag = { kind: Kind; text: string; line: number; quote?: string }
120
121export const findTags = (source: string): FileTag[] => {
122 const found: FileTag[] = []
123 const lines = source.split(/\r?\n/)
124 // A tag shown in a code block or a `code span` (a README explaining the syntax) is not a
125 // comment: both are blanked out, keeping every offset, before the search.
126 const inFence = findFencedLines(lines)
127 const plain = lines
128 .map((text, i) => (inFence[i] ? ' '.repeat(text.length) : text.replace(/(`+)[^`]*?\1/g, span => ' '.repeat(span.length))))
129 .join('\n')
130 for (const one of plain.matchAll(/<!--\s*([?!+-])\s+([\s\S]*?)\s*-->/g)) {
131 const line = plain.slice(0, one.index).split('\n').length
132 const near = lines
133 .slice(0, line)
134 .map((text, i) => (inFence[i] ? '' : visibleText(text).trim()))
135 .reverse()
136 .find(text => text !== '')
137 found.push({
138 kind: KIND_BY_MARK[one[1]!]!,
139 text: one[2]!.replace(/\s+/g, ' '),
140 line,
141 ...(near ? { quote: shortenText(near, 80) } : {}),
142 })
143 }
144
145 return found
146}
147
148// Whether two entries are the same one: queued at the same moment, of the same kind, with the
149// same text. Entries hold no id, and one tidied or edited since counts as another, so work that
150// waited on a long call finds nothing under the text it was given (3.16).
151// The line as typed with the entry's text tidied in its place: the mark and where it pointed
152// stay, only the words change. The line is left as it is when it no longer ends with the text
153// being replaced, as it does not when the quote was copied after it.
154export const retypedRaw = (entry: Pick<Entry, 'raw' | 'text'>, text: string): string | undefined =>
155 entry.raw?.endsWith(entry.text) ? `${entry.raw.slice(0, entry.raw.length - entry.text.length)}${text}` : entry.raw
156
157export const isSameEntry = (one: Entry, other: Entry): boolean => one.at === other.at && one.kind === other.kind && one.text === other.text
158
159// `list` without `gone`, one same entry for each of `gone`: two entries typed alike in one
160// message are the same by isSameEntry, and taking one of them leaves the other.
161export const withoutEntries = (list: readonly Entry[], gone: readonly Entry[]): Entry[] => {
162 const left = [...gone]
163
164 return list.filter(entry => {
165 const i = left.findIndex(one => isSameEntry(one, entry))
166 if (i >= 0) left.splice(i, 1)
167 return i === -1
168 })
169}
170
171// `list` with only `kept`, one same entry for each of them, as withoutEntries leaves them out.
172export const onlyEntries = (list: readonly Entry[], kept: readonly Entry[]): Entry[] => {
173 const left = [...kept]
174
175 return list.filter(entry => {
176 const i = left.findIndex(one => isSameEntry(one, entry))
177 if (i >= 0) left.splice(i, 1)
178 return i >= 0
179 })
180}
181
182// Which entry comes first, from the order setting. The store keeps the oldest first either
183// way, so the setting can change at any time; only what /aside shows, numbers and runs follows it.
184export type Order = 'oldest' | 'newest'
185
186export const inOrder = (list: readonly Entry[], order: Order): Entry[] => (order === 'newest' ? [...list].reverse() : [...list])
187