SLOPSHOPPER

pi-outliner

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…

newrowsguardtoastprompttool
v0.1.0no licenseupdated 2026-10-03float-ritual-stack/pi-herdr-outliner/claude-mod
A shopper browsing a rack in a slop shop
README

Claude Code mod: Recent Mentions, outline, workboard and door tools

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.

  • Only main-loop answers are sent. Subagent runs, interruptions, refusals and errors are skipped.

Which outline: the session's folder

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.

  • A nested binding is nearer than its parent's, so a project bound to its own outline keeps it. Similar path prefixes do not match.
  • An outline root too broad to name an outline after ($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.
  • Opening an unbound folder's guessed outline from Herdr (Ctrl-b u) records that folder (or its repository) as the outline's root. From then on it is bound, and Claude sessions in it feed that outline.
  • A folder bound to no outline feeds nothing: no mentions, no links, and the outline tools refuse. The CLI's folder-name guess and the host's default outline are never used, so an unrelated session never reaches your outline.
  • Mentions, links, 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.
  • The folder is found when the session starts (links and tools) and again after each answer (mentions), so binding a folder mid-session starts its mentions.
  • Herdr discovers the Outliner (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.

Opting out, and strict mode

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

ModeThe list
folder (unset with no list)Folders opted out: a session in one, or below it, feeds nothing even when bound.
allowlistStrict 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 listNothing 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.

Clickable references and Claude's Outliner pane

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

Where a note opens

A click, show and door_open share one open (openNote in hooks/register.ts), tried in this order:

  1. In an ep0ch-door tile (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).
  2. The door says who opened it (claude-code, or OUTLINER_ACTOR / EP0CH_AGENT), and an agent's open never moves the person's focus.
  3. If the door refuses (say it is on its menu, or the reader the tile links to holds an edit), its reason is the answer: the tool's denial or a toast. It is never shown somewhere else instead.
  4. If the door takes the request but doesn't answer within 5s, it says so; it isn't shown in Herdr too.
  5. Only if no door answers there (the door quit) does it go on.
  6. In Herdr (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.
  7. Anywhere else: a toast (or the tool's denial) saying it can't open the note here and why, with its ((id)) to copy. A click never fails silently.
  • The pane is recognized by its browsing context, which is the Claude session id, so it survives plugin reloads and resumed sessions. Close it and the next click splits a new one.
  • If you are editing in Claude's pane, a click or show is refused with a toast (the Outliner protects active edits) rather than opening a second pane.
  • Clicks reach the mod in the fullscreen terminal ("tui": "fullscreen").
  • Links carry 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.
  • A target the Outliner cannot resolve is a toast, never a new page.

Where this session runs

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.
  • Without a usable 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.
  • It never blocks or fails the session: the work runs after the start, and any failure leaves the context as it was. Outside a door, nothing is added.

Workboard tools

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.

Outline tools

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.

ToolInputReturns
outline_readref, 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_findtext?, property? (key=value or key), hasKey?, query?, under?, or view?; limit?rows: id, title, revision; complete
outline_resolverefid, title, revision, workId, fragmentId
outline_editref, expectedRevision, one of text, replaceSection {heading, body}, append; allowStructural?the new revision, a short diff, and with allowStructural what it dropped
outline_createparent (a ref or root), text, position?id, ref, revision
outline_commentref, body, quote (with start/prefix/suffix when it repeats) or whole: true, requestId?the thread id
outline_replythread, bodythe reply id
outline_resolve_threadthread, resolvedthe thread's lifecycle
outline_changessince (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_patchref, 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.

  • The safe path is read, then edit with the revision. 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.
  • Rewriting your own pages, such as a status page, is 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.
  • Every write is 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.

Door tools

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.
  • The door never lets an agent take the person's focus, keys or selection. Its refusal comes back as the tool's error; 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.

Use

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.

InstallerDoes
install-claude-mod.tsLoads 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 /folderOpts 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 --folderFolder 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.

Develop

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.json
Source 6 files
hooks/register.ts 712 lines
1import 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}
712
hooks/mention-message.ts 195 lines
1import 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}
195
hooks/references.ts 169 lines
1/**
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}
169
hooks/outline-tools.ts 380 lines
1/**
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}
380
hooks/work-tools.ts 194 lines
1/**
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]
194
hooks/where.ts 90 lines
1/**
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