SLOPSHOPPER

ep0ch

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…

newpanebandrowsguardcommand
v0.1.0no licenseupdated 2026-10-26float-ritual-stack/ep0ch/packages/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ep0ch
│ ┃ outliner-mentions ✕ › fix the failing auth test and add an audit log call │ ┃ No outline: /outline <name> binds one. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ✎ src/auth.ts │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ │ Where this Claude is bound h: hide (/outline shows it again) finding which outline this folder and door name… ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ep0ch: outline: finding…

Draws

Band
Where this Claude is bound h: hide (/outline shows it again) finding which outline this folder and door name… ⟨Claude Code's own drawing⟩
Pane · outliner-mentions
No outline: /outline <name> binds one.
Tool row
✎ src/auth.ts
README

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

A Claude Mod (a plugin with a function-hooks module) that forwards each completed Claude Code answer to the Outliner's mentions.ingest contract. It works the same way as the Codex Stop hook (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.

  • Only main-loop answers are sent. Subagent runs, interruptions, refusals and errors are skipped.
  • They also show in Claude Code itself: Recent mentions in Claude Code.

Which outline: the session's folder

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.

  • A nested .ep0ch is nearer than its parent's, so a project naming its own outline keeps it. Similar path prefixes do not match.
  • A folder that names no outline feeds nothing: no mentions, no links, and the outline tools refuse. No folder-name guess and no default outline is ever used, so an unrelated session never reaches your outline.
  • Mentions, links, 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.
  • The folder is found when the session starts (links and tools) and again after each answer (mentions), so binding a folder mid-session starts its mentions.
  • Herdr discovers the Outliner (herdr plugin list --plugin float.pi-outliner), and the mod calls the installed CLI's mentions ingest. It never starts a service. Failures show as one toast and leave the answer untouched. An Outliner older than bound-folder feeds nothing and says so once per session.
  • Named machines use the CLI's shared link-first, then SSH route. Successful CLI diagnostics, including the selected transport, appear in the mod's log.

Opting out, and strict mode

PI_OUTLINER_MENTIONS_WORKSPACES (or the workspaces option) lists absolute folders, separated by : or ,. What the list means is PI_OUTLINER_MENTIONS_MODE (or the mode option):

ModeThe list
folder (unset with no list)Folders opted out: a session in one, or below it, feeds nothing even when bound.
allowlistStrict mode, as before folder mode: only listed folders (and their subfolders) feed, bound or not; the nearest listed folder is the workspace, and the CLI resolves its outline as it always did.
unset, with a listNothing feeds anywhere, with one toast a session. The list may be an allowlist from before folder mode, and reading it either way would start feeding folders nobody chose. Set the mode, or drop the list (install-claude-mod.ts --folder: every bound folder then feeds its outline).

The option wins over the environment variable when it is set. Claude Code passes an unset option as an empty string, so an empty option always falls back to the variable. An entry that isn't an absolute folder (a relative path, or folder=name), or an unknown mode, is an error shown as a toast, never skipped.

Clickable references and the Outliner Detail beside Claude

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

Where a note opens

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

  1. In a door tile (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).
  2. The door says who opened it (the session's actor id, persona first), and an agent's open never moves the person's focus.
  3. If the door refuses (say it is on its menu, or the reader the tile links to holds an edit), its reason is the answer: the tool's denial or a toast. It is never shown somewhere else instead.
  4. If the door takes the request but doesn't answer within 5s, it says so; it isn't shown in Herdr too.
  5. Only if no door answers there (the door quit) does it go on.
  6. In Herdr (HERDR_PANE_ID set): 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.
  7. Here (neither: the desktop app's Code tab, VS Code, a terminal outside Herdr): the mentions pane shows the note in place of its list (The note here). A press seats the pane itself, so it is placed at any width; show opens it as the agent's own, which waits below 144 columns and says so.
  8. Without the session's outline (an unbound folder, a block id given to 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.
  • Only the invoking Herdr workspace's Trees count: a Detail in another workspace is never "beside you". Two Trees in one tab is the Outliner's refusal, said in a toast (and in the pane's heading, "can't open beside you: …"); the Outliner raises no Herdr notification for the mod's opens, which say it themselves.
  • If you are editing in that Detail, a click or show is refused with a toast (the Outliner protects active edits) rather than opening a second pane.
  • Clicks reach the mod in the fullscreen terminal ("tui": "fullscreen").
  • A reference in a reply reads as its label only, underlined, in every terminal (PIE-1042): Claude Code's 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.
  • A target the Outliner cannot resolve is a toast, never a new page.

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.

Where this Claude is bound

So nobody has to ask which outline Claude's tools reach (PIE-546), the mod says it three ways, from one set of facts:

  • A card in the band above the prompt, at the start and again after /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:
  • outline: the one the outline, workboard and mention tools use, and its machine (harbor, on far (another machine), or garden, on this machine (near-box)); or "none: the … tools are off";
  • why: the .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>);
  • careful (yellow): the folder names one outline and the door Claude sits in is on another. The outline tools write to the folder's; the door tools act in the door's;
  • Claude: the machine and folder Claude runs in;
  • door: the drawer or a tile of which door, or not in a door, and the Herdr pane;
  • tools: one set, from this mod.

Beside Recent mentions the card keeps to the binding (outline, why, the yellow line), so the mentions' keys stay on screen.

  • A status line that stays: 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.
  • The model's context: the same lines as a block (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.

Where this session runs

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.
  • Without a usable ep0ch (not on PATH, too old, an error, or no answer within 1.5s of the first prompt), the block has the variables alone and says they are unchecked.
  • It never blocks or fails the session: the work runs after the start, and any failure leaves the context as it was. Outside a door, this block isn't added (the binding card's is).

The workboard now, at the start (PIE-843)

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.

  • What it says: the latest build log (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).
  • Where it comes from: the service, through the installed Outliner CLI's 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.
  • Silent where the outline has no workboard (no roadmap item and no build log), where the session is bound to no outline, and where the host doesn't answer within 6s. gh is asked only when there is a workboard.
  • Never delays the session: the read starts at 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.

What the outline can hold, at the start (PIE-721)

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:

  • The guide: one more context block, 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.
  • The core tools up front: 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.
  • A written figure drawn back: a write's answer lists the 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.

What this Claude is doing, to its terminal (PIE-614)

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

Recent mentions in Claude Code

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.

  • Where: a compact band above the prompt (one row: 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.
  • What counts: only a ((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.
  • Preview: 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).
  • Scope: s switches between this conversation's mentions and every conversation's in the outline, as the Tree's s does.
  • Open: a click, or the mention's number, opens it through the one open every click, 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.
  • Every action is a Button with a hotkey: by mouse, or by keys once the band or pane holds them. The keys are Claude Code's, and each site's last line says them:
  • ctrl+x tab moves the keys to the band (or the pane) and back to the prompt; Esc hands them back too. A click on it does the same.
  • There, Tab and the arrows move between the mentions (the pane lists them before its controls), Enter or the mention's digit opens it, and p, s, m, x are the controls.
  • ctrl+x ctrl+a folds the band (its [-]); ctrl+x x closes the pane, which keeps it hidden until /mentions pane (or /mentions).
  • The pane never opens with focus, so the prompt keeps the keys. 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.

The note here

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.

  • What it shows: the note's title, "opens here: no door or Herdr around", then the note drawn by BlockView, one renderer per surface:
  • terminal: the door's cells (ep0ch show <id> --cells --rows 400), its references as buttons under it (hotkeys 1 to 9), since cells hold no links;
  • desktop and VS Code: Markdown: the note's first line as a heading, its text as written (lists, head
Source 26 files
hooks/register.ts 2397 lines
1import { 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 lines
1// 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};
1139
hooks/mention-message.ts 259 lines
1import type { TurnCompleteInput } from 'claude-code'
2
3/**
4 * The host-neutral contract `mentions.ingest` takes; `src/mentions-types.ts`
5 * owns it. Restated here because a hooks module cannot import application code.
6 */
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}
259
hooks/references.ts 203 lines
1/**
2 * Outliner references in a Claude reply, made clickable. `Markdown` only draws
3 * `https:`, `http:` and `file:` links as links, so each reference becomes an
4 * https stand-in on the reserved `.invalid` domain, which never resolves: a
5 * click the plugin does not take goes nowhere. The plugin maps it back to the
6 * `pi-outliner://` URI the Outliner's own link navigation takes.
7 */
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}
203
hooks/reply-lines.ts 163 lines
1/**
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}
163
hooks/connector-persona.ts 46 lines
1/**
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}
46
hooks/outline-tools.ts 348 lines
1/**
2 * Outline and door tools for Claude (PIE-504), so an agent never hand-rolls a
3 * script to read, edit or comment on a note, or to act in the door it runs in.
4 *
5 * - `outline_*`: each is one call to the installed CLI's `agent <operation>`
6 *   (src/agent-tools.ts) with the tool's input as JSON on stdin. 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}
348
hooks/work-tools.ts 266 lines
1/**
2 * Workboard tools for Claude: each one is a thin call to the installed CLI's
3 * `work` / `note` commands (src/work-tools.ts), so Claude, Codex and Pi share
4 * one implementation. This file only turns tool arguments into CLI argv.
5 */
6
7type Json = Record<string, unknown>
8
9export interface WorkToolDefinition {
10  name: string
11  description: string
12  inputSchema: Json
13  /** 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]
266
hooks/peer-log.ts 44 lines
1// 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}
44
hooks/settle.ts 324 lines
1import { 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}
324
hooks/program-status.ts 364 lines
1// 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);
364
hooks/claude-status.ts 92 lines
1// 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