Pi Outliner integration for Claude Code, following the outline the session folder is bound to: typed outline, workboard and door tools for agents (read, find…

A Claude Mod (a plugin with a function-hooks module) that forwards each completed Claude Code answer to the Outliner's mentions.ingest contract. It works the same way as the Codex Stop hook (src/mentions-codex.ts). [[page]], ((block-id)), Work IDs and existing UUIDs in the answer then show up in Tree/Detail ? → Recent mentions.
There is nothing to configure. A session uses the outline its folder is bound to, found the way every Outliner client finds it: the nearest folder, from the session's cwd up, that is bound, by a client.json (bind a folder with the choose-outline action) or as the root an outline host records for one of its outlines. The installed CLI answers (outliner bound-folder <cwd>), so the mod never keeps a resolver of its own.
$HOME, /, /tmp) binds nothing by itself (a client.json there still does). A subfolder with its own hash database is not its ancestor's binding, since every client uses that database there; it feeds nothing.show, the workboard and outline tools all use that folder and the outline it was found bound to: the mod passes that outline's name (OUTLINER_OUTLINE, or blanks an inherited one for a local or remote choice) and blanks an inherited OUTLINER_CONFIG_PATH, so Claude's environment never moves a write elsewhere. With OUTLINER_REMOTE or OUTLINER_SOCKET_PATH in Claude's environment nothing is fed (one toast); use strict mode for that setup.herdr plugin list --plugin float.pi-outliner), and the mod calls the installed CLI's mentions ingest. It never starts a service. Failures show as one toast and leave the answer untouched. An Outliner older than bound-folder feeds nothing and says so once per session.PI_OUTLINER_MENTIONS_WORKSPACES (or the workspaces option) lists absolute folders, separated by : or ,. What the list means is PI_OUTLINER_MENTIONS_MODE (or the mode option):
| Mode | The list |
|---|---|
folder (unset with no list) | Folders opted out: a session in one, or below it, feeds nothing even when bound. |
allowlist | Strict mode, as before folder mode: only listed folders (and their subfolders) feed, bound or not; the nearest listed folder is the workspace, and the CLI resolves its outline as it always did. |
| unset, with a list | Nothing feeds anywhere, with one toast a session. The list may be an allowlist from before folder mode, and reading it either way would start feeding folders nobody chose. Set the mode, or drop the list (install-claude-mod.ts --folder: every bound folder then feeds its outline). |
The option wins over the environment variable when it is set. Claude Code passes an unset option as an empty string, so an empty option always falls back to the variable. An entry that isn't an absolute folder (a relative path, or folder=name), or an unknown mode, is an error shown as a toast, never skipped.
In a bound folder, Work IDs (its outline's prefixes), [[pages]] and ((block references)) in Claude's replies are drawn as links. In an ep0ch-door tile a click opens the note in that door (Where a note opens). In Herdr a plain click shows the target in Claude's own Outliner Detail: a pane split below the Claude pane the first time, then reused for every later click in the session. It never navigates your Trees or Details and never takes focus; move or resize it as you like. References in code and existing links are left alone.
Claude can put a note there too, with the mcp__pi-outliner__show tool (a Work ID, [[page]], ((uuid)) or pi-outliner:// URI).
A click, show and door_open share one open (openNote in hooks/register.ts), tried in this order:
EP0CH_CONTROL set: the daily agent, the dock's agent, or a claude started in a ^W o s terminal tile, in the tile or in its Herdr pane): the CLI's door-open --from <tile> sends the door an agent's open from Claude's own tile over EP0CH_CONTROL. The tile is EP0CH_TILE, or its id (EP0CH_TILE_ID, t<n>) when the name is empty. The door puts it where that tile's opens land (its link: the daily layout links the claude tile to its middle detail) and says which reader that was. A door older than open from=, or without that tile, is asked again naming no tile, and puts it where its own open puts notes, which never takes the person's keys. The mod never names a reader (it used to ask for middle; a door refuses an agent that names the reader the person is on).claude-code, or OUTLINER_ACTOR / EP0CH_AGENT), and an agent's open never moves the person's focus.HERDR_PANE_ID set): Claude's own Detail, as above. A door tile drops Herdr's pane variables, so this is a plain Herdr pane, or the daily agent's Herdr pane after its door quit.((id)) to copy. A click never fails silently.show is refused with a toast (the Outliner protects active edits) rather than opening a second pane."tui": "fullscreen").https://pi-outliner.invalid/... stand-ins, because Claude Code only draws https, http and file links as links. Where it does not detect terminal hyperlink support (a Herdr pane), it prints each URL beside its text; set FORCE_HYPERLINK=1 in the settings env block to draw the text alone.In a door tile (EP0CH_NEST or EP0CH_CONTROL set), the mod runs ep0ch where --json when the session starts. The first prompt then carries its one-line summary as a context block (whereAmI), not a pane: the layers the session runs in, outermost first (ssh:pts/5 › herdr:w1:p1 › door:<pid>/desk/t1:claude, ep0ch-door's EP0CH_NEST), which of them are live, and where the person's keys are. Claude then doesn't have to guess from the repo name or a window title.
where only reads. The mod asks ep0ch help first. An ep0ch older than where would take the word for a socket and open a door, so it is never run. The help call carries a socket path that never exists, so an ep0ch older than help (which would take help the same way) stops at "no carrier" instead of opening a door.ep0ch (not on PATH, too old, an error, or no answer within 1.5s of the first prompt), the block has the variables alone and says they are unchecked.In a bound folder Claude also gets work_create, work_stage, work_set, work_deliver, work_complete, work_body and note_section. Each one runs the installed CLI's work / note command (see the roadmap operations reference) in the session's workspace, as an agent write attributed to claude-code and this session. Items are named by Work ID or block UUID, never by title. The tool result is the command's JSON; a refusal (stale revision, unknown stage, unmerged delivery…) comes back as the CLI's reason.
An item can have several deliveries, one per PR. work_deliver takes a key (door → PIE-123/door); work_complete takes deliveries or allMerged and is refused, naming what is left, while any other delivery is incomplete; and work_set sets delivery-stage on a delivery named by UUID or key, such as one left in validate on an item that is already done.
In a bound folder Claude gets typed tools for everything an agent does to the outline, so it never writes a script around list --subtree, update or a comment socket. Each runs the installed CLI's agent <operation> command (src/agent-tools.ts) with the tool's input as JSON on stdin, in the session's workspace, and returns compact JSON. The service keeps the rules; a refusal comes back as the tool's error with the reason.
| Tool | Input | Returns |
|---|---|---|
outline_read | ref, depth? (1, at most 6), limit? (50, at most 500) | full text (never the title alone), properties, revision, author, actorId, updated, children to depth with full text (60k characters of children's text at most), complete |
outline_find | text?, property? (key=value or key), hasKey?, query?, under?, or view?; limit? | rows: id, title, revision; complete |
outline_resolve | ref | id, title, revision, workId, fragmentId |
outline_edit | ref, expectedRevision, one of text, replaceSection {heading, body}, append; allowStructural? | the new revision, a short diff, and with allowStructural what it dropped |
outline_create | parent (a ref or root), text, position? | id, ref, revision |
outline_comment | ref, body, quote (with start/prefix/suffix when it repeats) or whole: true, requestId? | the thread id |
outline_reply | thread, body | the reply id |
outline_resolve_thread | thread, resolved | the thread's lifecycle |
outline_changes | since (an ISO time or a returned cursor), author?, actor?, limit?, before? | each changed block once, newest first, with who changed it; complete, with before for the older page when it is false; the next cursor |
outline_patch | ref, revision, patches: [{observed, replacement}], mark?, policy? (edit, the default, or prose), allowStructural? | draft.patch's outcome: applied, or proposed with the reason |
A ref is a block id, ((id)), [[page]] or a Work ID. A title is refused: find it with outline_find first.
outline_edit refuses an empty or whitespace-only result, a revision that isn't the block's (read it again), and an edit that drops a [page::…] or an ^anchor another note links to, unless allowStructural: true (the check is refuseDroppedStructure in src/work-tools.ts, over droppedLinkedStructure and droppedStructure). An agent's note_section and work_body get the same check, with no way past it: removing them is an outline_edit with allowStructural.outline_edit. For small edits to a note the person may be typing in, outline_patch sends draft.patch: the door holding the live draft applies it in place, and with none it is an ordinary edit of the saved note. Its default policy, edit, is outline_edit's guard (allowStructural likewise), refused as an error; policy: "prose" keeps every link, anchor and property. A patch whose text changed under it, or that prose refuses, becomes one proposal the person can apply.author: agent, with the session id as provenance, and an actor id: the call's actor, else OUTLINER_ACTOR, else EP0CH_AGENT (the name the door shows for the agent), else claude-code. The workboard tools use the same actor, from the environment.When Claude runs in an ep0ch-door tile (EP0CH_CONTROL set), it also gets door_where, door_peek, door_act { action, args?, tile? } and door_open { id }. They run ep0ch where --json, ep0ch peek and ep0ch act … on that socket; door_open is the same open as a click or show (Where a note opens). Without EP0CH_CONTROL they are not offered, and a call is refused.
door_act and door_open are attributed with --as (the same actor as above), and the door says so on the person's screen.block.mark is how to ask for their attention.ep0ch reads an argument starting with @ as a file, so the tool sends one such value through stdin (key=@-) and refuses a second.door_open resolves a [[page]] or Work ID in the session's outline first; a block id opens in a folder bound to no outline too. show (above) is the way to put a note beside Claude wherever it runs.Function hooks are early access and need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
The installer sets up everything below: install.sh --claude-mod, or, from a checkout, bun scripts/install-claude-mod.ts. It needs no folder.
claude --plugin-dir /path/to/checkout/claude-mod
To load it in every session, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and CLAUDE_CODE_PLUGIN_DIRS=/path/to/checkout/claude-mod in the env block of ~/.claude/settings.json; that is what the installer writes, replacing any other copy of this mod and backing the file up first.
| Installer | Does |
|---|---|
install-claude-mod.ts | Loads the mod. The folder list and mode are kept; a list with no mode is named (it feeds nothing until the mode is set). |
install-claude-mod.ts --exclude /folder | Opts the folder out (repeatable; PI_OUTLINER_MENTIONS_MODE=folder). |
install-claude-mod.ts /folder (or --allowlist /folder) | Strict mode: only these folders feed (PI_OUTLINER_MENTIONS_MODE=allowlist). |
install-claude-mod.ts --folder | Folder mode, dropping an allowlist (strict mode's, or a list with no mode from before folder mode) and naming its folders bound to no outline. |
install.sh passes --claude-exclude as --exclude and --claude-workspace as strict-mode folders. To stop the mod, remove its folder from CLAUDE_CODE_PLUGIN_DIRS.
claude plugin validate claude-mod
claude plugin test claude-mod
# types: run /plugin-types claude-mod/.claude/types in a session, then
tsc -p claude-mod/tsconfig.jsonhooks/register.ts 712 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import {
4 boundWorkspaceOf,
5 effectiveWorkspaces,
6 failureReasonOf,
7 isIngestible,
8 type MentionMessage,
9 mentionMessageOf,
10 mentionsModeOf,
11 sessionWorkspaceOf,
12 type Workspace,
13 workspaceEnvOf,
14 workspaceForCwd,
15} from './mention-message'
16import {
17 detailSplitArgv,
18 isProtectedDestination,
19 linkifyReferences,
20 type OutlinerClient,
21 outlinerBlockIdOf,
22 outlinerLabelOf,
23 outlinerReferenceOf,
24 outlinerUriFor,
25 outlinerUriOf,
26 scratchPaneOf,
27} from './references'
28import { actorOf, DOOR_TOOLS, doorActArgv, OUTLINE_TOOLS, peekOf } from './outline-tools'
29import { WORK_TOOLS } from './work-tools'
30import {
31 type DoorEnv,
32 doorTileOf,
33 envSummaryOf,
34 HELP_PROBE,
35 inDoorEnv,
36 knowsWhere,
37 WHERE_BLOCK,
38 WHERE_TIMEOUT_MS,
39 WHERE_WAIT_MS,
40 whereSummaryOf,
41 whereText,
42} from './where'
43
44/**
45 * What drawing a reply needs, read once per session: the Outliner workspace the
46 * session belongs to (null: its folder is bound to no outline, or opted out;
47 * nothing is linked) and its Work-ID prefixes. A render hook only reads, so
48 * this is loaded beside it, not in it.
49 */
50type ReferenceContext = { workspace: Workspace | null; prefixes: string[]; why?: string }
51let references: ReferenceContext | undefined
52/** The load in flight, so a tool call made while the session starts waits for it rather than being refused. */
53let loadingReferences: Promise<void> | undefined
54/**
55 * The environment an Outliner CLI run gets for one workspace: its bound
56 * folder, whose client.json the CLI resolves the outline from itself (so every
57 * client lands on the same outline), and the outline's name for a root only
58 * the host records.
59 */
60const envFor = workspaceEnvOf
61/** Each reason the session's workspace could not be found is toasted once. */
62const toldWorkspaceFailures = new Set<string>()
63/** The pane id Herdr gave the last Detail this session split, until it registers. */
64let splitScratchPane: string | undefined
65/** Shows run one at a time, so concurrent clicks and tool calls split one pane. */
66let showQueue: Promise<unknown> = Promise.resolve()
67/**
68 * Where this session runs (`ep0ch where`'s summary), started at session.start
69 * for the first prompt's context; null outside a door.
70 */
71let whereLoad: Promise<string | null> | undefined
72
73/**
74 * Registers Recent Mentions: each completed main-loop answer in a folder bound
75 * to an outline goes to that outline, as the Codex Stop hook sends Codex's.
76 *
77 * The session's workspace is its folder's nearest bound ancestor, resolved by
78 * the installed CLI the way every client resolves it (`sessionWorkspace`). An
79 * unbound folder feeds nothing. The `workspaces` option, or, left empty,
80 * `PI_OUTLINER_MENTIONS_WORKSPACES`, lists folders opted out; with the `mode`
81 * option or `PI_OUTLINER_MENTIONS_MODE` set to `allowlist` they are instead
82 * the only folders that feed (the strict mode). The engine hands an unset string
83 * option over as '', so empty and unset are one case: an empty option cannot
84 * override the environment.
85 *
86 * The answer passes on untouched. Delivery runs off the turn's dispatch, so a
87 * slow or absent service never delays the prompt; a failure is one toast.
88 *
89 * In the same workspaces, Work IDs, `[[pages]]` and `((block references))` in
90 * Claude's replies are drawn as links; a click opens the target where `show`
91 * and `door_open` do (`openNote`): the door this session runs in, else Claude's
92 * own Detail in Herdr, else a toast with the `((id))` to copy.
93 */
94export function register(on: On, options: PluginOptions): void {
95 const option = options
96
97 on('session.start', async ($, e, next) => {
98 const result = await next(e)
99 $.clock.after(0, () => void loadReferences($, option))
100 // Off the start's dispatch: a slow or missing `ep0ch` never holds the session up.
101 whereLoad = new Promise(resolve => {
102 $.clock.after(0, () => void loadWhere($).then(resolve, () => resolve(null)))
103 })
104 await $.tool.register({
105 name: 'show',
106 description:
107 "Show an Outliner note in Claude's own Outliner Detail pane, split below this conversation " +
108 'in Herdr and reused for every call, so the person can read it beside the chat. When this ' +
109 "session runs in an ep0ch-door tile, it opens in that door, where its tile's opens land, instead. It never " +
110 'moves their Trees, Details or focus. Use it when pointing the person at a note matters; ' +
111 'references in replies are already clickable.',
112 inputSchema: {
113 type: 'object',
114 properties: {
115 reference: {
116 type: 'string',
117 description: 'A Work ID (PIE-123), [[page]], ((block-uuid)), bare block UUID, or pi-outliner:// URI.',
118 },
119 },
120 required: ['reference'],
121 additionalProperties: false,
122 },
123 })
124 // Off the start's dispatch: each registration republishes the tool server (~20ms), and these tools are
125 // loaded on demand, so the session never waits for them. The door tools act in the door this Claude runs
126 // in: only in a door tile, where EP0CH_CONTROL names it.
127 $.clock.after(0, () => void (async () => {
128 const tools = [...WORK_TOOLS, ...OUTLINE_TOOLS, ...((await $.env.get('EP0CH_CONTROL')) ? DOOR_TOOLS : [])]
129 for (const tool of tools) {
130 await $.tool.register({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })
131 }
132 })().catch(() => {}))
133 return result
134 })
135
136 // Where this session runs, as one context block of the first prompt (not a pane): waited for briefly, else
137 // the variables alone. Nothing is added outside a door, and a failure adds nothing.
138 on('prompt.context', async ($, e, next) => {
139 const result = await next(e)
140 try {
141 const env = await doorEnvOf($)
142 if (!inDoorEnv(env)) return result
143 // Before session.start's work is queued (or after a reload): start it once, here.
144 const load = (whereLoad ??= loadWhere($).catch(() => null))
145 const summary = (await Promise.race([load, $.clock.sleep(WHERE_WAIT_MS).then(() => null)])) ?? envSummaryOf(env)
146 const block = { name: WHERE_BLOCK, text: whereText(summary) }
147 return { ...result, blocks: [...result.blocks.filter(b => b.name !== WHERE_BLOCK), block] }
148 } catch {
149 return result
150 }
151 })
152
153 for (const tool of WORK_TOOLS) {
154 on('tool.call', { tool: `mcp__pi-outliner__${tool.name}` }, async ($, e) => {
155 const command = tool.command(e as Record<string, unknown>)
156 if (typeof command === 'string') return { deny: command }
157 if (!references) await loadReferences($, option)
158 const workspace = references?.workspace
159 if (!workspace) return { deny: references?.why ? `No Outliner outline for this session: ${references.why}` : NOT_BOUND }
160 try {
161 return { result: await runWorkCommand($, workspace, command, await actorFor($, {})) }
162 } catch (error) {
163 return { deny: error instanceof Error ? error.message : String(error) }
164 }
165 })
166 }
167
168 for (const tool of OUTLINE_TOOLS) {
169 on('tool.call', { tool: `mcp__pi-outliner__${tool.name}` }, async ($, e) => {
170 const input = e as Record<string, unknown>
171 const command = tool.command(input)
172 if (typeof command === 'string') return { deny: command }
173 if (!references) await loadReferences($, option)
174 const workspace = references?.workspace
175 if (!workspace) return { deny: references?.why ? `No Outliner outline for this session: ${references.why}` : NOT_BOUND }
176 // outline_changes' `actor` filters by agent; every other tool's names who the write is attributed to.
177 const actor = await actorFor($, tool.name === 'outline_changes' ? {} : input)
178 try {
179 return { result: await runOutlinerCli($, workspace, ['agent', command.operation, '--stdin', '--actor', actor], JSON.stringify(command.input)) }
180 } catch (error) {
181 return { deny: error instanceof Error ? error.message : String(error) }
182 }
183 })
184 }
185
186 for (const tool of DOOR_TOOLS) {
187 on('tool.call', { tool: `mcp__pi-outliner__${tool.name}` }, async ($, e) => {
188 const control = await $.env.get('EP0CH_CONTROL')
189 if (!control) return { deny: 'The door tools work only in an ep0ch-door tile (EP0CH_CONTROL is not set).' }
190 try {
191 return { result: await runDoorTool($, tool.name, e as Record<string, unknown>, control, option) }
192 } catch (error) {
193 return { deny: error instanceof Error ? error.message : String(error) }
194 }
195 })
196 }
197
198 on('tool.call', { tool: 'mcp__pi-outliner__show' }, async ($, e) => {
199 // A plugin tool's arguments arrive flat on the event, beside `tool`.
200 const reference: unknown = e.reference
201 const uri = typeof reference === 'string' ? outlinerUriFor(reference) : null
202 if (!uri) return { deny: 'Give a Work ID, [[page]], ((block-uuid)) or pi-outliner:// URI to show.' }
203 if (!references) await loadReferences($, option)
204 try {
205 return { result: shownText(await openNote($, references?.workspace ?? null, uri, await actorFor($, {})), String(reference)) }
206 } catch (error) {
207 return { deny: deniedText(error, String(reference)) }
208 }
209 })
210
211 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
212 const workspace = references?.workspace
213 if (!workspace) return next(e)
214 const { text, hrefs } = linkifyReferences(e.props.text, references!.prefixes)
215 if (hrefs.length === 0 || text.length > 10_000) return next(e)
216 const { Box, Text, Markdown } = await $.ui.resolve(e)
217 return Box({
218 flexDirection: 'row',
219 children: [
220 Box({ width: 2, flexShrink: 0, children: Text({ children: e.props.isFirstOfReply ? '⏺' : '' }) }),
221 Box({
222 flexGrow: 1,
223 flexShrink: 1,
224 children: Markdown({
225 key: 'outliner-references',
226 text,
227 pressableLinks: hrefs.slice(0, 256),
228 onLinkPress: link => void openReference($, workspace, link.href),
229 }),
230 }),
231 ],
232 })
233 })
234
235 on('turn.complete', async ($, e, next) => {
236 const result = await next(e)
237 // A module reloaded mid-session never sees its session.start.
238 if (!references) $.clock.after(0, () => void loadReferences($, option))
239 if (!isIngestible(e)) return result
240 // Off the turn's dispatch: finding the folder's outline runs the CLI, and a slow one never delays the answer.
241 $.clock.after(0, () => void (async () => {
242 let workspace: Workspace | null
243 try {
244 workspace = await sessionWorkspace($, option, 'mentions')
245 } catch (error) {
246 const reason = error instanceof Error ? error.message : String(error)
247 if (toldWorkspaceFailures.has(reason)) return
248 toldWorkspaceFailures.add(reason)
249 $.ui.toast(`Outliner recent mentions unavailable: ${reason}`, { timeoutMs: 6000 })
250 return
251 }
252 const message = mentionMessageOf(e, { id: await $.session.id() }, workspace)
253 if (!message || !workspace) return
254 await deliver($, message, workspace).catch((error: unknown) => {
255 const reason = error instanceof Error ? error.message : String(error)
256 $.ui.toast(`Outliner recent mentions unavailable: ${reason}`, { timeoutMs: 6000 })
257 })
258 })())
259 return result
260 })
261}
262
263const NOT_BOUND = "This session's folder is not bound to an Outliner outline (bind it with the choose-outline action), or it is opted out."
264
265const PLUGIN_ID = 'float.pi-outliner'
266
267async function doorEnvOf($: EngineInterface): Promise<DoorEnv> {
268 const [EP0CH_NEST, EP0CH_CONTROL, EP0CH_TILE, EP0CH_TILE_ID] = await Promise.all([
269 $.env.get('EP0CH_NEST'),
270 $.env.get('EP0CH_CONTROL'),
271 $.env.get('EP0CH_TILE'),
272 $.env.get('EP0CH_TILE_ID'),
273 ])
274 return { EP0CH_NEST, EP0CH_CONTROL, EP0CH_TILE, EP0CH_TILE_ID }
275}
276
277/**
278 * `ep0ch where --json`'s one-line summary when this session runs in a door
279 * (EP0CH_NEST or EP0CH_CONTROL set); the variables alone when `ep0ch` isn't
280 * on PATH, is too old or fails; null outside a door. `where` only reads.
281 */
282async function loadWhere($: EngineInterface): Promise<string | null> {
283 const env = await doorEnvOf($)
284 if (!inDoorEnv(env)) return null
285 try {
286 // An ep0ch older than `where` would read `where` as a socket path and open a door on the terminal-less
287 // session (attaching, maybe creating, an outline): its help must list `where` first. An ep0ch older than
288 // `help` (before ep0ch-door #31) reads `help` the same way; the probe socket it then picks instead of
289 // the default one doesn't exist, so it stops at "no carrier" before opening anything.
290 const help = await $.process.run(['ep0ch', 'help', HELP_PROBE], { timeoutMs: 5000 })
291 if (help.exitCode !== 0 || !knowsWhere(help.stdout)) return envSummaryOf(env)
292 const ran = await $.process.run(['ep0ch', 'where', '--json'], { timeoutMs: WHERE_TIMEOUT_MS })
293 const summary = ran.exitCode === 0 ? whereSummaryOf(ran.stdout) : null
294 if (summary) return summary
295 } catch {
296 // Not on PATH, or it didn't answer in time: the variables alone.
297 }
298 return envSummaryOf(env)
299}
300
301/**
302 * The installed Outliner's root, as Herdr reports it: exactly one enabled
303 * `float.pi-outliner` with an absolute `plugin_root`. Never a guessed checkout.
304 * Null when the plugin is installed but disabled.
305 */
306async function outlinerRootOf($: EngineInterface): Promise<string | null> {
307 const listed = await $.process.run(
308 ['herdr', 'plugin', 'list', '--plugin', PLUGIN_ID, '--json'],
309 { timeoutMs: 5000 },
310 )
311 if (listed.exitCode !== 0) throw Error('Herdr could not discover the Outliner installation')
312 const plugins: unknown = JSON.parse(listed.stdout)?.result?.plugins
313 const matches = Array.isArray(plugins)
314 ? plugins.filter(plugin => plugin?.plugin_id === PLUGIN_ID)
315 : []
316 if (matches.length !== 1) throw Error(`Expected one installed ${PLUGIN_ID} plugin`)
317 const [plugin] = matches
318 if (plugin.enabled === false) return null
319 if (typeof plugin.plugin_root !== 'string' || !plugin.plugin_root.startsWith('/'))
320 throw Error('Herdr did not report an absolute plugin_root')
321 return plugin.plugin_root
322}
323
324/**
325 * The workspace a session feeds (`mentions`) or works in (`tools`: the outline
326 * tools and reference links), or null (`sessionWorkspaceOf`). Strict mode's
327 * list limits only what feeds; the tools follow the folder's binding.
328 * In folder mode the installed CLI's `bound-folder` says which folder, from
329 * the session's cwd up, is bound to an outline; an opted-out folder never
330 * asks. A disabled Outliner is null; a CLI that cannot answer (one older than
331 * `bound-folder`) throws with why, and nothing is fed.
332 */
333async function sessionWorkspace($: EngineInterface, options: PluginOptions, purpose: 'mentions' | 'tools'): Promise<Workspace | null> {
334 const [listedEnv, modeEnv, home, cwd] = await Promise.all([
335 $.env.get('PI_OUTLINER_MENTIONS_WORKSPACES'),
336 $.env.get('PI_OUTLINER_MENTIONS_MODE'),
337 $.env.get('HOME'),
338 $.session.cwd(),
339 ])
340 const listed = effectiveWorkspaces(options.workspaces, listedEnv, home)
341 const mode = mentionsModeOf(options.mode, modeEnv, listed)
342 const listedHere = workspaceForCwd(cwd, listed)
343 // Strict mode limits what feeds Recent Mentions; the tools and links still work in any bound folder.
344 if (mode === 'allowlist' ? purpose === 'mentions' || listedHere !== null : listedHere !== null) return sessionWorkspaceOf(cwd, mode, listed, null)
345 // A remote socket in Claude's environment would take every CLI run elsewhere than the folder's binding.
346 const [remote, socket] = await Promise.all([$.env.get('OUTLINER_REMOTE'), $.env.get('OUTLINER_SOCKET_PATH')])
347 if (remote?.trim() === '1' || socket?.trim()) {
348 throw Error("OUTLINER_REMOTE / OUTLINER_SOCKET_PATH in Claude's environment would send it elsewhere than this folder's outline; unset them, or use strict mode (PI_OUTLINER_MENTIONS_MODE=allowlist)")
349 }
350 const root = await outlinerRootOf($)
351 if (!root) return null
352 const ran = await $.process.run(
353 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, 'bound-folder', cwd],
354 { cwd, timeoutMs: 30_000 },
355 )
356 if (ran.exitCode !== 0) {
357 const reason = failureReasonOf(ran.stderr)
358 throw Error(/Unknown command: bound-folder/.test(reason)
359 ? "the installed Outliner is too old to find this folder's outline (no bound-folder); update it"
360 : `bound-folder failed${reason ? `: ${reason}` : ''}`)
361 }
362 const bound = boundWorkspaceOf(ran.stdout, cwd)
363 return mode === 'allowlist' ? bound : sessionWorkspaceOf(cwd, mode, listed, bound)
364}
365
366/**
367 * Posts one message through the installed CLI's `mentions ingest`. The
368 * session's workspace selects the outline, never this process's cwd; an
369 * existing connection override in the environment is kept, and no service is
370 * started.
371 */
372async function deliver($: EngineInterface, message: MentionMessage, workspace: Workspace): Promise<void> {
373 const root = await outlinerRootOf($)
374 if (root === null) return
375 const ingested = await $.process.run(
376 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, 'mentions', 'ingest'],
377 {
378 cwd: workspace.root,
379 env: envFor(workspace),
380 stdin: JSON.stringify(message),
381 timeoutMs: 30_000,
382 },
383 )
384 if (ingested.exitCode !== 0) {
385 const reason = failureReasonOf(ingested.stderr)
386 throw Error(`mentions ingest failed${reason ? `: ${reason}` : ''}`)
387 }
388}
389
390/**
391 * Runs one `work` / `note` command through the installed CLI in the session's
392 * workspace, as Claude (agent author, this session as provenance). Resolves to
393 * the command's JSON; a refusal throws with the CLI's reason.
394 */
395async function runWorkCommand(
396 $: EngineInterface,
397 workspace: Workspace,
398 command: { args: string[]; stdin?: string },
399 actor: string,
400): Promise<string> {
401 return runOutlinerCli($, workspace, [...command.args, '--author', 'agent', '--actor', actor], command.stdin)
402}
403
404/**
405 * Who this session's writes are attributed to: the tool call's `actor`, else
406 * OUTLINER_ACTOR, else EP0CH_AGENT (the door's name for the agent), else
407 * claude-code. The session id goes beside it as provenance.
408 */
409async function actorFor($: EngineInterface, input: Record<string, unknown>): Promise<string> {
410 const [OUTLINER_ACTOR, EP0CH_AGENT] = await Promise.all([$.env.get('OUTLINER_ACTOR'), $.env.get('EP0CH_AGENT')])
411 return actorOf(input, { ...(OUTLINER_ACTOR ? { OUTLINER_ACTOR } : {}), ...(EP0CH_AGENT ? { EP0CH_AGENT } : {}) })
412}
413
414/**
415 * Runs the installed CLI in the session's workspace with this session as the
416 * write's provenance (`--session`). Resolves to its output; a refusal throws
417 * with the CLI's reason.
418 */
419async function runOutlinerCli($: EngineInterface, workspace: Workspace, args: string[], stdin?: string): Promise<string> {
420 const root = await outlinerRootOf($)
421 if (!root) throw Error('the Outliner plugin is disabled')
422 const sessionId = await $.session.id()
423 const ran = await $.process.run(
424 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, ...args, '--session', sessionId],
425 {
426 cwd: workspace.root,
427 env: envFor(workspace),
428 ...(stdin === undefined ? {} : { stdin }),
429 timeoutMs: 60_000,
430 },
431 )
432 if (ran.exitCode !== 0) throw Error(failureReasonOf(ran.stderr) || `${args.slice(0, 2).join(' ')} failed`)
433 return ran.stdout.trim()
434}
435
436/**
437 * One door tool through `ep0ch` on this session's door (EP0CH_CONTROL, passed
438 * explicitly). Acting and opening are attributed with --as; the door's
439 * refusal (an agent never takes the person's focus) throws with its reason.
440 */
441async function runDoorTool(
442 $: EngineInterface,
443 name: string,
444 input: Record<string, unknown>,
445 control: string,
446 option: PluginOptions,
447): Promise<string> {
448 const ep0ch = async (argv: string[], stdin?: string) => {
449 const ran = await $.process.run(argv, { env: { EP0CH_CONTROL: control }, ...(stdin === undefined ? {} : { stdin }), timeoutMs: 15_000 })
450 if (ran.exitCode !== 0) throw Error(failureReasonOf(ran.stderr) || ran.stderr.trim() || `${argv.slice(0, 2).join(' ')} failed`)
451 return ran.stdout
452 }
453 const compact = (stdout: string) => {
454 try { return JSON.stringify(JSON.parse(stdout)) } catch { return stdout.trim() }
455 }
456 switch (name) {
457 case 'door_where': {
458 // An ep0ch older than `where` would open a door on this terminal-less session: its help must list it.
459 const help = await $.process.run(['ep0ch', 'help', HELP_PROBE], { timeoutMs: 5000 })
460 if (help.exitCode !== 0 || !knowsWhere(help.stdout)) throw Error('this ep0ch is too old for where; update it (ep0ch install)')
461 return compact(await ep0ch(['ep0ch', 'where', '--json']))
462 }
463 case 'door_peek':
464 return JSON.stringify(peekOf(await ep0ch(['ep0ch', 'peek'])))
465 case 'door_act': {
466 const command = doorActArgv(input, await actorFor($, input))
467 if (typeof command === 'string') throw Error(command)
468 return compact(await ep0ch(command.argv, command.stdin))
469 }
470 case 'door_open': {
471 // The same open as a click or `show`: in this door first (EP0CH_CONTROL is set, or the tool is refused).
472 const ref = typeof input.id === 'string' ? input.id.trim() : ''
473 const uri = ref ? outlinerUriFor(ref) : null
474 if (!uri) throw Error('Give the note to open: its id, ((id)), [[page]] or Work ID.')
475 if (!references) await loadReferences($, option)
476 try {
477 return shownText(await openNote($, references?.workspace ?? null, uri, await actorFor($, input)), ref)
478 } catch (error) {
479 throw Error(deniedText(error, ref))
480 }
481 }
482 default:
483 throw Error(`unknown door tool ${name}`)
484 }
485}
486
487/**
488 * Reads the session's workspace and Work-ID prefixes into `references`. A
489 * session whose folder is bound to no outline, or opted out, links nothing; a
490 * service that cannot answer leaves pages and block references linked, bare
491 * IDs not.
492 */
493function loadReferences($: EngineInterface, option: PluginOptions): Promise<void> {
494 return (loadingReferences ??= readReferences($, option).finally(() => { loadingReferences = undefined }))
495}
496
497async function readReferences($: EngineInterface, option: PluginOptions): Promise<void> {
498 try {
499 let workspace: Workspace | null
500 try {
501 workspace = await sessionWorkspace($, option, 'tools')
502 } catch (error) {
503 // Why no outline could be found: the tools' refusal says it.
504 references = { workspace: null, prefixes: [], why: error instanceof Error ? error.message : String(error) }
505 return
506 }
507 if (!workspace) {
508 references = { workspace: null, prefixes: [] }
509 return
510 }
511 references = { workspace, prefixes: [] }
512 const root = await outlinerRootOf($)
513 if (!root) return
514 const status = await $.process.run(
515 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, 'work-id-status'],
516 { cwd: workspace.root, env: envFor(workspace), timeoutMs: 30_000 },
517 )
518 if (status.exitCode !== 0) return
519 const { prefix, observedPrefixes } = JSON.parse(status.stdout) as { prefix?: unknown; observedPrefixes?: unknown }
520 const prefixes = [prefix, ...(Array.isArray(observedPrefixes) ? observedPrefixes : [])]
521 .filter((value): value is string => typeof value === 'string')
522 references = { workspace, prefixes: [...new Set(prefixes)] }
523 } catch {
524 // Linking is a convenience: the reply is drawn as before.
525 }
526}
527
528/** Where a note was opened: the door this session runs in (and the reader tile it landed in), or Claude's own Detail pane in Herdr. */
529type Shown = { title: string; place: 'door' | 'pane'; reader?: string }
530
531/**
532 * Neither a door nor Herdr took the note: the message says why and gives its
533 * `((id))` to copy. Shown as it is, never prefixed.
534 */
535class NotOpenedHere extends Error {}
536
537/** A tool's denial for a note it couldn't open: NotOpenedHere as it is, any other reason after the reference. */
538function deniedText(error: unknown, reference: string): string {
539 if (error instanceof NotOpenedHere) return error.message
540 return `Could not show ${reference}: ${error instanceof Error ? error.message : String(error)}`
541}
542
543/** How `show` and `door_open` report where a note went. */
544function shownText({ title, place, reader }: Shown, reference: string): string {
545 const where = place === 'door' ? (reader ? `in the door's ${reader} reader` : 'in the door') : "in Claude's Outliner pane"
546 return `Showing ${title || reference} ${where}.`
547}
548
549/**
550 * Opens an Outliner note where the person reads beside Claude: the one open
551 * every path shares (a click on a reference, `show`, `door_open`). In order:
552 *
553 * 1. In an ep0ch-door tile (EP0CH_CONTROL set): in that door, as the agent's
554 * `open` from this session's tile, landing where the tile's opens go (its
555 * link; the door says which reader). Attributed to `actor`; the door never
556 * lets it take the person's focus, and its refusal is the answer, never
557 * shown somewhere else instead. Only when no door answers on that socket
558 * (it quit) does it go on.
559 * 2. In Herdr: Claude's own Detail, the pane this session split below the
560 * Claude pane, reused while it lives, else split anew. It never navigates
561 * the person's Trees or Details, and never takes focus.
562 * 3. Otherwise a NotOpenedHere saying why, with the note's `((id))` to copy.
563 *
564 * `workspace` is the session's Outliner workspace: needed to resolve a page or
565 * Work ID and for Herdr; a block id opens in a door without one. Opens run one
566 * at a time, so concurrent clicks and tool calls split one pane. Resolves to
567 * the title and where it went; throws with the reason otherwise.
568 */
569function openNote($: EngineInterface, workspace: Workspace | null, uri: string, actor: string): Promise<Shown> {
570 const shown = showQueue.then(() => openNow($, workspace, uri, actor))
571 showQueue = shown.catch(() => {})
572 return shown
573}
574
575async function openNow($: EngineInterface, workspace: Workspace | null, uri: string, actor: string): Promise<Shown> {
576 const [control, tile, tileId, paneId, herdrWorkspace] = await Promise.all([
577 $.env.get('EP0CH_CONTROL'),
578 $.env.get('EP0CH_TILE'),
579 $.env.get('EP0CH_TILE_ID'),
580 $.env.get('HERDR_PANE_ID'),
581 $.env.get('HERDR_WORKSPACE_ID'),
582 ])
583 // Found on first use: outside a door and Herdr, a block id needs no installed Outliner to be named.
584 let installed: Promise<string | null> | undefined
585 const outliner = async (args: string[]) => {
586 const root = await (installed ??= outlinerRootOf($))
587 if (!root) throw Error('the Outliner plugin is disabled')
588 return $.process.run(
589 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, ...args],
590 { cwd: workspace?.root ?? await $.session.cwd(), ...(workspace ? { env: envFor(workspace) } : {}), timeoutMs: 30_000 },
591 )
592 }
593 let target: { id: string; title?: string } | undefined
594 const resolve = async () => {
595 if (target) return target
596 if (!workspace) {
597 const id = outlinerBlockIdOf(uri)
598 if (!id) throw Error("this session's folder is not bound to an Outliner outline to resolve it in; give a block id")
599 return (target = { id })
600 }
601 const resolved = await outliner(['resolve', uri])
602 if (resolved.exitCode !== 0) throw Error(failureReasonOf(resolved.stderr) || 'the target did not resolve')
603 return (target = JSON.parse(resolved.stdout) as { id: string; title?: string })
604 }
605
606 let why: string
607 if (control) {
608 const { id, title } = await resolve()
609 const from = doorTileOf({ ...(tile ? { EP0CH_TILE: tile } : {}), ...(tileId ? { EP0CH_TILE_ID: tileId } : {}) })
610 // From this tile, its opens' link; a door that doesn't know the tile (or is older than `from=`) is asked again
611 // naming none, and lands it where its opens land. Never a reader by name: an agent naming the reader the person
612 // reads is refused (ep0ch-door round 3), and where opens land never takes their keys.
613 const opened = await outliner(['door-open', id, '--control', control, '--actor', actor, ...(from ? ['--from', from] : [])])
614 if (opened.exitCode === 0) {
615 let reader: unknown
616 try { reader = JSON.parse(opened.stdout)?.reader } catch { reader = undefined }
617 return { title: title ?? '', place: 'door', ...(typeof reader === 'string' && reader ? { reader } : {}) }
618 }
619 // The door's refusal (it is on its menu, the reader holds an edit) is the answer.
620 if (opened.exitCode !== 3) throw Error(failureReasonOf(opened.stderr) || 'the door did not open it')
621 why = `no door answers on ${control} (it quit?)`
622 } else {
623 why = 'this session is not in an ep0ch-door tile'
624 }
625 if (paneId && herdrWorkspace && workspace) {
626 return { title: await showInHerdrPane($, outliner, workspace, uri), place: 'pane' }
627 }
628 why += paneId && herdrWorkspace ? ", and its folder is not bound to an Outliner outline for a Herdr pane" : ', nor in Herdr'
629 const label = outlinerLabelOf(uri)
630 let ref: string
631 try {
632 ref = `((${(await resolve()).id}))`
633 } catch {
634 // Unresolved here: the reference as the outline writes it.
635 ref = outlinerReferenceOf(uri)
636 }
637 throw new NotOpenedHere(`Can't open ${label} here: ${why}. Copy ${ref} to open it in the Outliner.`)
638}
639
640async function showInHerdrPane(
641 $: EngineInterface,
642 outliner: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>,
643 workspace: Workspace,
644 uri: string,
645): Promise<string> {
646 const [sessionId, paneId, herdrWorkspace] = await Promise.all([
647 $.session.id(),
648 $.env.get('HERDR_PANE_ID'),
649 $.env.get('HERDR_WORKSPACE_ID'),
650 ])
651 if (!paneId || !herdrWorkspace) throw Error('this session is not running inside Herdr')
652 const findScratchPane = async () => {
653 const [listedClients, listedPanes] = await Promise.all([
654 outliner(['clients']),
655 $.process.run(['herdr', 'pane', 'list', '--workspace', herdrWorkspace], { timeoutMs: 5000 }),
656 ])
657 if (listedClients.exitCode !== 0) throw Error(failureReasonOf(listedClients.stderr) || 'the Outliner service did not answer')
658 if (listedPanes.exitCode !== 0) throw Error('Herdr could not list panes')
659 const registered: unknown = JSON.parse(listedClients.stdout)
660 const clients: OutlinerClient[] = (Array.isArray(registered) ? registered : (registered as { clients?: unknown[] })?.clients ?? [])
661 .flatMap((client: any) => typeof client?.runtime?.paneId === 'string'
662 ? [{ clientId: String(client.clientId), role: String(client.role), paneId: client.runtime.paneId, contextId: client.contextId }]
663 : [])
664 const panes: unknown = JSON.parse(listedPanes.stdout)?.result?.panes
665 const paneTabs = new Map((Array.isArray(panes) ? panes : []).map((pane: any) => [String(pane.pane_id), String(pane.tab_id)]))
666 return { pane: scratchPaneOf(clients, paneTabs, sessionId), paneTabs }
667 }
668
669 let { pane, paneTabs } = await findScratchPane()
670 // A pane split moments ago may not have registered yet: wait for it rather
671 // than splitting a second one.
672 for (let wait = 0; !pane && splitScratchPane && paneTabs.has(splitScratchPane) && wait < 10; wait++) {
673 await $.clock.sleep(300)
674 ;({ pane, paneTabs } = await findScratchPane())
675 }
676 if (pane) {
677 const shown = await outliner(['link', uri, '--detail-client', pane.clientId, '--no-focus'])
678 if (shown.exitCode === 0) return String(JSON.parse(shown.stdout)?.title ?? '')
679 const reason = failureReasonOf(shown.stderr)
680 throw Error(isProtectedDestination(reason)
681 ? "Claude's Outliner pane is mid-edit; finish or cancel it there"
682 : reason || 'navigation failed')
683 }
684 const resolved = await outliner(['resolve', uri])
685 if (resolved.exitCode !== 0) throw Error(failureReasonOf(resolved.stderr) || 'the target did not resolve')
686 const { id, title, fragmentId } = JSON.parse(resolved.stdout) as { id: string; title?: string; fragmentId?: string }
687 const opened = await $.process.run(
688 detailSplitArgv({ paneId, workspace: workspace.root, ...(workspace.outline ? { outline: workspace.outline } : {}), sessionId, blockId: id, ...(fragmentId ? { fragmentId } : {}) }),
689 { timeoutMs: 15_000 },
690 )
691 if (opened.exitCode !== 0) throw Error(failureReasonOf(opened.stderr) || 'Herdr could not open a Detail')
692 try {
693 splitScratchPane = JSON.parse(opened.stdout)?.result?.plugin_pane?.pane?.pane_id
694 } catch {
695 splitScratchPane = undefined
696 }
697 return title ?? ''
698}
699
700/** A click on a reference: opened by `openNote`, or a toast saying why not. */
701async function openReference($: EngineInterface, workspace: Workspace, href: string): Promise<void> {
702 const uri = outlinerUriOf(href)
703 if (!uri) return
704 try {
705 await openNote($, workspace, uri, await actorFor($, {}))
706 } catch (error) {
707 if (error instanceof NotOpenedHere) return $.ui.toast(error.message, { timeoutMs: 12_000 })
708 const reason = error instanceof Error ? error.message : String(error)
709 $.ui.toast(`Could not open ${outlinerLabelOf(uri)} in the Outliner: ${reason}`, { timeoutMs: 6000 })
710 }
711}
712hooks/mention-message.ts 195 lines1import type { TurnCompleteInput } from 'claude-code'
2
3/**
4 * The host-neutral contract `mentions.ingest` takes; `src/mentions-types.ts`
5 * owns it. Restated here because a hooks module cannot import application code.
6 */
7export type MentionMessage = {
8 workspaceRoot: string
9 agent: 'claude'
10 sessionId: string
11 messageId: string
12 text: string
13}
14
15/**
16 * A folder list as configured (the `workspaces` option or
17 * `PI_OUTLINER_MENTIONS_WORKSPACES`): one absolute folder or several,
18 * separated by ':' or ','. Trailing slashes are dropped so `/a/b/` matches a
19 * cwd of `/a/b`. An entry names a folder only; any other entry is an error,
20 * never skipped. In folder mode the list opts folders out; in allowlist mode
21 * it is the only folders that feed an outline (`mentionsModeOf`).
22 */
23export function workspacesOf(value: unknown, home?: string): string[] {
24 const parts = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[:,]/) : []
25 return parts.flatMap(part => {
26 if (typeof part !== 'string') throw Error(`Outliner workspaces entry ${JSON.stringify(part)} is not a folder path`)
27 const typed = part.trim()
28 // `~` and `~/…` are written by people; expand them when the home folder is known.
29 const entry = home && (typed === '~' || typed.startsWith('~/')) ? home.replace(/\/+$/, '') + typed.slice(1) : typed
30 if (entry === '') return []
31 if (!entry.startsWith('/') || entry.includes('=')) {
32 throw Error(`Outliner workspaces entry "${entry}" is not an absolute folder; list folders only, and bind a folder to an outline in its client.json (the choose-outline action)`)
33 }
34 return [entry.replace(/(?<=.)\/+$/, '')]
35 })
36}
37
38/**
39 * The option when it names at least one folder, otherwise the environment
40 * variable. Claude Code passes an unset string option as '', so an empty
41 * option cannot be told from an unset one and never overrides the environment.
42 */
43export function effectiveWorkspaces(option: unknown, environment: string | undefined, home?: string): string[] {
44 const configured = workspacesOf(option, home)
45 return configured.length > 0 ? configured : workspacesOf(environment, home)
46}
47
48/**
49 * How a session's folder finds its outline:
50 *
51 * - `folder`: the nearest folder bound to an outline, the way every Outliner
52 * client resolves it (`bound-folder`: a `client.json`, or an outline root the
53 * host serves). The folder list opts folders out. An unbound folder feeds
54 * nothing; it never falls back to a default outline.
55 * - `allowlist`: only the listed folders, as before folder mode (the strict mode).
56 */
57export type MentionsMode = 'folder' | 'allowlist'
58
59/**
60 * The `mode` option when set, otherwise `PI_OUTLINER_MENTIONS_MODE`; neither:
61 * `folder`, unless folders are listed. A list with no mode is a setup from
62 * before folder mode, where the list was an allowlist, so it keeps meaning
63 * exactly that: only the folders someone chose feed. Any other value is an error.
64 */
65export function mentionsModeOf(option: unknown, environment: string | undefined, listed: readonly string[] = []): MentionsMode {
66 const typed = typeof option === 'string' && option.trim() !== '' ? option.trim() : (environment ?? '').trim()
67 if (typed === '' && listed.length > 0) return 'allowlist'
68 if (typed === '' || typed === 'folder' || typed === 'allowlist') return typed === 'allowlist' ? 'allowlist' : 'folder'
69 throw Error(`Outliner mentions mode "${typed}" is neither folder nor allowlist`)
70}
71
72/**
73 * The folder a session's Outliner CLI runs start from. A bound folder (folder
74 * mode) is `pinned` to the outline `bound-folder` found, `outline` when it
75 * names a host outline: its CLI runs go there whatever Claude's environment
76 * says. A strict-mode folder is the CLI's to resolve, as before folder mode.
77 */
78export type Workspace = { root: string; outline?: string; pinned?: true }
79
80/**
81 * The environment an Outliner CLI run gets for a workspace: its folder and,
82 * when pinned, the outline that bound it, blanking an inherited
83 * OUTLINER_OUTLINE or OUTLINER_CONFIG_PATH so the write lands where the folder
84 * was found bound (`resolveClientPaths` reads an empty one as unset).
85 */
86export function workspaceEnvOf(workspace: Workspace): Record<string, string> {
87 return {
88 OUTLINER_WORKSPACE_ROOT: workspace.root,
89 ...(workspace.pinned ? { OUTLINER_OUTLINE: workspace.outline ?? '', OUTLINER_CONFIG_PATH: '' } : {}),
90 }
91}
92
93/**
94 * The bound folder in `bound-folder`'s answer for `cwd`, or null: unbound, or
95 * an answer that is not one (a folder that does not contain `cwd` is not).
96 */
97export function boundWorkspaceOf(stdout: string, cwd: string): Workspace | null {
98 let answer: unknown
99 try { answer = JSON.parse(stdout) } catch { return null }
100 if (!answer || typeof answer !== 'object') return null
101 const { bound, source, folder, outline } = answer as Record<string, unknown>
102 if (bound !== true || typeof folder !== 'string') return null
103 const root = absolutePath(folder)
104 if (root === null || workspaceForCwd(cwd, [root]) !== root) return null
105 const named = typeof outline === 'string' && outline !== '' ? outline : undefined
106 if (source === 'host-root') return named ? { root, outline: named, pinned: true } : null
107 // A client.json naming a host outline is pinned to it; a local or remote choice is the folder's config to resolve.
108 if (source !== 'client') return null
109 const { mode } = answer as Record<string, unknown>
110 return mode === 'host' ? (named ? { root, outline: named, pinned: true } : null) : { root, pinned: true }
111}
112
113/**
114 * The workspace a session in `cwd` feeds, or null. In folder mode a listed
115 * folder (or one inside it) is opted out, and otherwise the bound folder wins;
116 * in allowlist mode the nearest listed folder, bound or not.
117 */
118export function sessionWorkspaceOf(
119 cwd: string,
120 mode: MentionsMode,
121 listed: readonly string[],
122 bound: Workspace | null,
123): Workspace | null {
124 const nearestListed = workspaceForCwd(cwd, listed)
125 if (mode === 'allowlist') return nearestListed === null ? null : { root: nearestListed }
126 return nearestListed === null ? bound : null
127}
128
129/**
130 * The reason in an Outliner CLI failure's stderr: Bun prints the thrown
131 * error's source excerpt, then `error: <message>`, its stack and a `Bun v…`
132 * trailer, so the last line never says why. Falls back to the last line that
133 * is not stack or trailer; '' when nothing is left.
134 */
135export function failureReasonOf(stderr: string): string {
136 const lines = stderr.split('\n').map(line => line.trim()).filter(Boolean)
137 const error = lines.findLast(line => line.startsWith('error: '))
138 if (error) return error.slice('error: '.length)
139 return lines.findLast(line => !/^at\s|^Bun v\d/.test(line)) ?? ''
140}
141
142/**
143 * Which completed turns reach Recent Mentions: the main loop's own answers,
144 * never a subagent's run, an interruption, a refusal or an error.
145 */
146export function isIngestible(e: TurnCompleteInput): boolean {
147 return e.agentId === undefined && e.reason === 'answer' && e.answer.trim() !== ''
148}
149
150// Lexical POSIX paths: no host filesystem access or Git-root assumptions.
151function absolutePath(path: string): string | null {
152 if (!path.startsWith('/')) return null
153 const parts: string[] = []
154 for (const part of path.split('/')) {
155 if (part === '..') parts.pop()
156 else if (part !== '' && part !== '.') parts.push(part)
157 }
158 return '/' + parts.join('/')
159}
160
161/** Nested configured roots own their descendants; siblings never match a prefix. */
162export function workspaceForCwd(cwd: string, workspaces: readonly string[]): string | null {
163 const path = absolutePath(cwd)
164 if (path === null) return null
165 let nearest: string | null = null
166 for (const workspace of workspaces) {
167 const root = absolutePath(workspace)
168 if (root === null) continue
169 if ((path === root || path.startsWith(root === '/' ? '/' : root + '/')) &&
170 (nearest === null || root.length > nearest.length)) nearest = root
171 }
172 return nearest
173}
174
175/**
176 * The message for one completed turn in a session feeding `workspace`, or
177 * null when the turn is not ingested or the session feeds none. The turn id
178 * is the message identity, so a repeated delivery of the same turn
179 * deduplicates in the service.
180 */
181export function mentionMessageOf(
182 e: TurnCompleteInput,
183 session: { id: string },
184 workspace: Workspace | null,
185): MentionMessage | null {
186 if (!isIngestible(e) || workspace === null) return null
187 return {
188 workspaceRoot: workspace.root,
189 agent: 'claude',
190 sessionId: session.id,
191 messageId: e.turnId,
192 text: e.answer,
193 }
194}
195hooks/references.ts 169 lines1/**
2 * Outliner references in a Claude reply, made clickable. `Markdown` only draws
3 * `https:`, `http:` and `file:` links as links, so each reference becomes an
4 * https stand-in on the reserved `.invalid` domain, which never resolves: a
5 * click the plugin does not take goes nowhere. The plugin maps it back to the
6 * `pi-outliner://` URI the Outliner's own link navigation takes.
7 */
8const STAND_IN = 'https://pi-outliner.invalid/'
9
10const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
11
12/**
13 * Code, existing links and URLs: never rewritten. An unclosed fence runs to the
14 * end, as the renderer draws it.
15 */
16const PROTECTED = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>|\b[a-z][a-z0-9+.-]*:\/\/[^\s<>()]+/gi
17
18export type Linkified = {
19 /** The markdown to draw, references as stand-in links. */
20 text: string
21 /** Every stand-in href in `text`, once each: what `pressableLinks` takes. */
22 hrefs: string[]
23}
24
25/**
26 * `[[page]]`, `[[target|label]]`, `((uuid))`, `((uuid|label))` and bare Work
27 * IDs with one of `prefixes`, rewritten as links outside protected ranges.
28 */
29export function linkifyReferences(text: string, prefixes: readonly string[]): Linkified {
30 const hrefs = new Set<string>()
31 const workIds = prefixes
32 .filter(prefix => /^[A-Z][A-Z0-9]*$/.test(prefix))
33 .map(prefix => `${prefix}-\\d+`)
34 const reference = new RegExp(
35 `\\[\\[([^\\[\\]|\\n]+)(?:\\|([^\\[\\]\\n]+))?\\]\\]` +
36 `|\\(\\((${UUID})(?:\\|([^()\\n]+))?\\)\\)` +
37 (workIds.length ? `|(?<![\\w/#-])(${workIds.join('|')})(?![\\w-])` : ''),
38 'g',
39 )
40 const rewrite = (plain: string) =>
41 plain.replace(reference, (whole, page?: string, pageLabel?: string, block?: string, blockLabel?: string, workId?: string) => {
42 const [kind, value, label] = page
43 ? ['page', page.trim(), pageLabel?.trim() || page.trim()]
44 : block
45 ? ['block', block.toLowerCase(), blockLabel?.trim() || whole]
46 : ['work', workId!.toUpperCase(), workId!]
47 const href = `${STAND_IN}${kind}/${encodeURIComponent(value)}`
48 hrefs.add(href)
49 return `[${label.replace(/[[\]\\]/g, '\\$&')}](${href})`
50 })
51
52 let out = ''
53 let last = 0
54 for (const match of text.matchAll(PROTECTED)) {
55 out += rewrite(text.slice(last, match.index)) + match[0]
56 last = match.index + match[0].length
57 }
58 out += rewrite(text.slice(last))
59 return { text: out, hrefs: [...hrefs] }
60}
61
62/**
63 * The `pi-outliner://` URI a stand-in href names, or null for any other link.
64 */
65export function outlinerUriOf(href: string): string | null {
66 if (!href.startsWith(STAND_IN)) return null
67 const [kind, value, ...rest] = href.slice(STAND_IN.length).split('/')
68 if (rest.length || !value || !['block', 'page', 'work'].includes(kind!)) return null
69 return `pi-outliner://${kind}/${value}`
70}
71
72export type OutlinerClient = {
73 clientId: string
74 role: string
75 paneId: string
76 contextId?: string
77}
78
79/**
80 * Claude's own Outliner Detail: the live Detail whose browsing context is this
81 * Claude session's id, which only a pane this session split carries. Null when
82 * there is none, or its pane is gone (`paneTabs` holds the workspace's live
83 * panes).
84 */
85export function scratchPaneOf(
86 clients: readonly OutlinerClient[],
87 paneTabs: ReadonlyMap<string, string>,
88 sessionId: string,
89): OutlinerClient | null {
90 return clients.find(client =>
91 client.role === 'detail' && client.contextId === sessionId && paneTabs.has(client.paneId)) ?? null
92}
93
94/**
95 * The `pi-outliner://` URI for a reference as the model writes it: a Work ID,
96 * `[[page]]`, `((uuid))`, a bare block UUID, a `pi-outliner://` URI, or else a
97 * page address as given. Null for an empty reference.
98 */
99export function outlinerUriFor(reference: string): string | null {
100 const text = reference.trim()
101 if (!text) return null
102 if (text.startsWith('pi-outliner://')) return text
103 if (new RegExp(`^${UUID}$`).test(text)) return `pi-outliner://block/${text.toLowerCase()}`
104 if (/^[A-Z][A-Z0-9]*-\d+$/.test(text)) return `pi-outliner://work/${text}`
105 const { hrefs } = linkifyReferences(text, [])
106 if (hrefs.length === 1 && /^(\[\[[^\]]+\]\]|\(\([^)]+\)\))$/.test(text)) return outlinerUriOf(hrefs[0]!)
107 return `pi-outliner://page/${encodeURIComponent(text)}`
108}
109
110/** What a `pi-outliner://` URI names, as the person reads it: the Work ID, page name or block id. */
111export function outlinerLabelOf(uri: string): string {
112 return decodeURIComponent(uri.slice(uri.indexOf('/', 'pi-outliner://'.length) + 1))
113}
114
115/** The block id a `pi-outliner://block/…` URI names, lowercased; null for a page, Work ID or anything else. */
116export function outlinerBlockIdOf(uri: string): string | null {
117 return new RegExp(`^pi-outliner://block/(${UUID})$`).exec(uri)?.[1]?.toLowerCase() ?? null
118}
119
120/**
121 * The reference the outline writes for a URI, the reverse of `outlinerUriFor`:
122 * `((id))` for a block, `[[page]]` for a page, the Work ID itself.
123 */
124export function outlinerReferenceOf(uri: string): string {
125 const block = outlinerBlockIdOf(uri)
126 if (block) return `((${block}))`
127 return uri.startsWith('pi-outliner://page/') ? `[[${outlinerLabelOf(uri)}]]` : outlinerLabelOf(uri)
128}
129
130/**
131 * Whether a navigation failed only because its destination is mid-edit (or
132 * holding a source selection): the Outliner protects it, and a new Detail is
133 * the way round, never overriding the protection.
134 */
135export function isProtectedDestination(reason: string): boolean {
136 return reason.startsWith('Destination is protected')
137}
138
139/**
140 * The Herdr command that splits Claude's own Detail below `paneId` (the Claude
141 * pane), unfocused, already showing the block. Its browsing context is the
142 * session id, which is how `scratchPaneOf` finds it again.
143 */
144export function detailSplitArgv(split: {
145 paneId: string
146 workspace: string
147 /** The outline, when the folder's own client.json doesn't name it (a root only the host records). */
148 outline?: string
149 sessionId: string
150 blockId: string
151 fragmentId?: string
152}): string[] {
153 const target = { kind: 'block', blockId: split.blockId, ...(split.fragmentId ? { fragmentId: split.fragmentId } : {}) }
154 return [
155 'herdr', 'plugin', 'pane', 'open',
156 '--plugin', 'float.pi-outliner',
157 '--entrypoint', 'detail',
158 '--placement', 'split',
159 '--target-pane', split.paneId,
160 '--direction', 'down',
161 '--no-focus',
162 '--cwd', split.workspace,
163 '--env', `OUTLINER_WORKSPACE_ROOT=${split.workspace}`,
164 ...(split.outline ? ['--env', `OUTLINER_OUTLINE=${split.outline}`] : []),
165 '--env', `OUTLINER_BROWSING_CONTEXT_ID=${split.sessionId}`,
166 '--env', `OUTLINER_DETAIL_TARGET=${encodeURIComponent(JSON.stringify(target))}`,
167 ]
168}
169hooks/outline-tools.ts 380 lines1/**
2 * Outline and door tools for Claude (PIE-504), so an agent never hand-rolls a
3 * script to read, edit or comment on a note, or to act in the door it runs in.
4 *
5 * - `outline_*`: each is one call to the installed CLI's `agent <operation>`
6 * (src/agent-tools.ts) with the tool's input as JSON on stdin. The service and
7 * that module own the rules; this file only names the operation and checks
8 * the input's shape.
9 * - `door_*`: each is one `ep0ch` command on the session's EP0CH_CONTROL, the
10 * door this Claude runs in. They exist only when EP0CH_CONTROL is set. The
11 * door enforces its rules (an agent never takes the person's focus); its
12 * refusals come back as the tool's denial.
13 *
14 * Every write is `author: agent`, attributed to the actor `actorOf` picks.
15 */
16
17type Json = Record<string, unknown>
18
19export interface OutlineToolDefinition {
20 name: string
21 description: string
22 inputSchema: Json
23 /** The CLI `agent` operation and its JSON input, or the reason the input is unusable. */
24 command(input: Record<string, unknown>): { operation: string; input: Json } | string
25}
26
27const ACTOR = {
28 type: 'string',
29 description: 'Who this is attributed to. Leave it out: it defaults to OUTLINER_ACTOR, EP0CH_AGENT or claude-code.',
30}
31const REF = {
32 type: 'string',
33 description: 'The block: its id, ((id)), [[page]] or a Work ID (PIE-123). Never a title: find it with outline_find first.',
34}
35const EXPECTED = {
36 type: 'integer',
37 minimum: 1,
38 description: 'The revision outline_read returned. A block that changed since is refused: read it again.',
39}
40
41/** A write's schema carries `actor`, the attribution override; a read's has none (outline_changes' `actor` is a filter). */
42function schema(properties: Json, required: string[], writes = true): Json {
43 return { type: 'object', properties: writes ? { ...properties, actor: ACTOR } : properties, required, additionalProperties: false }
44}
45
46/** The input without the actor, which travels as a CLI flag, and without keys left undefined. */
47function inputOf(input: Record<string, unknown>, keys: readonly string[]): Json {
48 const out: Json = {}
49 for (const key of keys) if (input[key] !== undefined) out[key] = input[key]
50 return out
51}
52
53const nonEmpty = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
54
55export const OUTLINE_TOOLS: readonly OutlineToolDefinition[] = [
56 {
57 name: 'outline_read',
58 description:
59 "Read a note: its full text (never just the title), properties, revision, who last wrote it and when, and its " +
60 'children to `depth` levels (default 1), at most `limit` descendants (default 50), each with full text. ' +
61 '`complete` says whether anything was left out; a child with `more` has unread children. Read before you ' +
62 'edit: outline_edit and outline_patch need the revision this returns.',
63 inputSchema: schema({
64 ref: REF,
65 depth: { type: 'integer', minimum: 0, maximum: 6 },
66 limit: { type: 'integer', minimum: 0, maximum: 500 },
67 }, ['ref'], false),
68 command(input) {
69 if (!nonEmpty(input.ref)) return 'Give the ref: a block id, ((id)), [[page]] or Work ID.'
70 return { operation: 'read', input: inputOf(input, ['ref', 'depth', 'limit']) }
71 },
72 },
73 {
74 name: 'outline_find',
75 description:
76 'Find blocks by text, a property (`key=value`, or `key`), a property key (hasKey), a query in the saved-view ' +
77 'grammar (`type=roadmap-item AND work-stage=doing`), or a saved view. text, property, hasKey, query and under ' +
78 'combine; view stands alone. Returns rows (id, title, revision); read one with outline_read.',
79 inputSchema: schema({
80 text: { type: 'string' },
81 property: { type: 'string', description: 'key=value, or key for any value' },
82 hasKey: { type: 'string' },
83 query: { type: 'string' },
84 view: { type: 'string', description: 'A saved view (virtual branch): its id or ((id))' },
85 under: { type: 'string', description: 'Only blocks under this one (a ref)' },
86 limit: { type: 'integer', minimum: 1, maximum: 200 },
87 }, [], false),
88 command(input) {
89 if (!['text', 'property', 'hasKey', 'query', 'view'].some(key => nonEmpty(input[key]))) {
90 return 'Give text, property, hasKey, query or view to find blocks by.'
91 }
92 return { operation: 'find', input: inputOf(input, ['text', 'property', 'hasKey', 'query', 'view', 'under', 'limit']) }
93 },
94 },
95 {
96 name: 'outline_resolve',
97 description:
98 'Resolve a reference ([[page]], ((id)), a Work ID or an id) to its block id, title and revision, without ' +
99 'reading the whole note. An unknown page is an error, never a new page.',
100 inputSchema: schema({ ref: REF }, ['ref'], false),
101 command(input) {
102 if (!nonEmpty(input.ref)) return 'Give the ref to resolve.'
103 return { operation: 'resolve', input: inputOf(input, ['ref']) }
104 },
105 },
106 {
107 name: 'outline_edit',
108 description:
109 'Rewrite a note, checked against the revision you read with outline_read: its whole `text`, one ' +
110 '`replaceSection` (the text under a heading, subheadings included, as Detail folds it), or an `append` at the end. Give exactly one. ' +
111 'Refused before anything is written: an empty or whitespace-only result, a stale revision (read again, then ' +
112 'edit), and dropping a [page::…] property or an ^anchor other notes link to (pass allowStructural: true only ' +
113 'when removing them is the point). Returns the new revision and a short diff. Use it for rewriting your own ' +
114 'pages, such as a status page; for small edits to a note the person may be typing in, use outline_patch.',
115 inputSchema: schema({
116 ref: REF,
117 expectedRevision: EXPECTED,
118 text: { type: 'string', description: 'The whole new text: title line, properties and body' },
119 replaceSection: {
120 type: 'object',
121 properties: {
122 heading: { type: 'string', description: 'Heading text, optionally with its ## level' },
123 body: { type: 'string' },
124 },
125 required: ['heading', 'body'],
126 additionalProperties: false,
127 },
128 append: { type: 'string', description: 'Added as a new paragraph at the end (start it with a newline to join the last line)' },
129 allowStructural: { type: 'boolean' },
130 }, ['ref', 'expectedRevision']),
131 command(input) {
132 if (!nonEmpty(input.ref)) return 'Give the ref of the note to edit.'
133 if (typeof input.expectedRevision !== 'number') return 'Give expectedRevision: read the note with outline_read first.'
134 const modes = ['text', 'replaceSection', 'append'].filter(key => input[key] !== undefined && input[key] !== null)
135 if (modes.length !== 1) return 'Give exactly one of text, replaceSection or append.'
136 if (modes[0] === 'text' && !nonEmpty(input.text)) return 'The new text is empty; an edit never blanks a note.'
137 if (modes[0] === 'append' && !nonEmpty(input.append)) return 'The text to append is empty.'
138 return { operation: 'edit', input: inputOf(input, ['ref', 'expectedRevision', 'text', 'replaceSection', 'append', 'allowStructural']) }
139 },
140 },
141 {
142 name: 'outline_create',
143 description:
144 'Create a block under `parent` (a ref, or root), at `position` among its siblings (0 is first; default last). ' +
145 'Returns its id, ((ref)) and revision.',
146 inputSchema: schema({
147 parent: { type: 'string', description: 'The parent: a ref, or root' },
148 text: { type: 'string' },
149 position: { type: 'integer', minimum: 0 },
150 }, ['parent', 'text']),
151 command(input) {
152 if (!nonEmpty(input.parent) || !nonEmpty(input.text)) return 'Give the parent and non-empty text.'
153 return { operation: 'create', input: inputOf(input, ['parent', 'text', 'position']) }
154 },
155 },
156 {
157 name: 'outline_comment',
158 description:
159 'Start a comment thread on a note, as you: on an exact `quote` of its source text (add start, prefix or ' +
160 'suffix when the quote repeats), or on the `whole` note. Returns the thread id for outline_reply and ' +
161 'outline_resolve_thread. A requestId makes a retry return the same thread.',
162 inputSchema: schema({
163 ref: REF,
164 body: { type: 'string' },
165 quote: { type: 'string', description: 'Exact source text the comment is about' },
166 whole: { type: 'boolean', description: 'true: about the whole note, instead of a quote' },
167 start: { type: 'integer', minimum: 0, description: 'The quote’s UTF-16 offset, when it repeats' },
168 prefix: { type: 'string' },
169 suffix: { type: 'string' },
170 requestId: { type: 'string' },
171 }, ['ref', 'body']),
172 command(input) {
173 if (!nonEmpty(input.ref) || !nonEmpty(input.body)) return 'Give the note and a non-empty comment.'
174 if ((input.whole === true) === (typeof input.quote === 'string')) return 'Give either quote (exact source text) or whole: true.'
175 return { operation: 'comment', input: inputOf(input, ['ref', 'body', 'quote', 'whole', 'start', 'prefix', 'suffix', 'requestId']) }
176 },
177 },
178 {
179 name: 'outline_reply',
180 description: 'Reply in a comment thread, as you. `thread` is the id outline_comment returned (or a thread you read).',
181 inputSchema: schema({ thread: { type: 'string' }, body: { type: 'string' }, requestId: { type: 'string' } }, ['thread', 'body']),
182 command(input) {
183 if (!nonEmpty(input.thread) || !nonEmpty(input.body)) return 'Give the thread and a non-empty reply.'
184 return { operation: 'reply', input: inputOf(input, ['thread', 'body', 'requestId']) }
185 },
186 },
187 {
188 name: 'outline_resolve_thread',
189 description: 'Resolve a comment thread (resolved: true), or reopen it (false), as you.',
190 inputSchema: schema({ thread: { type: 'string' }, resolved: { type: 'boolean' } }, ['thread', 'resolved']),
191 command(input) {
192 if (!nonEmpty(input.thread) || typeof input.resolved !== 'boolean') return 'Give the thread and resolved: true or false.'
193 return { operation: 'resolve-thread', input: inputOf(input, ['thread', 'resolved']) }
194 },
195 },
196 {
197 name: 'outline_changes',
198 description:
199 'What changed since a point: an ISO time, or the `cursor` an earlier call returned. Each block once, at its ' +
200 'latest change, newest first, with who made it (author, actorId, sessionId). `author` (user, agent, system) and ' +
201 '`actor` (an agent id, such as claude-code) narrow it. `complete: false`: more changed than `limit`; call again ' +
202 'with the same since and the returned `before` for older ones. Once complete, pass `cursor` as since next time.',
203 inputSchema: schema({
204 since: { type: ['string', 'integer'], description: 'An ISO time, or a cursor from an earlier call' },
205 author: { type: 'string', enum: ['user', 'agent', 'system'] },
206 actor: { type: 'string', description: 'Only this agent’s changes, by actor id (claude-code, garden-agent…)' },
207 limit: { type: 'integer', minimum: 1, maximum: 100 },
208 before: { type: 'integer', minimum: 1, description: 'The `before` an incomplete answer returned: its older changes' },
209 }, ['since'], false),
210 command(input) {
211 if (!nonEmpty(input.since) && typeof input.since !== 'number') return 'Give since: an ISO time or a cursor.'
212 return { operation: 'changes', input: inputOf(input, ['since', 'author', 'actor', 'limit', 'before']) }
213 },
214 },
215 {
216 name: 'outline_patch',
217 description:
218 'Small edits to a note the person may be typing in, without waiting for their save (draft.patch); for ' +
219 'rewriting your own pages, such as a status page, use outline_edit. Each patch names the exact `observed` text ' +
220 '(never empty) and its `replacement`, against the `revision` outline_read returned; all apply as one edit or ' +
221 'none. A live draft gets it in place; otherwise it is an ordinary edit of the saved note. policy `edit` (the ' +
222 'default) has outline_edit\'s guard (allowStructural likewise); `prose` keeps every link, anchor and property. ' +
223 'If the text changed under it, or prose refuses, it becomes one proposal for the person (outcome: proposed).',
224 inputSchema: schema({
225 ref: REF,
226 revision: EXPECTED,
227 patches: {
228 type: 'array',
229 minItems: 1,
230 items: {
231 type: 'object',
232 properties: {
233 observed: { type: 'string' },
234 replacement: { type: 'string' },
235 range: {
236 type: 'object',
237 properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 } },
238 required: ['start', 'end'],
239 description: 'Where you saw it (UTF-16 offsets): a hint',
240 },
241 },
242 required: ['observed', 'replacement'],
243 },
244 },
245 mark: { type: 'string', description: 'The @request line the patch answers: spans must end above it' },
246 policy: { type: 'string', enum: ['edit', 'prose'], default: 'edit' },
247 allowStructural: { type: 'boolean' },
248 }, ['ref', 'revision', 'patches']),
249 command(input) {
250 if (!nonEmpty(input.ref) || typeof input.revision !== 'number') return 'Give the ref and the revision you read.'
251 if (!Array.isArray(input.patches) || input.patches.length === 0) return 'Give at least one patch: {observed, replacement}.'
252 if (input.policy !== undefined && input.policy !== 'edit' && input.policy !== 'prose') return 'policy is edit (the default) or prose.'
253 return { operation: 'patch', input: { policy: 'edit', ...inputOf(input, ['ref', 'revision', 'patches', 'mark', 'policy', 'allowStructural']) } }
254 },
255 },
256]
257
258// ─── Door tools ────────────────────────────────────────────────────────────
259
260export interface DoorToolDefinition {
261 name: string
262 description: string
263 inputSchema: Json
264}
265
266export const DOOR_TOOLS: readonly DoorToolDefinition[] = [
267 {
268 name: 'door_where',
269 description:
270 'Where this session runs (ep0ch where): the layers (ssh › herdr › door tile), which are live, and whether the ' +
271 'person is typing in your tile. Only reads.',
272 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
273 },
274 {
275 name: 'door_peek',
276 description: 'What the door shows the person now (ep0ch peek): the screen as structured state and as text. Only reads.',
277 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
278 },
279 {
280 name: 'door_act',
281 description:
282 "Run one of the door's actions as you (ep0ch act, attributed with --as and said on the person's screen). " +
283 '`ep0ch actions` names them; door_peek shows the state they act on. The door never lets an agent take the ' +
284 "person's focus, keys or selection: such an action is refused with the reason, which comes back as this " +
285 "tool's error. To point the person at something, use block.mark. To open a note, door_open.",
286 inputSchema: {
287 type: 'object',
288 properties: {
289 action: { type: 'string', description: 'The action name, e.g. layout.get, view.scrollTo, block.mark' },
290 args: {
291 type: 'object',
292 additionalProperties: { type: ['string', 'number', 'boolean'] },
293 description: 'The action’s arguments, key: value',
294 },
295 tile: { type: 'string', description: 'The tile to act in, when the action needs one' },
296 actor: ACTOR,
297 },
298 required: ['action'],
299 additionalProperties: false,
300 },
301 },
302 {
303 name: 'door_open',
304 description:
305 'Open a note in the door this session runs in, where your tile’s opens land (from=$EP0CH_TILE), as you. It ' +
306 "never moves the person's focus; the door says which reader it went to.",
307 inputSchema: {
308 type: 'object',
309 properties: { id: { type: 'string', description: 'A block id, ((id)), [[page]] or Work ID' }, actor: ACTOR },
310 required: ['id'],
311 additionalProperties: false,
312 },
313 },
314]
315
316const ACT_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/
317
318/**
319 * `ep0ch act` argv (and stdin) for an action as `actor`. Values go as
320 * `key=value` words. `ep0ch` reads a value starting with `@` as a file (`@-`
321 * as stdin), so one such value is sent through stdin instead; a second is
322 * refused rather than read as a path.
323 */
324export function doorActArgv(
325 input: Record<string, unknown>,
326 actor: string,
327): { argv: string[]; stdin?: string } | string {
328 const action = input.action
329 if (!nonEmpty(action) || /\s/.test(action)) return 'Give the action name (door_peek and `ep0ch actions` list them).'
330 const args = input.args ?? {}
331 if (typeof args !== 'object' || args === null || Array.isArray(args)) return 'args is an object of key: value.'
332 const words: string[] = []
333 let stdin: string | undefined
334 for (const [key, raw] of Object.entries(args as Record<string, unknown>)) {
335 if (!ACT_KEY.test(key) || key === 'as' || key === 'tile') return `"${key}" is not an argument name (use tile and actor for those).`
336 if (!['string', 'number', 'boolean'].includes(typeof raw)) return `${key} must be a string, number or boolean.`
337 const value = String(raw)
338 if (value.startsWith('@')) {
339 if (stdin !== undefined) return 'Only one argument may start with @.'
340 stdin = value
341 words.push(`${key}=@-`)
342 } else {
343 words.push(`${key}=${value}`)
344 }
345 }
346 if (input.tile !== undefined) {
347 if (!nonEmpty(input.tile) || String(input.tile).startsWith('@')) return 'tile is a tile name.'
348 words.push(`tile=${input.tile}`)
349 }
350 return { argv: ['ep0ch', 'act', action, ...words, '--as', actor], ...(stdin === undefined ? {} : { stdin }) }
351}
352
353/**
354 * `ep0ch peek` prints the screen's state as indented JSON, then the screen's
355 * text. Both, as one compact value; the text alone when the state doesn't parse.
356 */
357export function peekOf(stdout: string): { screen?: unknown; text: string } {
358 const lines = stdout.split('\n')
359 const end = lines.findIndex(line => line === '}' || line === ']')
360 if (lines[0]?.startsWith('{') || lines[0]?.startsWith('[')) {
361 try {
362 return { screen: JSON.parse(lines.slice(0, end + 1).join('\n')), text: lines.slice(end + 1).join('\n').trimEnd() }
363 } catch {
364 // Not the state this expects: the text alone.
365 }
366 }
367 return { text: stdout.trimEnd() }
368}
369
370/**
371 * Who a write is attributed to: the caller's `actor`, else OUTLINER_ACTOR, else
372 * EP0CH_AGENT (the door's own name for the agent), else claude-code.
373 */
374export function actorOf(input: Record<string, unknown>, env: { OUTLINER_ACTOR?: string; EP0CH_AGENT?: string }): string {
375 for (const candidate of [input.actor, env.OUTLINER_ACTOR, env.EP0CH_AGENT]) {
376 if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
377 }
378 return 'claude-code'
379}
380hooks/work-tools.ts 194 lines1/**
2 * Workboard tools for Claude: each one is a thin call to the installed CLI's
3 * `work` / `note` commands (src/work-tools.ts), so Claude, Codex and Pi share
4 * one implementation. This file only turns tool arguments into CLI argv.
5 */
6
7type Json = Record<string, unknown>
8
9export interface WorkToolDefinition {
10 name: string
11 description: string
12 inputSchema: Json
13 /** The CLI arguments and stdin for one call, or the reason the input is unusable. */
14 command(input: Record<string, unknown>): { args: string[]; stdin?: string } | string
15}
16
17const ITEM = { type: 'string', description: 'The roadmap item: a Work ID (PIE-123) or its block UUID. Never a title.' }
18const EXPECTED = {
19 type: 'integer',
20 minimum: 1,
21 description: 'The revision you read; the write is refused if the block changed since.',
22}
23
24function text(input: Record<string, unknown>, key: string): string | undefined {
25 const value = input[key]
26 return typeof value === 'string' && value.trim() ? value : undefined
27}
28
29function expected(input: Record<string, unknown>): string[] {
30 const value = input.expectedRevision
31 return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? ['--expected', String(value)] : []
32}
33
34function schema(properties: Json, required: string[]): Json {
35 return { type: 'object', properties, required, additionalProperties: false }
36}
37
38export const WORK_TOOLS: readonly WorkToolDefinition[] = [
39 {
40 name: 'work_create',
41 description:
42 "Create a roadmap item under the project's work queue with a newly allocated Work ID. Returns the Work ID, " +
43 'block reference and revision. New work defaults to unprioritized; pass stage only when it was agreed.',
44 inputSchema: schema({
45 title: { type: 'string', description: 'Concise outcome, without a Work ID or property tokens' },
46 project: { type: 'string' },
47 arc: { type: 'string' },
48 tracks: { type: 'array', items: { type: 'string' }, minItems: 1 },
49 priority: { type: 'string', enum: ['high', 'medium', 'low'] },
50 stage: { type: 'string', enum: ['unprioritized', 'later', 'queued', 'doing', 'review', 'validate'] },
51 batch: { type: 'string', description: 'UUID of the agreed work-batch' },
52 body: { type: 'string', description: 'Markdown body: context and acceptance criteria' },
53 }, ['title', 'project', 'arc', 'tracks', 'priority']),
54 command(input) {
55 const tracks = Array.isArray(input.tracks) ? input.tracks.filter((track): track is string => typeof track === 'string') : []
56 const [title, project, arc, priority] = ['title', 'project', 'arc', 'priority'].map(key => text(input, key))
57 if (!title || !project || !arc || !priority || tracks.length === 0) return 'Give a title, project, arc, priority and at least one track.'
58 const args = ['work', 'create', '--title', title, '--project', project, '--arc', arc, '--priority', priority,
59 ...tracks.flatMap(track => ['--track', track])]
60 const stage = text(input, 'stage')
61 const batch = text(input, 'batch')
62 if (stage) args.push('--stage', stage)
63 if (batch) args.push('--batch', batch)
64 const body = text(input, 'body')
65 return body ? { args: [...args, '--stdin'], stdin: body } : { args }
66 },
67 },
68 {
69 name: 'work_stage',
70 description:
71 "Move a roadmap item to another work stage (queued, doing, review, validate, later…), checked against its " +
72 'revision and read back. Done needs proof: use work_complete.',
73 inputSchema: schema({ item: ITEM, stage: { type: 'string' }, expectedRevision: EXPECTED }, ['item', 'stage']),
74 command(input) {
75 const [item, stage] = [text(input, 'item'), text(input, 'stage')]
76 if (!item || !stage) return 'Give the item and the stage.'
77 return { args: ['work', 'stage', item, stage, ...expected(input)] }
78 },
79 },
80 {
81 name: 'work_set',
82 description:
83 'Set one single-valued property on a roadmap item (priority, work-batch, arc…), checked against its revision ' +
84 'and read back. Identity properties and multi-valued ones are refused. On a delivery only delivery-stage can ' +
85 'be set: complete finishes a merged delivery (e.g. one left in validate on an item already done), validate reopens it.',
86 inputSchema: schema({
87 item: {
88 type: 'string',
89 description: 'A roadmap item (Work ID or block UUID), or for delivery-stage a delivery: its block UUID or key (PIE-123/door).',
90 },
91 key: { type: 'string' },
92 value: { type: 'string' },
93 expectedRevision: EXPECTED,
94 }, ['item', 'key', 'value']),
95 command(input) {
96 const [item, key, value] = [text(input, 'item'), text(input, 'key'), text(input, 'value')]
97 if (!item || !key || !value) return 'Give the item, the property key and its value.'
98 return { args: ['work', 'set', item, key, value, ...expected(input)] }
99 },
100 },
101 {
102 name: 'work_deliver',
103 description:
104 "Record a GitHub pull request as one of the item's deliveries and sync its live state: an open PR moves the " +
105 "item to review, a merged one to validate. The PR's branches must match the delivery's. An item can have " +
106 'several deliveries (one per repository or branch), each under its own key.',
107 inputSchema: schema({
108 item: ITEM,
109 repo: { type: 'string', description: 'owner/name' },
110 pr: { type: 'integer', minimum: 1 },
111 key: {
112 type: 'string',
113 description:
114 'Delivery name (door → PIE-123/door). Omitted: the delivery already recording this repo and branch, else ' +
115 'primary, else (primary is another repo) the repository name. A second branch in the same repo needs one.',
116 },
117 base: { type: 'string', description: "Base branch; defaults to the PR's" },
118 branch: { type: 'string', description: "Work branch; defaults to the PR's head" },
119 }, ['item', 'repo', 'pr']),
120 command(input) {
121 const [item, repo] = [text(input, 'item'), text(input, 'repo')]
122 const pr = input.pr
123 if (!item || !repo || typeof pr !== 'number' || !Number.isSafeInteger(pr) || pr < 1) return 'Give the item, repo and PR number.'
124 const args = ['work', 'deliver', item, '--repo', repo, '--pr', String(pr)]
125 const [key, base, branch] = [text(input, 'key'), text(input, 'base'), text(input, 'branch')]
126 if (key) args.push('--key', key)
127 if (base) args.push('--base', base)
128 if (branch) args.push('--branch', branch)
129 return { args }
130 },
131 },
132 {
133 name: 'work_complete',
134 description:
135 'Accept a roadmap item with linked proof. Every incomplete delivery must be covered: name them in deliveries ' +
136 'or set allMerged; each must be merged. If another delivery is still incomplete the call is refused, naming it ' +
137 'and how to finish it. The proof is added as a child block (or an existing linked proof block is used), the ' +
138 'covered deliveries become complete and the item done.',
139 inputSchema: schema({
140 item: ITEM,
141 deliveries: {
142 type: 'array',
143 items: { type: 'string' },
144 minItems: 1,
145 description: 'Deliveries to complete: block UUID, key (PIE-123/door) or name (door)',
146 },
147 allMerged: { type: 'boolean', description: 'Complete every delivery whose PR is merged, instead of naming them' },
148 proof: { type: 'string', description: 'Proof as Markdown: first line is its title' },
149 proofBlock: { type: 'string', description: 'An existing proof block UUID linked to the item, instead of proof text' },
150 }, ['item']),
151 command(input) {
152 const item = text(input, 'item')
153 const [proof, proofBlock] = [text(input, 'proof'), text(input, 'proofBlock')]
154 const deliveries = Array.isArray(input.deliveries)
155 ? input.deliveries.filter((delivery): delivery is string => typeof delivery === 'string' && delivery.trim() !== '')
156 : []
157 if (!item) return 'Give the item.'
158 if (!proof === !proofBlock) return 'Give either proof text or an existing proofBlock.'
159 if (input.allMerged === true && deliveries.length) return 'Name deliveries or set allMerged, not both.'
160 const args = ['work', 'complete', item, ...deliveries.flatMap(delivery => ['--delivery', delivery]),
161 ...(input.allMerged === true ? ['--all-merged'] : [])]
162 return proof ? { args: [...args, '--stdin'], stdin: proof } : { args: [...args, '--proof-block', proofBlock!] }
163 },
164 },
165 {
166 name: 'work_body',
167 description:
168 "Replace a roadmap item's (or any block's) body below its title and property lines, checked against its revision.",
169 inputSchema: schema({ item: ITEM, body: { type: 'string' }, expectedRevision: EXPECTED }, ['item', 'body']),
170 command(input) {
171 const item = text(input, 'item')
172 if (!item || typeof input.body !== 'string') return 'Give the item and its new body.'
173 return { args: ['work', 'body', item, '--stdin', ...expected(input)], stdin: input.body }
174 },
175 },
176 {
177 name: 'note_section',
178 description:
179 'Replace one Markdown section of a note: the text under a heading up to the next heading of its level, as ' +
180 'Detail folds it. The heading stays; the replaced text is returned as "previous".',
181 inputSchema: schema({
182 block: { type: 'string', description: 'The note: block UUID or Work ID' },
183 heading: { type: 'string', description: 'Heading text, optionally with its ## level' },
184 body: { type: 'string' },
185 expectedRevision: EXPECTED,
186 }, ['block', 'heading', 'body']),
187 command(input) {
188 const [block, heading] = [text(input, 'block'), text(input, 'heading')]
189 if (!block || !heading || typeof input.body !== 'string') return 'Give the note, the heading and the new section text.'
190 return { args: ['note', 'section', block, heading, '--stdin', ...expected(input)], stdin: input.body }
191 },
192 },
193]
194hooks/where.ts 90 lines1/**
2 * Where this session runs: the stack of layers outside it (ssh, Herdr, an
3 * ep0ch-door and its tile), from ep0ch-door's `EP0CH_NEST`, checked by
4 * `ep0ch where --json`. Claude gets it as one context block, so it never has
5 * to guess from the repo name or a window title.
6 *
7 * `EP0CH_NEST` is one line, outermost first, with ` › ` between layers:
8 * ssh:pts/5 › herdr:w1:p1 › door:1388380/desk/t1:claude › herdr:door-claude
9 * (ep0ch-door's docs/AGENT-INTERFACE.md, "Where am I").
10 */
11
12/** The variables a door gives a tile's program (and the daily agent's Herdr pane). */
13export type DoorEnv = {
14 EP0CH_NEST?: string
15 EP0CH_CONTROL?: string
16 EP0CH_TILE?: string
17 EP0CH_TILE_ID?: string
18}
19
20export const WHERE_BLOCK = 'whereAmI'
21/** How long `ep0ch where` may take: it asks a door and Herdr, each with its own shorter timeout. */
22export const WHERE_TIMEOUT_MS = 8000
23/** How long the first prompt waits for it before using the variables alone. */
24export const WHERE_WAIT_MS = 1500
25
26/**
27 * The extra argument `ep0ch help` is asked with: a socket path that never
28 * exists. Every ep0ch with `help` prints its usage and ignores it; one older
29 * than `help` takes it for the socket to open and stops at "no carrier",
30 * instead of opening a door on the default outline.
31 */
32export const HELP_PROBE = '/nonexistent/ep0ch-where-probe.sock'
33
34/** One line: control characters (a newline in an inherited EP0CH_NEST) become spaces. */
35const oneLine = (s: string): string => s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim()
36
37/** Whether to look at all: only a session a door started (or its Herdr agent pane). */
38export const inDoorEnv = (env: DoorEnv): boolean => !!(env.EP0CH_NEST?.trim() || env.EP0CH_CONTROL?.trim())
39
40/** `ep0ch help` lists `where`: this ep0ch has it (an older one would open a door instead). */
41export const knowsWhere = (help: string): boolean => /^\s*ep0ch where\b/m.test(help)
42
43/**
44 * The summary `ep0ch where --json` printed, or null when its output isn't
45 * that (an ep0ch too old to know `where` prints its usage or an error).
46 */
47export function whereSummaryOf(stdout: string): string | null {
48 try {
49 const parsed: unknown = JSON.parse(stdout)
50 const summary = (parsed as { summary?: unknown } | null)?.summary
51 return typeof summary === 'string' && oneLine(summary) ? oneLine(summary).slice(0, 1000) : null
52 } catch {
53 return null
54 }
55}
56
57/** What the variables alone say, when `ep0ch where` can't be run: unchecked. */
58export function envSummaryOf(env: DoorEnv): string {
59 const nest = oneLine(env.EP0CH_NEST ?? '').slice(0, 1000)
60 const tile = oneLine(env.EP0CH_TILE_ID || env.EP0CH_TILE || '').slice(0, 120)
61 return [
62 nest ? `stack: ${nest}` : `in an ep0ch-door tile${tile ? ` (${tile})` : ''}, from a door older than EP0CH_NEST`,
63 'unchecked: `ep0ch where` could not run, so no layer was checked and where the keys are is unknown',
64 ].join(' · ')
65}
66
67/** The context block's text. */
68export function whereText(summary: string): string {
69 return [
70 `This Claude session runs inside these layers (outermost first; EP0CH_NEST): ${summary}.`,
71 'Trust this over guesses from the repo name, window titles or other agents. `ep0ch where` checks it again, read-only.',
72 'To see or act in this door (what the person sees, opening a note in front of them, marks, tiles), use the ep0ch skill: `ep0ch peek` and `ep0ch actions` read; `ep0ch act <action> … --as <your name>` acts, attributed, and never takes the person\'s keys. EP0CH_CONTROL already points at this door.',
73 ].join('\n')
74}
75
76/**
77 * The tile `door-open --from` names: EP0CH_TILE, the name the door's links and
78 * the daily layout use (it survives a door restart for an agent kept in its
79 * Herdr pane), else the tile's id (`t<n>`) when the name is empty. The door
80 * takes either; one it doesn't know falls back to where its own open puts
81 * notes (src/door-control.ts), which never takes the person's keys.
82 */
83export function doorTileOf(env: { EP0CH_TILE?: string; EP0CH_TILE_ID?: string }): string | null {
84 const name = env.EP0CH_TILE?.trim()
85 if (name) return name
86 const id = env.EP0CH_TILE_ID?.trim()
87 return id && /^t\d+$/.test(id) ? id : null
88}
89
90