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

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 (the outliner's src/mentions-codex.ts). [[page]], ((block-id)), Work IDs and existing UUIDs in the answer then show up in Tree/Detail ? → Recent mentions.
There is nothing to configure. A session uses the outline its folder names, found the way every client finds it (PIE-530): the nearest .ep0ch, from the session's cwd up (it holds ws = "<name>"; ep0ch init or Herdr's Choose outline popup writes it). The installed CLI answers (outliner bound-folder <cwd>), so the mod never keeps a resolver of its own.
.ep0ch is nearer than its parent's, so a project naming its own outline keeps it. Similar path prefixes do not match.show, the workboard and outline tools all use that folder and the outline its .ep0ch names: the mod passes that name as EP0CH_WS, over an inherited one (and its machine, as EP0CH_MACHINE, empty for this one), so Claude's environment never moves a write elsewhere. With EP0CH_SOCKET (a host on another machine) in Claude's environment nothing is fed (one toast); use strict mode for that setup.herdr plugin list --plugin float.pi-outliner), and the mod calls the installed CLI's mentions ingest. It never starts a service. Failures show as one toast and leave the answer untouched. An Outliner older than bound-folder feeds nothing and says so once per session.PI_OUTLINER_MENTIONS_WORKSPACES (or the workspaces option) lists absolute folders, separated by : or ,. What the list means is PI_OUTLINER_MENTIONS_MODE (or the mode option):
| Mode | The list |
|---|---|
folder (unset with no list) | Folders opted out: a session in one, or below it, feeds nothing even when bound. |
allowlist | Strict mode, as before folder mode: only listed folders (and their subfolders) feed, bound or not; the nearest listed folder is the workspace, and the CLI resolves its outline as it always did. |
| unset, with a list | Nothing feeds anywhere, with one toast a session. The list may be an allowlist from before folder mode, and reading it either way would start feeding folders nobody chose. Set the mode, or drop the list (install-claude-mod.ts --folder: every bound folder then feeds its outline). |
The option wins over the environment variable when it is set. Claude Code passes an unset option as an empty string, so an empty option always falls back to the variable. An entry that isn't an absolute folder (a relative path, or folder=name), or an unknown mode, is an error shown as a toast, never skipped.
In a bound folder, Work IDs (its outline's prefixes), [[pages]] and ((block references)) in Claude's replies are drawn as links. In a door tile a click opens the note in that door (Where a note opens). In Herdr a plain click shows the target in the Outliner Detail beside Claude: the admin Detail linked to the Tree in Claude's Herdr workspace, else a Detail opened below that Tree, else a new Tree and Detail beside Claude. The Outliner finds or opens it (its own ensure-detail, run with --no-focus), so there is one Detail finder. It never takes focus. References in code and existing links are left alone.
Claude can put a note there too, with the mcp__ep0ch__show tool (a Work ID, [[page]], ((uuid)) or pi-outliner:// URI).
A click, show and door_open share one open (openNote in hooks/register.ts), tried in this order:
EP0CH_CONTROL set: the daily agent, the drawer'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 its id (EP0CH_TILE_ID: t-<uuid>, a drawer agent tab's too), which names one tile; EP0CH_TILE when it has none. 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 without that tile is asked again naming no tile (the CLI says which tile it couldn't find), and puts it where its own open puts notes, which never takes the person's keys. The mod never names a reader (a door refuses an agent that names the reader the person is on).HERDR_PANE_ID set): the Outliner Detail beside Claude, as above: the installed Outliner's src/herdr-open.ts --mode ensure-detail --no-focus names it (detailClientId), then outliner link <uri> --detail-client <id> --no-focus shows the note there. 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.show opens it as the agent's own, which waits below 144 columns and says so.show): a toast (or the tool's denial) saying it can't open the note here and why, with the exact command that reads it in any terminal (ep0ch show <id>, the door's renderer, needing only the outline host) and its ((id)) to copy. A click never fails silently.show is refused with a toast (the Outliner protects active edits) rather than opening a second pane."tui": "fullscreen").Markdown and Link print the address beside a link when the terminal has no hyperlinks, and cannot be told not to, so in a terminal the mod draws the lines that hold a reference itself (hooks/reply-lines.ts: one pressable Button per reference, prose around it with bold, italic and code kept) and leaves every other line to the engine's Markdown. The desktop and VS Code keep the Markdown, whose links are real, on https://pi-outliner.invalid/... stand-ins. https://my.ep0ch.sh/pub/p/<PIE-n|page|id> that Claude writes, bare or as label, is the same reference (a bare one drawn as its PIE-n, page name or the note's title). An unaliased ((id)) or ((id^anchor)) is drawn as its note's title (cut to 48 characters, then › anchor), looked up once through the same cache as a tool row's titles and redrawn when it lands; until then, and when the block is not found, as its first eight characters and …; a press still opens it. Any other http(s) link, bare or label, is drawn in the terminal as the engine's Link (an OSC 8 hyperlink the terminal opens) in the dim inactive colour with a trailing ↗, apart from the underlined references that open in the door; a bare address keeps its text. Emphasis spanning a reference and other text stays bold or italic, and a table with a reference in a cell is drawn whole by the mod, its references pressable (PIE-1043). A press opens in the door ep0ch where finds (the one the door tools use), read at each press: a stale or missing EP0CH_CONTROL no longer sends it to the mentions pane. With no EP0CH_TILE (a Claude that came in over ssh, or runs in a background session or a tmux attached from a tile) the press writes a marker to Claude's terminal (OSC 7501 ; ?mark=<token>, carried over ssh in the pty stream; in tmux wrapped in its passthrough, which needs tmux set -g allow-passthrough on; PIE-1306) and asks the door which terminal tile drew it (host.tileOf mark=<token> port=<client port in SSH_CONNECTION>, the port tried when no tile saw the marker), then opens from that tile, so the Claude tile's wire is followed; no tile found, it opens where the screen's opens land. The marker is write-only: Claude owns its terminal's input, so nothing is read back.Composer prompts with references use the same labels and presses as replies, with the prompt marker and theme background (PIE-1054). Prompts with image or pasted-content placeholders, expanded transcript rows, and messages from agents or tasks keep the engine's drawing. This is a Claude transcript feature, so its demonstration and regression coverage live in the plugin render tests rather than the door's showcase.
So nobody has to ask which outline Claude's tools reach (PIE-546), the mod says it three ways, from one set of facts:
/clear, until h (or its hide button) puts it away; /outline shows it again and prints it, and /outline <name> [machine] binds the session to that outline (PIE-756). Which outline the tools use, first match wins: that call (or outline_bind), then EP0CH_WS (with EP0CH_MACHINE; the door passes both to every tile program, the drawer agent and the Herdr launcher's pane), then, in a door tile, the door's outline, then the .ep0ch of the folder the session started in. A shell cd moves none of them. It says, in plain words:harbor, on far (another machine), or garden, on this machine (near-box)); or "none: the … tools are off";.ep0ch that names it, or that nothing names one here, with the exact command that binds the folder (ep0ch init harbor --machine far in a door: the door's outline; else ep0ch init <name>);Beside Recent mentions the card keeps to the binding (outline, why, the yellow line), so the mentions' keys stay on screen.
outline: harbor @ far · folder, door (or · folder · ⚠ door is …, none (tools off) · … · ep0ch init …). The folder's outline and where are read again on a turn at most every 30s (the Work-ID prefixes kept), so a door that reconnected or changed, or a new .ep0ch, shows, and the tools follow the same read.outlineBinding) of the first prompt (and after /clear), made again when a later read changes them, or when the first prompt came before the first read landed.The facts come from two places, never guessed: the folder's outline as the tools found it (bound-folder, above), and ep0ch where --json (this machine and folder, the door, its outline and machine, the Herdr pane). where runs at the start in or out of a door; without a usable ep0ch the card says the door wasn't checked.
In a door tile (EP0CH_NEST or EP0CH_CONTROL set), the mod uses ep0ch where --json (run 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, the door's EP0CH_NEST), which of them are live, and where the person's keys are. Claude then doesn't have to guess from the repo name or a window title.
where only reads. The mod asks ep0ch help first. An ep0ch older than where would take the word for a socket and open a door, so it is never run. The help call carries a socket path that never exists, so an ep0ch older than help (which would take help the same way) stops at "no carrier" instead of opening a door.ep0ch (not on PATH, too old, an error, or no answer within 1.5s of the first prompt), the block has the variables alone and says they are unchecked.A session bound to an outline that keeps a workboard starts oriented: the first prompt carries one more context block, workboardNow, one screen of where the work stands. It is made again after a compaction and after /clear, read anew each time.
type::build-log, newest created, with its ((id))); the roadmap items in doing, review and validate (six a lane, the rest counted; a read the service cut short says its counts are partial); the items waiting on the person (needs::sysop, with its reason, in any stage but done or superseded); and the open PRs of the session's folder (gh pr list), when gh answers within 3s (a slow GitHub drops the PRs, never the board).list --query in the views' query grammar (hooks/workboard-now.ts holds the queries; the mod keeps nothing of its own). Two reads, and a third (type=roadmap-item, one row) only when nothing is in flight.gh is asked only when there is a workboard.session.start (and when a compaction or /clear begins). The first prompt waits for it no longer than for the binding (1.5s); one that lands later makes the context again.A session bound to an outline also starts knowing what a note there can hold, so it reaches for figures, views, callouts and rules without being reminded:
ep0ch-outline-guide: ep0ch library --guide (outline-core's agent-guide.ts), the house lines and every component the outline has, one line each, grouped by its own group, marked live when it reads a query, an extension's with its origin. Built from the merged schemas, so a new component or an extension's appears with nothing written here. Read once a session off the start; the first prompt waits for it no longer than for the binding, and one that lands later makes the context again. Silent outside a bound folder or with an ep0ch that can't say it.outline_read, outline_find, outline_edit, outline_create, outline_patch and outline_components register with isDeferred: false, in the prompt's tool list instead of behind tool search. Their descriptions say what a note can hold and what the answer reports.components the note now holds and its warnings (outline-core's component-warnings.ts: a figure's name or key with a typo, YAML that doesn't read, a value outside a property's; with the references that lead nowhere, as before), and the house rules measured on what was written (outline-core's write-shape.ts, PIE-1270: a title over about 70 characters, a paragraph past three sentences, prose with no figure, a nested callout, a question for Evan below the top with no [needs::sysop]), each worded "fix before you call this done: …", then a next check. The same rules sit in the write tools' descriptions (agent-tool-definitions.ts), so they are in the prompt beside every call. When the note holds a ::graph-*, the answer starts with the note as it draws (ep0ch show <id> --width 80 --rows 40), its JSON still last for the tool rows. A drawing that fails leaves the answer as it was.The mod reports Claude's own state to the terminal it runs in with OSC 7501 (the Program Status Protocol), so a door's terminal tile (or Ghostty, Rex) knows it without reading Claude's screen: idle at the start, working when a turn starts and after a tool runs, blocked with kind=permission when a permission dialog shows (and the Notification permission_prompt) or kind=question for AskUserQuestion and an MCP elicitation, done with the answer's first line when the main loop's turn ends, idle when it's interrupted, error for a refusal or an API error, cleared when the session ends. Every report carries app=claude-code. hooks/claude-status.ts holds the mapping (pure); hooks/program-status.ts is outline-core's encoder, copied (a hooks module can't import outside the plugin) and kept identical by outline-core's test. It writes only where the terminal speaks the protocol: EP0CH_PROGRAM_STATUS says outright (1, 0), else tput Pst (a door's tiles have it); it never sends the feature query, as Claude owns the input. This build of Claude Code reports nothing itself (its plugin API has no such call); once it does, EP0CH_PROGRAM_STATUS=native makes the mod step aside, so a record never has two writers (the mod can't read Claude's own output to notice by itself).
The blocks this conversation's answers mentioned show beside it, read from the same retained history Tree and Detail show (outliner mentions list --agent claude --session <id>, the service's mentions.list), never a list of the mod's own. It is read again after each answer is ingested.
1: Bike shed 2: Chain oil … p: previews s: all conversations m: to pane x: hide), or a fuller pane beside the transcript (each mention with what was written, when, and the words around it). m moves it between them, x hides it, and the choice is kept across sessions ($.store, mentions-view). Closing the pane with its mark keeps it hidden. /mentions [band | pane | off | preview | scope] makes the same choices by keys; /mentions alone shows a hidden band. Only band, pane, off and /mentions alone move anything: preview and scope leave the band or pane where it is (a band standing in for a waiting pane stays the band, and says so). The band draws only once there is something to show, so an empty one leaves the slot above the prompt to others.((id)), [[page]] or Work ID that resolves in the session's outline (the service resolves it at ingest). A source number from a document (S-87), another outline's Work ID or a page nobody made is dropped, never listed. "(gone)" is kept for a block named by id that was deleted since; a page that no longer resolves drops off the list.p turns on a small preview of each block (the newest two in the band, every one in the pane), drawn by the door (BlockView).s switches between this conversation's mentions and every conversation's in the outline, as the Tree's s does.show and door_open share (Where a note opens): in the door this session runs in, as the agent, never taking focus; else the Outliner Detail beside Claude in Herdr; else here, in the pane, in place of the list (The note here). The pane's heading says which it will be: "opens in this door"; in Herdr, "opens in the Outliner Detail beside you" when the Outliner's find-detail (which opens nothing) finds one, else "opens in a new Outliner Detail beside you"; with neither, "opens here, in a detail pane". It asks again each time the list is read.p, s, m, x are the controls.[-]); ctrl+x x closes the pane, which keeps it hidden until /mentions pane (or /mentions).m (and /mentions pane, every time, even when the pane is already the choice) opens it as the person's own ask, placed at any width. Opened at session start (the pane was the choice), Claude Code seats it only from 144 columns (110 once you opened it): below that it waits undrawn, and so does a pane the engine drops. While the pane is chosen but not on screen, the band stands in for it, its m reading show pane, so the mentions never vanish. The m press opens the pane from its own ui.press hook: an open from the drawing's closure counts as the plugin's, not the person's, and used to leave a narrow terminal with neither.With no door or Herdr around, a note opens in the mod's own pane: the mentions pane shows it in place of the list (a second pane would be a tab behind the first, out of sight). It is the third case of the one open (openNote), not a router of its own; hooks/detail-view.ts is its pure half.
ep0ch show <id> --cells --rows 400), its references as buttons under it (hotkeys 1 to 9), since cells hold no links;Markdown: the note's first line as a heading, its text as written (lists, headhooks/register.ts 2397 lines1import { formatEp0chBlockUri } from "./link-grammar.js";
2import type { EngineInterface, On, PluginOptions, RenderElement, RenderInput, RenderSurface, UiOpenResult } from 'claude-code'
3
4import {
5 boundWorkspaceOf,
6 effectiveWorkspaces,
7 failureReasonOf,
8 isIngestible,
9 type MentionMessage,
10 mentionMessageOf,
11 mentionsModeOf,
12 sessionWorkspaceOf,
13 type Workspace,
14 doorWorkspaceOf,
15 MACHINE_NAME,
16 namedWorkspaceOf,
17 sameHostSocket,
18 OUTLINE_NAME,
19 workspaceEnvOf,
20 workspaceForCwd,
21 openedTileOf,
22} from './mention-message'
23import {
24 isProtectedDestination,
25 linkifyReferences,
26 unlabelledBlockIds,
27 replyBlockTargets,
28 outlinerBlockIdOf,
29 outlinerLabelOf,
30 outlinerReferenceOf,
31 outlinerUriFor,
32 outlinerUriOf,
33} from './references'
34import { replyBlocks, tableWidths, type LinePart, type ReplyBlock } from './reply-lines'
35import { CONNECTOR_PERSONA_MATCH, personaFilled, slipOf } from './connector-persona'
36import { actorOf, BIND_TOOL, machineNameOf, checkedInput, COMPONENTS_TOOL, droppedNote, componentsArgv, DOOR_TOOLS, doorActArgv, OUTLINE_TOOLS, peekArgv, peekOf, sessionPersonaOf, SHOW_TOOL } from './outline-tools'
37import { WORK_TOOLS, withOptions } from './work-tools'
38import { createPageArgv, entryArgv, entryOf, findPageArgv, pageIdOf, unwrapPeer } from './peer-log'
39import { baselineOf, facetCountsOf, logLine, mergedCounts, outlineLabel, outlinesOf, settleAsks, settleDiff, settleNoteArgs, settleNotesOf, settlesFor, threadsOf, told, tuningOf, newestWith, wakeLine, wakeText, type SettleConfig, type SettleOutline, type SettleStore, type Thread, type ThreadCount } from './settle'
40import { type StatusInput, tileMark, tmuxPassthrough } from './program-status'
41import { notified, permissionAsked, PST_ARGV, questionAsked, sequenceOf, sessionEnded, sessionStarted, statusSetting, stopFailed, ttyArgv, turnEnded, working } from './claude-status'
42import { fileTouchesOff, fileTouchGlobsOf, projectOf, touchAllowed, touchInputOf, touchOf } from './file-touches'
43import { fileRefOf, threadedPathOf, threadsHeldNote } from './file-threads'
44import {
45 FILE_DIFF_REF,
46 FILE_REF,
47 FILE_TOOLS,
48 fileRowOf,
49 idsToTitle,
50 READ_TOOLS,
51 TOOL_PREFIX,
52 TOOL_ROWS_COMMAND,
53 TOOL_ROWS_STORE_KEY,
54 TOOL_ROWS_USAGE,
55 toolResultLineOf,
56 toolRowOf,
57 type ToolRowElements,
58 toolRowsCommandOf,
59 toolRowsPrefsOf,
60 toolRowTree,
61 WRITE_TOOLS,
62} from './tool-rows'
63import {
64 bandHasContent,
65 choicesText,
66 COMMAND_USAGE,
67 commandChoice,
68 mentionRowsOf,
69 mentionsListArgs,
70 mentionsTree,
71 opensIn,
72 PANE_ID as MENTIONS_PANE,
73 PREFS_STORE_KEY,
74 PREVIEW_ROWS,
75 prefsOf,
76 previewedRows,
77 previewWidthOf,
78 DEFAULT_PREFS,
79 type SiteElements,
80} from './mentions-view'
81import { BlockView, type BlockViewElements, type BlockViewProps, type BlockViewSource, blockViewId, ep0chHelp, loadBlockView, viewColumns } from './block-view'
82import {
83 currentOf,
84 DETAIL_ROWS,
85 detailSourceOf,
86 detailTree,
87 historyOf,
88 knowsExport,
89 listed,
90 moved,
91 pushed,
92 routeOf,
93 exportArgv,
94 exportBodyOf,
95 SUBTREE_LIMIT,
96 subtreeMarkdownOf,
97 type DetailElements,
98} from './detail-view'
99import type { DetailEntry, DetailSource, MentionRow, MentionsList, MentionsPrefs } from '../types'
100import {
101 type DoorEnv,
102 doorTileOf,
103 envSummaryOf,
104 inDoorEnv,
105 WHERE_BLOCK,
106 WHERE_TIMEOUT_MS,
107 WHERE_WAIT_MS,
108 whereSummaryOf,
109 whereText,
110 reachText,
111} from './where'
112import {
113 BINDING_BLOCK,
114 BINDING_COMMAND,
115 bindCall,
116 bindHint,
117 type BindingFacts,
118 bindingText,
119 cardLines,
120 type CardElements,
121 cardTree,
122 type FolderFacts,
123 statusLine,
124 type WhereFacts,
125 whereFactsOf,
126} from './binding'
127import {
128 ANY_ITEM_QUERY,
129 BOARD_QUERY,
130 BUILD_LOG_QUERY,
131 LIST_LIMIT,
132 listArgs,
133 listedOf,
134 openPrsOf,
135 PRS_ARGV,
136 PRS_TIMEOUT_MS,
137 WORKBOARD_BLOCK,
138 WORKBOARD_TIMEOUT_MS,
139 workboardText,
140} from './workboard-now'
141import { DRAW_TIMEOUT_MS, drawArgv, drawnNoteOf, GUIDE_ARGV, GUIDE_BLOCK, GUIDE_TIMEOUT_MS, guideText, UPFRONT_TOOLS, withDrawing } from './outline-guide'
142
143type AbovePromptRender = RenderInput<'AbovePrompt'>
144/** The binding card's state: written here, in this file, as the engine lists what a module reads and writes. */
145const BINDING_STATE = { plugin: 'ep0ch', key: 'binding' } as const
146/** One run of `ep0ch where --json`: its summary (null: it didn't answer) and the facts the binding card reads. */
147type WhereRun = { summary: string | null; facts: WhereFacts | null; why?: string }
148
149/**
150 * What drawing a reply needs, read once per session: the Outliner workspace the
151 * session belongs to (null: its folder is bound to no outline, or opted out;
152 * nothing is linked) and its Work-ID prefixes. A render hook only reads, so
153 * this is loaded beside it, not in it.
154 */
155type ReferenceContext = { workspace: Workspace | null; prefixes: string[]; why?: string; optedOut?: string }
156let references: ReferenceContext | undefined
157/** The load in flight, so a tool call made while the session starts waits for it rather than being refused. */
158let loadingReferences: Promise<void> | undefined
159/** Its first half in flight: the session's workspace found (`references.workspace` set), before the Work-ID prefixes. */
160let loadingWorkspace: Promise<void> | undefined
161/**
162 * The environment an Outliner CLI run gets for one workspace: its bound
163 * folder, and the outline its `.ep0ch` names, so the run lands on that outline
164 * whatever Claude's environment says.
165 */
166const envFor = workspaceEnvOf
167/** Each reason the session's workspace could not be found is toasted once. */
168const toldWorkspaceFailures = new Set<string>()
169/** Shows run one at a time, so concurrent clicks and tool calls split one pane. */
170let showQueue: Promise<unknown> = Promise.resolve()
171/**
172 * `ep0ch where --json`, run once at session.start (and again when the binding
173 * is read again): its summary for the first prompt's context in a door, and
174 * the facts the binding card reads.
175 */
176let whereLoad: Promise<WhereRun> | undefined
177/** Where this Claude is bound (hooks/binding.ts): the load in flight, and when the last one started (the session clock). */
178let bindingLoad: Promise<BindingFacts> | undefined
179let bindingReadAt = -Infinity
180/**
181 * Program status (OSC 7501, hooks/claude-status.ts): whether this terminal takes it (asked once), the report last sent
182 * (the same one twice is sent once), and the sends in order, each after the one before.
183 */
184let statusWanted: Promise<boolean> | undefined
185let statusSent = ''
186let statusChain: Promise<void> = Promise.resolve()
187/** The last report asked for (sent or not): what a later event's report is weighed against. */
188let statusAsked: StatusInput | null = null
189
190/** Say what this Claude is doing to its terminal, off the event's dispatch, in order. Nothing when the terminal doesn't speak it. */
191function reportStatus($: EngineInterface, r: StatusInput): void {
192 statusAsked = r
193 statusChain = statusChain.then(async () => {
194 statusWanted ??= (async () => statusSetting(await $.env.get('EP0CH_PROGRAM_STATUS'))
195 ?? (!!(await $.env.get('TERM')) && (await $.process.run([...PST_ARGV]).then(x => x.exitCode === 0, () => false))))()
196 if (!(await statusWanted)) return
197 const seq = sequenceOf(r)
198 if (seq === statusSent) return
199 // Remembered as sent only once it was: a failed write is tried again with the next report, even the same one.
200 const ran = await $.process.run(ttyArgv(seq), { timeoutMs: 3000 })
201 statusSent = ran.exitCode === 0 ? seq : ''
202 }).catch(() => {})
203}
204
205/**
206 * "Workboard now" (hooks/workboard-now.ts): the read in flight or done (its text, null: no workboard or no answer),
207 * its generation (a newer read wins), and what the model's context carried (undefined: not asked yet).
208 */
209let workboardLoad: Promise<string | null> | undefined
210let workboardGeneration = 0
211let workboardGiven: string | null | undefined
212
213/**
214 * The outline's guide (hooks/outline-guide.ts): the read for a folder and outline (another binding reads it again), and
215 * what the model's context carried (null: made without it, so one that lands later makes the context again).
216 */
217let guideLoad: { key: string; load: Promise<string | null> } | undefined
218let guideGiven: string | null | undefined
219
220/** A turn reads the binding again at most this often, so a door upgrade or reconnect shows within a turn or two. */
221const BINDING_REFRESH_MS = 30_000
222/** How long the first prompt waits for the binding before saying it is still being looked up: as long as for `where`. */
223const BINDING_WAIT_MS = WHERE_WAIT_MS
224
225async function drawReferences($: EngineInterface, e: RenderInput<'AssistantMessage' | 'UserMessage'>): Promise<RenderElement | null> {
226 const prompt = 'origin' in e.props ? e.props : null
227 // Attachment placeholders need the engine's stored content; the full transcript stays the engine's too.
228 if (prompt && (prompt.origin.kind !== 'composer' || prompt.from || prompt.task || prompt.isExpanded ||
229 /\[(?:Image(?: #\d+)?|Pasted text[^\]]*|\.\.\.Truncated text[^\]]*|\u2726 Team setup guide[^\]]*)\]/.test(prompt.text))) return null
230 const workspace = references?.workspace
231 if (!workspace) return null
232 const titles = new Map<string, string>()
233 const outlines = new Map<string, string>();
234 for (const id of new Set([...replyBlockTargets(e.props.text), ...unlabelledBlockIds(e.props.text)])) {
235 const title = await titleFor($, id)
236 if (title) titles.set(id, title)
237 const location = await $.state.get({ ...TOOL_OUTLINES, id });
238 if (location.value) outlines.set(id, location.value);
239 }
240 const { text, hrefs } = linkifyReferences(e.props.text, references!.prefixes, id => titles.get(id), id => outlines.get(id))
241 if (prompt && hrefs.length === 0) return null
242 // A terminal draws a link out of the outline itself too (a trailing arrow, dim), so a reply with one is not left to the engine.
243 const blocks: ReplyBlock[] = e.surface === 'terminal' ? replyBlocks(text) : [{ kind: 'markdown', text }]
244 if ((hrefs.length === 0 && blocks.every(block => block.kind === 'markdown')) || text.length > 10_000) return null
245 const { Box, Text, Markdown, Button, Link } = await $.ui.resolve(e)
246 const open = (href: string, surface: RenderSurface) => void openReference($, workspace, href, surface)
247 // A terminal prints the address beside a link it cannot make a hyperlink of, so there the references are drawn
248 // here, as their labels (PIE-1042); the desktop draws the Markdown, whose links are real.
249 const seen = new Map<string, number>()
250 const drawParts = (parts: LinePart[], at: string, allBold: boolean) => parts.flatMap((part, i) => {
251 if ('ref' in part) {
252 const count = (seen.get(part.ref) ?? 0) + 1
253 seen.set(part.ref, count)
254 // A link out of the outline: dim, with a trailing arrow, and the terminal's own hyperlink opens it (OSC 8).
255 if (part.external) {
256 return [Link({
257 key: count === 1 ? part.ref : `${part.ref}#${count}`,
258 href: part.ref,
259 children: [Text({ key: 'label', color: 'inactive', bold: allBold || undefined, children: part.label }), Text({ key: 'arrow', color: 'inactive', children: ' »' })],
260 })]
261 }
262 return [Button({
263 key: count === 1 ? part.ref : `${part.ref}#${count}`,
264 plain: true,
265 onPress: press => open(part.ref, press.surface),
266 children: Text({ underline: true, color: 'suggestion', bold: allBold || undefined, children: part.label }),
267 })]
268 }
269 return (part.text.match(/\S+\s*|\s+/g) ?? []).map((word, w) =>
270 Text({
271 key: `${at}.t${i}.${w}`,
272 bold: allBold || part.style === 'bold' || part.style === 'bold-italic' || undefined,
273 italic: part.style === 'italic' || part.style === 'bold-italic' || undefined,
274 color: part.style === 'code' ? 'permission' : part.style === 'dim' ? 'inactive' : undefined,
275 children: word,
276 }))
277 })
278 const body = blocks.map((block, n) => {
279 if (block.kind === 'markdown') {
280 return Markdown(block.text === text
281 ? { key: 'outliner-references', text, pressableLinks: hrefs.slice(0, 256), onLinkPress: (link, press) => open(link.href, press.surface) }
282 : { text: block.text })
283 }
284 if (block.kind === 'table') {
285 // A table drawn whole (PIE-1043): a row per Box, a fixed-width cell per column, pressable references inside.
286 const widths = tableWidths(block)
287 const row = (cells: LinePart[][], key: string, bold: boolean) => Box({
288 key,
289 flexDirection: 'row',
290 children: cells.flatMap((cell, c) => [
291 ...(c ? [Text({ key: `bar${c}`, dimColor: true, children: ' │ ' })] : []),
292 Box({ key: `c${c}`, width: widths[c], flexShrink: 0, flexDirection: 'row', flexWrap: 'wrap', children: drawParts(cell, `${key}.${c}`, bold) }),
293 ]),
294 })
295 return Box({
296 key: `outliner-table-${n}`,
297 flexDirection: 'column',
298 children: [
299 row(block.header, 'head', true),
300 Text({ key: 'rule', dimColor: true, children: widths.map(w => '─'.repeat(w)).join('─┼─') }),
301 ...block.rows.map((cells, r) => row(cells, `row${r}`, false)),
302 ],
303 })
304 }
305 return Box({ key: `outliner-line-${n}`, flexDirection: 'row', flexWrap: 'wrap', children: drawParts(block.parts, `l${n}`, false) })
306 })
307 return Box({
308 flexDirection: 'row',
309 backgroundColor: prompt ? 'userMessageBackground' : undefined,
310 children: [
311 Box({ width: 2, flexShrink: 0, children: Text({ children: prompt ? '\u276f ' : 'isFirstOfReply' in e.props && e.props.isFirstOfReply ? '⏺' : '' }) }),
312 Box({ flexGrow: 1, flexShrink: 1, flexDirection: 'column', children: body }),
313 ],
314 })
315}
316
317/**
318 * Registers Recent Mentions: each completed main-loop answer in a folder bound
319 * to an outline goes to that outline, as the Codex Stop hook sends Codex's.
320 *
321 * The session's workspace is its folder's nearest bound ancestor, resolved by
322 * the installed CLI the way every client resolves it (`sessionWorkspace`). An
323 * unbound folder feeds nothing. The `workspaces` option, or, left empty,
324 * `PI_OUTLINER_MENTIONS_WORKSPACES`, lists folders opted out; with the `mode`
325 * option or `PI_OUTLINER_MENTIONS_MODE` set to `allowlist` they are instead
326 * the only folders that feed (the strict mode). The engine hands an unset string
327 * option over as '', so empty and unset are one case: an empty option cannot
328 * override the environment.
329 *
330 * The answer passes on untouched. Delivery runs off the turn's dispatch, so a
331 * slow or absent service never delays the prompt; a failure is one toast.
332 *
333 * In the same workspaces, Work IDs, `[[pages]]` and `((block references))` in
334 * Claude's replies are drawn as links; a click opens the target where `show`
335 * and `door_open` do (`openNote`): the door this session runs in, else the
336 * Outliner Detail beside Claude in Herdr, else a toast with the `((id))` to copy.
337 */
338export function register(on: On, options: PluginOptions): void {
339 const option = options
340 // A registration is one session: the folder it started in is read anew, never the last registration's (a test file
341 // registers once per case, each in a folder of its own).
342 sessionCwd = null
343 // Recent mentions in Claude Code itself (hooks/mentions-view.ts): a band above the prompt or a pane, over the
344 // outline's own mentions.list, each press opened by openNote like every other click.
345 // The band above the prompt: the binding card (at the start and after /clear, until hidden) over Recent mentions.
346 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
347 const [card, below] = await Promise.all([$.state.get(BINDING_STATE).then(({ value }) => value), mentionsBand($, e, next, option)])
348 if (e.props.hasSurvey || !card?.shown) return below.tree
349 const ui = $.ui.resolve(e) as unknown as CardElements
350 // Beside Recent mentions, or in a short band, the card says the binding alone, so the mentions' keys stay on screen.
351 const compact = below.isMentions || e.props.maxRows < 16
352 const drawn = cardTree(ui, card.facts ?? null, {
353 columns: e.props.bodyColumns, compact,
354 hide: () => void (async () => $.state.set(BINDING_STATE, { facts: (await $.state.get(BINDING_STATE)).value?.facts ?? null, shown: false }))(),
355 })
356 return ui.Box({ flexDirection: 'column', children: [drawn, below.tree] })
357 })
358
359 on('ui.render', { component: 'Pane', requestId: MENTIONS_PANE }, async ($, e) => {
360 const [, history] = await Promise.all([mentionsListOf($), detailHistoryOf($)])
361 const workspace = references?.workspace
362 if (!workspace) return $.ui.resolve(e).Text({ dimColor: true, children: references ? NOT_BOUND_SHORT : "Finding this folder's outline…" })
363 // A drawing that throws makes the engine drop the pane (`ui.close`, origin unload): say why in it instead.
364 try {
365 // No door or Herdr around: a note opened here shows in the pane in place of the list (hooks/detail-view.ts).
366 if (history.isShown) return await drawDetail($, e, $.ui.resolve(e) as unknown as DetailElements & BlockViewElements, e.props.bodyColumns)
367 return await drawMentions($, e, $.ui.resolve(e) as unknown as SiteElements, 'pane', e.props.bodyColumns, workspace, option)
368 } catch (error) {
369 return $.ui.resolve(e).Text({ dimColor: true, children: `Recent mentions could not be drawn: ${error instanceof Error ? error.message : String(error)}` })
370 }
371 })
372
373 // `m` (to the pane, or back to the band) in the press's own hook: an open made with this `$` answers the person's
374 // press, so the engine places the pane at any width. One made from the drawing's closure is the plugin's own, and
375 // waits below 144 columns: the pane never showed and the band was gone.
376 on('ui.press', { plugin: 'ep0ch', element: 'mentions-move' }, async ($, e) => {
377 const placement = e.component === 'Pane' ? 'band' : 'pane'
378 await chooseMentions($, x => ({ ...x, placement }), option, true)
379 return { element: e.element }
380 })
381
382 // The person closing the pane (its mark, ctrl+x x) is their choice too: kept as hidden. Any other close (the
383 // engine dropping it) leaves the choice, and the band stands in for it.
384 // A pane opened only for a note (the band the choice) closed by them leaves the band; the pane opens on the list next.
385 on('ui.close', async ($, e, next) => {
386 const result = await next(e)
387 const prefs = await mentionsPrefsOf($)
388 if (e.id === MENTIONS_PANE && e.origin.kind === 'person' && prefs.placement === 'pane') await keepMentionsPrefs($, { ...prefs, placement: 'off' })
389 if (e.id === MENTIONS_PANE) await $.state.set(DETAIL, listed(await detailHistoryOf($)))
390 if (e.id === MENTIONS_PANE) $.ui.invalidate('ui.render')
391 return result
392 })
393
394 // A press that opens a note here (a mention, a reference in a reply, a tool row's note) seats the mentions pane from the press itself: an open made with the press's `$`
395 // answers the person, so the engine places it at any width (openNote's open that follows finds it up). In a door
396 // or Herdr nothing is seated: the note opens there.
397 on('ui.press', { plugin: 'ep0ch' }, async ($, e, next) => {
398 const opensNote = /^mention-\d+$/.test(e.element) || e.element.startsWith('tool-row-open-') ||
399 (e.element === 'outliner-references' && !!e.link && outlinerUriOf(e.link.href) !== null)
400 // The cached variables read without waiting, so the press reaches its closure before anything queued after it.
401 if (opensNote && references?.workspace && routeOf(routeEnv ?? await routeEnvOf($)) === 'here') {
402 await $.ui.open({ id: MENTIONS_PANE, title: MENTIONS_TITLE }).catch(() => {})
403 }
404 return next(e)
405 })
406
407 on('command.run', { command: 'mentions' }, async ($, e) => {
408 const choice = commandChoice(e.args)
409 if (!choice) return { text: COMMAND_USAGE }
410 const chosen = await chooseMentions($, choice.change, option, choice.places)
411 await refreshMentions($, option)
412 return { text: choicesText(chosen, chosen.placement !== 'pane' || await paneIsPlaced($)) }
413 })
414
415 // Messages between Claude sessions (hooks/peer-log.ts): each one this session sends or receives (the model's
416 // SendMessage, a mod's `$.session.send`, the /msg prompt command) is copied under the bound outline's [[peer-messages]]
417 // page, off the delivery so a slow or missing `ep0ch` never holds a message up.
418 on('session.send', async ($, e, next) => {
419 const sent = await next(e)
420 // Only messages between sessions (loki, kitty, cowboy…), never to this session's own subagents or teammates
421 // (Evan, Oct 16): a send to one of them is addressed by the id or teammate id `$.agent.list()` lists.
422 if (sent.isDelivered && e.agentId === undefined && !(await toOwnAgent($, e.to))) {
423 const ms = await $.clock.now()
424 $.clock.after(0, () => void logPeerMessage($, option, 'to', e.to, ms, e.text))
425 }
426 return sent
427 }).catch(($, e, next) => next(e))
428
429 on('session.receive', async ($, e, next) => {
430 const got = await next(e)
431 if (got.consumed === undefined && e.agentId === undefined && (e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message')) {
432 const { from, body } = unwrapPeer(got.text)
433 const ms = await $.clock.now()
434 $.clock.after(0, () => void logPeerMessage($, option, 'from', from, ms, body))
435 }
436 return got
437 }).catch(($, e, next) => next(e))
438
439 on('session.start', async ($, e, next) => {
440 const result = await next(e)
441 reportStatus($, sessionStarted())
442 sessionCwd = typeof e.cwd === 'string' && e.cwd ? e.cwd : null
443 rebound = null; touchGlobs = null
444 // A session start (or the module's reload) starts mentions again: the command, the kept choices, the list.
445 mentionsStarting = undefined
446 $.clock.after(0, () => void startMentionsOnce($, option))
447 // Off the start's dispatch: a slow or missing `ep0ch` never holds the session up. The binding card shows from
448 // the start (saying it is looking) and fills in when `where` and the folder's outline are read.
449 whereLoad = new Promise(resolve => {
450 $.clock.after(0, () => void runWhere($).then(resolve, () => resolve({ summary: null, facts: null, why: 'ep0ch where failed' })))
451 })
452 bindingLoad = undefined
453 bindingGiven = undefined
454 workboardGiven = undefined
455 $.clock.after(0, () => void readWorkboard($, option))
456 guideGiven = undefined
457 $.clock.after(0, () => void readGuide($, option))
458 // The settle check, in the deployer's session only (hooks/settle.ts).
459 settleStarting = undefined
460 $.clock.after(0, () => void startSettleOnce($, option))
461 await $.state.set(BINDING_STATE, { shown: true, facts: (await $.state.get(BINDING_STATE)).value?.facts ?? null })
462 $.ui.status(statusLine(null))
463 $.clock.after(0, () => void readBinding($, option, false).catch(() => {}))
464 await $.tool.register({ name: SHOW_TOOL.name, description: SHOW_TOOL.description, inputSchema: SHOW_TOOL.inputSchema })
465 // Off the start's dispatch: each registration republishes the tool server (~20ms), and these tools are
466 // loaded on demand, so the session never waits for them. The door tools act in the door this Claude runs
467 // in: only in a door tile, where EP0CH_CONTROL names it.
468 $.clock.after(0, () => void (async () => {
469 // Offered when a door is reachable (PIE-715): EP0CH_CONTROL, or, for a Claude that does not descend from a tile
470 // (a background job, a resumed session), the door of the folder's outline, by `ep0ch where`'s own resolution.
471 const door = (await $.env.get('EP0CH_CONTROL'))?.trim() || (await whereLoad)?.facts?.reach?.control
472 const tools = [...WORK_TOOLS, ...OUTLINE_TOOLS, COMPONENTS_TOOL, BIND_TOOL, ...(door ? DOOR_TOOLS : [])]
473 for (const tool of tools) {
474 // The core ones in the prompt's tool list, not behind tool search (PIE-721): what a session reads and writes notes with.
475 await $.tool.register({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, ...(UPFRONT_TOOLS.includes(tool.name) ? { isDeferred: false } : {}) })
476 }
477 })().catch(() => {}))
478 return result
479 })
480
481 // Where this session runs, as one context block of the first prompt (not a pane): waited for briefly, else
482 // the variables alone. Nothing is added outside a door, and a failure adds nothing.
483 on('prompt.context', async ($, e, next) => {
484 const result = await next(e)
485 try {
486 const env = await doorEnvOf($)
487 // Before session.start's work is queued (or after a reload): start it once, here.
488 const load = (whereLoad ??= runWhere($).catch(() => ({ summary: null, facts: null })))
489 const facts = (bindingLoad ?? readBinding($, option, false)).catch(() => null)
490 const board = workboardLoad ?? readWorkboard($, option)
491 const [ran, bound, workboard, guide] = await Promise.all([
492 inDoorEnv(env) ? Promise.race([load, $.clock.sleep(WHERE_WAIT_MS).then(() => null)]) : null,
493 Promise.race([facts, $.clock.sleep(BINDING_WAIT_MS).then(() => null)]),
494 // Not waited for longer than the binding: one that lands later makes the context again (readWorkboard).
495 Promise.race([board, $.clock.sleep(BINDING_WAIT_MS).then(() => null)]),
496 // The same for the guide (readGuide).
497 Promise.race([readGuide($, option), $.clock.sleep(BINDING_WAIT_MS).then(() => null)]),
498 ])
499 const blocks = result.blocks.filter(b => b.name !== WHERE_BLOCK && b.name !== BINDING_BLOCK && b.name !== WORKBOARD_BLOCK && b.name !== GUIDE_BLOCK)
500 if (inDoorEnv(env)) blocks.push({ name: WHERE_BLOCK, text: whereText(ran?.summary ?? envSummaryOf(env)) })
501 else if (bound?.where?.reach?.control && bound.where.reach.rule !== 'control') blocks.push({ name: WHERE_BLOCK, text: reachText(bound.where.reach) })
502 bindingGiven = bound ? bindingText(bound) : null
503 blocks.push({
504 name: BINDING_BLOCK,
505 // Still looking: the context is made again when it lands (readBinding).
506 text: bindingGiven ?? 'Which outline this Claude session is bound to is still being looked up.',
507 })
508 workboardGiven = workboard
509 if (workboard) blocks.push({ name: WORKBOARD_BLOCK, text: workboard })
510 guideGiven = guide
511 if (guide) blocks.push({ name: GUIDE_BLOCK, text: guide })
512 // Made without it: one that lands later makes the context again.
513 else void readGuide($, option).then(text => { if (text && guideGiven === null) $.ui.invalidate('prompt.context') })
514 // A newer read (a compaction, a /clear) started while this one was awaited: when it lands, the context follows it.
515 const newer = workboardLoad
516 if (newer && newer !== board) void newer.then(text => { if (newer === workboardLoad && text !== workboardGiven) $.ui.invalidate('prompt.context') })
517 return { ...result, blocks }
518 } catch {
519 return result
520 }
521 })
522
523 // A compaction makes the first prompt's context again: the workboard is read anew for it, while the summary is made.
524 on('session.compact', async ($, e, next) => {
525 if (e.agentId === undefined) void readWorkboard($, option)
526 return next(e)
527 })
528
529 // /clear starts the conversation afresh: the card shows again, read anew, and the next first prompt carries it.
530 on('session.end', async ($, e, next) => {
531 const result = await next(e)
532 // Its program status: a /clear is a fresh conversation at its prompt; an end takes the record away.
533 reportStatus($, e.reason === 'clear' ? sessionStarted() : sessionEnded())
534 if (e.reason !== 'clear') return result
535 // The next conversation's first prompt reads this one, never the read before the /clear.
536 bindingGiven = undefined
537 void readBinding($, option, true).catch(() => {})
538 workboardGiven = undefined
539 void readWorkboard($, option)
540 guideGiven = undefined
541 await $.state.set(BINDING_STATE, { shown: true, facts: (await $.state.get(BINDING_STATE)).value?.facts ?? null })
542 return result
543 })
544
545 // `/outline`: the card again, read anew, and its words in the transcript.
546 on('command.run', { command: BINDING_COMMAND }, async ($, e) => {
547 // `/outline <name> [machine]` binds this session to that outline (PIE-756); bare `/outline` is the card.
548 const [name, machine, ...extra] = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
549 if (name) {
550 if (extra.length) return { text: 'Usage: /outline [<name> [<machine>]] (ep0ch outline list --all lists the names)' }
551 try { return { text: await bindSession($, option, name, machine) } } catch (error) { return { text: error instanceof Error ? error.message : String(error) } }
552 }
553 const facts = await readBinding($, option, true)
554 await $.state.set(BINDING_STATE, { shown: true, facts })
555 return { text: cardLines(facts).map(l => `${l.label ? `${l.label}: ` : ''}${l.text}`).join('\n') }
556 })
557
558 for (const tool of WORK_TOOLS) {
559 on('tool.call', { tool: `mcp__ep0ch__${tool.name}` }, async ($, e) => {
560 const checked = checkedInput(tool, e as Record<string, unknown>)
561 if (typeof checked === 'string') return { deny: checked }
562 const command = tool.command(checked.input)
563 if (typeof command === 'string') return { deny: command }
564 if (!references?.workspace) await loadReferences($, option)
565 const workspace = references?.workspace
566 if (!workspace) return { deny: await notBoundText($, references?.why) }
567 try {
568 return { result: droppedNote(tool, checked.dropped) + await runWorkCommand($, workspace, command, await actorFor($, {})) }
569 } catch (error) {
570 return { deny: error instanceof Error ? error.message : String(error) }
571 }
572 })
573 }
574
575 for (const tool of OUTLINE_TOOLS) {
576 on('tool.call', { tool: `mcp__ep0ch__${tool.name}` }, async ($, e) => {
577 const checked = checkedInput(tool, e as Record<string, unknown>)
578 if (typeof checked === 'string') return { deny: checked }
579 const input = checked.input
580 const command = tool.command(input)
581 if (typeof command === 'string') return { deny: command }
582 if (!references?.workspace) await loadReferences($, option)
583 const workspace = references?.workspace
584 if (!workspace) return { deny: await notBoundText($, references?.why) }
585 // outline_changes' `actor` filters by agent; every other tool's names who the write is attributed to.
586 const actor = await actorFor($, tool.name === 'outline_changes' ? {} : input)
587 try {
588 const written = await runOutlinerCli($, workspace, ['agent', command.operation, '--stdin', '--actor', actor], JSON.stringify(command.input))
589 // A note that now holds a figure comes back drawn (PIE-721), so a figure that doesn't draw is seen in this turn.
590 const drawn = drawnNoteOf(tool.name, written, input)
591 const drawing = drawn ? await $.process.run(drawArgv(drawn), { cwd: workspace.root, env: envFor(workspace), timeoutMs: DRAW_TIMEOUT_MS }).then(r => r.exitCode === 0 ? r.stdout : '', () => '') : ''
592 return { result: droppedNote(tool, checked.dropped) + withDrawing(written, drawing) }
593 } catch (error) {
594 return { deny: error instanceof Error ? error.message : String(error) }
595 }
596 })
597 }
598
599 // The library's brief, from the session's outline: `ep0ch` is the door's CLI, run in the bound folder.
600 on('tool.call', { tool: `mcp__ep0ch__${COMPONENTS_TOOL.name}` }, async ($, e) => {
601 const checked = checkedInput(COMPONENTS_TOOL, e as Record<string, unknown>)
602 if (typeof checked === 'string') return { deny: checked }
603 const command = componentsArgv(checked.input)
604 if (typeof command === 'string') return { deny: command }
605 if (!references?.workspace) await loadReferences($, option)
606 const workspace = references?.workspace
607 if (!workspace) return { deny: await notBoundText($, references?.why) }
608 const ran = await $.process.run(command.argv, { cwd: workspace.root, env: envFor(workspace), timeoutMs: 30_000 })
609 .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
610 if (ran.exitCode !== 0) return { deny: failureReasonOf(ran.stderr) || ran.stderr.trim() || 'ep0ch library --brief failed' }
611 return { result: ran.stdout.trim() }
612 })
613
614 // Binds the session's outline by name; works with nothing bound, which is when it is needed.
615 on('tool.call', { tool: `mcp__ep0ch__${BIND_TOOL.name}` }, async ($, e) => {
616 const checked = checkedInput(BIND_TOOL, e as Record<string, unknown>)
617 if (typeof checked === 'string') return { deny: checked }
618 try {
619 return { result: await bindSession($, option, String(checked.input.name).trim(), typeof checked.input.machine === 'string' && checked.input.machine.trim() ? checked.input.machine.trim() : undefined) }
620 } catch (error) {
621 return { deny: error instanceof Error ? error.message : String(error) }
622 }
623 })
624
625 for (const tool of DOOR_TOOLS) {
626 on('tool.call', { tool: `mcp__ep0ch__${tool.name}` }, async ($, e) => {
627 const reached = await doorReachFor($)
628 if (!reached.control) return { deny: `No door is reachable from here: ${reached.text}` }
629 const control = reached.control
630 const checked = checkedInput(tool, e as Record<string, unknown>)
631 if (typeof checked === 'string') return { deny: checked }
632 try {
633 return { result: await runDoorTool($, tool.name, checked.input, control, option) }
634 } catch (error) {
635 return { deny: error instanceof Error ? error.message : String(error) }
636 }
637 })
638 }
639
640 on('tool.call', { tool: 'mcp__ep0ch__show' }, async ($, e) => {
641 // A plugin tool's arguments arrive flat on the event, beside `tool`.
642 const checked = checkedInput(SHOW_TOOL, e as Record<string, unknown>)
643 if (typeof checked === 'string') return { deny: checked }
644 const reference: unknown = checked.input.ref
645 const uri = typeof reference === 'string' ? outlinerUriFor(reference) : null
646 if (!uri) return { deny: 'Give the ref: a Work ID, [[page]], ((block-uuid)) or pi-outliner:// URI to show.' }
647 if (!references?.workspace) await loadReferences($, option)
648 try {
649 return { result: shownText(await openNote($, references?.workspace ?? null, uri, await actorFor($, {})), String(reference)) }
650 } catch (error) {
651 return { deny: deniedText(error, String(reference)) }
652 }
653 })
654
655 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) =>
656 await drawReferences($, e) ?? next(e))
657 on('ui.render', { component: 'UserMessage' }, async ($, e, next) =>
658 await drawReferences($, e) ?? next(e))
659
660 // The mod's own tool calls as compact rows that fold open on what they wrote (hooks/tool-rows.ts). Anything the
661 // formatter doesn't recognise, a drawing that throws, or the person's `/tool-rows off` leaves the engine's row.
662 // Claude Code's Edit and Write rows too (PIE-602): the file, the lines added and removed, pressed it opens in the door.
663 for (const tool of [...[...WRITE_TOOLS, ...READ_TOOLS].map(name => `${TOOL_PREFIX}${name}`), ...FILE_TOOLS]) {
664 on('ui.render', { component: 'ToolUse', props: { tool } }, async ($, e, next) => {
665 try {
666 const open = { ...TOOL_ROW_OPEN, id: e.props.tool_use_id }
667 const [prefs, expanded, titles] = await Promise.all([
668 $.state.get(TOOL_ROWS_PREFS).then(({ value }) => toolRowsPrefsOf(value)),
669 $.state.get(open).then(({ value }) => value === true),
670 toolTitlesOf($, e.props),
671 ])
672 if (!prefs.enabled) {
673 drawnToolRows.delete(e.props.tool_use_id)
674 return next(e)
675 }
676 const row = (FILE_TOOLS as readonly string[]).includes(tool) ? fileRowOf(e.props, sessionCwd ?? undefined) : toolRowOf(e.props, id => titles.get(id))
677 if (!row) {
678 drawnToolRows.delete(e.props.tool_use_id)
679 return next(e)
680 }
681 const tree = toolRowTree($.ui.resolve(e) as unknown as ToolRowElements, row, {
682 id: e.props.tool_use_id,
683 expanded,
684 toggle: () => void (async () => $.state.set(open, !(await $.state.get(open)).value))(),
685 open: (ref, surface) => void openToolTarget($, ref, surface),
686 })
687 drawnToolRows.add(e.props.tool_use_id)
688 return tree
689 } catch {
690 drawnToolRows.delete(e.props.tool_use_id)
691 return next(e)
692 }
693 })
694 // The row already says what happened: the result is one line (`✓ rev 2`), or nothing for a read. Errors in full.
695 on('ui.render', { component: 'ToolResult', props: { tool } }, async ($, e, next) => {
696 try {
697 // Only under a row the mod drew: where the call's row is the engine's, so is its result.
698 if (!toolRowsPrefsOf((await $.state.get(TOOL_ROWS_PREFS)).value).enabled || !drawnToolRows.has(e.props.tool_use_id)) return next(e)
699 const line = toolResultLineOf(e.props.tool, e.props.output, e.props.isErrored)
700 if (line === null) return next(e)
701 const { Box, Text } = $.ui.resolve(e)
702 return line ? Box({ paddingLeft: 2, children: Text({ dimColor: true, children: `⎿ ${line}` }) }) : Box({})
703 } catch {
704 return next(e)
705 }
706 })
707 }
708
709 // The claude.ai connector's writes carry this session's persona unless the call names one (PIE-1256): the connector
710 // is one principal with one default persona, so a write that names none is recorded as someone else's. A write
711 // recorded as another persona than this session's says so once per name, in a dim transcript line.
712 const slipped = new Set<string>()
713 on('tool.call', { tool: CONNECTOR_PERSONA_MATCH }, async ($, e, next) => {
714 const persona = await sessionPersona($)
715 const filled = personaFilled(e as Record<string, unknown>, persona)
716 const answered = await next((filled ?? e) as typeof e)
717 const slip = slipOf('result' in answered ? answered.result : undefined, persona)
718 if (slip && !slipped.has(slip.by)) {
719 slipped.add(slip.by)
720 $.ui.log(slip.line)
721 }
722 return answered
723 })
724
725 // Each file an Edit or Write changed, recorded in the session's outline after the call ran (PIE-602): off the call, so
726 // the tool's answer never waits for the outline, and one at a time, so a file's touches count up in order.
727 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
728 const held = await holdForThreads($, option, e)
729 if (held) return { deny: held }
730 const result = await next(e)
731 queueTouch($, option, e, result)
732 return result
733 })
734 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
735 const held = await holdForThreads($, option, e)
736 if (held) return { deny: held }
737 const result = await next(e)
738 queueTouch($, option, e, result)
739 return result
740 })
741
742 on('command.run', { command: TOOL_ROWS_COMMAND }, async ($, e) => {
743 const chosen = toolRowsCommandOf(e.args)
744 if (!chosen) return { text: `${TOOL_ROWS_USAGE} Now ${toolRowsPrefsOf((await $.state.get(TOOL_ROWS_PREFS)).value).enabled ? 'on' : 'off'}.` }
745 await $.state.set(TOOL_ROWS_PREFS, chosen)
746 await $.store.set(TOOL_ROWS_STORE_KEY, chosen)
747 return { text: chosen.enabled ? 'Outline tool rows on: the outline and workboard tool calls draw as compact rows; ▸ unfolds what each wrote.' : "Outline tool rows off: Claude Code's own rows draw the outline tools." }
748 })
749
750 // What this Claude is doing, to its terminal (OSC 7501; hooks/claude-status.ts has the events and why).
751 on('turn.start', async ($, e, next) => { reportStatus($, working()); return next(e) })
752 on('classic.PermissionRequest', async ($, e, next) => {
753 const result = await next(e)
754 // A hook that answered it (allow or deny) shows no dialog: nothing waits on the person.
755 if (!result.decision) reportStatus($, permissionAsked(e.tool_name, e.tool_input))
756 return result
757 })
758 on('classic.Notification', async ($, e, next) => {
759 const r = notified(e.notification_type, e.message)
760 // The dialog's own PermissionRequest said what it asks ("Allow Bash: …?"): its generic notification doesn't replace that.
761 if (r && !(r.kind && statusAsked?.state === 'blocked' && statusAsked.kind === r.kind)) reportStatus($, r)
762 return next(e)
763 })
764 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
765 reportStatus($, questionAsked(e.input))
766 try { return await next(e) } finally { reportStatus($, working()) }
767 })
768 // The tool ran, failed or was denied after its dialog: Claude goes on.
769 on('classic.PostToolUse', async ($, e, next) => { reportStatus($, working()); return next(e) })
770 on('classic.PostToolUseFailure', async ($, e, next) => { reportStatus($, working()); return next(e) })
771 on('classic.PermissionDenied', async ($, e, next) => { reportStatus($, working()); return next(e) })
772 on('classic.StopFailure', async ($, e, next) => { reportStatus($, stopFailed(e.error, e.error_details)); return next(e) })
773
774 on('turn.complete', async ($, e, next) => {
775 const result = await next(e)
776 // The main loop's turn (a subagent's isn't the session's): done, idle when interrupted, error when it failed.
777 if (e.agentId === undefined) reportStatus($, turnEnded(e.reason, e.answer))
778 // A module reloaded mid-session never sees its session.start.
779 if (!references) $.clock.after(0, () => void loadReferences($, option))
780 if (!settleStarting) $.clock.after(0, () => void startSettleOnce($, option))
781 // The binding read again now and then, so the status line follows a door's upgrade or reconnect, or a new .ep0ch.
782 else if ((await $.clock.now()) - bindingReadAt >= BINDING_REFRESH_MS) $.clock.after(0, () => void readBinding($, option, true).catch(() => {}))
783 if (!isIngestible(e)) return result
784 // Off the turn's dispatch: finding the folder's outline runs the CLI, and a slow one never delays the answer.
785 $.clock.after(0, () => void (async () => {
786 let workspace: Workspace | null
787 try {
788 workspace = await sessionWorkspace($, option, 'mentions')
789 } catch (error) {
790 const reason = error instanceof Error ? error.message : String(error)
791 if (toldWorkspaceFailures.has(reason)) return
792 toldWorkspaceFailures.add(reason)
793 $.ui.toast(`Outliner recent mentions unavailable: ${reason}`, { timeoutMs: 6000 })
794 return
795 }
796 const message = mentionMessageOf(e, { id: await $.session.id() }, workspace)
797 if (!message || !workspace) return
798 try {
799 await deliver($, message, workspace)
800 } catch (error) {
801 const reason = error instanceof Error ? error.message : String(error)
802 $.ui.toast(`Outliner recent mentions unavailable: ${reason}`, { timeoutMs: 6000 })
803 return
804 }
805 // The band and pane read the list again; a failure there never touches the delivery.
806 await refreshMentions($, option).catch(() => {})
807 })())
808 return result
809 })
810}
811
812/** Recent mentions in the band (`isMentions`), or the engine's band (`next`) when there are none to show. */
813async function mentionsBand($: EngineInterface, e: AbovePromptRender, next: (e: AbovePromptRender) => Promise<RenderElement>, option: PluginOptions): Promise<{ tree: RenderElement; isMentions: boolean }> {
814 const engine = async () => ({ tree: await next(e), isMentions: false })
815 // Read before anything else, so the band is drawn again when the list first lands (the outline is found then).
816 const [prefs, list] = await Promise.all([mentionsPrefsOf($), mentionsListOf($)])
817 // A module reloaded mid-session may not see its session.start: start mentions off the draw, once.
818 if (!mentionsStarting) $.clock.after(0, () => void startMentionsOnce($, option))
819 const workspace = references?.workspace
820 if (e.props.hasSurvey || !workspace || prefs.placement === 'off' || !bandHasContent(list)) return engine()
821 // The pane chosen but not on screen (it waits below the width an unasked pane needs, or the engine dropped it):
822 // the band stands in, its `m` showing the pane, so the mentions never vanish.
823 if (prefs.placement === 'pane' && await paneIsPlaced($)) return engine()
824 try {
825 return { tree: await drawMentions($, e, $.ui.resolve(e) as unknown as SiteElements, 'band', e.props.bodyColumns, workspace, option), isMentions: true }
826 } catch (error) {
827 return { tree: $.ui.resolve(e).Text({ dimColor: true, children: `Recent mentions could not be drawn: ${error instanceof Error ? error.message : String(error)}` }), isMentions: true }
828 }
829}
830
831const NOT_BOUND = "This session is not bound to an Outliner outline (nothing names one: no EP0CH_WS, no .ep0ch in the folder it started in, no outline_bind), or its folder is opted out."
832const NOT_BOUND_SHORT = 'No outline: /outline <name> binds one.'
833
834/**
835 * Why the outline tools are off and the exact call that turns them on (PIE-756): the name EP0CH_WS gives, else the
836 * door's outline, else the outlines `ep0ch outline list` shows, with a real one as the example.
837 */
838async function notBoundText($: EngineInterface, why?: string): Promise<string> {
839 const base = why ? `No Outliner outline for this session: ${why}.` : NOT_BOUND
840 const ws = (await $.env.get('EP0CH_WS'))?.trim()
841 const machine = (await $.env.get('EP0CH_MACHINE'))?.trim()
842 const door = (await (whereLoad ?? Promise.resolve(null)))?.facts?.door
843 const given = ws && OUTLINE_NAME.test(ws) ? { name: ws, machine: machine && MACHINE_NAME.test(machine) ? machine : null }
844 : door?.outline ? { name: door.outline, machine: door.machine } : null
845 const listed = given ? [] : await outlinesListed($).then(l => ('outlines' in l ? l.outlines : []), () => [])
846 return `${base.replace(/\.\.$/, '.')} ${bindHint(given, listed)}`
847}
848
849/** Every outline `ep0ch outline list --all --lines` shows (this machine's, then each machine opened), or why it could not. */
850async function outlinesListed($: EngineInterface): Promise<{ outlines: { name: string; machine: string | null }[] } | { why: string }> {
851 const ran = await $.process.run(['ep0ch', 'outline', 'list', '--all', '--lines'], { cwd: await startFolderOf($), timeoutMs: 15_000 })
852 .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
853 if (ran.exitCode !== 0) return { why: failureReasonOf(ran.stderr) || ran.stderr.trim() || '`ep0ch outline list` failed' }
854 const outlines = ran.stdout.split('\n').flatMap(line => {
855 const [name, machine] = line.split('\t')
856 return name && OUTLINE_NAME.test(name) ? [{ name, machine: machine?.trim() || null }] : []
857 })
858 return { outlines }
859}
860
861/**
862 * `/outline <name> [machine]` and outline_bind: this session's outline is the one named, for the rest of the session,
863 * over EP0CH_WS and every folder. An outline nobody has made is refused (a write would otherwise make it), with the
864 * ones that exist. Returns the words to say; throws with the reason when it does not bind.
865 */
866function bindSession($: EngineInterface, option: PluginOptions, name: string, machine?: string): Promise<string> {
867 // One at a time: two overlapping binds would each read back the other's outline.
868 const run = bindQueue.then(() => bindSessionNow($, option, name, machine))
869 bindQueue = run.catch(() => undefined)
870 return run
871}
872let bindQueue: Promise<unknown> = Promise.resolve()
873
874async function bindSessionNow($: EngineInterface, option: PluginOptions, name: string, machine?: string): Promise<string> {
875 if (!OUTLINE_NAME.test(name)) throw Error(`"${name}" isn't an outline name (lowercase letters, digits and hyphens, up to 32). ${bindHint(null, [])}`)
876 if (machine && !MACHINE_NAME.test(machine)) throw Error(`"${machine}" isn't an ssh config name (a Host in ~/.ssh/config). Leave machine out for an outline on this machine.`)
877 const listed = await outlinesListed($)
878 if ('why' in listed) throw Error(`could not check that ${name} exists: ${listed.why}. Try \`ep0ch outline list --all\`.`)
879 const same = (o: { name: string; machine: string | null }) => o.machine === (machine ?? null) || (!machine && !o.machine)
880 if (!listed.outlines.some(o => o.name === name && same(o))) {
881 const here = listed.outlines.filter(o => same(o)).map(o => o.name)
882 const other = listed.outlines.filter(o => o.name === name && !same(o)).map(o => o.machine)
883 throw Error(`there is no outline "${name}"${machine ? ` on ${machine}` : ' on this machine'}${other.length ? ` (a "${name}" is on ${other.map(m => m ?? 'this machine').join(', ')}: ${bindCall(name, other[0])})` : ''}. ${here.length ? `Here: ${here.join(', ')}; e.g. ${bindCall(here[0]!, machine)}.` : 'Run `ep0ch outline list --all`.'}`)
884 }
885 await loadingReferences?.catch(() => {})
886 touchGlobs = null
887 rebound = { outline: name, ...(machine ? { machine } : {}) }
888 const facts = await readBinding($, option, true)
889 await $.state.set(BINDING_STATE, { shown: true, facts })
890 await refreshMentions($, option)
891 const ws = references?.workspace
892 if (!ws || ws.outline !== name || (ws.machine ?? undefined) !== machine) throw Error(`bound to ${name}, but the session's outline reads back as ${ws?.outline ?? 'none'}${references?.why ? `: ${references.why}` : ''}`)
893 return `This session's outline tools now use ${name}${machine ? ` on ${machine}` : ''} (bound by call; a shell cd does not move it).`
894}
895
896const PLUGIN_ID = 'float.pi-outliner'
897
898async function doorEnvOf($: EngineInterface): Promise<DoorEnv> {
899 const [EP0CH_NEST, EP0CH_CONTROL, EP0CH_TILE, EP0CH_TILE_ID] = await Promise.all([
900 $.env.get('EP0CH_NEST'),
901 $.env.get('EP0CH_CONTROL'),
902 $.env.get('EP0CH_TILE'),
903 $.env.get('EP0CH_TILE_ID'),
904 ])
905 return { EP0CH_NEST, EP0CH_CONTROL, EP0CH_TILE, EP0CH_TILE_ID }
906}
907
908/** Whether this session's `ep0ch` knows `where` (its help asked once): null until asked. */
909/**
910 * `ep0ch where --json`, in a door or not, run in the session's folder: its
911 * one-line summary (null when it didn't answer: not on PATH, too old, an
912 * error) and the facts the binding card reads. `where` only reads.
913 */
914async function runWhere($: EngineInterface): Promise<WhereRun> {
915 try {
916 const ran = await $.process.run(['ep0ch', 'where', '--json'], { cwd: await startFolderOf($), timeoutMs: WHERE_TIMEOUT_MS })
917 if (ran.exitCode !== 0) return { summary: null, facts: null, why: failureReasonOf(ran.stderr) || '`ep0ch where` failed' }
918 return { summary: whereSummaryOf(ran.stdout), facts: whereFactsOf(ran.stdout) }
919 } catch {
920 return { summary: null, facts: null, why: 'no `ep0ch` on PATH, or `ep0ch where` did not answer in time' }
921 }
922}
923
924/** Each read of the binding is numbered: only the newest one writes the card and the status line. */
925let bindingGeneration = 0
926/** The binding text the first prompt's context was given (null: that it was still being looked up), to redo it on a change. */
927let bindingGiven: string | null | undefined
928
929/**
930 * Reads where this Claude is bound: the folder's outline as the tools found it
931 * (`references`, from `bound-folder`) and `ep0ch where --json`; then the card,
932 * the status line and (when it changed) the model's context show it. `again`:
933 * read both anew (a /clear, `/outline`, a turn now and then); else the
934 * session's first reads are used. It waits for the workspace only, never the
935 * Work-ID prefixes.
936 */
937function readBinding($: EngineInterface, option: PluginOptions, again: boolean): Promise<BindingFacts> {
938 if (!again && bindingLoad) return bindingLoad
939 const generation = ++bindingGeneration
940 const where = again || !whereLoad ? runWhere($) : whereLoad
941 whereLoad = where
942 const load = (async () => {
943 bindingReadAt = await $.clock.now()
944 if (again || !references) {
945 void loadReferences($, option)
946 await loadingWorkspace
947 }
948 // The shell's folder now, shown on the card as where Claude is; no decision about an outline reads it (PIE-756).
949 const [ran, cwd, home, control] = await Promise.all([where, $.session.cwd(), $.env.get('HOME'), $.env.get('EP0CH_CONTROL')])
950 const facts: BindingFacts = {
951 folder: folderFactsOf(),
952 where: ran.facts,
953 ...(ran.facts ? {} : { whereWhy: ran.why ?? '`ep0ch where` did not answer' }),
954 cwd,
955 ...(home ? { home } : {}),
956 doorTools: !!control?.trim() || !!ran.facts?.reach?.control,
957 }
958 if (generation !== bindingGeneration) return facts
959 await $.state.set(BINDING_STATE, { shown: (await $.state.get(BINDING_STATE)).value?.shown ?? true, facts })
960 $.ui.status(statusLine(facts))
961 // The model was told something else (or that it was still looking): its context is made again.
962 if (bindingGiven !== undefined && bindingGiven !== bindingText(facts)) $.ui.invalidate('prompt.context')
963 return facts
964 })()
965 bindingLoad = load
966 return load
967}
968
969/**
970 * Reads "workboard now" (hooks/workboard-now.ts) for the session's outline: the items in flight and waiting on the
971 * person, the build logs (one `list --query` each, the service's own grammar) and open PRs (`gh`, when it answers in
972 * time). Null: no outline, no workboard, or no answer within WORKBOARD_TIMEOUT_MS. Never throws. When it lands after
973 * the context was made without it (or with an older read), the context is made again.
974 */
975/**
976 * The outline's guide (`ep0ch library --guide`, hooks/outline-guide.ts), read once for a folder and outline: null outside
977 * a bound folder, or when `ep0ch` can't say it (asked again at the next prompt). prompt.context makes the context again
978 * when one lands after it.
979 */
980async function readGuide($: EngineInterface, option: PluginOptions): Promise<string | null> {
981 try {
982 if (!references?.workspace) await loadReferences($, option)
983 const workspace = references?.workspace
984 if (!workspace) return null
985 const key = `${workspace.root}\n${workspace.outline ?? ''}\n${workspace.machine ?? ''}`
986 if (guideLoad?.key !== key) {
987 const load = $.process.run([...GUIDE_ARGV], { cwd: workspace.root, env: envFor(workspace), timeoutMs: GUIDE_TIMEOUT_MS })
988 .then(ran => ran.exitCode === 0 ? guideText(ran.stdout) : null, () => null)
989 // No guide this time (no host yet, a timeout): the next prompt asks again, unless a newer read took its place.
990 .then(text => { if (text === null && guideLoad?.load === load) guideLoad = undefined; return text })
991 guideLoad = { key, load }
992 }
993 return await guideLoad.load
994 } catch {
995 return null
996 }
997}
998
999function readWorkboard($: EngineInterface, option: PluginOptions): Promise<string | null> {
1000 const generation = ++workboardGeneration
1001 // The outline's half: null when there is no outline, no workboard, or no answer.
1002 const board = async () => {
1003 if (!references?.workspace) await loadReferences($, option)
1004 const workspace = references?.workspace
1005 if (!workspace) return null
1006 const root = await outlinerRootOf($)
1007 if (!root) return null
1008 const list = async (args: string[]) => {
1009 const ran = await $.process.run(['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, ...args], { cwd: workspace.root, env: envFor(workspace), timeoutMs: WORKBOARD_TIMEOUT_MS })
1010 return ran.exitCode === 0 ? listedOf(ran.stdout) : null
1011 }
1012 const [items, logs] = await Promise.all([list(listArgs(BOARD_QUERY, LIST_LIMIT)), list(listArgs(BUILD_LOG_QUERY, LIST_LIMIT, 'id,title,timestamps'))])
1013 if (!items || !logs) return null
1014 const hasItems = items.blocks.length > 0 || !!(await list(listArgs(ANY_ITEM_QUERY, 1, 'id')))?.blocks.length
1015 if (!hasItems && !logs.blocks.length) return null
1016 return { workspace, items, logs, hasItems }
1017 }
1018 const read = async (): Promise<string | null> => {
1019 const found = await Promise.race([board().catch(() => null), $.clock.sleep(WORKBOARD_TIMEOUT_MS).then(() => null)])
1020 if (!found) return null
1021 // Open PRs only for an outline with a workboard, from the folder the session works in, on a budget of their own.
1022 const prs = await Promise.race([
1023 $.process.run([...PRS_ARGV], { cwd: found.workspace.root, timeoutMs: PRS_TIMEOUT_MS }).then(ran => ran.exitCode === 0 ? openPrsOf(ran.stdout) : null, () => null),
1024 $.clock.sleep(PRS_TIMEOUT_MS).then(() => null),
1025 ])
1026 return workboardText({ outline: found.workspace.outline ?? 'outline', items: found.items.blocks, itemsComplete: found.items.complete, logs: found.logs.blocks, prs }, found.hasItems)
1027 }
1028 const load = read().catch(() => null).then(text => {
1029 if (generation === workboardGeneration && workboardGiven !== undefined && workboardGiven !== text) $.ui.invalidate('prompt.context')
1030 return text
1031 })
1032 workboardLoad = load
1033 return load
1034}
1035
1036/** The folder's outline as the tools found it (`references`): bound, none named, opted out, or why it couldn't be found. */
1037function folderFactsOf(): FolderFacts {
1038 if (references?.workspace) return { kind: 'bound', workspace: references.workspace }
1039 if (references?.why) return { kind: 'failed', why: references.why }
1040 if (references?.optedOut) return { kind: 'opted-out', root: references.optedOut }
1041 return references ? { kind: 'unbound' } : { kind: 'failed', why: "the folder's outline was not read yet" }
1042}
1043
1044/**
1045 * The installed Outliner's root, as Herdr reports it: exactly one enabled
1046 * `float.pi-outliner` with an absolute `plugin_root`. Never a guessed checkout.
1047 * Null when the plugin is installed but disabled.
1048 */
1049async function outlinerRootOf($: EngineInterface): Promise<string | null> {
1050 const listed = await $.process.run(
1051 ['herdr', 'plugin', 'list', '--plugin', PLUGIN_ID, '--json'],
1052 { timeoutMs: 5000 },
1053 )
1054 if (listed.exitCode !== 0) throw Error('Herdr could not discover the Outliner installation')
1055 const plugins: unknown = JSON.parse(listed.stdout)?.result?.plugins
1056 const matches = Array.isArray(plugins)
1057 ? plugins.filter(plugin => plugin?.plugin_id === PLUGIN_ID)
1058 : []
1059 if (matches.length !== 1) throw Error(`Expected one installed ${PLUGIN_ID} plugin`)
1060 const [plugin] = matches
1061 if (plugin.enabled === false) return null
1062 if (typeof plugin.plugin_root !== 'string' || !plugin.plugin_root.startsWith('/'))
1063 throw Error('Herdr did not report an absolute plugin_root')
1064 return plugin.plugin_root
1065}
1066
1067/**
1068 * The workspace a session feeds (`mentions`) or works in (`tools`: the outline
1069 * tools and reference links), or null (`sessionWorkspaceOf`). Strict mode's
1070 * list limits only what feeds; the tools follow the folder's binding.
1071 * In folder mode the installed CLI's `bound-folder` says which folder, from
1072 * the session's cwd up, is bound to an outline; an opted-out folder never
1073 * asks. A disabled Outliner is null; a CLI that cannot answer (one older than
1074 * `bound-folder`) throws with why, and nothing is fed.
1075 */
1076async function sessionWorkspace($: EngineInterface, options: PluginOptions, purpose: 'mentions' | 'tools', note: { optedOut?: string } = {}): Promise<Workspace | null> {
1077 const [listedEnv, modeEnv, home, cwd] = await Promise.all([
1078 $.env.get('PI_OUTLINER_MENTIONS_WORKSPACES'),
1079 $.env.get('PI_OUTLINER_MENTIONS_MODE'),
1080 $.env.get('HOME'),
1081 startFolderOf($),
1082 ])
1083 const listed = effectiveWorkspaces(options.workspaces, listedEnv, home)
1084 const mode = mentionsModeOf(options.mode, modeEnv, listed)
1085 const listedHere = workspaceForCwd(cwd, listed)
1086 // A call (/outline, outline_bind) names the outline outright, over an opted-out folder too (PIE-756).
1087 if (rebound) return namedWorkspaceOf(cwd, rebound, undefined, undefined)
1088 // So does EP0CH_WS for the tools (order: call, EP0CH_WS, the folder the session started in). An opted-out folder only
1089 // keeps its mentions from feeding; strict mode keeps its own folder list.
1090 if (purpose === 'tools' && mode === 'folder') {
1091 const named = await namedFromEnv($, cwd)
1092 if (named) return named
1093 }
1094 // In folder mode a listed folder is opted out: said on the binding card as it is, never worked out again there.
1095 if (mode === 'folder' && listedHere !== null) note.optedOut = listedHere
1096 // Strict mode limits what feeds Recent Mentions; the tools and links still work in any bound folder.
1097 if (mode === 'allowlist' ? purpose === 'mentions' || listedHere !== null : listedHere !== null) return sessionWorkspaceOf(cwd, mode, listed, null)
1098 // A remote socket in Claude's environment would take every CLI run elsewhere than the folder's binding.
1099 const socket = await $.env.get('EP0CH_SOCKET')
1100 if (socket?.trim()) {
1101 throw Error("EP0CH_SOCKET in Claude's environment would send it to another machine's host than this session's outline; unset it, or use strict mode (PI_OUTLINER_MENTIONS_MODE=allowlist)")
1102 }
1103 // Mentions follow EP0CH_WS too, once the folder is not opted out; it beats the door's outline and any .ep0ch.
1104 if (purpose === 'mentions') {
1105 const named = await namedFromEnv($, cwd)
1106 if (named) return named
1107 }
1108 const root = await outlinerRootOf($)
1109 if (!root) return null
1110 const ran = await $.process.run(
1111 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, 'bound-folder', cwd],
1112 { cwd, timeoutMs: 30_000 },
1113 )
1114 if (ran.exitCode !== 0) {
1115 const reason = failureReasonOf(ran.stderr)
1116 throw Error(/Unknown command: bound-folder/.test(reason)
1117 ? "the installed Outliner is too old to find this folder's outline (no bound-folder); update it"
1118 : `bound-folder failed${reason ? `: ${reason}` : ''}`)
1119 }
1120 const bound = boundWorkspaceOf(ran.stdout, cwd)
1121 if (mode === 'allowlist') return bound
1122 const folderWorkspace = sessionWorkspaceOf(cwd, mode, listed, bound)
1123 // In a door tile the door's outline is the binding (PIE-755), whatever the folder says; opted-out folders stay off.
1124 if (purpose === 'tools' && (await $.env.get('EP0CH_CONTROL'))?.trim()) {
1125 return doorWorkspaceOf((await (whereLoad ?? runWhere($))).facts?.door, cwd, folderWorkspace)
1126 }
1127 return folderWorkspace
1128}
1129
1130/**
1131 * The workspace EP0CH_WS (with EP0CH_MACHINE) names, or null (PIE-756). The order is a call, EP0CH_WS, the folder the
1132 * session started in; nothing reads the shell's current folder to pick an outline.
1133 */
1134async function namedFromEnv($: EngineInterface, cwd: string): Promise<Workspace | null> {
1135 const ws = await $.env.get('EP0CH_WS')
1136 if (!ws?.trim()) return null
1137 const machine = await $.env.get('EP0CH_MACHINE')
1138 // EP0CH_SOCKET with EP0CH_WS names a host and the outline on it, the pair an outline host hands every program it
1139 // starts (an extension's process, @margin's session, with its EP0CH_OUTLINES): taken only when it is this machine's
1140 // host socket. Any other (a forward to another machine, wherever it lives) would take every CLI run elsewhere.
1141 if (!sameHostSocket(await $.env.get('EP0CH_SOCKET'), machine, await $.env.get('EP0CH_OUTLINES'), await $.env.get('HOME'))) {
1142 throw Error("EP0CH_SOCKET in Claude's environment would send it to another machine's host than EP0CH_WS names; unset it, or use strict mode (PI_OUTLINER_MENTIONS_MODE=allowlist)")
1143 }
1144 return namedWorkspaceOf(cwd, null, ws, machine)
1145}
1146
1147/**
1148 * The folder this session started in: the one its outline binding follows. A shell `cd` never moves it
1149 * (PIE-755); a new session, a /clear and `/outline` read it anew.
1150 */
1151async function startFolderOf($: EngineInterface): Promise<string> {
1152 return sessionCwd ?? (sessionCwd = await $.session.cwd())
1153}
1154
1155/**
1156 * Posts one message through the installed CLI's `mentions ingest`. The
1157 * session's workspace selects the outline, never this process's cwd; an
1158 * existing connection override in the environment is kept, and no service is
1159 * started.
1160 */
1161async function deliver($: EngineInterface, message: MentionMessage, workspace: Workspace): Promise<void> {
1162 const root = await outlinerRootOf($)
1163 if (root === null) return
1164 const ingested = await $.process.run(
1165 ['/bin/sh', `${root}/scripts/run-bun.sh`, `${root}/src/cli.ts`, 'mentions', 'ingest'],
1166 {
1167 cwd: workspace.root,
1168 env: envFor(workspace),
1169 stdin: JSON.stringify(message),
1170 timeoutMs: 30_000,
1171 },
1172 )
1173 if (ingested.exitCode !== 0) {
1174 const reason = failureReasonOf(ingested.stderr)
1175 throw Error(`mentions ingest failed${reason ? `: ${reason}` : ''}`)
1176 }
1177}
1178
1179/**
1180 * Runs one `work` / `note` command through the installed CLI in the session's
1181 * workspace, as Claude (agent author, this session as provenance). Resolves to
1182 * the command's JSON; a refusal throws with the CLI's reason.
1183 */
1184async function runWorkCommand(
1185 $: EngineInterface,
1186 workspace: Workspace,
1187 command: { args: string[]; stdin?: string },
1188 actor: string,
1189): Promise<string> {
1190 return runOutlinerCli($, workspace, withOptions(command.args, ['--author', 'agent', '--actor', actor]), command.stdin)
1191}
1192
1193/**
1194 * Who this session's writes are attributed to: this client on this machine (`claude-code@float-2`), with the tool
1195 * call's `actor`, else OUTLINER_ACTOR, else EP0CH_AGENT (the door's name for the agent), else the `agent` the session
1196 * folder's `.ep0ch` names (`ep0ch where` reads it) as the persona on top (`loki/claude-code@float-2`, ADR 0003). The
1197 * session id goes beside it as provenance. The door tools act as the same id.
1198 */
1199async function actorFor($: EngineInterface, input: Record<string, unknown>): Promise<string> {
1200 const [env, machine, folder] = await Promise.all([personaEnv($), machineName($), folderPersona($)])hooks/link-grammar.js 1139 lines1// Generated by bun scripts/link-grammar.ts from outline-core.
2// packages/outline-core/src/protocol.ts
3var OUTLINE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
4var SHARE_TTL = { defaultMs: 60 * 60000, maxMs: 24 * 60 * 60000, minMs: 60000, neverMs: 100 * 365 * 24 * 60 * 60000 };
5var WEB_SESSION_TTL_MS = 30 * 24 * 60 * 60000;
6
7// node:path
8function assertPath(path) {
9 if (typeof path !== "string")
10 throw TypeError("Path must be a string. Received " + JSON.stringify(path));
11}
12function normalizeStringPosix(path, allowAboveRoot) {
13 var res = "", lastSegmentLength = 0, lastSlash = -1, dots = 0, code;
14 for (var i = 0;i <= path.length; ++i) {
15 if (i < path.length)
16 code = path.charCodeAt(i);
17 else if (code === 47)
18 break;
19 else
20 code = 47;
21 if (code === 47) {
22 if (lastSlash === i - 1 || dots === 1)
23 ;
24 else if (lastSlash !== i - 1 && dots === 2) {
25 if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) {
26 if (res.length > 2) {
27 var lastSlashIndex = res.lastIndexOf("/");
28 if (lastSlashIndex !== res.length - 1) {
29 if (lastSlashIndex === -1)
30 res = "", lastSegmentLength = 0;
31 else
32 res = res.slice(0, lastSlashIndex), lastSegmentLength = res.length - 1 - res.lastIndexOf("/");
33 lastSlash = i, dots = 0;
34 continue;
35 }
36 } else if (res.length === 2 || res.length === 1) {
37 res = "", lastSegmentLength = 0, lastSlash = i, dots = 0;
38 continue;
39 }
40 }
41 if (allowAboveRoot) {
42 if (res.length > 0)
43 res += "/..";
44 else
45 res = "..";
46 lastSegmentLength = 2;
47 }
48 } else {
49 if (res.length > 0)
50 res += "/" + path.slice(lastSlash + 1, i);
51 else
52 res = path.slice(lastSlash + 1, i);
53 lastSegmentLength = i - lastSlash - 1;
54 }
55 lastSlash = i, dots = 0;
56 } else if (code === 46 && dots !== -1)
57 ++dots;
58 else
59 dots = -1;
60 }
61 return res;
62}
63function _format(sep, pathObject) {
64 var dir = pathObject.dir || pathObject.root, base = pathObject.base || (pathObject.name || "") + (pathObject.ext || "");
65 if (!dir)
66 return base;
67 if (dir === pathObject.root)
68 return dir + base;
69 return dir + sep + base;
70}
71function resolve() {
72 var resolvedPath = "", resolvedAbsolute = false, cwd;
73 for (var i = arguments.length - 1;i >= -1 && !resolvedAbsolute; i--) {
74 var path;
75 if (i >= 0)
76 path = arguments[i];
77 else {
78 if (cwd === undefined)
79 cwd = process.cwd();
80 path = cwd;
81 }
82 if (assertPath(path), path.length === 0)
83 continue;
84 resolvedPath = path + "/" + resolvedPath, resolvedAbsolute = path.charCodeAt(0) === 47;
85 }
86 if (resolvedPath = normalizeStringPosix(resolvedPath, !resolvedAbsolute), resolvedAbsolute)
87 if (resolvedPath.length > 0)
88 return "/" + resolvedPath;
89 else
90 return "/";
91 else if (resolvedPath.length > 0)
92 return resolvedPath;
93 else
94 return ".";
95}
96function normalize(path) {
97 if (assertPath(path), path.length === 0)
98 return ".";
99 var isAbsolute = path.charCodeAt(0) === 47, trailingSeparator = path.charCodeAt(path.length - 1) === 47;
100 if (path = normalizeStringPosix(path, !isAbsolute), path.length === 0 && !isAbsolute)
101 path = ".";
102 if (path.length > 0 && trailingSeparator)
103 path += "/";
104 if (isAbsolute)
105 return "/" + path;
106 return path;
107}
108function isAbsolute(path) {
109 return assertPath(path), path.length > 0 && path.charCodeAt(0) === 47;
110}
111function join() {
112 if (arguments.length === 0)
113 return ".";
114 var joined;
115 for (var i = 0;i < arguments.length; ++i) {
116 var arg = arguments[i];
117 if (assertPath(arg), arg.length > 0)
118 if (joined === undefined)
119 joined = arg;
120 else
121 joined += "/" + arg;
122 }
123 if (joined === undefined)
124 return ".";
125 return normalize(joined);
126}
127function relative(from, to) {
128 if (assertPath(from), assertPath(to), from === to)
129 return "";
130 if (from = resolve(from), to = resolve(to), from === to)
131 return "";
132 var fromStart = 1;
133 for (;fromStart < from.length; ++fromStart)
134 if (from.charCodeAt(fromStart) !== 47)
135 break;
136 var fromEnd = from.length, fromLen = fromEnd - fromStart, toStart = 1;
137 for (;toStart < to.length; ++toStart)
138 if (to.charCodeAt(toStart) !== 47)
139 break;
140 var toEnd = to.length, toLen = toEnd - toStart, length = fromLen < toLen ? fromLen : toLen, lastCommonSep = -1, i = 0;
141 for (;i <= length; ++i) {
142 if (i === length) {
143 if (toLen > length) {
144 if (to.charCodeAt(toStart + i) === 47)
145 return to.slice(toStart + i + 1);
146 else if (i === 0)
147 return to.slice(toStart + i);
148 } else if (fromLen > length) {
149 if (from.charCodeAt(fromStart + i) === 47)
150 lastCommonSep = i;
151 else if (i === 0)
152 lastCommonSep = 0;
153 }
154 break;
155 }
156 var fromCode = from.charCodeAt(fromStart + i), toCode = to.charCodeAt(toStart + i);
157 if (fromCode !== toCode)
158 break;
159 else if (fromCode === 47)
160 lastCommonSep = i;
161 }
162 var out = "";
163 for (i = fromStart + lastCommonSep + 1;i <= fromEnd; ++i)
164 if (i === fromEnd || from.charCodeAt(i) === 47)
165 if (out.length === 0)
166 out += "..";
167 else
168 out += "/..";
169 if (out.length > 0)
170 return out + to.slice(toStart + lastCommonSep);
171 else {
172 if (toStart += lastCommonSep, to.charCodeAt(toStart) === 47)
173 ++toStart;
174 return to.slice(toStart);
175 }
176}
177function _makeLong(path) {
178 return path;
179}
180function dirname(path) {
181 if (assertPath(path), path.length === 0)
182 return ".";
183 var code = path.charCodeAt(0), hasRoot = code === 47, end = -1, matchedSlash = true;
184 for (var i = path.length - 1;i >= 1; --i)
185 if (code = path.charCodeAt(i), code === 47) {
186 if (!matchedSlash) {
187 end = i;
188 break;
189 }
190 } else
191 matchedSlash = false;
192 if (end === -1)
193 return hasRoot ? "/" : ".";
194 if (hasRoot && end === 1)
195 return "//";
196 return path.slice(0, end);
197}
198function basename(path, ext) {
199 if (ext !== undefined && typeof ext !== "string")
200 throw TypeError('"ext" argument must be a string');
201 assertPath(path);
202 var start = 0, end = -1, matchedSlash = true, i;
203 if (ext !== undefined && ext.length > 0 && ext.length <= path.length) {
204 if (ext.length === path.length && ext === path)
205 return "";
206 var extIdx = ext.length - 1, firstNonSlashEnd = -1;
207 for (i = path.length - 1;i >= 0; --i) {
208 var code = path.charCodeAt(i);
209 if (code === 47) {
210 if (!matchedSlash) {
211 start = i + 1;
212 break;
213 }
214 } else {
215 if (firstNonSlashEnd === -1)
216 matchedSlash = false, firstNonSlashEnd = i + 1;
217 if (extIdx >= 0)
218 if (code === ext.charCodeAt(extIdx)) {
219 if (--extIdx === -1)
220 end = i;
221 } else
222 extIdx = -1, end = firstNonSlashEnd;
223 }
224 }
225 if (start === end)
226 end = firstNonSlashEnd;
227 else if (end === -1)
228 end = path.length;
229 return path.slice(start, end);
230 } else {
231 for (i = path.length - 1;i >= 0; --i)
232 if (path.charCodeAt(i) === 47) {
233 if (!matchedSlash) {
234 start = i + 1;
235 break;
236 }
237 } else if (end === -1)
238 matchedSlash = false, end = i + 1;
239 if (end === -1)
240 return "";
241 return path.slice(start, end);
242 }
243}
244function extname(path) {
245 assertPath(path);
246 var startDot = -1, startPart = 0, end = -1, matchedSlash = true, preDotState = 0;
247 for (var i = path.length - 1;i >= 0; --i) {
248 var code = path.charCodeAt(i);
249 if (code === 47) {
250 if (!matchedSlash) {
251 startPart = i + 1;
252 break;
253 }
254 continue;
255 }
256 if (end === -1)
257 matchedSlash = false, end = i + 1;
258 if (code === 46) {
259 if (startDot === -1)
260 startDot = i;
261 else if (preDotState !== 1)
262 preDotState = 1;
263 } else if (startDot !== -1)
264 preDotState = -1;
265 }
266 if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)
267 return "";
268 return path.slice(startDot, end);
269}
270function format(pathObject) {
271 if (pathObject === null || typeof pathObject !== "object")
272 throw TypeError('The "pathObject" argument must be of type Object. Received type ' + typeof pathObject);
273 return _format("/", pathObject);
274}
275function parse(path) {
276 assertPath(path);
277 var ret = { root: "", dir: "", base: "", ext: "", name: "" };
278 if (path.length === 0)
279 return ret;
280 var code = path.charCodeAt(0), isAbsolute2 = code === 47, start;
281 if (isAbsolute2)
282 ret.root = "/", start = 1;
283 else
284 start = 0;
285 var startDot = -1, startPart = 0, end = -1, matchedSlash = true, i = path.length - 1, preDotState = 0;
286 for (;i >= start; --i) {
287 if (code = path.charCodeAt(i), code === 47) {
288 if (!matchedSlash) {
289 startPart = i + 1;
290 break;
291 }
292 continue;
293 }
294 if (end === -1)
295 matchedSlash = false, end = i + 1;
296 if (code === 46) {
297 if (startDot === -1)
298 startDot = i;
299 else if (preDotState !== 1)
300 preDotState = 1;
301 } else if (startDot !== -1)
302 preDotState = -1;
303 }
304 if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {
305 if (end !== -1)
306 if (startPart === 0 && isAbsolute2)
307 ret.base = ret.name = path.slice(1, end);
308 else
309 ret.base = ret.name = path.slice(startPart, end);
310 } else {
311 if (startPart === 0 && isAbsolute2)
312 ret.name = path.slice(1, startDot), ret.base = path.slice(1, end);
313 else
314 ret.name = path.slice(startPart, startDot), ret.base = path.slice(startPart, end);
315 ret.ext = path.slice(startDot, end);
316 }
317 if (startPart > 0)
318 ret.dir = path.slice(0, startPart - 1);
319 else if (isAbsolute2)
320 ret.dir = "/";
321 return ret;
322}
323var sep = "/";
324var delimiter = ":";
325var posix = ((p) => (p.posix = p, p))({ resolve, normalize, isAbsolute, join, relative, _makeLong, dirname, basename, extname, format, parse, sep, delimiter, win32: null, posix: null });
326
327// packages/outline-core/src/attribution.ts
328var NAME = "[A-Za-z0-9][A-Za-z0-9._-]";
329var PERSONA_PATTERN = new RegExp(`^${NAME}{0,63}$`);
330var CLIENT = `${NAME}{0,252}`;
331var CLIENT_PATTERN = new RegExp(`^${CLIENT}$`);
332var PRINCIPAL_PATTERN = new RegExp(`^${CLIENT}(@${NAME}{0,31})?$`);
333
334// packages/outline-core/src/outline-location.ts
335function isOutlineName(name) {
336 return typeof name === "string" && OUTLINE_NAME_PATTERN.test(name);
337}
338var MACHINE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
339function isMachineName(name) {
340 return typeof name === "string" && MACHINE_NAME_PATTERN.test(name);
341}
342
343// packages/outline-core/src/addressable-resource.ts
344var BLOCK_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
345var FRAGMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
346function encodeField(value) {
347 return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
348}
349function referenceEnvelopeEnd(text, from, singleLine = false, minimum = from) {
350 let depth = 0;
351 let first = -1;
352 for (let cursor = from;cursor < text.length - 1; cursor += 1) {
353 const character = text[cursor];
354 if ((singleLine || first >= 0) && (character === `
355` || character === "\r"))
356 break;
357 if (character === ")" && text[cursor + 1] === ")" && cursor >= minimum) {
358 if (first < 0)
359 first = cursor + 2;
360 if (depth <= 0)
361 return depth === 0 ? cursor + 2 : first;
362 }
363 if (character === "(")
364 depth += 1;
365 else if (character === ")")
366 depth -= 1;
367 if (depth < 0 && first >= 0)
368 return first;
369 }
370 return first;
371}
372function normalizeBlockId(value) {
373 if (!BLOCK_ID_PATTERN.test(value))
374 throw new Error(`Invalid block id: ${value}`);
375 return value.toLowerCase();
376}
377function formatEp0chBlockUri(target) {
378 if (!isOutlineName(target.outline))
379 throw new Error(`Invalid outline name: ${target.outline}`);
380 if (!isMachineName(target.machine))
381 throw new Error(`Invalid machine name: ${target.machine}`);
382 const blockId = normalizeBlockId(target.blockId);
383 if (target.fragment !== undefined && !FRAGMENT_ID_PATTERN.test(target.fragment))
384 throw new Error(`Invalid fragment id: ${target.fragment}`);
385 return `ep0ch://${encodeField(target.outline)}@${encodeField(target.machine)}/b/${blockId}${target.fragment ? `#${encodeField(target.fragment)}` : ""}`;
386}
387
388// packages/outline-core/src/code-fence.ts
389function leadingColumns(line) {
390 let columns = 0, at = 0;
391 for (;at < line.length; at++) {
392 if (line[at] === " ")
393 columns += 1;
394 else if (line[at] === "\t")
395 columns += 4 - columns % 4;
396 else
397 break;
398 }
399 return { columns, at };
400}
401var content = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
402function codeFenceOpen(line, contentIndent = 0) {
403 const text = content(line);
404 const { columns, at } = leadingColumns(text);
405 if (columns < contentIndent || columns - contentIndent > 3)
406 return null;
407 const char = text[at];
408 if (char !== "`" && char !== "~")
409 return null;
410 let end = at;
411 while (end < text.length && text[end] === char)
412 end++;
413 const length = end - at;
414 if (length < 3)
415 return null;
416 const info = text.slice(end).replace(/^[ \t]+|[ \t]+$/g, "");
417 if (char === "`" && info.includes("`"))
418 return null;
419 return { char, length, indent: columns, info };
420}
421function closesCodeFence(line, fence, contentIndent = 0) {
422 const closing = codeFenceOpen(line, contentIndent);
423 return !!closing && closing.char === fence.char && closing.length >= fence.length && closing.info === "";
424}
425function fenceEnd(lines, start, fence) {
426 let stop = -1;
427 for (let i = start + 1;i < lines.length; i++) {
428 const line = lines[i];
429 if (closesCodeFence(line, fence))
430 return { end: i, closed: true };
431 const other = codeFenceOpen(line);
432 if (other && other.char === fence.char && other.length >= fence.length && other.info !== "" && stop >= 0)
433 return { end: stop - 1, closed: false };
434 if (stop < 0 && (/^[ \t]*\r?$/.test(line) || other))
435 stop = i;
436 }
437 return { end: stop >= 0 ? stop - 1 : lines.length - 1, closed: false };
438}
439function listMarker(line) {
440 const match = /^([ \t]*)(?:[-+*]|\d{1,9}[.)])([ \t]+)/.exec(line);
441 if (!match)
442 return null;
443 const indent = leadingColumns(match[1]).columns;
444 const marker = match[0].slice(match[1].length).replace(/[^ \t]/g, " ");
445 return { indent, contentIndent: indent + leadingColumns(marker).columns, contentOffset: match[0].length };
446}
447function codeBlocks(lines) {
448 const fences = [], indented = [];
449 let listContainers = [];
450 let active = null;
451 let activeIndentedCode = false;
452 let canStartIndentedCode = true;
453 for (let i = 0;i < lines.length; i++) {
454 const line = lines[i];
455 if (active) {
456 const fence = active.block.fence;
457 const giveUp = (atLeast) => {
458 const end = active.stop >= 0 ? active.stop - 1 : atLeast;
459 active.block.end = Math.min(active.block.end, end);
460 listContainers = active.resume;
461 active = null;
462 return end;
463 };
464 if (active.contentIndent > 0 && line.trim() && leadingColumns(line).columns < active.contentIndent) {
465 if (active.stop >= 0) {
466 i = giveUp(i - 1);
467 continue;
468 }
469 active = null;
470 } else {
471 active.block.end = i;
472 if (closesCodeFence(line, fence, active.contentIndent)) {
473 active.block.closed = true;
474 active = null;
475 canStartIndentedCode = true;
476 continue;
477 }
478 const other = codeFenceOpen(line, active.contentIndent);
479 const reopens = !!other && other.char === fence.char && other.length >= fence.length && other.info !== "" && active.stop >= 0 && active.stop < i;
480 if (active.stop < 0 && (other || /^[ \t]*\r?$/.test(line)))
481 active.stop = i;
482 if (reopens || i === lines.length - 1) {
483 const end = giveUp(i);
484 if (end < i)
485 i = end;
486 }
487 continue;
488 }
489 }
490 if (/^[ \t]*\r?$/.test(line)) {
491 canStartIndentedCode = true;
492 continue;
493 }
494 const indent = leadingColumns(line).columns;
495 while (listContainers.length > 0 && indent < listContainers[listContainers.length - 1].contentIndent)
496 listContainers.pop();
497 const marker = listMarker(line);
498 const parent = listContainers[listContainers.length - 1];
499 const startsListItem = marker !== null && (parent ? marker.indent - parent.contentIndent <= 3 : marker.indent <= 3);
500 let contentIndent = parent?.contentIndent ?? 0;
501 let openingLine = line;
502 if (startsListItem) {
503 listContainers.push(marker);
504 activeIndentedCode = false;
505 contentIndent = marker.contentIndent;
506 openingLine = line.slice(marker.contentOffset);
507 }
508 const fence = codeFenceOpen(openingLine, startsListItem ? 0 : contentIndent);
509 if (fence) {
510 const block = { start: i, end: i, closed: false, fence };
511 fences.push(block);
512 active = { block, contentIndent, resume: [...listContainers], stop: -1 };
513 activeIndentedCode = false;
514 } else if (!startsListItem) {
515 const isIndented = indent - contentIndent >= 4;
516 if (isIndented && (activeIndentedCode || canStartIndentedCode)) {
517 indented.push(i);
518 activeIndentedCode = true;
519 } else if (!isIndented) {
520 activeIndentedCode = false;
521 }
522 }
523 canStartIndentedCode = false;
524 }
525 return { fences, indented };
526}
527
528// packages/outline-core/src/callouts.ts
529var BUILTIN_CALLOUTS = [
530 { name: "note", title: "Note", icon: "✎", tone: "blue", aliases: [] },
531 { name: "abstract", title: "Abstract", icon: "≡", tone: "blue", aliases: ["summary", "tldr"] },
532 { name: "info", title: "Info", icon: "ℹ", tone: "blue", aliases: [] },
533 { name: "todo", title: "Todo", icon: "☐", tone: "blue", aliases: [] },
534 { name: "tip", title: "Tip", icon: "✦", tone: "green", aliases: ["hint", "important"] },
535 { name: "success", title: "Success", icon: "✓", tone: "green", aliases: ["check", "done"] },
536 { name: "question", title: "Question", icon: "?", tone: "violet", aliases: ["help", "faq"] },
537 { name: "warning", title: "Warning", icon: "⚠", tone: "amber", aliases: ["caution", "attention"] },
538 { name: "failure", title: "Failure", icon: "✗", tone: "coral", aliases: ["fail", "missing"] },
539 { name: "danger", title: "Danger", icon: "△", tone: "coral", aliases: ["error"] },
540 { name: "bug", title: "Bug", icon: "※", tone: "coral", aliases: [] },
541 { name: "example", title: "Example", icon: "◆", tone: "violet", aliases: [] },
542 { name: "quote", title: "Quote", icon: "❝", tone: "neutral", aliases: ["cite"] }
543];
544var UNKNOWN_CALLOUT_ICON = "●";
545var calloutTitleOf = (name) => name.replace(/[-_]+/g, " ").replace(/\b\p{L}/gu, (l) => l.toUpperCase());
546function calloutRegistry(custom = []) {
547 const types = BUILTIN_CALLOUTS.map((t) => ({ ...t }));
548 for (const c of custom) {
549 const at = types.findIndex((t) => t.name === c.name);
550 if (at >= 0)
551 types[at] = { ...types[at], ...c, aliases: [...new Set([...types[at].aliases, ...c.aliases])] };
552 else
553 types.push(c);
554 }
555 const by = new Map;
556 for (const t of types)
557 by.set(t.name, t);
558 for (const t of types)
559 for (const a of t.aliases)
560 if (!by.has(a))
561 by.set(a, t);
562 const resolve = (name) => by.get(name.trim().toLowerCase()) ?? null;
563 return {
564 types,
565 resolve,
566 style: (name) => resolve(name) ?? { name: name.trim().toLowerCase(), title: calloutTitleOf(name.trim().toLowerCase()), icon: UNKNOWN_CALLOUT_ICON, tone: "neutral", aliases: [] }
567 };
568}
569var BUILTIN_CALLOUT_REGISTRY = calloutRegistry();
570var EMOJI_PRESENTATION = new Set([9748, 9749, 9800, 9801, 9802, 9803, 9804, 9805, 9806, 9807, 9808, 9809, 9810, 9811, 9855, 9875, 9889, 9898, 9899, 9917, 9918, 9924, 9925, 9934, 9940, 9962, 9970, 9971, 9973, 9978, 9981, 9989, 9994, 9995, 10024, 10060, 10062, 10067, 10068, 10069, 10071, 10133, 10134, 10135, 10160, 10175]);
571var QUOTE = /^[ \t]{0,3}>[ \t]?/;
572function quoteDepth(line) {
573 let depth = 0, rest = line;
574 for (let m = QUOTE.exec(rest);m; m = QUOTE.exec(rest)) {
575 rest = rest.slice(m[0].length);
576 depth++;
577 }
578 return { depth, content: rest };
579}
580function stripQuotes(line, n) {
581 let rest = line;
582 for (let i = 0;i < n; i++) {
583 const m = QUOTE.exec(rest);
584 if (!m)
585 break;
586 rest = rest.slice(m[0].length);
587 }
588 return rest;
589}
590
591// packages/outline-core/src/property-grammar.ts
592var PROPERTY_KEY_SOURCE = "[A-Za-z][A-Za-z0-9_.-]*";
593var PROPERTY_HEAD_SOURCE = String.raw`\[(${PROPERTY_KEY_SOURCE})::`;
594var PROPERTY_KEY_PATTERN = new RegExp(`^${PROPERTY_KEY_SOURCE}$`);
595var ADDRESS_KEYS = new Set(["page", "work-id"]);
596var QUERY_KEYS = new Set(["query"]);
597
598// packages/outline-core/src/component-block.ts
599var COMPONENT_OPEN = /^ {0,3}::([a-z][a-z0-9-]*)(?:[ \t]+(?!\^[A-Za-z0-9][\w-]{0,63}[ \t]*$)(\S[^\n]*?)|(\{(?:[^{}\n]|\{\{[^\n]*?\}\}|\{[A-Za-z][\w.-]*\})*\}))?(?:[ \t]+\^([A-Za-z0-9][\w-]{0,63}))?[ \t]*$/;
600var COMPONENT_CLOSE = /^[ \t]*::[ \t]*$/;
601var HEADING = /^ {0,3}#{1,6}(?:[ \t]|$)/;
602var YAML_FENCE = /^ {0,3}---[ \t]*$/;
603var QUOTES = { '"': '"', "'": "'", "“": "”", "‘": "’" };
604function splitWords(line, lines, shell = true) {
605 const rows = [];
606 let out = [];
607 let word = "", inWord = false, close = null, escaped = false, brace = false, list = false, from = 0, at = 0;
608 const push = (end) => {
609 out.push({ value: word, raw: line.slice(from, end), ...close ? { quote: close } : {} });
610 word = "";
611 inWord = false;
612 };
613 for (const c of line) {
614 const here = at;
615 at += c.length;
616 if (brace && c === `
617`)
618 brace = false;
619 if (brace) {
620 word += c;
621 if (c === "}" && word.endsWith("}}"))
622 brace = false;
623 continue;
624 }
625 if (c === "{" && !close && !escaped && word.endsWith("{") && !word.endsWith("\\{")) {
626 word += c;
627 brace = true;
628 continue;
629 }
630 if (!shell && !close && !escaped && c === "[" && word.endsWith("="))
631 list = true;
632 if (list) {
633 word += c;
634 if (escaped) {
635 escaped = false;
636 continue;
637 }
638 if (c === "\\" && close !== "'") {
639 escaped = true;
640 continue;
641 }
642 if (close) {
643 if (c === close)
644 close = null;
645 } else if (c === '"' || c === "'")
646 close = c;
647 else if (c === "]")
648 list = false;
649 continue;
650 }
651 if (escaped) {
652 word += close && c !== close && c !== "\\" || c === "{" ? `\\${c}` : c;
653 escaped = false;
654 continue;
655 }
656 if (c === "\\" && close !== "'" && close !== "’") {
657 if (!inWord)
658 from = here;
659 escaped = true;
660 inWord = true;
661 continue;
662 }
663 if (close) {
664 if (c === close)
665 close = null;
666 else
667 word += c;
668 continue;
669 }
670 if (QUOTES[c] && (shell || !inWord || word.endsWith("="))) {
671 if (!inWord)
672 from = here;
673 close = QUOTES[c];
674 inWord = true;
675 continue;
676 }
677 if (/\s/.test(c)) {
678 if (inWord)
679 push(here);
680 if (lines && c === `
681`) {
682 rows.push(out);
683 out = [];
684 }
685 continue;
686 }
687 if (!inWord)
688 from = here;
689 word += c;
690 inWord = true;
691 }
692 if (inWord)
693 push(at);
694 rows.push(out);
695 return rows;
696}
697var ATTR_KEY = /^[A-Za-z][\w.-]*$/;
698function componentAttrs(attrs) {
699 const classes = [], pairs = [];
700 for (const { value: word } of splitWords(attrs, false, false).flat()) {
701 const cls = /^\.([A-Za-z][\w-]*)$/.exec(word);
702 if (cls) {
703 classes.push(cls[1]);
704 continue;
705 }
706 const eq = word.indexOf("=");
707 const key = eq < 0 ? word : word.slice(0, eq);
708 if (ATTR_KEY.test(key))
709 pairs.push({ key, value: eq < 0 ? "on" : word.slice(eq + 1) });
710 }
711 return { classes, pairs };
712}
713function componentBlocks(lines) {
714 return scanComponents(lines, false, (start, fence) => fenceEnd(lines, start, fence).end);
715}
716function scanComponents(lines, firstOnly = false, fenceStop) {
717 const frames = [], stack = [];
718 const finish = (end) => {
719 const frame = stack.pop();
720 if (end !== undefined) {
721 frame.block.end = end;
722 frame.closed = true;
723 } else if (frame.block.args !== null)
724 frame.closed = true;
725 };
726 let i = -1, skipThrough = -1;
727 scan:
728 for (const raw of lines) {
729 i++;
730 if (i <= skipThrough)
731 continue;
732 const line = raw.replace(/\r$/, "");
733 let parent = stack.at(-1);
734 if (parent) {
735 if (parent.fence !== null && parent.fence !== "yaml") {
736 if (closesCodeFence(line, parent.fence))
737 parent.fence = null;
738 continue;
739 }
740 if (COMPONENT_CLOSE.test(line)) {
741 finish(i);
742 if (firstOnly && !stack.length)
743 break;
744 continue;
745 }
746 if (parent.fence === "yaml") {
747 if (YAML_FENCE.test(line))
748 parent.fence = null;
749 continue;
750 }
751 }
752 const open = COMPONENT_OPEN.exec(line);
753 while (parent && (parent.block.args !== null && !line.trim() || parent.block.name !== "box" && (HEADING.test(line) || open))) {
754 finish();
755 if (firstOnly && !stack.length)
756 break scan;
757 parent = stack.at(-1);
758 }
759 if (open) {
760 const block = {
761 name: open[1],
762 args: open[2] ?? null,
763 start: i,
764 end: i,
765 ...open[3] ? { attrs: open[3].slice(1, -1) } : {},
766 ...open[4] ? { id: open[4] } : {}
767 };
768 const inline = block.name === "use" || block.name === "button" && block.attrs !== undefined && componentAttrs(block.attrs).pairs.some((p) => p.key.toLowerCase() === "act" || p.key.toLowerCase() === "of");
769 const frame = { block, fence: null, yamlSeen: false, closed: !!inline };
770 frames.push(frame);
771 if (!inline)
772 stack.push(frame);
773 else if (firstOnly && !stack.length)
774 break;
775 continue;
776 }
777 if (parent) {
778 if (parent.block.name !== "box" && YAML_FENCE.test(line) && !parent.yamlSeen) {
779 parent.fence = "yaml";
780 parent.yamlSeen = true;
781 } else
782 parent.fence = codeFenceOpen(line);
783 } else {
784 if (firstOnly)
785 break;
786 const fence = codeFenceOpen(line);
787 if (fence)
788 skipThrough = fenceStop(i, fence);
789 }
790 }
791 while (stack.length)
792 finish();
793 return frames.filter((frame) => frame.closed).map((frame) => frame.block);
794}
795
796// packages/outline-core/src/code-ranges.ts
797function sourceLines(text) {
798 const lines = [];
799 let start = 0;
800 while (start < text.length) {
801 const newline = text.indexOf(`
802`, start);
803 const end = newline < 0 ? text.length : newline + 1;
804 const contentEnd = newline < 0 ? text.length : newline > start && text[newline - 1] === "\r" ? newline - 1 : newline;
805 lines.push({ start, end, contentEnd });
806 start = end;
807 }
808 if (text.length === 0)
809 lines.push({ start: 0, end: 0, contentEnd: 0 });
810 return lines;
811}
812var YAML_FENCE_LINE = /^ {0,3}---[ \t]*$/;
813function structuralLiteralLines(lines, yaml = true) {
814 const spans = [];
815 for (const block of yaml ? componentBlocks(lines) : []) {
816 if (block.name === "box")
817 continue;
818 let open = -1, closed = false;
819 for (let i = block.start + 1;i <= block.end; i++) {
820 if (!YAML_FENCE_LINE.test(lines[i].replace(/\r$/, "")))
821 continue;
822 if (open < 0)
823 open = i;
824 else {
825 spans.push({ start: open, end: i });
826 closed = true;
827 break;
828 }
829 }
830 if (open >= 0 && !closed)
831 spans.push({ start: open, end: block.end });
832 }
833 return [...spans, ...quotedFenceLines(lines)];
834}
835function quotedFenceLines(lines) {
836 const spans = [];
837 let fence = null;
838 lines.forEach((raw, i) => {
839 const line = raw.replace(/\r$/, ""), { depth, content } = quoteDepth(line);
840 if (fence && depth < fence.depth) {
841 spans.push({ start: fence.start, end: i - 1 });
842 fence = null;
843 }
844 if (fence) {
845 if (closesCodeFence(stripQuotes(line, fence.depth), fence.code)) {
846 spans.push({ start: fence.start, end: i });
847 fence = null;
848 }
849 return;
850 }
851 const code = depth ? codeFenceOpen(content) : null;
852 if (code)
853 fence = { depth, code, start: i };
854 });
855 const unclosed = fence;
856 if (unclosed)
857 spans.push({ start: unclosed.start, end: lines.length - 1 });
858 return spans;
859}
860function structuralLiteralRanges(text, lines, withBreak = true, yaml = true) {
861 const texts = lines.map((line) => text.slice(line.start, line.contentEnd));
862 return structuralLiteralLines(texts, yaml).map((span) => ({ start: lines[span.start].start, end: withBreak ? lines[span.end].end : lines[span.end].contentEnd }));
863}
864function protectedCodeRanges(text, yaml = true) {
865 const lines = text.split(`
866`), starts = [];
867 for (let i = 0, at = 0;i < lines.length; at += lines[i].length + 1, i++)
868 starts.push(at);
869 const lineRange = (i) => ({ start: starts[i], end: starts[i] + lines[i].length });
870 const { fences, indented } = codeBlocks(lines);
871 const ranges = [];
872 for (const block of fences)
873 for (let i = block.start;i <= block.end; i++)
874 ranges.push(lineRange(i));
875 for (const i of indented)
876 ranges.push(lineRange(i));
877 ranges.push(...structuralLiteralRanges(text, sourceLines(text), false, yaml));
878 ranges.push(...codeSpanRanges(text));
879 return ranges;
880}
881function codeSpanRanges(text) {
882 const ranges = [];
883 if (!text.includes("`"))
884 return ranges;
885 const runs = [];
886 for (let i = 0, line = 0;i < text.length; i++) {
887 const c = text.charCodeAt(i);
888 if (c === 10)
889 line++;
890 else if (c === 96) {
891 let e = i + 1;
892 while (e < text.length && text.charCodeAt(e) === 96)
893 e++;
894 runs.push({ start: i, end: e, line });
895 i = e - 1;
896 }
897 }
898 for (let a = 0;a < runs.length; ) {
899 const open = runs[a], length = open.end - open.start;
900 let b = a + 1;
901 while (b < runs.length && runs[b].line === open.line && runs[b].end - runs[b].start !== length)
902 b++;
903 if (b < runs.length && runs[b].line === open.line) {
904 ranges.push({ start: open.start, end: runs[b].end });
905 a = b + 1;
906 } else
907 a++;
908 }
909 return ranges;
910}
911function codeHides(range, code) {
912 return code.some((c) => c.start < range.end && range.start < c.end && !(range.start < c.start && c.end < range.end));
913}
914
915// packages/outline-core/src/link-syntax.ts
916var mayHoldCode = (text) => /[`~]|(?:^|\n)(?: {4}|[ ]{0,3}\t| {0,3}::)/.test(text);
917function codeRangesOf(text, scope = "note") {
918 if (typeof scope !== "string")
919 return scope;
920 if (!mayHoldCode(text))
921 return [];
922 return scope === "inline" ? codeSpanRanges(text) : protectedCodeRanges(text);
923}
924function outsideCode(text, found, scope) {
925 if (!found.length)
926 return found;
927 const code = codeRangesOf(text, scope);
928 return code.length ? found.filter((f) => !codeHides(f, code)) : found;
929}
930var FRAGMENT_ID_SOURCE = String.raw`[A-Za-z0-9][A-Za-z0-9_-]{0,63}`;
931function qualifiedReference(input) {
932 const colon = input.indexOf(":");
933 if (colon > 0 && input[colon + 1] !== ":" && OUTLINE_NAME_PATTERN.test(input.slice(0, colon))) {
934 return { outline: input.slice(0, colon), target: input.slice(colon + 1) };
935 }
936 return { target: input };
937}
938var BLOCK_REFERENCE_HEAD_SOURCE = String.raw`\(\(((?:[a-z0-9][a-z0-9-]{0,31}:)?[A-Za-z0-9_-]{8,})(?:\^(${FRAGMENT_ID_SOURCE}))?(?=\)\)|\|)`;
939function blockReferenceMatches(text) {
940 const matches = [];
941 const pattern = new RegExp(BLOCK_REFERENCE_HEAD_SOURCE, "g");
942 for (let match = pattern.exec(text);match; match = pattern.exec(text)) {
943 const head = match.index + match[0].length;
944 let end = head + 2;
945 let label;
946 if (text[head] === "|") {
947 end = referenceEnvelopeEnd(text, head + 1, true, head + 2);
948 if (end < 0) {
949 pattern.lastIndex = match.index + 1;
950 continue;
951 }
952 label = text.slice(head + 1, end - 2);
953 }
954 const { target: blockId, outline } = qualifiedReference(match[1]);
955 matches.push({
956 blockId,
957 ...outline ? { outline } : {},
958 ...match[2] ? { fragmentId: match[2] } : {},
959 ...label !== undefined ? { label } : {},
960 start: match.index,
961 end
962 });
963 pattern.lastIndex = end;
964 }
965 return matches;
966}
967function blockReferenceOccurrences(text, code) {
968 return outsideCode(text, blockReferenceMatches(text).filter((match) => match.label === undefined || match.label.trim()), code);
969}
970function blockReferenceEnvelopeRanges(text) {
971 const ranges = [];
972 const references = text.includes("((") ? blockReferenceMatches(text).map((reference) => reference.start) : [];
973 for (let start = text.indexOf("((");start >= 0; start = text.indexOf("((", start)) {
974 const end = referenceEnvelopeEnd(text, start + 2, true);
975 if (end < 0 || !references.includes(start) && references.some((at) => at > start && at < end)) {
976 start += 2;
977 continue;
978 }
979 ranges.push({ start, end });
980 start = end;
981 }
982 return ranges;
983}
984var EMBED_PATTERN_SOURCE = String.raw`!\(\(((?:[a-z0-9][a-z0-9-]{0,31}:)?[A-Za-z0-9_-]{8,})(?:\^(${FRAGMENT_ID_SOURCE}))?\)\)`;
985var CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
986function pageLinkSpans(text, code = []) {
987 const spans = [];
988 if (!text.includes("[["))
989 return spans;
990 const open = [];
991 const cs = code.length > 1 ? [...code].sort((a, b) => a.start - b.start) : code;
992 let c = 0;
993 for (let at = 0;at < text.length; ) {
994 while (c < cs.length && cs[c].end <= at)
995 c += 1;
996 if (c < cs.length && cs[c].start <= at) {
997 at = cs[c].end;
998 continue;
999 }
1000 const ch = text[at];
1001 if (ch === `
1002` || ch === "\r") {
1003 open.length = 0;
1004 at += 1;
1005 continue;
1006 }
1007 if (ch === "[" && text[at + 1] === "[") {
1008 open.push(at);
1009 at += 2;
1010 continue;
1011 }
1012 if (ch === "]" && text[at + 1] === "]" && open.length) {
1013 spans.push({ start: open.pop(), end: at + 2, depth: 0 });
1014 at += 2;
1015 continue;
1016 }
1017 at += 1;
1018 }
1019 spans.sort((a, b) => a.start - b.start);
1020 const ends = [];
1021 for (const span of spans) {
1022 while (ends.length && ends[ends.length - 1] <= span.start)
1023 ends.pop();
1024 span.depth = ends.length;
1025 ends.push(span.end);
1026 }
1027 return spans;
1028}
1029function labelBar(authored) {
1030 let depth = 0;
1031 for (let at = 0;at < authored.length; at += 1) {
1032 if (authored.startsWith("[[", at)) {
1033 depth += 1;
1034 at += 1;
1035 } else if (authored.startsWith("]]", at) && depth) {
1036 depth -= 1;
1037 at += 1;
1038 } else if (authored[at] === "|" && !depth)
1039 return at;
1040 }
1041 return -1;
1042}
1043function bracketsClose(address) {
1044 if (!address.includes("]"))
1045 return true;
1046 let depth = 0;
1047 for (let at = 0;at < address.length; at += 1) {
1048 if (address.startsWith("[[", at)) {
1049 depth += 1;
1050 at += 1;
1051 } else if (address[at] === "]") {
1052 if (!depth || address[at + 1] !== "]")
1053 return false;
1054 depth -= 1;
1055 at += 1;
1056 }
1057 }
1058 return true;
1059}
1060var PAGE_ADDRESS_MAX_LENGTH = 512;
1061function normalizePageAddress(input) {
1062 const displayAddress = input.trim();
1063 if (!displayAddress)
1064 throw new Error("Page address cannot be empty");
1065 if (displayAddress.length > PAGE_ADDRESS_MAX_LENGTH) {
1066 throw new Error(`Page address cannot exceed ${PAGE_ADDRESS_MAX_LENGTH} characters`);
1067 }
1068 if (CONTROL_PATTERN.test(displayAddress)) {
1069 throw new Error("Page address contains control characters");
1070 }
1071 if (!bracketsClose(displayAddress)) {
1072 throw new Error("Page address cannot contain ] except to close a [[page]] it holds");
1073 }
1074 const normalizedAddress = displayAddress.normalize("NFKC").replace(/\s+/gu, " ").toUpperCase().toLowerCase();
1075 return { displayAddress, normalizedAddress };
1076}
1077function tryNormalizePageAddress(input) {
1078 try {
1079 return normalizePageAddress(input);
1080 } catch {
1081 return null;
1082 }
1083}
1084function pageAddressReferences(text, code) {
1085 const references = [];
1086 const codeRanges = codeRangesOf(text, code);
1087 const holding = [];
1088 for (const span of pageLinkSpans(text, codeRanges)) {
1089 const authored = text.slice(span.start + 2, span.end - 2);
1090 const separator = labelBar(authored);
1091 const target = separator < 0 ? authored : authored.slice(0, separator);
1092 const label = separator < 0 ? undefined : authored.slice(separator + 1).trim();
1093 if (separator >= 0 && !label)
1094 continue;
1095 const address = tryNormalizePageAddress(target);
1096 if (!address)
1097 continue;
1098 while (holding.length && holding[holding.length - 1] <= span.start)
1099 holding.pop();
1100 const nested = holding.length > 0;
1101 holding.push(span.end);
1102 const { outline } = qualifiedReference(address.displayAddress);
1103 references.push({ ...address, ...outline ? { outline } : {}, ...label ? { label } : {}, ...nested ? { nested: true } : {}, start: span.start, end: span.end });
1104 }
1105 return outsideCode(text, references, codeRanges);
1106}
1107var MARKDOWN_LINK_SOURCE = String.raw`(?<!!)\[([^\[\]\r\n]*)\]\(([^)\r\n]*)\)`;
1108var MARKDOWN_LINK_OR_IMAGE = /!?\[[^\]\n]*\]\([^)\n]*\)/g;
1109var FRAGMENT_ANCHOR_PATTERN = new RegExp(String.raw`(?:^|\s)\^(${FRAGMENT_ID_SOURCE})\s*$`);
1110var overlaps = (a, b) => a.start < b.end && b.start < a.end;
1111function linkOccurrences(text, code) {
1112 const codeRanges = codeRangesOf(text, code);
1113 const refs = blockReferenceOccurrences(text, []);
1114 const holds = (outer, inner) => outer.start <= inner.start && inner.end <= outer.end;
1115 const free = (m) => !refs.some((ref) => holds(ref, m) && !holds(m, ref));
1116 const markdown = [...text.matchAll(MARKDOWN_LINK_OR_IMAGE)].map((m) => ({ start: m.index, end: m.index + m[0].length })).filter(free);
1117 const out = [...text.matchAll(new RegExp(MARKDOWN_LINK_SOURCE, "g"))].map((m) => ({ kind: "markdown", text: m[1], url: m[2], start: m.index, end: m.index + m[0].length })).filter(free);
1118 for (const ref of refs) {
1119 if (markdown.some((range) => overlaps(ref, range)))
1120 continue;
1121 const embed = ref.label === undefined && text[ref.start - 1] === "!";
1122 out.push({ kind: "block", embed, ...ref, start: embed ? ref.start - 1 : ref.start });
1123 }
1124 const envelopes = blockReferenceEnvelopeRanges(text);
1125 for (const page of pageAddressReferences(text, codeRanges)) {
1126 if (page.nested || markdown.some((range) => overlaps(page, range)) || envelopes.some((range) => overlaps(page, range)))
1127 continue;
1128 out.push({ kind: "page", ...page });
1129 }
1130 return outsideCode(text, out, codeRanges).sort((a, b) => a.start - b.start);
1131}
1132export {
1133 COMPONENT_OPEN,
1134 componentBlocks,
1135 formatEp0chBlockUri,
1136 linkOccurrences,
1137 qualifiedReference
1138};
1139hooks/mention-message.ts 259 lines1import type { TurnCompleteInput } from 'claude-code'
2
3/**
4 * The host-neutral contract `mentions.ingest` takes; `src/mentions-types.ts`
5 * owns it. Restated here because a hooks module cannot import application code.
6 */
7/** The agent this mod ingests Claude's answers as, and reads their mentions back by. */
8export const MENTIONS_AGENT = 'claude'
9
10export type MentionMessage = {
11 workspaceRoot: string
12 agent: typeof MENTIONS_AGENT
13 sessionId: string
14 messageId: string
15 text: string
16}
17
18/**
19 * A folder list as configured (the `workspaces` option or
20 * `PI_OUTLINER_MENTIONS_WORKSPACES`): one absolute folder or several,
21 * separated by ':' or ','. Trailing slashes are dropped so `/a/b/` matches a
22 * cwd of `/a/b`. An entry names a folder only; any other entry is an error,
23 * never skipped. In folder mode the list opts folders out; in allowlist mode
24 * it is the only folders that feed an outline (`mentionsModeOf`).
25 */
26export function workspacesOf(value: unknown, home?: string): string[] {
27 const parts = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[:,]/) : []
28 return parts.flatMap(part => {
29 if (typeof part !== 'string') throw Error(`Outliner workspaces entry ${JSON.stringify(part)} is not a folder path`)
30 const typed = part.trim()
31 // `~` and `~/…` are written by people; expand them when the home folder is known.
32 const entry = home && (typed === '~' || typed.startsWith('~/')) ? home.replace(/\/+$/, '') + typed.slice(1) : typed
33 if (entry === '') return []
34 if (!entry.startsWith('/') || entry.includes('=')) {
35 throw Error(`Outliner workspaces entry "${entry}" is not an absolute folder; list folders only, and name a folder's outline in its .ep0ch (ep0ch init)`)
36 }
37 return [entry.replace(/(?<=.)\/+$/, '')]
38 })
39}
40
41/**
42 * The option when it names at least one folder, otherwise the environment
43 * variable. Claude Code passes an unset string option as '', so an empty
44 * option cannot be told from an unset one and never overrides the environment.
45 */
46export function effectiveWorkspaces(option: unknown, environment: string | undefined, home?: string): string[] {
47 const configured = workspacesOf(option, home)
48 return configured.length > 0 ? configured : workspacesOf(environment, home)
49}
50
51/**
52 * How a session's folder finds its outline:
53 *
54 * - `folder`: the nearest folder whose `.ep0ch` names an outline, the way every
55 * client resolves it (`bound-folder`). The folder list opts folders out. An
56 * unbound folder feeds nothing; it never falls back to a guess.
57 * - `allowlist`: only the listed folders, as before folder mode (the strict mode).
58 */
59export type MentionsMode = 'folder' | 'allowlist'
60
61/**
62 * The `mode` option when set, otherwise `PI_OUTLINER_MENTIONS_MODE`; neither:
63 * `folder`, unless folders are listed. A list with no mode is a setup from
64 * before folder mode, where the list was an allowlist, so it keeps meaning
65 * exactly that: only the folders someone chose feed. Any other value is an error.
66 */
67export function mentionsModeOf(option: unknown, environment: string | undefined, listed: readonly string[] = []): MentionsMode {
68 const typed = typeof option === 'string' && option.trim() !== '' ? option.trim() : (environment ?? '').trim()
69 if (typed === '' && listed.length > 0) return 'allowlist'
70 if (typed === '' || typed === 'folder' || typed === 'allowlist') return typed === 'allowlist' ? 'allowlist' : 'folder'
71 throw Error(`Outliner mentions mode "${typed}" is neither folder nor allowlist`)
72}
73
74/**
75 * The folder a session's Outliner CLI runs start from. A bound folder (folder
76 * mode: its nearest `.ep0ch`) is `pinned` to the outline `bound-folder` found,
77 * on the machine it names (none: this one): its CLI runs go there whatever
78 * Claude's environment says. A strict-mode folder is the CLI's to resolve, as
79 * before folder mode.
80 */
81export type Workspace = { root: string; outline?: string; machine?: string; pinned?: true; via?: 'env' | 'call' }
82
83/** An outline's name (outline-core's OUTLINE_NAME_PATTERN; a hooks module imports no application code) and an ssh config name. */
84export const OUTLINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
85export const MACHINE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
86
87/**
88 * The workspace a session is told to work in by name (PIE-756): `call` (`/outline <name>`, outline_bind) beats `ws`
89 * (EP0CH_WS, with EP0CH_MACHINE for an outline on another machine), and either beats any folder. `root` is the folder
90 * the session started in, only where the CLI runs; it never picks the outline. Null: nothing names one. Throws
91 * when EP0CH_WS or EP0CH_MACHINE is set to something that is not a name.
92 */
93export function namedWorkspaceOf(
94 root: string,
95 call: { outline: string; machine?: string } | null,
96 ws: string | undefined,
97 machine: string | undefined,
98): Workspace | null {
99 if (call) return { root, outline: call.outline, ...(call.machine ? { machine: call.machine } : {}), pinned: true, via: 'call' }
100 const name = ws?.trim()
101 if (!name) return null
102 if (!OUTLINE_NAME.test(name)) throw Error(`EP0CH_WS=${JSON.stringify(name)} isn't an outline name (lowercase letters, digits and hyphens, up to 32)`)
103 const on = machine?.trim()
104 if (on && !MACHINE_NAME.test(on)) throw Error(`EP0CH_MACHINE=${JSON.stringify(on)} isn't an ssh config name`)
105 return { root, outline: name, ...(on ? { machine: on } : {}), pinned: true, via: 'env' }
106}
107
108/**
109 * Whether EP0CH_SOCKET (when set) is this machine's outline host: exactly `<outlines>/.host/host.sock`, the outlines
110 * folder being EP0CH_OUTLINES, else `~/outlines`, with no EP0CH_MACHINE beside it. Any other socket (an ssh forward
111 * anywhere, `/tmp/remote.sock` too) could be another machine's host, so it's refused. Unset is fine.
112 */
113export function sameHostSocket(socket: string | undefined, machine: string | undefined, outlines: string | undefined, home: string | undefined): boolean {
114 const path = socket?.trim()
115 if (!path) return true
116 if (machine?.trim()) return false
117 const folder = (outlines?.trim() || (home?.trim() ? `${home.trim().replace(/\/+$/, '')}/outlines` : '')).replace(/\/+$/, '')
118 return !!folder && folder.startsWith('/') && path === `${folder}/.host/host.sock`
119}
120
121/**
122 * The environment an Outliner CLI run gets for a workspace: its folder and,
123 * when pinned, the outline its `.ep0ch` names and its machine (empty for this
124 * one), over an inherited EP0CH_WS or EP0CH_MACHINE, so the write lands where
125 * the folder was found bound.
126 */
127export function workspaceEnvOf(workspace: Workspace): Record<string, string> {
128 return {
129 OUTLINER_WORKSPACE_ROOT: workspace.root,
130 ...(workspace.pinned && workspace.outline ? { EP0CH_WS: workspace.outline, EP0CH_MACHINE: workspace.machine ?? '' } : {}),
131 }
132}
133
134/**
135 * The bound folder in `bound-folder`'s answer for `cwd`, or null: unbound, or
136 * an answer that is not one (a folder that does not contain `cwd` is not).
137 */
138export function boundWorkspaceOf(stdout: string, cwd: string): Workspace | null {
139 let answer: unknown
140 try { answer = JSON.parse(stdout) } catch { return null }
141 if (!answer || typeof answer !== 'object') return null
142 const { bound, folder, outline, machine } = answer as Record<string, unknown>
143 if (bound !== true || typeof folder !== 'string') return null
144 const root = absolutePath(folder)
145 if (root === null || workspaceForCwd(cwd, [root]) !== root) return null
146 // A `.ep0ch` always names its outline; an answer without one is not a binding.
147 if (typeof outline !== 'string' || outline === '') return null
148 return { root, outline, ...(typeof machine === 'string' && machine !== '' ? { machine } : {}), pinned: true }
149}
150
151/**
152 * The workspace a session in `cwd` feeds, or null. In folder mode a listed
153 * folder (or one inside it) is opted out, and otherwise the bound folder wins;
154 * in allowlist mode the nearest listed folder, bound or not.
155 */
156export function sessionWorkspaceOf(
157 cwd: string,
158 mode: MentionsMode,
159 listed: readonly string[],
160 bound: Workspace | null,
161): Workspace | null {
162 const nearestListed = workspaceForCwd(cwd, listed)
163 if (mode === 'allowlist') return nearestListed === null ? null : { root: nearestListed }
164 return nearestListed === null ? bound : null
165}
166
167/**
168 * The workspace a session in a door tile works in (PIE-755): the outline the
169 * answering door shows, whatever the folder names. The folder's own binding is
170 * kept when it names that outline (its root, its pinned machine); else the
171 * session's starting folder is the root and the door's outline is pinned. No
172 * answering door: the folder's binding as it is.
173 */
174export function doorWorkspaceOf(
175 door: { answers: boolean; outline: string | null; machine: string | null } | null | undefined,
176 cwd: string,
177 folder: Workspace | null,
178): Workspace | null {
179 if (!door?.answers || !door.outline) return folder
180 const machine = door.machine ?? undefined
181 if (folder?.outline === door.outline && (folder.machine ?? undefined) === machine) return folder
182 return { root: cwd, outline: door.outline, ...(machine ? { machine } : {}), pinned: true }
183}
184
185/**
186 * The reason in an Outliner CLI failure's stderr: Bun prints the thrown
187 * error's source excerpt, then `error: <message>`, its stack and a `Bun v…`
188 * trailer, so the last line never says why. Falls back to the last line that
189 * is not stack or trailer; '' when nothing is left.
190 */
191export function failureReasonOf(stderr: string): string {
192 const lines = stderr.split('\n').map(line => line.trim()).filter(Boolean)
193 const error = lines.findLast(line => line.startsWith('error: '))
194 if (error) return error.slice('error: '.length)
195 return lines.findLast(line => !/^at\s|^Bun v\d/.test(line)) ?? ''
196}
197
198/**
199 * Which completed turns reach Recent Mentions: the main loop's own answers,
200 * never a subagent's run, an interruption, a refusal or an error.
201 */
202export function isIngestible(e: TurnCompleteInput): boolean {
203 return e.agentId === undefined && e.reason === 'answer' && e.answer.trim() !== ''
204}
205
206// Lexical POSIX paths: no host filesystem access or Git-root assumptions.
207function absolutePath(path: string): string | null {
208 if (!path.startsWith('/')) return null
209 const parts: string[] = []
210 for (const part of path.split('/')) {
211 if (part === '..') parts.pop()
212 else if (part !== '' && part !== '.') parts.push(part)
213 }
214 return '/' + parts.join('/')
215}
216
217/** Nested configured roots own their descendants; siblings never match a prefix. */
218export function workspaceForCwd(cwd: string, workspaces: readonly string[]): string | null {
219 const path = absolutePath(cwd)
220 if (path === null) return null
221 let nearest: string | null = null
222 for (const workspace of workspaces) {
223 const root = absolutePath(workspace)
224 if (root === null) continue
225 if ((path === root || path.startsWith(root === '/' ? '/' : root + '/')) &&
226 (nearest === null || root.length > nearest.length)) nearest = root
227 }
228 return nearest
229}
230
231/**
232 * The message for one completed turn in a session feeding `workspace`, or
233 * null when the turn is not ingested or the session feeds none. The turn id
234 * is the message identity, so a repeated delivery of the same turn
235 * deduplicates in the service.
236 */
237export function mentionMessageOf(
238 e: TurnCompleteInput,
239 session: { id: string },
240 workspace: Workspace | null,
241): MentionMessage | null {
242 if (!isIngestible(e) || workspace === null) return null
243 return {
244 workspaceRoot: workspace.root,
245 agent: MENTIONS_AGENT,
246 sessionId: session.id,
247 messageId: e.turnId,
248 text: e.answer,
249 }
250}
251
252/** The door's destination is an address; its name is only what the confirmation shows. */
253export function openedTileOf(stdout: string): { tile: string; name: string } | null {
254 try {
255 const value = JSON.parse(stdout)
256 return typeof value.tile === 'string' && typeof value.name === 'string' ? { tile: value.tile, name: value.name } : null
257 } catch { return null }
258}
259hooks/references.ts 203 lines1/**
2 * Outliner references in a Claude reply, made clickable. `Markdown` only draws
3 * `https:`, `http:` and `file:` links as links, so each reference becomes an
4 * https stand-in on the reserved `.invalid` domain, which never resolves: a
5 * click the plugin does not take goes nowhere. The plugin maps it back to the
6 * `pi-outliner://` URI the Outliner's own link navigation takes.
7 */
8import { codeBlocks } from './code-fence'
9import { linkOccurrences, qualifiedReference } from './link-grammar.js'
10
11const STAND_IN = 'https://pi-outliner.invalid/'
12
13const 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}'
14
15/** A fragment id, as outline-core's link grammar has it (`((id^fragment))`). */
16const FRAGMENT = '[A-Za-z0-9][A-Za-z0-9_-]{0,63}'
17
18/**
19 * Inline code, existing links and URLs: never rewritten. Code blocks (fenced and indented) are found by the one fence
20 * rule, `code-fence.ts`, so a four-backtick fence isn't closed by three.
21 */
22const PROTECTED = /`[^`\n]*`|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>|\b[a-z][a-z0-9+.-]*:\/\/[^\s<>()]+/gi
23
24/** `text` cut into runs of prose and of code-block lines (each run whole lines, newlines kept), in order. */
25function codeRuns(text: string): { code: boolean; text: string }[] {
26 const lines = text.split('\n')
27 const { fences, indented } = codeBlocks(lines)
28 const code = new Set(indented)
29 for (const fence of fences) for (let line = fence.start; line <= fence.end; line++) code.add(line)
30 const runs: { code: boolean; text: string }[] = []
31 lines.forEach((line, i) => {
32 const piece = i < lines.length - 1 ? `${line}\n` : line
33 const last = runs.at(-1)
34 if (last && last.code === code.has(i)) last.text += piece
35 else runs.push({ code: code.has(i), text: piece })
36 })
37 return runs
38}
39
40/** A `my.ep0ch.sh/pub/p/<PIE-n|page|id>` address: the web form of a reference (the tailnet's published notes). */
41const WEB = 'https://my\\.ep0ch\\.sh/pub/p/([^\\s<>()\\[\\]?#]+)(?:[?#][^\\s<>()]*)?'
42const WEB_LINK = new RegExp(`^\\[([^\\]\\n]*)\\]\\(${WEB}\\)$`, 'i')
43const WEB_BARE = new RegExp(`^${WEB}$`, 'i')
44
45/**
46 * A `my.ep0ch.sh` address Claude wrote, bare or as `[label](url)`, as the same stand-in link a reference gets (labelled
47 * by its label, else the Work ID, the page name or the note's title); null for any other link or URL, left as written.
48 */
49function mirrored(piece: string, hrefs: Set<string>, titleOf: (id: string) => string | undefined): string | null {
50 const trimmed = /^(.*?)([.,;:!?]*)$/s.exec(piece)!
51 const given = WEB_LINK.exec(piece)
52 const bare = given ? null : WEB_BARE.exec(trimmed[1]!)
53 const target = given?.[2] ?? bare?.[1]
54 if (!target) return null
55 let value: string
56 try { value = decodeURIComponent(target) } catch { return null }
57 const [kind, id] = /^[A-Z][A-Z0-9]*-\d+$/.test(value) ? ['work', value]
58 : new RegExp(`^${UUID}$`).test(value) ? ['block', value.toLowerCase()]
59 : ['page', value]
60 const href = `${STAND_IN}${kind}/${encodeURIComponent(id)}`
61 hrefs.add(href)
62 const label = (given?.[1]?.trim() || (kind === 'block' ? blockLabel(id, undefined, titleOf) : id)).replace(/[[\]\\]/g, '\\$&')
63 return `[${label}](${href})${given ? '' : trimmed[2]}`
64}
65
66/** The longest a note's title is drawn in a reply before it is cut with `…`. */
67const TITLE_WIDTH = 48
68
69/**
70 * What an unaliased block reference shows (PIE-1043): the note's title (cut to fit, then ` › anchor` when the reference
71 * names a fragment), and until the title is known, or when there is none, the id's first eight characters and `…`.
72 */
73function blockLabel(id: string, fragment: string | undefined, titleOf: (id: string) => string | undefined): string {
74 const title = titleOf(id)?.replace(/\s+/g, ' ').trim()
75 if (!title) return `${id.slice(0, 8)}…`
76 const cut = title.length > TITLE_WIDTH ? `${title.slice(0, TITLE_WIDTH - 1).trimEnd()}…` : title
77 return fragment ? `${cut} › ${fragment}` : cut
78}
79
80/** The block ids a reply names without a label (`((id))`, `((id^anchor))`, a `my.ep0ch.sh` address), lowercased: the titles to look up before it is drawn. */
81export function unlabelledBlockIds(text: string): string[] {
82 const ids = new Set<string>()
83 for (const m of text.matchAll(new RegExp(`\\(\\((${UUID})(?:\\^${FRAGMENT})?\\)\\)|(?<=my\\.ep0ch\\.sh/pub/p/)(${UUID})`, 'g'))) ids.add((m[1] ?? m[2]!).toLowerCase())
84 return [...ids]
85}
86
87/** Every block address a reply needs to resolve, including labelled references whose outline is not written. */
88export function replyBlockTargets(text: string): string[] {
89 return [...new Set(linkOccurrences(text).flatMap(r => r.kind === 'block' ? [`${r.outline ? r.outline + ':' : ''}${r.blockId.toLowerCase()}`] : []))];
90}
91
92export type Linkified = {
93 /** The markdown to draw, references as stand-in links. */
94 text: string
95 /** Every stand-in href in `text`, once each: what `pressableLinks` takes. */
96 hrefs: string[]
97}
98
99/**
100 * `[[page]]`, `[[target|label]]`, `((uuid))`, `((uuid|label))` and bare Work
101 * IDs with one of `prefixes`, rewritten as links outside protected ranges.
102 */
103export function linkifyReferences(text: string, prefixes: readonly string[], titleOf: (id: string) => string | undefined = () => undefined, outlineOf: (id: string) => string | undefined = () => undefined): Linkified {
104 const hrefs = new Set<string>()
105 const workIds = prefixes
106 .filter(prefix => /^[A-Z][A-Z0-9]*$/.test(prefix))
107 .map(prefix => `${prefix}-\\d+`)
108 const rewrite = (plain: string) => {
109 const links = linkOccurrences(plain);
110 const work = workIds.length ? [...plain.matchAll(new RegExp(`(?<![\\w/#-])(${workIds.join('|')})(?![\\w-])`, 'g'))]
111 .filter(m => !links.some(l => l.start <= m.index! && m.index! < l.end))
112 .map(m => ({ kind: 'work' as const, start: m.index!, end: m.index! + m[0].length, value: m[0] })) : [];
113 let out = '', at = 0;
114 for (const ref of [...links, ...work].sort((a, b) => a.start - b.start)) {
115 if (ref.kind === 'markdown') continue;
116 const kind = ref.kind === 'block' ? 'block' : ref.kind === 'page' ? 'page' : 'work';
117 const authoredOutline = ref.kind === 'work' ? undefined : ref.outline;
118 const address = ref.kind === 'block' ? `${authoredOutline ? authoredOutline + ':' : ''}${ref.blockId.toLowerCase()}` : '';
119 const outline = authoredOutline ?? (ref.kind === 'block' ? outlineOf(address) : undefined);
120 const value = ref.kind === 'block' ? `${authoredOutline ? authoredOutline + ':' : ''}${ref.blockId.toLowerCase()}${ref.fragmentId ? '^' + ref.fragmentId : ''}`
121 : ref.kind === 'page' ? ref.displayAddress : ref.value;
122 const label = (outline ? `${outline} · ` : '') + (ref.kind === 'block' ? ref.label ?? blockLabel(ref.blockId, ref.fragmentId, () => titleOf(address))
123 : ref.kind === 'page' ? ref.label ?? qualifiedReference(ref.displayAddress).target : ref.value);
124 const href = `${STAND_IN}${kind}/${encodeURIComponent(value)}`;
125 hrefs.add(href);
126 out += plain.slice(at, ref.start) + `[${label.replace(/[[\]\\]/g, '\\$&')}](${href})`;
127 at = ref.end;
128 }
129 return out + plain.slice(at);
130 };
131
132 let out = ''
133 for (const run of codeRuns(text)) {
134 if (run.code) { out += run.text; continue }
135 let last = 0
136 for (const match of run.text.matchAll(PROTECTED)) {
137 out += rewrite(run.text.slice(last, match.index)) + (mirrored(match[0], hrefs, titleOf) ?? match[0])
138 last = match.index + match[0].length
139 }
140 out += rewrite(run.text.slice(last))
141 }
142 return { text: out, hrefs: [...hrefs] }
143}
144
145/**
146 * The `pi-outliner://` URI a stand-in href names, or null for any other link.
147 */
148export function outlinerUriOf(href: string): string | null {
149 if (!href.startsWith(STAND_IN)) return null
150 const [kind, value, ...rest] = href.slice(STAND_IN.length).split('/')
151 if (rest.length || !value || !['block', 'page', 'work'].includes(kind!)) return null
152 if (kind === 'block') {
153 const [id, fragment] = decodeURIComponent(value).split('^');
154 return `pi-outliner://block/${encodeURIComponent(id!)}${fragment ? '?fragment=' + encodeURIComponent(fragment) : ''}`;
155 }
156 return `pi-outliner://${kind}/${value}`
157}
158
159/**
160 * The `pi-outliner://` URI for a reference as the model writes it: a Work ID,
161 * `[[page]]`, `((uuid))`, a bare block UUID, a `pi-outliner://` URI, or else a
162 * page address as given. Null for an empty reference.
163 */
164export function outlinerUriFor(reference: string): string | null {
165 const text = reference.trim()
166 if (!text) return null
167 if (text.startsWith('pi-outliner://')) return text
168 if (new RegExp(`^${UUID}$`).test(text)) return `pi-outliner://block/${text.toLowerCase()}`
169 if (/^[A-Z][A-Z0-9]*-\d+$/.test(text)) return `pi-outliner://work/${text}`
170 const { hrefs } = linkifyReferences(text, [])
171 if (hrefs.length === 1 && /^(\[\[[^\]]+\]\]|\(\([^)]+\)\))$/.test(text)) return outlinerUriOf(hrefs[0]!)
172 return `pi-outliner://page/${encodeURIComponent(text)}`
173}
174
175/** What a `pi-outliner://` URI names, as the person reads it: the Work ID, page name or block id. */
176export function outlinerLabelOf(uri: string): string {
177 return decodeURIComponent(uri.slice(uri.indexOf('/', 'pi-outliner://'.length) + 1))
178}
179
180/** The block id a `pi-outliner://block/…` URI names, lowercased; null for a page, Work ID or anything else. */
181export function outlinerBlockIdOf(uri: string): string | null {
182 return new RegExp(`^pi-outliner://block/(${UUID})$`).exec(uri)?.[1]?.toLowerCase() ?? null
183}
184
185/**
186 * The reference the outline writes for a URI, the reverse of `outlinerUriFor`:
187 * `((id))` for a block, `[[page]]` for a page, the Work ID itself.
188 */
189export function outlinerReferenceOf(uri: string): string {
190 const block = outlinerBlockIdOf(uri)
191 if (block) return `((${block}))`
192 return uri.startsWith('pi-outliner://page/') ? `[[${outlinerLabelOf(uri)}]]` : outlinerLabelOf(uri)
193}
194
195/**
196 * Whether a navigation failed only because its destination is mid-edit (or
197 * holding a source selection): the Outliner protects it, and the person
198 * finishes there; it is never overridden.
199 */
200export function isProtectedDestination(reason: string): boolean {
201 return reason.startsWith('Destination is protected')
202}
203hooks/reply-lines.ts 163 lines1/**
2 * A reply's references, drawn as their labels only (PIE-1042). Claude Code's `Markdown` and `Link` print the address
3 * beside the text in a terminal that has no hyperlinks (`PIE-1008 (https://pi-outliner.invalid/work/PIE-1008)`), and
4 * neither can be told not to. So in a terminal the lines that hold a reference are drawn by the mod, one pressable
5 * label per reference; every other line stays the engine's Markdown, in runs. Pure: the elements come from register.ts.
6 */
7import { codeBlocks } from './code-fence'
8import { outlinerUriOf } from './references'
9
10/** One drawn piece of a line: prose with its inline style, or a reference to press. */
11export type LinePart =
12 | { text: string; style?: 'bold' | 'italic' | 'bold-italic' | 'code' | 'dim' }
13 | { ref: string; label: string; external?: true }
14
15export type ReplyBlock =
16 | { kind: 'markdown'; text: string }
17 | { kind: 'line'; parts: LinePart[] }
18 | { kind: 'table'; header: LinePart[][]; rows: LinePart[][][] }
19
20/** `[label](stand-in)` as `linkifyReferences` writes it (labels have `[`, `]` and `\` escaped). */
21const LINK = /\[((?:[^\]\\\n]|\\.)*)\]\((https?:\/\/[^)\s]*)\)|`[^`\n]*`|\bhttps?:\/\/[^\s<>()[\]`]+/gi
22
23/** A reference stands in the text as `OPEN n CLOSE` while emphasis is found around it (private-use characters). */
24const OPEN = '\uE000'
25const CLOSE = '\uE001'
26const SLOT = /\uE000(\d+)\uE001/g
27
28type Ref = { ref: string; label: string; external?: true; bare?: true }
29type Style = 'bold' | 'italic' | 'bold-italic' | 'code' | 'dim'
30
31const EMPHASIS = /`([^`\n]+)`|(\*\*\*|\*\*|__)(?=\S)(.+?)(?<=\S)\2|(?<![\w*])([*_])(?=[^\s*_])([^\n]*?[^\s*_])\4(?![\w*])/g
32
33function withStyle(base: Style | undefined, add: 'bold' | 'italic'): Style {
34 if (!base || base === add) return add
35 return base === 'code' ? base : 'bold-italic'
36}
37
38/** A run of text holding reference slots, as parts: each slot a reference, the rest in `style`. */
39function slots(text: string, refs: Ref[], style: Style | undefined): LinePart[] {
40 const parts: LinePart[] = []
41 let last = 0
42 for (const match of text.matchAll(SLOT)) {
43 if (match.index > last) parts.push({ text: text.slice(last, match.index), ...(style ? { style } : {}) })
44 const ref = refs[Number(match[1])]!
45 // Link's no-hyperlink fallback repeats its address beside its children. A bare URL is already the address.
46 parts.push(ref.bare ? { text: `${ref.label} »`, style: 'dim' } : { ...ref })
47 last = match.index + match[0].length
48 }
49 if (last < text.length) parts.push({ text: text.slice(last), ...(style ? { style } : {}) })
50 return parts
51}
52
53/**
54 * Prose and reference slots as parts: `**bold**`, `*italic*`, `***both***` and `` `code` `` as styles with the markers
55 * dropped, around references and other text alike (PIE-1043); the references themselves carry no style (underlined already).
56 */
57function styled(text: string, refs: Ref[], style?: Style): LinePart[] {
58 const parts: LinePart[] = []
59 let last = 0
60 for (const match of text.matchAll(EMPHASIS)) {
61 if (match.index > last) parts.push(...slots(text.slice(last, match.index), refs, style))
62 if (match[1] !== undefined) {
63 parts.push({ text: match[1].replace(SLOT, (_, n) => refs[Number(n)]!.label), style: 'code' })
64 } else if (match[2] !== undefined) {
65 const add = match[2] === '***' ? 'bold-italic' : 'bold'
66 parts.push(...styled(match[3]!, refs, add === 'bold-italic' ? 'bold-italic' : withStyle(style, 'bold')))
67 } else {
68 parts.push(...styled(match[5]!, refs, withStyle(style, 'italic')))
69 }
70 last = match.index + match[0].length
71 }
72 if (last < text.length) parts.push(...slots(text.slice(last), refs, style))
73 return parts
74}
75
76/** Inline parts of some text holding references, or null when it holds none the mod can open. */
77function inlineOf(text: string): LinePart[] | null {
78 const refs: Ref[] = []
79 const slotted = text.replace(LINK, (whole, label: string | undefined, href: string | undefined) => {
80 if (whole.startsWith('`')) return whole
81 if (label !== undefined && href !== undefined) {
82 // `[label](address)`: a reference when the address is a stand-in, else a link out (PIE-1043).
83 refs.push(outlinerUriOf(href) ? { ref: href, label: label.replace(/\\(.)/g, '$1') } : { ref: href, label: label.replace(/\\(.)/g, '$1'), external: true })
84 return `${OPEN}${refs.length - 1}${CLOSE}`
85 }
86 // A bare address: the text stays as written, trailing punctuation outside it.
87 const [, url, tail] = /^(.*?)([.,;:!?]*)$/s.exec(whole)!
88 refs.push({ ref: url!, label: url!, external: true, bare: true })
89 return `${OPEN}${refs.length - 1}${CLOSE}${tail}`
90 })
91 return refs.length ? styled(slotted, refs) : null
92}
93
94/** One line holding references as its parts, or null when it holds none the mod can open. */
95function lineOf(line: string): LinePart[] | null {
96 const heading = /^#{1,6}\s+/.exec(line)
97 const bullet = heading ? null : /^(\s*)[-*+]\s+/.exec(line)
98 const body = inlineOf(line.slice((heading ?? bullet)?.[0].length ?? 0))
99 if (!body) return null
100 const parts: LinePart[] = bullet ? [{ text: `${bullet[1]}• ` }, ...body] : body
101 return heading ? parts.map(part => 'ref' in part || part.style === 'dim' ? part : { ...part, style: part.style === 'italic' || part.style === 'bold-italic' ? 'bold-italic' as const : 'bold' as const }) : parts
102}
103
104const TABLE_ROW = /^\s*\|.*\|\s*$/
105const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
106
107function cellsOf(line: string): string[] {
108 const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '')
109 return inner.split(/(?<!\\)\|/).map(cell => cell.replace(/\\\|/g, '|').trim())
110}
111
112/** The text a part shows, for a column's width. */
113const shown = (parts: LinePart[]) => parts.reduce((n, part) => n + ('ref' in part ? part.label.length + (part.external ? 2 : 0) : part.text.length), 0)
114
115/** Each column's width: its longest cell. */
116export function tableWidths(table: { header: LinePart[][]; rows: LinePart[][][] }): number[] {
117 return table.header.map((_, c) => Math.max(1, shown(table.header[c]!), ...table.rows.map(row => shown(row[c] ?? []))))
118}
119
120/**
121 * The linkified reply cut into runs of Markdown, lines of parts and whole tables, in order. A line in a code block is
122 * never a line of parts. A table with a reference in any cell is drawn whole by the mod (the engine would print the
123 * address beside its links); one with none stays the engine's. Joined, the Markdown runs, lines and tables read as the
124 * reply did, minus the addresses.
125 */
126export function replyBlocks(linkified: string): ReplyBlock[] {
127 const lines = linkified.split('\n')
128 const { fences, indented } = codeBlocks(lines)
129 const code = new Set(indented)
130 for (const fence of fences) for (let i = fence.start; i <= fence.end; i++) code.add(i)
131 const blocks: ReplyBlock[] = []
132 const markdown = (line: string) => {
133 const last = blocks.at(-1)
134 if (last?.kind === 'markdown') last.text += `\n${line}`
135 else blocks.push({ kind: 'markdown', text: line })
136 }
137 for (let i = 0; i < lines.length; i++) {
138 const line = lines[i]!
139 if (!code.has(i) && TABLE_ROW.test(line) && i + 1 < lines.length && !code.has(i + 1) && TABLE_RULE.test(lines[i + 1]!) && lines[i + 1]!.includes('-')) {
140 let end = i + 2
141 while (end < lines.length && !code.has(end) && TABLE_ROW.test(lines[end]!)) end++
142 const header = cellsOf(line)
143 const body = lines.slice(i + 2, end).map(row => cellsOf(row))
144 const drawn = [header, ...body].map(row => row.map(cell => ({ cell, parts: inlineOf(cell) })))
145 if (drawn.some(row => row.some(({ parts }) => parts))) {
146 const columns = header.length
147 const asParts = (row: { cell: string; parts: LinePart[] | null }[]) =>
148 Array.from({ length: columns }, (_, c) => row[c]?.parts ?? (row[c] ? styled(row[c]!.cell, []) : []))
149 blocks.push({ kind: 'table', header: asParts(drawn[0]!), rows: drawn.slice(1).map(asParts) })
150 i = end - 1
151 continue
152 }
153 for (let j = i; j < end; j++) markdown(lines[j]!)
154 i = end - 1
155 continue
156 }
157 const parts = code.has(i) ? null : lineOf(line)
158 if (parts) blocks.push({ kind: 'line', parts })
159 else markdown(line)
160 }
161 return blocks
162}
163hooks/connector-persona.ts 46 lines1/**
2 * The claude.ai connector's recorded calls from a Claude Code session (PIE-1256). The connector is one OAuth principal, and the
3 * gateway's `EP0CH_MCP_PERSONAS` maps it to one default persona, so a write that names none is recorded as that
4 * default, whoever in the session made it. The mod fills `persona` with the session's own (the one the "writes as"
5 * line shows) on every write the call doesn't name one for, and says so when an answer was recorded as someone else.
6 * Pure: a hooks module can't import application code.
7 */
8
9import { outputJsonOf } from './tool-rows'
10
11/** The connector's tool prefix in Claude Code: the server `claude.ai ep0ch`. */
12export const CONNECTOR_PREFIX = 'mcp__claude_ai_ep0ch__'
13
14/**
15 * The door's `PERSONA_TOOLS` (packages/door/src/mcp-writes.ts): every gateway tool that records an actor and so takes
16 * `persona`, which a hooks module can't import. The door's test/tool-args.test.ts fails when the two lists differ.
17 */
18export const CONNECTOR_PERSONA_TOOLS = [
19 'outline_create', 'outline_patch', 'outline_comment', 'outline_reply', 'outline_resolve_thread', 'outline_quote', 'outline_set_property',
20 'outline_move', 'outline_trash', 'outline_restore', 'outline_assign_id', 'outline_proposal_apply', 'outline_proposal_dismiss', 'outline_proposal_resolve',
21 'outline_write_status', 'outline_snapshot', 'script_run', 'share_start', 'share_revoke', 'loki_lease', 'artifact_upload_lease', 'outline_new', 'outline_archive',
22] as const
23
24/** The `tool.call` matcher for those tools. */
25export const CONNECTOR_PERSONA_MATCH = new RegExp(`^${CONNECTOR_PREFIX}(${CONNECTOR_PERSONA_TOOLS.join('|')})$`)
26
27/** The call with the session's persona in it: unchanged (null) when it names one already or the session has none. */
28export function personaFilled<I extends Record<string, unknown>>(input: I, persona: string | undefined): (I & { persona: string }) | null {
29 if (!persona) return null
30 const named = input.persona
31 if (typeof named === 'string' ? named.trim() : named !== undefined && named !== null) return null
32 return { ...input, persona }
33}
34
35/**
36 * What a write's answer says about who it was recorded as, when that isn't the session's persona: the line to show,
37 * and the `by` it is about (`daddy (claude.ai)`). Null for an answer that isn't a write's, or one recorded as the session.
38 */
39export function slipOf(output: unknown, persona: string | undefined): { by: string; line: string } | null {
40 if (!persona) return null
41 const by = outputJsonOf(output)?.by
42 if (typeof by !== 'string' || !by.trim()) return null
43 if (by === persona || by.startsWith(`${persona} (`)) return null
44 return { by, line: `ep0ch: that write through the claude.ai connector was recorded as ${by}, not ${persona}. Pass persona: "${persona}" on the call, or check EP0CH_MCP_PERSONAS.` }
45}
46hooks/outline-tools.ts 348 lines1/**
2 * Outline and door tools for Claude (PIE-504), so an agent never hand-rolls a
3 * script to read, edit or comment on a note, or to act in the door it runs in.
4 *
5 * - `outline_*`: each is one call to the installed CLI's `agent <operation>`
6 * (src/agent-tools.ts) with the tool's input as JSON on stdin. Each tool is
7 * defined once, for every surface (agent-tool-definitions.ts, PIE-720); this
8 * file registers them with the session's identity (`actor`).
9 * - `outline_components`: `ep0ch library --brief`, the components a note can hold as this session's outline has them
10 * (its own styles and types, its extensions'). It is the door's CLI that answers, from the schema read the
11 * door and the MCP gateway share.
12 * - `door_*`: each is one `ep0ch` command on the session's EP0CH_CONTROL, the
13 * door this Claude runs in. They exist only when EP0CH_CONTROL is set. The
14 * door enforces its rules (an agent never takes the person's focus); its
15 * refusals come back as the tool's denial.
16 *
17 * Every write is `author: agent`, attributed to the actor `actorOf` picks.
18 */
19
20import { checkToolArgs, REF_ALIASES, type ToolSchema } from './tool-args'
21import { AGENT_TOOL_DEFINITIONS, AGENT_TOOL_SURFACES, describeTool, toolInputSchema, type AgentToolDefinition } from './agent-tool-definitions'
22
23type Json = Record<string, unknown>
24
25export interface OutlineToolDefinition {
26 name: string
27 description: string
28 inputSchema: Json
29 /** A call that works. */
30 example: Json
31 /** Names this tool also answers to for an argument, beyond the reference aliases for `ref`. */
32 aliases?: Readonly<Record<string, readonly string[]>>
33 /** The CLI `agent` operation and its JSON input, or the reason the input is unusable. */
34 command(input: Record<string, unknown>): { operation: string; input: Json } | string
35}
36
37const ACTOR = {
38 type: 'string',
39 description: 'Who this is attributed to. Leave it out: it defaults to OUTLINER_ACTOR, EP0CH_AGENT, else the agent the folder\'s .ep0ch names. It names a persona; the write is recorded as `<persona>/claude-code@<machine>`, so the machine and client are always shown.',
40}
41
42const nonEmpty = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
43
44/** A shared definition as this mod registers it: a write's schema carries `actor`, the attribution override (outline_changes' `actor` is a filter). */
45function modTool(definition: AgentToolDefinition): OutlineToolDefinition {
46 return {
47 name: definition.name,
48 description: describeTool(definition),
49 inputSchema: toolInputSchema(definition, definition.writes ? { actor: ACTOR } : {}) as Json,
50 example: definition.example,
51 ...(definition.aliases ? { aliases: definition.aliases } : {}),
52 command: input => definition.command(input),
53 }
54}
55
56/** The outline tools: each defined once (agent-tool-definitions.ts, PIE-720), the ones this mod registers. */
57export const OUTLINE_TOOLS: readonly OutlineToolDefinition[] = AGENT_TOOL_DEFINITIONS.filter(d => AGENT_TOOL_SURFACES[d.name]?.mod === true).map(modTool)
58
59// ─── Door tools ────────────────────────────────────────────────────────────
60
61export interface DoorToolDefinition {
62 name: string
63 description: string
64 inputSchema: Json
65 /** A call that works. */
66 example: Json
67}
68
69export const COMPONENTS_TOOL = {
70 name: 'outline_components',
71 example: { components: ['callout'] } as Json,
72 description:
73 'The components a note can hold, as this session\'s outline has them (ep0ch library --brief): per component its ' +
74 'purpose, where it goes, each property as `key: values (default) — meaning`, and a minimal example. The ' +
75 "outline's own heading styles, callout types and its extensions' components are among the values. Name " +
76 'components to read only those. Only reads; the library screen is for people.',
77 inputSchema: {
78 type: 'object',
79 properties: { components: { type: 'array', items: { type: 'string' }, description: 'Component ids (heading-style, callout, rule, …); leave out for all' } },
80 additionalProperties: false,
81 } as Json,
82} as const
83
84/** `ep0ch library --brief` argv for the components named (all when none are), or the reason the input is unusable. */
85export function componentsArgv(input: Record<string, unknown>): { argv: string[] } | string {
86 const names = input.components ?? []
87 if (!Array.isArray(names) || !names.every(n => typeof n === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(n))) {
88 return 'components is a list of component ids such as heading-style or callout (leave it out for all).'
89 }
90 return { argv: ['ep0ch', 'library', '--brief', ...(names as string[])] }
91}
92
93/**
94 * `outline_bind`: points this session's outline tools at the outline `name` (on `machine`, when another machine's),
95 * for the rest of the session. The mod's own handler (hooks/register.ts) runs it; `/outline <name>` is the same call.
96 */
97export const BIND_TOOL = {
98 name: 'outline_bind',
99 example: { name: 'garden' } as Json,
100 description:
101 "Bind this session's outline, workboard and mention tools to the outline `name` (`ep0ch outline list` lists them), " +
102 'on `machine` (an ssh config name) only for an outline on another machine. It stays for the rest of the session, ' +
103 'over EP0CH_WS and any folder, and a shell `cd` never moves it. Use it when a tool says this session is not bound ' +
104 'or is bound to the wrong outline. An outline nobody has made is refused, never created.',
105 inputSchema: {
106 type: 'object',
107 properties: {
108 name: { type: 'string', description: 'The outline name: lowercase letters, digits and hyphens (garden)' },
109 machine: { type: 'string', description: 'An ssh config name, only for an outline on another machine' },
110 },
111 required: ['name'],
112 additionalProperties: false,
113 } as Json,
114} as const
115
116export const DOOR_TOOLS: readonly DoorToolDefinition[] = [
117 {
118 name: 'door_where',
119 example: {},
120 description:
121 'Where this session runs (ep0ch where): the layers (ssh › herdr › door tile), which are live, and whether the ' +
122 'person is typing in your tile. Only reads.',
123 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
124 },
125 {
126 name: 'door_peek',
127 example: {},
128 description:
129 'What the door shows the person now (ep0ch peek): by default the compact text, a few lines of state (where, ' +
130 'the person, the door\'s speed) and the screen as text, a few KB. `full: true` is the whole structured state as ' +
131 'well (ep0ch peek --json: layout, drawer, tiles, hundreds of KB); `section` is one top-level part of it ' +
132 '(speed, person, layout, drawer, state, …). Only reads.',
133 inputSchema: {
134 type: 'object',
135 properties: {
136 full: { type: 'boolean', description: 'The whole structured state and the screen text, not the compact text.' },
137 section: { type: 'string', description: 'One part of the state, e.g. speed, person, layout, drawer, state, text.' },
138 xray: { type: 'boolean', description: 'Include every visible tile controls as hint-card rows (the whole state, as full).' },
139 },
140 additionalProperties: false,
141 },
142 },
143 {
144 name: 'door_act',
145 example: { action: 'block.mark', args: { id: '7d9a1f40-3c52-4b8e-a6d1-0e5f2b9c8a34' } },
146 description:
147 "Run one of the door's actions as you (ep0ch act, attributed with --as and said on the person's screen). " +
148 '`ep0ch actions` names them; door_peek shows the state they act on. The door never lets an agent take the ' +
149 "person's focus, keys or selection: such an action is refused with the reason, which comes back as this " +
150 "tool's error. To point the person at something, use block.mark. To open a note, door_open.",
151 inputSchema: {
152 type: 'object',
153 properties: {
154 action: { type: 'string', description: 'The action name, e.g. layout.get, view.scrollTo, block.mark' },
155 args: {
156 type: 'object',
157 additionalProperties: { type: ['string', 'number', 'boolean'] },
158 description: 'The action’s arguments, key: value',
159 },
160 tile: { type: 'string', description: 'The tile to act in, when the action needs one' },
161 actor: ACTOR,
162 },
163 required: ['action'],
164 additionalProperties: false,
165 },
166 },
167 {
168 name: 'door_open',
169 example: { ref: 'PIE-123' },
170 description:
171 'Open the note `ref` in the door this session runs in, where your tile’s opens land (from=$EP0CH_TILE), as you. It ' +
172 "never moves the person's focus; the door says which reader it went to.",
173 inputSchema: {
174 type: 'object',
175 properties: { ref: { type: 'string', description: 'The note: a block id, ((id)), [[page]] or Work ID' }, actor: ACTOR },
176 required: ['ref'],
177 additionalProperties: false,
178 },
179 },
180]
181
182const ACT_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/
183
184/**
185 * `ep0ch act` argv (and stdin) for an action as `actor`. Values go as
186 * `key=value` words. `ep0ch` reads a value starting with `@` as a file (`@-`
187 * as stdin), so one such value is sent through stdin instead; a second is
188 * refused rather than read as a path.
189 */
190export function doorActArgv(
191 input: Record<string, unknown>,
192 actor: string,
193): { argv: string[]; stdin?: string } | string {
194 const action = input.action
195 if (!nonEmpty(action) || /\s/.test(action)) return 'Give the action name (door_peek and `ep0ch actions` list them).'
196 const args = input.args ?? {}
197 if (typeof args !== 'object' || args === null || Array.isArray(args)) return 'args is an object of key: value.'
198 const words: string[] = []
199 let stdin: string | undefined
200 for (const [key, raw] of Object.entries(args as Record<string, unknown>)) {
201 if (!ACT_KEY.test(key) || key === 'as' || key === 'tile') return `"${key}" is not an argument name (use tile and actor for those).`
202 if (!['string', 'number', 'boolean'].includes(typeof raw)) return `${key} must be a string, number or boolean.`
203 const value = String(raw)
204 if (value.startsWith('@')) {
205 if (stdin !== undefined) return 'Only one argument may start with @.'
206 stdin = value
207 words.push(`${key}=@-`)
208 } else {
209 words.push(`${key}=${value}`)
210 }
211 }
212 if (input.tile !== undefined) {
213 if (!nonEmpty(input.tile) || String(input.tile).startsWith('@')) return 'tile is a tile name.'
214 words.push(`tile=${input.tile}`)
215 }
216 return { argv: ['ep0ch', 'act', action, ...words, '--as', actor], ...(stdin === undefined ? {} : { stdin }) }
217}
218
219/** What a `door_peek` call asks `ep0ch peek` for: the compact text (`text`), one `section` of the state, or the `full` state as one JSON object. */
220export type PeekCall = { argv: string[]; reads: 'text' | 'section' | 'full' }
221export function peekArgv(input: Json): PeekCall | string {
222 const section = typeof input.section === 'string' ? input.section.trim() : ''
223 if (section && !/^[A-Za-z][A-Za-z0-9_-]*$/.test(section)) return 'section is the name of one part of the state: speed, person, layout, drawer, state, text.'
224 if (section) {
225 if (input.full === true || input.xray === true) return 'section is one part of the state; full and xray are the whole of it: give one or the other.'
226 return { argv: ['ep0ch', 'peek', section], reads: section === 'text' ? 'text' : 'section' }
227 }
228 const full = input.full === true || input.xray === true
229 return { argv: ['ep0ch', 'peek', ...(full ? ['--json'] : []), ...(input.xray === true ? ['--xray'] : [])], reads: full ? 'full' : 'text' }
230}
231
232/**
233 * What `ep0ch peek` printed, as one compact value: the compact text as `{ text }`; the full state (`--json`: one object
234 * holding the screen's `text` lines) as `{ screen, text }`; a section (JSON alone) as `{ section }`.
235 */
236export function peekOf(stdout: string, reads: PeekCall['reads'] = 'text'): { screen?: unknown; section?: unknown; text?: string } {
237 if (reads !== 'text') {
238 try {
239 const value = JSON.parse(stdout) as Json
240 if (reads === 'section') return { section: value }
241 const { text, ...screen } = value
242 return { screen, text: Array.isArray(text) ? text.join('\n').trimEnd() : '' }
243 } catch {
244 // Not the JSON this asked for: the text alone.
245 }
246 }
247 return { text: stdout.trimEnd() }
248}
249
250/** A persona's name: what an actor id holds before the `/`. */
251const PERSONA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
252
253/** This machine's name as a principal holds it: letters, digits, `.`, `_` and `-`, starting with a letter or digit, up to 31. */
254export function machineNameOf(raw: string): string | undefined {
255 const cleaned = raw.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 31)
256 return cleaned || undefined
257}
258
259/**
260 * Who a write is attributed to (PIE-679, ADR 0003): the principal, which is this client on this machine
261 * (`claude-code@float-2`), and the persona on top, first match wins: the caller's `actor`, else OUTLINER_ACTOR, else
262 * EP0CH_AGENT (the door's own name for the agent), else `folder`, the `agent` the session folder's `.ep0ch` names (as
263 * `ep0ch where` reads it). `loki/claude-code@float-2`; with no persona, the principal alone. The door tools act as the
264 * same id, so one agent's door writes and outline writes are one actor. A persona is a recorded name, never a
265 * permission. Without a machine (it couldn't be read) the principal is the bare client, as it was.
266 */
267export function actorOf(input: Record<string, unknown>, env: { OUTLINER_ACTOR?: string; EP0CH_AGENT?: string }, machine?: string, folder?: string): string {
268 const principal = machine ? `claude-code@${machine}` : 'claude-code'
269 const persona = sessionPersonaOf(input, env, folder)
270 return persona ? `${persona}/${principal}` : principal
271}
272
273/**
274 * The persona a session writes as, `actorOf`'s first-match rule without the principal: what the "writes as" line
275 * shows, and what the claude.ai connector's writes are filled with (hooks/connector-persona.ts).
276 */
277export function sessionPersonaOf(input: Record<string, unknown>, env: { OUTLINER_ACTOR?: string; EP0CH_AGENT?: string }, folder?: string): string | undefined {
278 return personaOf(input, env) ?? personaOf({}, { OUTLINER_ACTOR: folder })
279}
280
281function personaOf(input: Record<string, unknown>, env: { OUTLINER_ACTOR?: string; EP0CH_AGENT?: string }): string | undefined {
282 for (const candidate of [input.actor, env.OUTLINER_ACTOR, env.EP0CH_AGENT]) {
283 if (typeof candidate !== 'string' || !candidate.trim()) continue
284 // A claimed persona is a label, never a principal: anything before a `/` or `@` the claim brings is dropped.
285 const persona = candidate.trim().split(/[/@]/)[0]!.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 64)
286 if (persona && PERSONA.test(persona) && persona !== 'claude-code') return persona
287 }
288 return undefined
289}
290
291// ─── Checked input ─────────────────────────────────────────────────────────
292
293/** Any tool this mod registers, as `checkedInput` needs it. */
294export interface ToolWithSchema {
295 name: string
296 inputSchema: Json
297 example?: Json
298 aliases?: Record<string, readonly string[]>
299}
300
301/**
302 * A tool call's input checked once, for every tool: aliases of `ref` renamed, unknown, missing and mistyped
303 * arguments answered with the schema line and a working call (tool-args.ts). A plugin tool's arguments arrive flat on
304 * the event, beside `tool`, so `tool` is not one of them. The input to use, or the denial text.
305 */
306export function checkedInput(tool: ToolWithSchema, event: Record<string, unknown>): { input: Record<string, unknown>; dropped: string[] } | string {
307 const checked = checkToolArgs(
308 { name: tool.name, schema: tool.inputSchema as ToolSchema, example: tool.example ?? {}, ...(tool.aliases ? { aliases: tool.aliases } : {}), ignore: EVENT_KEYS, dropUnknown: true },
309 event,
310 )
311 if (!checked.ok) return checked.error
312 // What was dropped as no argument (host metadata, or a name the model made up: PIE-871's `kind`): said with the answer.
313 const declared = (tool.inputSchema as ToolSchema).properties ?? {}
314 const known = new Set<string>([...EVENT_KEYS, ...Object.keys(declared), ...('ref' in declared ? REF_ALIASES : []), ...Object.values(tool.aliases ?? {}).flat()])
315 return { input: checked.args, dropped: Object.keys(event).filter(key => event[key] !== undefined && !known.has(key)) }
316}
317
318/** The line put before a tool's answer when names were dropped (checkedInput): the call ran without them. */
319export function droppedNote(tool: ToolWithSchema, dropped: readonly string[]): string {
320 if (!dropped.length) return ''
321 const names = Object.keys((tool.inputSchema as ToolSchema).properties ?? {}).filter(name => name !== 'actor')
322 return `Not used: ${dropped.map(name => `\`${name}\``).join(', ')} ${dropped.length === 1 ? 'is no argument' : 'are no arguments'} of ${tool.name} (its arguments: ${names.join(', ')}); the call ran without ${dropped.length === 1 ? 'it' : 'them'}.\n`
323}
324
325/** What a plugin tool event carries besides the tool's arguments. */
326const EVENT_KEYS = ['tool', 'tool_use_id', 'toolUseId', 'type', 'hook_event_name', 'session_id', 'cwd', 'agentId', 'agent_id', 'agentType', 'agent_type'] as const
327
328/** `show`: opens a note for the person (the mod's own handler, hooks/register.ts). */
329export const SHOW_TOOL: ToolWithSchema & { description: string } = {
330 name: 'show',
331 example: { ref: 'PIE-123' },
332 description:
333 'Show the note `ref` to the person: in the Outliner Detail beside this conversation in Herdr (the one linked to the ' +
334 "Tree in this Herdr workspace, or one opened when there is none), so the person can read it beside the chat. " +
335 "When this session runs in an ep0ch-door tile, it opens in that door, where its tile's opens land, instead; " +
336 'with neither (the desktop app, VS Code, a terminal outside Herdr), in this mod\'s mentions pane beside the chat. ' +
337 'It never moves their focus, and a Detail they are editing in refuses it. Use it when pointing the person at a note matters; ' +
338 'references in replies are already clickable.',
339 inputSchema: {
340 type: 'object',
341 properties: {
342 ref: { type: 'string', description: 'The note: a Work ID (PIE-123), [[page]], ((block-uuid)), bare block UUID, or pi-outliner:// URI.' },
343 },
344 required: ['ref'],
345 additionalProperties: false,
346 },
347}
348hooks/work-tools.ts 266 lines1/**
2 * Workboard tools for Claude: each one is a thin call to the installed CLI's
3 * `work` / `note` commands (src/work-tools.ts), so Claude, Codex and Pi share
4 * one implementation. This file only turns tool arguments into CLI argv.
5 */
6
7type Json = Record<string, unknown>
8
9export interface WorkToolDefinition {
10 name: string
11 description: string
12 inputSchema: Json
13 /** A call that works. */
14 example: Json
15 /** Names this tool also answers to for an argument (`item` is `ref`). */
16 aliases?: Record<string, readonly string[]>
17 /** The CLI arguments and stdin for one call, or the reason the input is unusable. */
18 command(input: Record<string, unknown>): { args: string[]; stdin?: string } | string
19}
20
21const ITEM = { type: 'string', description: 'The roadmap item: a Work ID (PIE-123) or its block UUID. Never a title.' }
22const EXPECTED = {
23 type: 'integer',
24 minimum: 1,
25 description: 'The revision you read (outline_read returns it); the write is refused if the block changed since.',
26}
27
28function text(input: Record<string, unknown>, key: string): string | undefined {
29 const value = input[key]
30 return typeof value === 'string' && value.trim() ? value : undefined
31}
32
33function expected(input: Record<string, unknown>): string[] {
34 const value = input.expectedRevision
35 return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? ['--expected', String(value)] : []
36}
37
38/**
39 * The revision a text-replacing tool was built on, required (PIE-631): replacement text from an older read, checked
40 * against the block as it is now, would overwrite an edit made in between. Missing, the reason and the next step.
41 */
42function readRevision(input: Record<string, unknown>, ref: string): string[] | string {
43 const args = expected(input)
44 return args.length ? args : `Read ${ref} first with outline_read and pass its revision as expectedRevision: this tool replaces text, so it is checked against the revision you read.`
45}
46
47/**
48 * `--name=value`, one word: a value that begins with `--` (a title about `--machine`) is the option's value, never
49 * read as an option of its own ("argument is ambiguous").
50 */
51function opt(name: string, value: string): string {
52 return `--${name}=${value}`
53}
54
55/**
56 * `args` with `options` added before its operands: before a `--` that ends the options (an item, a heading or a value
57 * that begins with `--` is an operand after it), else at the end. How the caller's own flags (who, which session) join.
58 */
59export function withOptions(args: readonly string[], options: readonly string[]): string[] {
60 const at = args.indexOf('--')
61 return at < 0 ? [...args, ...options] : [...args.slice(0, at), ...options, ...args.slice(at)]
62}
63
64function schema(properties: Json, required: string[]): Json {
65 return { type: 'object', properties, required, additionalProperties: false }
66}
67
68export const WORK_TOOLS: readonly WorkToolDefinition[] = [
69 {
70 name: 'work_create',
71 example: {"title": "Show the weather on the home screen", "project": "ep0ch", "arc": "door", "priority": "medium", "tracks": ["door"]},
72 description:
73 "Create a roadmap item under the project's work queue with a newly allocated Work ID. Returns the Work ID, " +
74 'block reference and revision. New work defaults to unprioritized; pass stage only when it was agreed.',
75 inputSchema: schema({
76 title: { type: 'string', description: 'Concise outcome, without a Work ID or property tokens' },
77 project: { type: 'string' },
78 arc: { type: 'string' },
79 tracks: { type: 'array', items: { type: 'string' }, minItems: 1 },
80 priority: { type: 'string', enum: ['high', 'medium', 'low'] },
81 stage: { type: 'string', enum: ['unprioritized', 'later', 'queued', 'doing', 'review', 'validate'] },
82 batch: { type: 'string', description: 'UUID of the agreed work-batch' },
83 wave: { type: 'string', description: 'The wave it ships in, when it is one of several tickets under one theme ([wave::<theme>])' },
84 from: {
85 type: 'string',
86 description: 'The note it came from (block UUID), recorded as [source-block::…]; a request copy ([type::request]) gets [ticket::<the new Work ID>]',
87 },
88 body: { type: 'string', description: 'Markdown body: context and acceptance criteria' },
89 }, ['title', 'project', 'arc', 'tracks', 'priority']),
90 command(input) {
91 const tracks = Array.isArray(input.tracks) ? input.tracks.filter((track): track is string => typeof track === 'string') : []
92 const [title, project, arc, priority] = ['title', 'project', 'arc', 'priority'].map(key => text(input, key))
93 if (!title || !project || !arc || !priority || tracks.length === 0) return 'Give a title, project, arc, priority and at least one track.'
94 const args = ['work', 'create', opt('title', title), opt('project', project), opt('arc', arc), opt('priority', priority),
95 ...tracks.map(track => opt('track', track))]
96 const [stage, batch, wave, from] = ['stage', 'batch', 'wave', 'from'].map(key => text(input, key))
97 if (stage) args.push(opt('stage', stage))
98 if (batch) args.push(opt('batch', batch))
99 if (wave) args.push(opt('wave', wave))
100 if (from) args.push(opt('source', from))
101 const body = text(input, 'body')
102 return body ? { args: [...args, '--stdin'], stdin: body } : { args }
103 },
104 },
105 {
106 name: 'work_stage',
107 example: {"ref": "PIE-123", "stage": "doing"},
108 aliases: { ref: ['item'] },
109 description:
110 "Move the roadmap item `ref` to another work stage (queued, doing, review, validate, later…), checked against its " +
111 'revision and read back. Done needs proof: use work_complete.',
112 inputSchema: schema({ ref: ITEM, stage: { type: 'string' }, expectedRevision: EXPECTED }, ['ref', 'stage']),
113 command(input) {
114 const [item, stage] = [text(input, 'ref'), text(input, 'stage')]
115 if (!item || !stage) return 'Give the ref and the stage.'
116 return { args: ['work', 'stage', ...expected(input), '--', item, stage] }
117 },
118 },
119 {
120 name: 'work_set',
121 example: {"ref": "PIE-123", "key": "priority", "value": "high"},
122 aliases: { ref: ['item'] },
123 description:
124 'Set one single-valued property on the roadmap item `ref` (priority, work-batch, arc…), checked against its revision ' +
125 'and read back. Identity properties and multi-valued ones are refused. On a delivery only delivery-stage can ' +
126 'be set: complete finishes a merged delivery (e.g. one left in validate on an item already done), validate reopens it.',
127 inputSchema: schema({
128 ref: {
129 type: 'string',
130 description: 'A roadmap item (Work ID or block UUID), or for delivery-stage a delivery: its block UUID or key (PIE-123/door).',
131 },
132 key: { type: 'string' },
133 value: { type: 'string' },
134 expectedRevision: EXPECTED,
135 }, ['ref', 'key', 'value']),
136 command(input) {
137 const [item, key, value] = [text(input, 'ref'), text(input, 'key'), text(input, 'value')]
138 if (!item || !key || !value) return 'Give the ref, the property key and its value.'
139 return { args: ['work', 'set', ...expected(input), '--', item, key, value] }
140 },
141 },
142 {
143 name: 'work_link',
144 example: {"ref": "0c909b93-266b-4d70-b3b3-e215f1fbd97d", "ticket": "PIE-123"},
145 description:
146 'Name the roadmap item a note became: [ticket::PIE-n] on the note `ref` (a request copy), replacing one it named ' +
147 'before. The service adds [ticket-stage::…] and keeps it following the item\'s work-stage. A new item made with ' +
148 'work_create from the request names it already.',
149 inputSchema: schema({
150 ref: { type: 'string', description: 'The note that asked for the work (a request copy): its block UUID' },
151 ticket: ITEM,
152 expectedRevision: EXPECTED,
153 }, ['ref', 'ticket']),
154 command(input) {
155 const [note, ticket] = [text(input, 'ref'), text(input, 'ticket')]
156 if (!note || !ticket) return 'Give the ref of the note and the ticket (a Work ID).'
157 return { args: ['work', 'link', ...expected(input), '--', note, ticket] }
158 },
159 },
160 {
161 name: 'work_deliver',
162 example: {"ref": "PIE-123", "repo": "example-org/example-repo", "pr": 42},
163 aliases: { ref: ['item'] },
164 description:
165 "Record a GitHub pull request as one of the deliveries of the item `ref` and sync its live state: an open PR moves the " +
166 "item to review, a merged one to validate. The PR's branches must match the delivery's. An item can have " +
167 'several deliveries (one per repository or branch), each under its own key.',
168 inputSchema: schema({
169 ref: ITEM,
170 repo: { type: 'string', description: 'owner/name' },
171 pr: { type: 'integer', minimum: 1 },
172 key: {
173 type: 'string',
174 description:
175 'Delivery name (door → PIE-123/door). Omitted: the delivery already recording this repo and branch, else ' +
176 'primary, else (primary is another repo) the repository name. A second branch in the same repo needs one.',
177 },
178 base: { type: 'string', description: "Base branch; defaults to the PR's" },
179 branch: { type: 'string', description: "Work branch; defaults to the PR's head" },
180 }, ['ref', 'repo', 'pr']),
181 command(input) {
182 const [item, repo] = [text(input, 'ref'), text(input, 'repo')]
183 const pr = input.pr
184 if (!item || !repo || typeof pr !== 'number' || !Number.isSafeInteger(pr) || pr < 1) return 'Give the ref, repo and PR number.'
185 const args = ['work', 'deliver', opt('repo', repo), opt('pr', String(pr))]
186 const [key, base, branch] = [text(input, 'key'), text(input, 'base'), text(input, 'branch')]
187 if (key) args.push(opt('key', key))
188 if (base) args.push(opt('base', base))
189 if (branch) args.push(opt('branch', branch))
190 return { args: [...args, '--', item] }
191 },
192 },
193 {
194 name: 'work_complete',
195 example: {"ref": "PIE-123", "allMerged": true, "proof": "Shipped\n\nChecked on a scratch host."},
196 aliases: { ref: ['item'] },
197 description:
198 'Accept the roadmap item `ref` with linked proof. Every incomplete delivery must be covered: name them in deliveries ' +
199 'or set allMerged; each must be merged. If another delivery is still incomplete the call is refused, naming it ' +
200 'and how to finish it. The proof is added as a child block (or an existing linked proof block is used), the ' +
201 'covered deliveries become complete and the item done.',
202 inputSchema: schema({
203 ref: ITEM,
204 deliveries: {
205 type: 'array',
206 items: { type: 'string' },
207 minItems: 1,
208 description: 'Deliveries to complete: block UUID, key (PIE-123/door) or name (door)',
209 },
210 allMerged: { type: 'boolean', description: 'Complete every delivery whose PR is merged, instead of naming them' },
211 proof: { type: 'string', description: 'Proof as Markdown: first line is its title' },
212 proofBlock: { type: 'string', description: 'An existing proof block UUID linked to the item, instead of proof text' },
213 }, ['ref']),
214 command(input) {
215 const item = text(input, 'ref')
216 const [proof, proofBlock] = [text(input, 'proof'), text(input, 'proofBlock')]
217 const deliveries = Array.isArray(input.deliveries)
218 ? input.deliveries.filter((delivery): delivery is string => typeof delivery === 'string' && delivery.trim() !== '')
219 : []
220 if (!item) return 'Give the ref.'
221 if (!proof === !proofBlock) return 'Give either proof text or an existing proofBlock.'
222 if (input.allMerged === true && deliveries.length) return 'Name deliveries or set allMerged, not both.'
223 const args = ['work', 'complete', ...deliveries.map(delivery => opt('delivery', delivery)),
224 ...(input.allMerged === true ? ['--all-merged'] : [])]
225 return proof ? { args: [...args, '--stdin', '--', item], stdin: proof } : { args: [...args, opt('proof-block', proofBlock!), '--', item] }
226 },
227 },
228 {
229 name: 'work_body',
230 example: {"ref": "PIE-123", "body": "Context and acceptance criteria.", "expectedRevision": 4},
231 aliases: { ref: ['item'] },
232 description:
233 "Replace the body of the roadmap item (or any block) `ref` below its title and property lines, checked against the revision you read (outline_read).",
234 inputSchema: schema({ ref: ITEM, body: { type: 'string' }, expectedRevision: EXPECTED }, ['ref', 'body', 'expectedRevision']),
235 command(input) {
236 const item = text(input, 'ref')
237 if (!item || typeof input.body !== 'string') return 'Give the ref and its new body.'
238 const revision = readRevision(input, item)
239 if (typeof revision === 'string') return revision
240 return { args: ['work', 'body', '--stdin', ...revision, '--', item], stdin: input.body }
241 },
242 },
243 {
244 name: 'note_section',
245 example: {"ref": "PIE-123", "heading": "## Now", "body": "Updated text.", "expectedRevision": 4},
246 aliases: { ref: ['item'] },
247 description:
248 'Replace one Markdown section of the note `ref`: the text under a heading up to the next heading of its level, as ' +
249 'Detail folds it, checked against the revision you read (outline_read). The heading stays; the replaced text is ' +
250 'returned as "previous".',
251 inputSchema: schema({
252 ref: { type: 'string', description: 'The note: block UUID or Work ID' },
253 heading: { type: 'string', description: 'Heading text, optionally with its ## level' },
254 body: { type: 'string' },
255 expectedRevision: EXPECTED,
256 }, ['ref', 'heading', 'body', 'expectedRevision']),
257 command(input) {
258 const [block, heading] = [text(input, 'ref'), text(input, 'heading')]
259 if (!block || !heading || typeof input.body !== 'string') return 'Give the ref of the note, the heading and the new section text.'
260 const revision = readRevision(input, block)
261 if (typeof revision === 'string') return revision
262 return { args: ['note', 'section', '--stdin', ...revision, '--', block, heading], stdin: input.body }
263 },
264 },
265]
266hooks/peer-log.ts 44 lines1// Messages between Claude sessions (SendMessage, `$.session.send`, the /msg prompt command in commands/msg.md): each one sent or received is copied as a note
2// under the bound outline's [[peer-messages]] page, so a cross-machine nudge leaves a record outside the chat. Pure: the
3// hooks in register.ts run these argvs.
4
5export const PEER_PAGE = 'peer-messages'
6const MAX_CHARS = 4000
7
8/** A peer's delivery reads `<cross-session-message from="…" from-name="loki-sr" …>body</cross-session-message>`. */
9export function unwrapPeer(text: string): { from: string; body: string } {
10 const from = /from-name="([^"]+)"/.exec(text)?.[1] ?? /from="([^"]+)"/.exec(text)?.[1] ?? 'a peer'
11 const body = /<cross-session-message[^>]*>([\s\S]*?)<\/cross-session-message>/.exec(text)?.[1] ?? text
12 return { from, body: body.trim() }
13}
14
15/** The local time a note is titled with, to the minute: `2026-10-16 14:02`. */
16export function stampOf(ms: number): string {
17 const d = new Date(ms)
18 const two = (n: number) => String(n).padStart(2, '0')
19 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
20}
21
22/** One message as a note: its first line says which way, with whom and when; the text under it, cut past 4000 characters. */
23export function entryOf(direction: 'to' | 'from', peer: string, ms: number, body: string): string {
24 const cut = body.length > MAX_CHARS ? `${body.slice(0, MAX_CHARS)}\n… cut at ${MAX_CHARS} characters` : body
25 return `${direction === 'to' ? '→ to' : '← from'} ${peer} · ${stampOf(ms)} [type::peer-message] [peer::${peer}]\n\n${cut}`
26}
27
28export const findPageArgv = (): string[] => ['ep0ch', 'find', '--query', `page = "${PEER_PAGE}"`, '--ids']
29export const createPageArgv = (actor: string): string[] =>
30 ['ep0ch', 'new', `[page::${PEER_PAGE}]\nMessages between Claude sessions (SendMessage, /msg), one note each, newest last.`, '--as', actor, '--json']
31export const entryArgv = (pageId: string, text: string, actor: string): string[] => ['ep0ch', 'new', text, '--near', pageId, '--as', actor]
32
33/** The page's id from `find --ids` (`((id))` a line, the first one) or `new --json` (`{ id }`); undefined when neither has one. */
34export function pageIdOf(stdout: string): string | undefined {
35 const ref = /\(\(([0-9a-f-]{8,})\)\)/.exec(stdout)?.[1]
36 if (ref) return ref
37 try {
38 const id = (JSON.parse(stdout) as { id?: unknown }).id
39 return typeof id === 'string' && id ? id : undefined
40 } catch {
41 return undefined
42 }
43}
44hooks/settle.ts 324 lines1import { LIST_LIMIT, listArgs, listedOf } from './workboard-now'
2import { MACHINE_NAME, OUTLINE_NAME } from './mention-message'
3
4/**
5 * The settle check (PIE-1268, "Settle, then shape"): ideas gather while they are warm, and the deployer's session is
6 * woken once a thread has gone quiet, so the backlog drains because a thread settled, not because the person said go.
7 * Per `[thread::…]` value across the outlines it reads: how many notes, how many of them the person's own, when it was
8 * last touched, and whether a ticket, a capability note or a hub note carries it. All of that is counts and a max over what the service already
9 * answers for `list --facets thread` (outline-core's QuestionFacet: `count` over every match, `last` the newest
10 * update), so no service code decides it; this file only joins three answers and applies the thresholds.
11 *
12 * Pure: register.ts runs the commands and the clock, since only it is handed `$`.
13 */
14
15/** The property a thread is named by. The only one the check knows. */
16export const THREAD_KEY = 'thread'
17
18/** The session that runs the check unless the option says otherwise: the deployer's. */
19export const SETTLE_PERSONA = 'loki'
20
21export type SettleState =
22 /** Touched within the quiet window: hold and collect. */
23 | 'warming'
24 /** Enough notes, the person's among them, quiet for the window (and not for longer than `within`): wake the deployer. */
25 | 'settled'
26 /** A ticket, a capability note or a hub note carries the thread: it has a home, so stop watching. */
27 | 'shaped'
28 /** Quiet, but too thin to shape, or dormant for weeks: nothing to say. */
29 | 'quiet'
30
31export type SettleConfig = {
32 /** Minutes between checks. */
33 every: number
34 /** Notes a thread needs to settle. */
35 chirps: number
36 /** How many of them must be the person's own. */
37 evan: number
38 /** Minutes without a touch before a thread is quiet. */
39 quiet: number
40 /** Minutes after which a quiet thread is dormant, not settling (so the first check isn't a flood of old threads). */
41 within: number
42 /** The notes a thread is counted over. Roadmap items are the tickets, not the conversation. */
43 base: string
44 /** The person's own notes among them: an author, an actor, or the quoted "Evan, …" form. */
45 evanQuery: string
46 /** The notes that shape a thread by carrying it: tickets, capability notes and hubs (`type=thread` is kitty's hub for journals and publications). */
47 shapedQuery: string
48 /** Threads one wake names, the newest first; the rest wait for the next check, which names the next ones. */
49 wakeMax: number
50}
51
52/** Guesses (the capability note says so); tune them from a week of real threads with `settleTuning`. */
53export const SETTLE_DEFAULTS: SettleConfig = {
54 every: 30,
55 chirps: 3,
56 evan: 1,
57 quiet: 60,
58 within: 3 * 24 * 60,
59 base: `${THREAD_KEY} AND NOT type=roadmap-item`,
60 evanQuery: 'by:user OR actor=evan OR text~"Evan, "',
61 shapedQuery: `(type=roadmap-item OR type=capability OR type=hub OR type=thread) AND ${THREAD_KEY}`,
62 wakeMax: 5,
63}
64
65const NUMBERS = ['every', 'chirps', 'evan', 'quiet', 'within', 'wakeMax'] as const
66const TEXTS: Record<string, 'base' | 'evanQuery' | 'shapedQuery'> = { base: 'base', 'evan-query': 'evanQuery', 'shaped-query': 'shapedQuery' }
67const NUMBER_KEYS: Record<string, (typeof NUMBERS)[number]> = { every: 'every', chirps: 'chirps', evan: 'evan', quiet: 'quiet', within: 'within', 'wake-max': 'wakeMax' }
68export const TUNING_FORM = 'chirps=3; evan=1; quiet=60; within=4320; every=30; wake-max=5; base=<query>; evan-query=<query>; shaped-query=<query>'
69
70/**
71 * The `settleTuning` option or EP0CH_SETTLE_TUNING: `key=value` pairs separated by `;`. Minutes for quiet, within and
72 * every. An unknown key or a value that isn't a positive whole number is a problem named with the form, never skipped.
73 */
74export function tuningOf(text: string | undefined): { config: SettleConfig; problems: string[] } {
75 const config = { ...SETTLE_DEFAULTS }
76 const problems: string[] = []
77 for (const pair of (text ?? '').split(/[;\n]/).map(p => p.trim()).filter(Boolean)) {
78 const at = pair.indexOf('=')
79 const key = (at < 0 ? pair : pair.slice(0, at)).trim().toLowerCase()
80 const value = at < 0 ? '' : pair.slice(at + 1).trim()
81 if (key in NUMBER_KEYS) {
82 const n = Number(value)
83 if (!/^\d+$/.test(value) || !Number.isSafeInteger(n) || n < 1) problems.push(`${key} wants a positive whole number, not "${value}"`)
84 else config[NUMBER_KEYS[key]!] = n
85 } else if (key in TEXTS) {
86 if (!value) problems.push(`${key} wants a query in the views' grammar`)
87 else config[TEXTS[key]!] = value
88 } else problems.push(`unknown key "${key}"`)
89 }
90 if (problems.length) problems.push(`write it like: ${TUNING_FORM}`)
91 return { config, problems }
92}
93
94/** Whether this session runs the check: `on`/`off` (the option, else EP0CH_SETTLE) decides outright, else the deployer's persona. */
95export function settlesFor(setting: string | undefined, persona: string | undefined): boolean {
96 const s = setting?.trim().toLowerCase()
97 if (s === 'on' || s === '1' || s === 'true') return true
98 if (s === 'off' || s === '0' || s === 'false') return false
99 return persona === SETTLE_PERSONA
100}
101
102export type SettleOutline = { outline: string; machine?: string }
103
104/**
105 * The outlines the check reads: `name[@machine]` separated by commas or spaces (`gurgle@float-2, pie@float-2`), else
106 * pie and gurgle on the bound outline's machine. A name that isn't an outline's or a machine that isn't an ssh config name is a problem.
107 */
108export function outlinesOf(text: string | undefined, bound: SettleOutline | null): { outlines: SettleOutline[]; problems: string[] } {
109 const words = (text ?? '').split(/[,\s]+/).filter(Boolean)
110 if (!words.length) return { outlines: bound ? DEFAULT_OUTLINES.map(outline => ({ outline, ...(bound.machine ? { machine: bound.machine } : {}) })) : [], problems: [] }
111 const outlines: SettleOutline[] = []
112 const problems: string[] = []
113 for (const word of words) {
114 const [outline, machine, ...rest] = word.split('@')
115 if (!OUTLINE_NAME.test(outline ?? '') || rest.length || (machine !== undefined && !MACHINE_NAME.test(machine))) problems.push(`"${word}" is not name or name@ssh-name`)
116 else outlines.push({ outline: outline!, ...(machine ? { machine } : {}) })
117 }
118 if (problems.length) problems.push('write it like: gurgle@float-2, pie@float-2')
119 return { outlines, problems }
120}
121
122/** The outlines a session reads when nothing names them: the ones the person works in, on the bound outline's machine. */
123export const DEFAULT_OUTLINES = ['pie', 'gurgle'] as const
124
125/** The properties the settle capability note carries its settings on, edited in the outline where the design lives. */
126export const TUNING_KEY = 'settle-tuning'
127export const OUTLINES_KEY = 'settle-outlines'
128
129/** `list`'s argv for the capability notes carrying either setting, rows with their update time. */
130export function settleNoteArgs(): string[] {
131 return listArgs(`type=capability AND (${TUNING_KEY} OR ${OUTLINES_KEY})`, LIST_LIMIT, 'id,title,properties,timestamps')
132}
133
134/** A capability note's settings: the `settleTuning` form (TUNING_FORM) and/or the outlines (`gurgle@float-2, pie`). */
135export type SettleNote = { id: string; updatedAt: number; tuning?: string; outlines?: string }
136
137/** The settings notes in one `list` answer, or null when it isn't one. An empty property or a missing update time is no setting. */
138export function settleNotesOf(stdout: string): SettleNote[] | null {
139 const listed = listedOf(stdout)
140 if (!listed) return null
141 return listed.blocks.flatMap(b => {
142 const valueOf = (key: string) => b.properties.find(p => p.key === key)?.value.trim() || undefined
143 const tuning = valueOf(TUNING_KEY)
144 const outlines = valueOf(OUTLINES_KEY)
145 const updatedAt = Date.parse(b.updatedAt ?? '')
146 return (tuning || outlines) && Number.isFinite(updatedAt) ? [{ id: b.id, updatedAt, ...(tuning ? { tuning } : {}), ...(outlines ? { outlines } : {}) }] : []
147 })
148}
149
150/** The newest edited note that carries `key`, across the outlines read: the last edit wins, wherever it lives. */
151export function newestWith(notes: readonly SettleNote[], key: 'tuning' | 'outlines'): (SettleNote & { [K in typeof key]: string }) | null {
152 return notes.reduce<(SettleNote & { [K in typeof key]: string }) | null>((best, n) => (n[key] && (!best || n.updatedAt > best.updatedAt) ? (n as SettleNote & { [K in typeof key]: string }) : best), null)
153}
154
155/** The outline as the store and the wake name it: `gurgle@float-2`, or `pie` here. */
156export const outlineLabel = (o: SettleOutline): string => (o.machine ? `${o.outline}@${o.machine}` : o.outline)
157
158// ─── The three questions ───────────────────────────────────────────────────
159
160/** `list`'s argv for a query's value counts of the thread key over every match (one row asked for, none needed). */
161export function facetArgs(query: string): string[] {
162 return [...listArgs(query, 1, 'id'), '--facets', THREAD_KEY, '--facet-values', '1000']
163}
164
165export type SettleAsks = { all: string[]; evan: string[]; shaped: string[] }
166
167export function settleAsks(config: SettleConfig): SettleAsks {
168 return {
169 all: facetArgs(config.base),
170 evan: facetArgs(`(${config.base}) AND (${config.evanQuery})`),
171 shaped: facetArgs(config.shapedQuery),
172 }
173}
174
175export type ThreadCount = { count: number; last: number }
176
177/** The counts and last update per thread value in `list --facets thread`'s JSON, or null when it isn't an answer with that facet. */
178export function facetCountsOf(stdout: string): Map<string, ThreadCount> | null {
179 let parsed: unknown
180 try { parsed = JSON.parse(stdout) } catch { return null }
181 const facets = (parsed as { facets?: unknown } | null)?.facets
182 if (!Array.isArray(facets)) return null
183 // A key nobody carries has no facet at all: that is an empty answer, not a failed one.
184 const facet = facets.find(f => f?.key === THREAD_KEY)
185 const out = new Map<string, ThreadCount>()
186 for (const v of Array.isArray(facet?.values) ? facet.values : []) {
187 const last = Date.parse(String(v?.last))
188 if (typeof v?.value === 'string' && typeof v.count === 'number' && Number.isFinite(last)) out.set(v.value, { count: v.count, last })
189 }
190 return out
191}
192
193// ─── Joining them ──────────────────────────────────────────────────────────
194
195/** The same question answered by several outlines, as one: a thread is one thread wherever its notes are (counts add, the last touch is the newest). */
196export function mergedCounts(parts: readonly Map<string, ThreadCount>[]): Map<string, ThreadCount> {
197 const out = new Map<string, ThreadCount>()
198 for (const part of parts) {
199 for (const [thread, c] of part) {
200 const seen = out.get(thread)
201 out.set(thread, seen ? { count: seen.count + c.count, last: Math.max(seen.last, c.last) } : { ...c })
202 }
203 }
204 return out
205}
206
207export type Thread = {
208 thread: string
209 /** Notes in the thread, roadmap items excluded. */
210 chirps: number
211 /** How many of them are the person's own. */
212 evan: number
213 /** When the thread was last touched (ms). */
214 last: number
215 /** A ticket, a capability note or a hub carries it. */
216 shaped: boolean
217 state: SettleState
218}
219
220const MINUTE = 60_000
221
222export function stateOf(t: Omit<Thread, 'state'>, now: number, config: SettleConfig): SettleState {
223 if (t.shaped) return 'shaped'
224 const quietFor = now - t.last
225 if (quietFor < config.quiet * MINUTE) return 'warming'
226 if (t.chirps >= config.chirps && t.evan >= config.evan && quietFor <= config.within * MINUTE) return 'settled'
227 return 'quiet'
228}
229
230/**
231 * Every thread of the base query, with its state. A thread only the shaped query names (a ticket and no notes of its
232 * own) is shaped with no notes: it appears so a shaped thread never vanishes from the view.
233 */
234export function threadsOf(all: Map<string, ThreadCount>, evan: Map<string, ThreadCount>, shaped: Map<string, ThreadCount>, now: number, config: SettleConfig): Thread[] {
235 const names = new Set([...all.keys(), ...shaped.keys()])
236 return [...names].map(thread => {
237 const base = all.get(thread)
238 const ticket = shaped.get(thread)
239 const t = { thread, chirps: base?.count ?? 0, evan: evan.get(thread)?.count ?? 0, last: Math.max(base?.last ?? 0, ticket?.last ?? 0), shaped: !!ticket }
240 return { ...t, state: stateOf(t, now, config) }
241 }).sort((a, b) => b.last - a.last)
242}
243
244// ─── What changed since the last check ─────────────────────────────────────
245
246/** What one thread was at the last check, and the newest touch the deployer was already told about. */
247export type Seen = { state: SettleState; last: number; announced: number }
248export type SettleStore = Record<string, Seen>
249
250export type Change = { thread: Thread; from: SettleState | null }
251
252/**
253 * What this check found against what the last one kept:
254 * - `wake`: threads settled whose newest touch the deployer was not told about. A thread that stays settled isn't
255 * told again; one that warms and settles again is (its newest touch moved on).
256 * - `changes`: the state changes worth a build-log line, those into or out of settled or shaped. A thread first seen
257 * settled counts (it is about to be woken for); one first seen shaped was shaped before the check began.
258 * - `next`: the store to keep. It does not mark a wake as told: `told` does, once the wake went.
259 */
260export function settleDiff(store: SettleStore, threads: readonly Thread[]): { wake: Thread[]; changes: Change[]; next: SettleStore } {
261 const next: SettleStore = { ...store }
262 const wake: Thread[] = []
263 const changes: Change[] = []
264 for (const t of threads) {
265 const prev = store[t.thread]
266 next[t.thread] = { state: t.state, last: t.last, announced: prev?.announced ?? 0 }
267 if (t.state === 'settled' && t.last > (prev?.announced ?? 0)) wake.push(t)
268 const from = prev?.state ?? null
269 const matters = (s: SettleState | null) => s === 'settled' || s === 'shaped'
270 if (from !== t.state && (from === null ? t.state === 'settled' : matters(from) || matters(t.state))) changes.push({ thread: t, from })
271 }
272 return { wake, changes, next }
273}
274
275/**
276 * The first check's baseline: the store as `settleDiff` left it with every thread already settled marked told, so only
277 * a thread that settles from now on wakes, and the one line that says so for the build log.
278 */
279export function baselineOf(next: SettleStore, threads: readonly Thread[]): { store: SettleStore; line: string } {
280 const settled = threads.filter(t => t.state === 'settled')
281 return {
282 store: told(next, settled),
283 line: `Settle check baseline: ${settled.length} settled ${settled.length === 1 ? 'thread' : 'threads'} at the first run, recorded, not announced (${threads.filter(t => t.state === 'shaped').length} shaped, ${threads.filter(t => t.state === 'warming').length} warming)`,
284 }
285}
286
287/** The store with these threads marked told, up to their newest touch: only a wake that went marks them, so a failed one is tried again. */
288export function told(store: SettleStore, threads: readonly Thread[]): SettleStore {
289 const next = { ...store }
290 for (const t of threads) next[t.thread] = { state: t.state, last: t.last, announced: t.last }
291 return next
292}
293
294// ─── Saying it ─────────────────────────────────────────────────────────────
295
296/** Minutes as the wake says them: `74 min`, `3 h`, `2 d`. */
297export function agoOf(ms: number): string {
298 const min = Math.max(0, Math.round(ms / MINUTE))
299 return min < 120 ? `${min} min` : min < 48 * 60 ? `${Math.round(min / 60)} h` : `${Math.round(min / 1440)} d`
300}
301
302/** One thread's wake line. Nothing the shaped query reads carries a settled thread, so it says how to shape it. */
303export function wakeLine(t: Thread, now: number): string {
304 return `thread ${t.thread} settled: ${t.chirps} chirps, ${t.evan} Evan's, quiet ${agoOf(now - t.last)}, nothing shapes it yet (write [thread::${t.thread}] on its capability note, its hub or its tickets)`
305}
306
307/** The prompt that wakes the deployer: one line per thread (cut at `max`), and what to do with them. */
308export function wakeText(lines: readonly string[], more: number): string {
309 return [
310 `Settle check: ${lines.length + more} ${lines.length + more === 1 ? 'thread has' : 'threads have'} gone quiet.`,
311 ...lines.map(l => `- ${l}`),
312 ...(more ? [`- and ${more} more: the next check names them`] : []),
313 'Shape each on a capability note, or point it at its hub, with [thread::<name>] (the loki skill, "A settled thread"); cut tickets only for primitives, and put [thread::<name>] on them.',
314 ].join('\n')
315}
316
317const STATE_WORDS: Record<SettleState, string> = { warming: 'warming', settled: 'settled', shaped: 'shaped', quiet: 'quiet' }
318
319/** The build-log line for a state change: `Thread backlog-groom: warming → settled · 5 chirps, 2 Evan's, quiet 74 min`. */
320export function logLine(c: Change, now: number): string {
321 const t = c.thread
322 return `Thread ${t.thread}: ${c.from ? STATE_WORDS[c.from] : 'new'} → ${STATE_WORDS[t.state]} · ${t.chirps} chirps, ${t.evan} Evan's, quiet ${agoOf(now - t.last)}`
323}
324hooks/program-status.ts 364 lines1// A copy of outline-core's src/program-status.ts: a hooks module can't import outside the plugin. outline-core's test/program-status.test.ts keeps it identical.
2// Program status (OSC 7501, the Program Status Protocol, spec rev 0.2: superlogical.com/rex/docs/build/program-status):
3// a program tells its terminal what it is doing, `ESC ] 7501 ; state=…:id=…:kind=…:progress=…:app=…:title=…:msg=… ST`.
4// This is the one reader and writer of it, for every side: the door's terminal tiles read it (src/desk/pty.ts), the
5// door reports its own, and the emitters (the Claude mod, `ep0ch install`, `ep0ch backup run`, the scripts, the
6// outliner's panes) write it. The terminal's records follow the spec exactly: one record per id, each report
7// replacing its record whole, `clear` taking a record and everything beneath it, the limits checked on every pair
8// before any record is touched, and text with a control character refused. Pure: no I/O, no globals beyond the
9// language's own (base64 and UTF-8 are done here), so the Claude mod's copy runs where there is no Node.
10
11/** The OSC number. */
12export const PROGRAM_STATUS_OSC = 7501;
13/** The states a record holds, in the spec's order. `clear` is not one: it removes records. */
14export const PROGRAM_STATES = ["idle", "working", "done", "blocked", "error"] as const;
15export type ProgramState = (typeof PROGRAM_STATES)[number];
16/** What a `blocked` record waits for: approval, a typed answer, a login or token. */
17export const BLOCKED_KINDS = ["permission", "question", "auth"] as const;
18export type BlockedKind = (typeof BLOCKED_KINDS)[number];
19
20/**
21 * The spec's hard caps, per report and per terminal. `sequence` is OSC through ST: a body is measured with a two-byte
22 * ST (`ESC \`), so a report ended by BEL is held to one byte less (terminals MAY choose lower limits).
23 */
24export const PROGRAM_STATUS_LIMITS = {
25 sequence: 4096, key: 16, msgEncoded: 2732, msgDecoded: 2048, titleEncoded: 256, titleDecoded: 192, app: 32,
26 id: 128, segment: 32, depth: 8, records: 256,
27} as const;
28
29/** Feature detection: a program sends it, and a terminal that supports the protocol answers with the same bytes. */
30export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\";
31/** The terminfo capability a supporting terminal adds (extended string, `tic -x`): the report with its body as the one parameter. */
32export const PROGRAM_STATUS_TERMINFO = "Pst=\\E]7501;%p1%s\\E\\\\";
33
34/** One report as read: `id` "" is the root record. */
35export interface StatusReport {
36 state: ProgramState | "clear";
37 id: string;
38 kind?: BlockedKind;
39 progress?: number;
40 app?: string;
41 title?: string;
42 msg?: string;
43}
44/** A record the terminal holds: a report's fields (never `clear`). */
45export type StatusRecord = Omit<StatusReport, "state"> & { state: ProgramState };
46
47/**
48 * Which tile (PIE-1306, ep0ch's own, beside the feature query): a program asks the terminal it is drawn in which tile
49 * that is, and an ep0ch door answers on the program's input, `OSC 7501 ; ?tile=<t-uuid> ST` (the tile registry's id:
50 * the one name of a tile, the one `open from=` takes). It travels over ssh because it is in the pty stream (not through
51 * tmux, which keeps a query's answer for itself); a terminal that isn't a door answers nothing (send primary device
52 * attributes after it to hear that at once).
53 */
54export const TILE_QUERY = "\x1b]7501;?tile\x1b\\";
55/** A marker's token: what `host.tileOf mark=` is asked with. */
56const MARK = /^[A-Za-z0-9_-]{8,64}$/;
57/**
58 * A marker (PIE-1306): a program that can't read its own input (Claude Code owns it) writes `OSC 7501 ; ?mark=<token> ST`
59 * to its terminal, and the door keeps which tile saw it; the program then asks the door over its control socket
60 * (`host.tileOf mark=<token>`). Nothing is answered on the program's input. A token outside the grammar throws.
61 */
62export function tileMark(token: string): string {
63 if (!MARK.test(token)) throw new Error(`not a tile marker token: ${token}`);
64 return `\x1b]7501;?mark=${token}\x1b\\`;
65}
66/**
67 * `seq` wrapped for tmux's passthrough (`ESC P tmux; … ESC \`, each ESC inside doubled), so it reaches the terminal tmux
68 * runs in instead of being read and dropped by tmux. tmux passes it only with `allow-passthrough on` (off by default).
69 */
70export const tmuxPassthrough = (seq: string): string => `\x1bPtmux;${seq.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
71/** A door's answer to TILE_QUERY for the tile `id`. */
72export const tileReply = (id: string): string => `\x1b]7501;?tile=${id}\x1b\\`;
73/** The tile a reply body (between `7501;` and ST) names, or null when it isn't a tile reply. */
74export function tileOfReply(body: string): string | null {
75 const m = /^\?tile=(t-[0-9a-f-]{36})$/.exec(body);
76 return m ? m[1]! : null;
77}
78
79/**
80 * What a body (the text between `7501;` and ST) is: the feature query, the tile query, a marker, a report, or nothing
81 * to apply and why.
82 */
83export type ParsedStatus =
84 | { t: "query" } | { t: "tile" } | { t: "mark"; token: string }
85 | { t: "report"; report: StatusReport } | { t: "ignored"; why: string };
86
87const KEY = /^[a-z]+$/;
88const VALUE = /^[A-Za-z0-9_.,+/=-]*$/;
89const SEGMENT = /^[A-Za-z0-9_.+-]{1,32}$/;
90const APP = /^[A-Za-z0-9_.+-]{1,32}$/;
91const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
92/** The prefix and the longest ST: what a body's bytes are added to for the sequence limit. */
93const FRAME_BYTES = "\x1b]7501;".length + 2;
94
95/**
96 * Read one report's body. Malformed pairs (no `=`, an empty or non-`[a-z]` key, a value outside the set) are skipped;
97 * unknown keys are ignored; a repeated key's last value wins. A broken limit, base64 that doesn't decode (or isn't
98 * UTF-8), or decoded text with a control character discards the whole report. No `state`, an unknown one, or an id
99 * outside the grammar ignores it (an id is never taken as the root's).
100 */
101export function parseProgramStatus(body: string): ParsedStatus {
102 if (utf8Length(body) + FRAME_BYTES > PROGRAM_STATUS_LIMITS.sequence) return { t: "ignored", why: "longer than 4096 bytes" };
103 const asked = body.trim();
104 if (asked === "?") return { t: "query" };
105 if (asked === "?tile") return { t: "tile" };
106 if (body.startsWith("?mark=")) return MARK.test(body.slice(6)) ? { t: "mark", token: body.slice(6) } : { t: "ignored", why: "marker token outside the grammar" };
107 const pairs = new Map<string, string>();
108 /** msg and title as text, decoded and checked pair by pair. */
109 const texts = new Map<string, string>();
110 for (const raw of body.split(":")) {
111 const eq = raw.indexOf("=");
112 if (eq < 0) continue;
113 const key = raw.slice(0, eq).trim(), value = raw.slice(eq + 1).trim();
114 if (utf8Length(key) > PROGRAM_STATUS_LIMITS.key) return { t: "ignored", why: `key longer than ${PROGRAM_STATUS_LIMITS.key} bytes` };
115 if (!KEY.test(key) || !VALUE.test(value)) continue;
116 // Every pair is held to its limits, a repeated one too: nothing of a report that broke one is applied.
117 const over = overLimit(key, value);
118 if (over) return { t: "ignored", why: over };
119 if (key === "msg" || key === "title") {
120 const text = decodeText(value);
121 if (text === null) return { t: "ignored", why: `${key} is not base64 of UTF-8` };
122 if (utf8Length(text) > (key === "msg" ? PROGRAM_STATUS_LIMITS.msgDecoded : PROGRAM_STATUS_LIMITS.titleDecoded)) return { t: "ignored", why: `${key} too long decoded` };
123 if (hasControl(text)) return { t: "ignored", why: `${key} holds a control character` };
124 texts.set(key, text);
125 }
126 pairs.set(key, value);
127 }
128 const state = pairs.get("state");
129 if (state === undefined) return { t: "ignored", why: "no state" };
130 if (state !== "clear" && !(PROGRAM_STATES as readonly string[]).includes(state)) return { t: "ignored", why: `unknown state ${state}` };
131 const id = pairs.get("id");
132 if (id !== undefined && !id.split("/").every(s => SEGMENT.test(s))) return { t: "ignored", why: "id outside the grammar" };
133 const report: StatusReport = { state: state as StatusReport["state"], id: id ?? "" };
134 for (const field of ["msg", "title"] as const) {
135 const text = texts.get(field);
136 if (text !== undefined) report[field] = text;
137 }
138 const app = pairs.get("app");
139 if (app !== undefined && APP.test(app)) report.app = app;
140 const kind = pairs.get("kind");
141 if (state === "blocked" && kind !== undefined && (BLOCKED_KINDS as readonly string[]).includes(kind)) report.kind = kind as BlockedKind;
142 const progress = pairs.get("progress");
143 if ((state === "working" || state === "blocked") && progress !== undefined && /^\d{1,3}$/.test(progress) && Number(progress) <= 100) report.progress = Number(progress);
144 return { t: "report", report };
145}
146
147/** The limit `key=value` breaks, or null. */
148function overLimit(key: string, value: string): string | null {
149 const L = PROGRAM_STATUS_LIMITS;
150 if (key === "msg" && value.length > L.msgEncoded) return `msg longer than ${L.msgEncoded} bytes encoded`;
151 if (key === "title" && value.length > L.titleEncoded) return `title longer than ${L.titleEncoded} bytes encoded`;
152 if (key === "app" && value.length > L.app) return `app longer than ${L.app} bytes`;
153 if (key === "id") {
154 if (value.length > L.id) return `id longer than ${L.id} bytes`;
155 const segments = value.split("/");
156 if (segments.length > L.depth) return `id deeper than ${L.depth} levels`;
157 if (segments.some(s => s.length > L.segment)) return `id segment longer than ${L.segment} bytes`;
158 }
159 return null;
160}
161
162/** How much a state asks of the person, most first: blocked, error, done, working, idle. */
163export const ATTENTION: Readonly<Record<ProgramState, number>> = { blocked: 4, error: 3, done: 2, working: 1, idle: 0 };
164/** The state that asks most of the person, or null for none. */
165export function mostUrgent(states: Iterable<ProgramState>): ProgramState | null {
166 let out: ProgramState | null = null;
167 for (const s of states) if (out === null || ATTENTION[s] > ATTENTION[out]) out = s;
168 return out;
169}
170
171/**
172 * One terminal's records (the spec's "a terminal holds a set of records, one per id"). Each report replaces its record
173 * whole; past the cap, the record updated least recently goes. Lifetimes are the terminal's events: `exited` (the
174 * process attached to it exited) and `prompt` (OSC 133 A) drop `working` and `blocked`; `reset` (RIS) drops all;
175 * `seen` drops `done` and `error`, which the terminal decides (here: the person came back to the terminal).
176 */
177export class StatusRecords {
178 /** In order of last update: the first is the least recently updated. */
179 private records = new Map<string, StatusRecord>();
180 constructor(private readonly cap: number = PROGRAM_STATUS_LIMITS.records) {}
181
182 /** Apply one report: true when the records changed. */
183 apply(r: StatusReport): boolean {
184 if (r.state === "clear") return this.drop(id => r.id === "" || id === r.id || id.startsWith(`${r.id}/`));
185 const { state, ...rest } = r;
186 const had = this.records.has(r.id);
187 this.records.delete(r.id);
188 if (!had) while (this.records.size >= this.cap) this.records.delete(this.records.keys().next().value!);
189 this.records.set(r.id, { ...rest, state });
190 return true;
191 }
192 /** The process attached to the terminal exited. */
193 exited(): boolean { return this.drop((_, s) => s === "working" || s === "blocked"); }
194 /** A new shell prompt began (OSC 133 A). */
195 prompt(): boolean { return this.exited(); }
196 /** A full reset (RIS): every record goes. */
197 reset(): boolean { return this.drop(() => true); }
198 /** The person saw what was finished: `done` and `error` go. */
199 seen(): boolean { return this.drop((_, s) => s === "done" || s === "error"); }
200
201 private drop(f: (id: string, s: ProgramState) => boolean): boolean {
202 let changed = false;
203 for (const [id, r] of [...this.records]) if (f(id, r.state)) { this.records.delete(id); changed = true; }
204 return changed;
205 }
206
207 get size(): number { return this.records.size; }
208 /** The record at `id`, as stored. */
209 get(id: string): StatusRecord | undefined { return this.records.get(id); }
210 /**
211 * Every record, root first then by id, each with its `app` as shown: its own, else its nearest ancestor's (the
212 * spec's inheritance).
213 */
214 list(): StatusRecord[] {
215 return [...this.records.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(r => {
216 if (r.app !== undefined) return r;
217 const app = this.inherited(r.id);
218 return app === undefined ? r : { ...r, app };
219 });
220 }
221 private inherited(id: string): string | undefined {
222 let at = id;
223 while (at !== "") {
224 at = at.includes("/") ? at.slice(0, at.lastIndexOf("/")) : "";
225 const app = this.records.get(at)?.app;
226 if (app !== undefined) return app;
227 }
228 return undefined;
229 }
230 /** The record that asks most of the person (blocked over error over done over working over idle), or null. */
231 urgent(): StatusRecord | null {
232 let out: StatusRecord | null = null;
233 for (const r of this.list()) if (!out || ATTENTION[r.state] > ATTENTION[out.state]) out = r;
234 return out;
235 }
236}
237
238// ── writing ──────────────────────────────────────────────────────────────────
239
240/** What an emitter says: `msg` and `title` as text (encoded here), cut to the limits and made one line. */
241export interface StatusInput {
242 state: ProgramState | "clear";
243 id?: string;
244 kind?: BlockedKind;
245 progress?: number;
246 app?: string;
247 title?: string;
248 msg?: string;
249}
250
251/**
252 * The whole report sequence for `r` (ST is `ESC \`). Text is made one line (control characters become spaces) and cut
253 * at a character to its limit; an `app` or `progress` outside the grammar is left out; an id outside the grammar
254 * throws (a program's own mistake: a terminal would ignore the report).
255 */
256export function encodeProgramStatus(r: StatusInput): string {
257 const pairs: string[] = [`state=${r.state}`];
258 if (r.id) {
259 if (!r.id.split("/").every(s => SEGMENT.test(s)) || r.id.length > PROGRAM_STATUS_LIMITS.id || r.id.split("/").length > PROGRAM_STATUS_LIMITS.depth) throw new Error(`not a program status id: ${r.id}`);
260 pairs.push(`id=${r.id}`);
261 }
262 if (r.state === "blocked" && r.kind) pairs.push(`kind=${r.kind}`);
263 if ((r.state === "working" || r.state === "blocked") && r.progress !== undefined && Number.isInteger(r.progress) && r.progress >= 0 && r.progress <= 100) pairs.push(`progress=${r.progress}`);
264 if (r.app !== undefined && APP.test(r.app)) pairs.push(`app=${r.app}`);
265 if (r.title) pairs.push(`title=${encodeText(r.title, PROGRAM_STATUS_LIMITS.titleDecoded)}`);
266 if (r.msg) pairs.push(`msg=${encodeText(r.msg, PROGRAM_STATUS_LIMITS.msgDecoded)}`);
267 return `\x1b]7501;${pairs.join(":")}\x1b\\`;
268}
269
270/** A name made an id segment: the characters the grammar allows (others become `-`), at most 32, never empty. */
271export function statusSegment(name: string): string {
272 const s = name.replace(/[^A-Za-z0-9_.+-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, PROGRAM_STATUS_LIMITS.segment);
273 return s || "x";
274}
275
276/** A reply to the feature query: the body starts with `?` (a later revision may add pairs after it). */
277export function isStatusQueryReply(body: string): boolean { return body.startsWith("?"); }
278
279/**
280 * Text from a record made safe to show outside the terminal's grid: text direction overrides and other invisible
281 * formatting characters (zero-width, bidi isolates, the BOM, soft hyphen) taken out. Control characters never get
282 * this far: a report holding one is refused.
283 */
284export function statusDisplayText(s: string): string {
285 return s.replace(/[-----]/g, "");
286}
287
288/** One line of text, control characters as spaces, cut at a character to `max` UTF-8 bytes, as standard base64. */
289function encodeText(text: string, max: number): string {
290 const line = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
291 const bytes: number[] = [];
292 for (const ch of line) {
293 const b = utf8(ch.codePointAt(0)!);
294 if (bytes.length + b.length > max) break;
295 bytes.push(...b);
296 }
297 return base64(bytes);
298}
299
300const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
301
302function base64(bytes: readonly number[]): string {
303 let out = "";
304 for (let i = 0; i < bytes.length; i += 3) {
305 const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2];
306 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
307 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (b === undefined ? "=" : B64[(n >> 6) & 63]!) + (c === undefined ? "=" : B64[n & 63]!);
308 }
309 return out;
310}
311
312/** Standard base64 (padding optional, but right where it's there) of UTF-8, decoded; null when it isn't. */
313function decodeText(v: string): string | null {
314 if (!BASE64.test(v)) return null;
315 const s = v.replace(/=+$/, ""), pad = v.length - s.length;
316 if (s.length % 4 === 1) return null;
317 // Padding, when given, fills the last group exactly: two after two characters, one after three.
318 if (pad && (v.length % 4 !== 0 || pad !== 4 - (s.length % 4))) return null;
319 const bytes: number[] = [];
320 let acc = 0, bits = 0;
321 for (const ch of s) {
322 acc = (acc << 6) | B64.indexOf(ch);
323 bits += 6;
324 if (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 255); }
325 }
326 return fromUtf8(bytes);
327}
328
329/** Strict UTF-8: null for an invalid, overlong or surrogate sequence. */
330function fromUtf8(b: readonly number[]): string | null {
331 let out = "";
332 for (let i = 0; i < b.length;) {
333 const x = b[i]!;
334 const n = x < 0x80 ? 0 : x >= 0xc2 && x < 0xe0 ? 1 : x >= 0xe0 && x < 0xf0 ? 2 : x >= 0xf0 && x < 0xf5 ? 3 : -1;
335 if (n < 0) return null;
336 let cp = n === 0 ? x : x & (0x3f >> n);
337 for (let k = 1; k <= n; k++) {
338 const c = b[i + k];
339 if (c === undefined || (c & 0xc0) !== 0x80) return null;
340 cp = (cp << 6) | (c & 0x3f);
341 }
342 if ((n === 2 && (cp < 0x800 || (cp >= 0xd800 && cp <= 0xdfff))) || (n === 3 && (cp < 0x10000 || cp > 0x10ffff))) return null;
343 out += String.fromCodePoint(cp);
344 i += n + 1;
345 }
346 return out;
347}
348
349function utf8(cp: number): number[] {
350 if (cp >= 0xd800 && cp <= 0xdfff) cp = 0xfffd;
351 if (cp < 0x80) return [cp];
352 if (cp < 0x800) return [0xc0 | (cp >> 6), 0x80 | (cp & 63)];
353 if (cp < 0x10000) return [0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)];
354 return [0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)];
355}
356
357function utf8Length(s: string): number {
358 let n = 0;
359 for (const ch of s) n += utf8(ch.codePointAt(0)!).length;
360 return n;
361}
362
363const hasControl = (s: string) => /[\u0000-\u001f\u007f-\u009f]/.test(s);
364hooks/claude-status.ts 92 lines1// What this Claude is doing, as program status (OSC 7501, PIE-614): the mod reports it to the terminal Claude runs in,
2// so a door's terminal tile (or Ghostty, Rex) knows Claude is working, waiting on a permission or a question, done, or
3// failed, from Claude's own events instead of reading its screen. Pure: each event's report; register.ts sends it
4// (a child that writes it to /dev/tty, Claude's terminal) and says whether to at all.
5//
6// The events, from the engine's own (`turn.start`, `turn.complete`, the classic PermissionRequest, Notification,
7// PostToolUse, PermissionDenied and StopFailure hooks):
8// - a turn starts: working;
9// - a permission dialog is shown (PermissionRequest that no hook answered; the Notification `permission_prompt`):
10// blocked, kind=permission, saying what it asks;
11// - AskUserQuestion, or an MCP elicitation dialog: blocked, kind=question;
12// - the tool runs or is denied after that (PostToolUse, PostToolUseFailure, PermissionDenied): working again;
13// - the main loop's turn ends: answered, done (its answer's first line); interrupted, idle; refused or an API error, error;
14// - the session starts: idle; it ends: cleared.
15import { encodeProgramStatus, type StatusInput } from './program-status'
16
17/** The app every report carries (a report replaces its record whole). */
18export const CLAUDE_APP = 'claude-code'
19/** Asking the terminal's terminfo whether it speaks the protocol: the door puts `Pst` in a tile's xterm-256color. */
20export const PST_ARGV = ['tput', 'Pst'] as const
21
22/**
23 * What EP0CH_PROGRAM_STATUS says: true (1, on), false (0, off, native: Claude Code reports itself), or null (the terminal's terminfo decides: `Pst`, asked
24 * only where there is a TERM). Claude owns its terminal's input, so the mod never sends the feature query.
25 */
26export function statusSetting(setting: string | null | undefined): boolean | null {
27 const v = setting?.trim().toLowerCase() ?? ''
28 if (/^(1|on|yes|true)$/.test(v)) return true
29 // `native`: Claude Code reports its own status (OSC 7501) and the mod steps aside, so a record never has two writers.
30 if (/^(0|off|no|false|native)$/.test(v)) return false
31 return null
32}
33
34/** The child that writes one report to Claude's terminal. */
35export function ttyArgv(seq: string): string[] {
36 return ['sh', '-c', 'printf "%s" "$1" > /dev/tty', 'ep0ch-status', seq]
37}
38
39/** The report's sequence, with the app. */
40export const sequenceOf = (r: StatusInput): string => encodeProgramStatus(r.state === 'clear' ? r : { ...r, app: CLAUDE_APP })
41
42const line = (s: string, max = 200): string => {
43 const first = s.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
44 return first.length > max ? `${first.slice(0, max - 1)}…` : first
45}
46
47/** What a tool call is about, in a few words: its command, file, URL or pattern. */
48function about(input: unknown): string {
49 if (!input || typeof input !== 'object') return ''
50 const i = input as Record<string, unknown>
51 for (const k of ['command', 'file_path', 'path', 'url', 'pattern', 'description']) if (typeof i[k] === 'string' && i[k]) return line(i[k] as string, 160)
52 return ''
53}
54
55export const sessionStarted = (): StatusInput => ({ state: 'idle' })
56export const sessionEnded = (): StatusInput => ({ state: 'clear' })
57export const working = (): StatusInput => ({ state: 'working' })
58
59/** A permission dialog for `tool`: what it would do. */
60export function permissionAsked(tool: string, input: unknown): StatusInput {
61 const what = about(input)
62 return { state: 'blocked', kind: 'permission', msg: `Allow ${tool}${what ? `: ${what}` : ''}?` }
63}
64
65/** AskUserQuestion's first question. */
66export function questionAsked(input: unknown): StatusInput {
67 const qs = (input as { questions?: { question?: unknown }[] } | null)?.questions
68 const q = Array.isArray(qs) && typeof qs[0]?.question === 'string' ? line(qs[0].question) : ''
69 return { state: 'blocked', kind: 'question', ...(q ? { msg: q } : { msg: 'Claude asks you a question' }) }
70}
71
72/** A Notification: a permission prompt or an elicitation dialog blocks; the rest (idle_prompt, auth_success) says nothing new. */
73export function notified(type: string, message: string): StatusInput | null {
74 if (type === 'permission_prompt') return { state: 'blocked', kind: 'permission', ...(message ? { msg: line(message) } : {}) }
75 if (type === 'elicitation_dialog') return { state: 'blocked', kind: 'question', ...(message ? { msg: line(message) } : {}) }
76 return null
77}
78
79/** The main loop's turn ended: answered, interrupted, refused, or stopped by an API error. */
80export function turnEnded(reason: string, answer: string): StatusInput {
81 if (reason === 'answer') { const a = line(answer); return { state: 'done', ...(a ? { msg: a } : {}) } }
82 if (reason === 'aborted') return { state: 'idle', msg: 'interrupted' }
83 if (reason === 'refusal') return { state: 'error', msg: 'the model refused' }
84 return { state: 'error', msg: 'stopped on an API error' }
85}
86
87/** StopFailure: the turn died on an API error. */
88export function stopFailed(error: unknown, details: string | undefined): StatusInput {
89 const what = details ? line(details) : typeof error === 'string' ? error : ''
90 return { state: 'error', msg: what ? `stopped: ${what}` : 'stopped on an API error' }
91}
92