SLOPSHOPPER

aside

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…

panebandrowsguardcommandtoast
★ 1v0.37.4Apache-2.0updated 2026-10-26gravityXX/aside
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aside
│ ┃ aside ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ The queue is empty. │ aside │ │ ┃ ⏺ Read(src/auth.ts) │ Could not note the start of this turn │ │ ┃ [ close ] ⎿ Read 6 lines │ (undefined is not an object (evaluating │ │ ⏺ Update(src/auth.ts) │ 'text.includes')); a running plan may not │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aside │ ⎿ aside: The queue is empty. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aside
The queue is empty. [ close ]
README

aside

<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>

While Claude works on your task, your ideas and comments go to a queue instead of to Claude. When it is done you answer it as usual, and /aside plan hands the queue over as steps, which Claude sums up after the last one. While Claude works on your task, your ideas and comments go to a queue instead of to Claude. When it is done you answer it as usual, and /aside plan hands the queue over as steps, which Claude sums up after the last one.

How it works

1. Note it while Claude works

To comment on something, copy it first (<kbd>Ctrl</kbd>+<kbd>C</kbd> or <kbd>&#x2318;</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.

Copy a sentence from a markdown preview with Ctrl+C, then type +? and a question in Claude Code: the comment is queued, pinned to docs/report.md line 42 with the copied sentence. The marks: + idea, +? question, +! change, +- drop, ++ dig deeper. Copy a sentence from a markdown preview with Ctrl+C, then type +? and a question in Claude Code: the comment is queued, pinned to docs/report.md line 42 with the copied sentence. The marks: + idea, +? question, +! change, +- drop, ++ dig deeper.

[!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 tidy to off in /config. The other settings are under Settings.

2. Answer Claude as usual

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.

Claude asks whether to update the docs. Above the prompt, the queue line shows 1 idea and 4 comments with a list button. You answer as usual; the queue keeps waiting. Claude asks whether to update the docs. Above the prompt, the queue line shows 1 idea and 4 comments with a list button. You answer as usual; the queue keeps waiting.

3. Look at it when ready

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 shows the list in a frame, 1 idea and 1 comment with its file, line and quote; /aside pop 2 puts entry 2 in the prompt box. /aside shows the list in a frame, 1 idea and 1 comment with its file, line and quote; /aside pop 2 puts entry 2 in the prompt box.

4. Plan and run

/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.

/aside plan has Claude save the queue as steps and /aside run all sends them one after another. Above the prompt: step 2 of 3 running, with list and stop buttons. The plan shows step 1 done with its result under it, step 2 running and step 3 to do; after the last step Claude ends with a summary of the run. /aside plan has Claude save the queue as steps and /aside run all sends them one after another. Above the prompt: step 2 of 3 running, with list and stop buttons. The plan shows step 1 done with its result under it, step 2 running and step 3 to do; after the last step Claude ends with a summary of the run.

Every situation <kbd>aside</kbd> handles, with diagrams of how it works, is in docs/use-cases.md.

Install

/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.

  • Terminal on Linux, macOS, Windows and WSL: everything.
  • Remote Control from a phone or the web: + entries are queued; the list shows in the terminal.
  • Desktop: once its built-in Claude Code reaches 2.1.301; add entries with /aside TEXT.
  • Not yet: cloud sessions, Desktop sessions inside WSL, and drawing in VS Code.

Commands

/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.

Settings

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.

More

[!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 plan or /aside run, or afterClaudeAnswers does. With tidy on, the text you type goes to Haiku to be corrected; what you copied never does. With planModel on a model, /aside plan sends the queue to that model in place of the conversation.

  • Keeps the queue, the plan and what /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.
  • Reads <!-- ? --> 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.
  • Runs a clipboard reader (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.
  • Sends the entries to Claude only on /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.

Contributing

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.

Support

<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.

Source 33 files
hooks/register.tsx 55 lines
1// 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}
55
hooks/context.ts 115 lines
1// 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}
115
hooks/features/background.ts 107 lines
1// 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}
107
hooks/features/capture.ts 423 lines
1// 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}
423
hooks/features/commands.ts 443 lines
1// /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}
443
hooks/features/plan.ts 468 lines
1// 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}
468
hooks/features/plantool.ts 238 lines
1// 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}
238
hooks/features/steps.ts 387 lines
1// 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}
387
hooks/ui/band.tsx 69 lines
1// 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}
69
hooks/ui/output.tsx 78 lines
1// 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}
78
hooks/ui/pane.tsx 95 lines
1// 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}
95
hooks/core/entries.ts 187 lines
1// 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