attend's agent-facing driver in Claude Code (ADR-406): dormant by default, listening on request or by project config, one status row, typed tools over the…

<img src="docs/images/agent-ways-logo.svg" alt="Agent Ways logo — a W made of nodes and edges with a red-to-green gradient representing attention falloff, emerging from a dim background knowledge graph" width="600" />
Organizational socialization for AI coding agents. Ways encode "the way we do it around here" — the local norms an agent cannot know because it was never told them — and deliver them the way human teams actually transmit norms: situated, at the moment of relevant action, just before tools execute.
An LLM session cannot internalize norms — no weight updates, no carried memory; every session is a new hire. So the system re-enacts socialization mechanically, on a spaced schedule that substitutes for the memory the agent does not have. In one sentence: procedural memory for coding agents, maintained by spaced repetition. Every project-coined term here maps to an established concept — the vocabulary reference is the index.
Current status: Agent Ways ships with full support for Claude Code. Support for additional CLI-based coding agents is in development.
<a href="https://youtu.be/Ma4sDBPJVvU"><img src="https://img.youtube.com/vi/Ma4sDBPJVvU/maxresdefault.jpg" alt="Video: A turn with agent-ways, a two-minute animated explainer on YouTube" width="720" /></a> <sub><b>A turn with agent-ways</b> (2 min): one Claude Code turn as the hooks see it, then two sessions and a person on the attend bus. <a href="https://youtu.be/Ma4sDBPJVvU">Watch on YouTube</a> or play the <a href="https://aaronsb.github.io/agent-ways/explainers/a-turn-with-ways/">interactive version</a>.</sub>
sequenceDiagram
participant U as You
participant C as Agent
participant W as Ways
participant J as Judge (optional)
rect rgba(21, 101, 192, 0.2)
U->>C: "fix the auth bug"
W->>J: matched: Security, Debugging, Commits
J-->>W: relevant: Security, Debugging
W-->>C: Security · Debugging
end
rect rgba(106, 27, 154, 0.2)
C->>W: about to run: git commit
W-->>C: Commit format rules
Note right of W: tool lanes skip the judge
end
rect rgba(0, 105, 92, 0.2)
C->>W: spawning subagent
W-->>C: Security, to the subagent
end
rect rgba(198, 40, 40, 0.15)
Note over U,J: Context fills up → auto-compact → ways reset → cycle repeats
end
Ways = policy and process encoded as contextual guidance. Triggered by keywords, commands, and file patterns — they fire before tools execute, re-disclose on a token-distance cadence as their influence fades, and carry into subagents.
Why this works: System prompt adherence decays as a power law over conversation turns — instructions at position zero lose influence as context grows. This is the forgetting curve (Ebbinghaus) operating over token distance instead of days, and the countermeasure is the one human learning already uses: spaced repetition. Ways inject small, relevant guidance near the attention cursor at the moment it matters and re-disclose it as its influence fades, maintaining steady-state adherence instead of a damped sawtooth. It's progressive disclosure applied to the model itself.
ways session replayways session replay replays a session's way-firing history frame by frame, and follows a live session as it writes. Each frame shows a way firing at a specific point in the conversation — you can see how guidance clusters near the active attention cursor and packs into the context window like a compression pattern.
<sub>Download the recording (228K MP4).</sub>
Semantic matching runs on your machine through the embedding engine (all-MiniLM-L6-v2, a ~21MB GGUF model). It handles similarity of meaning: "pin lockfile versions" matches the supply chain way even though those exact words are absent from the way's vocabulary. ways status reports the matcher's current calibration.
Matching proposes; the relevance judge decides. On your prompts, and on messages you queue while Claude works, the ways that matched go to a small hosted model (Claude Haiku, through Anthropic or OpenRouter) that answers yes or no for each, up to eight per prompt. Only the ways it passes are injected, so a way that shares words with your prompt but not its intent stays out. The judge runs in the resident ways-agent, which holds the API key. Without a key, or when the judge can't answer in time, every matched way is injected as before. The relevance judge covers the flow, what it sends, and what it costs.
This repo ships with software development ways, but the mechanism is general-purpose. You could have ways for:
agent-ways is a suite of binaries plus the ways corpus, the hooks that deliver it, skills and subagents. tools/suite-bins lists the Rust binaries the installer builds and links onto your PATH.
flowchart LR
classDef outside fill:#475569,stroke:#94a3b8,color:#ffffff
classDef core fill:#7c3aed,stroke:#4a5568,color:#ffffff
classDef compute fill:#2d7d9a,stroke:#4a5568,color:#ffffff
classDef store fill:#2d8e5e,stroke:#4a5568,color:#ffffff
classDef external fill:#f6821f,stroke:#4a5568,color:#1a1a1a
CC["Claude Code<br>hooks · Monitor · MCP"]:::outside
W["ways CLI<br>the hook engine"]:::core
E["way-embed<br>local embeddings"]:::compute
R[("ways roots<br>project · user · core")]:::store
A["ways-agent<br>judge daemon, holds the key"]:::core
P["hosted provider<br>Anthropic or OpenRouter"]:::external
M["ways-mcp<br>registered as agent-ways<br>ways_status"]:::core
AT["attend<br>sensor loop"]:::compute
B[("message bus<br>signal files · channels")]:::store
CH["attend-chat<br>you, on the bus"]:::outside
AU["ways-audit<br>compliance claims"]:::compute
CC -->|hook events| W
W -->|guidance| CC
W -->|match| E
W -->|read ways| R
W -->|prompt and queued matches| A
A -->|yes or no per way| P
CC -->|Monitor, Stop hook| AT
CC -->|MCP tools| M
AT <--> B
CH <--> B
AU -->|"project or core root"| R
The judge is the only part that sends conversation text off your machine, and only when you have stored a provider key. docs/architecture.md draws the hook flow, the matching pipeline and the per-agent disclosure state machine.
| Component | What it does | Docs |
|---|---|---|
ways | The CLI and the hook engine. Every hook script calls it to match ways, track session state and inject guidance. It also carries install, update, settings and authoring commands. | CLI reference |
way-embed | The embedding engine (C++, llama.cpp) for semantic matching. It is optional: without it, only pattern:, commands: and files: triggers fire. | Matching, finishing an install |
ways-agent | A resident per-user daemon that holds your provider key and runs the relevance judge. The hook starts it on first use. | Relevance judge, ADR-502 |
ways-mcp | The agent-ways MCP server, registered with Claude Code as agent-ways. Today it hosts one read-only tool, ways_status. ADR-501 plans attend and keepalive modules on it. | ADR-501 |
attend | The awareness layer: sensors for git state, peer sessions and process activity, surfaced into a running session as notifications. | Attend and Monitor |
attend-chat | A terminal chat that puts you on the same signal bus the agents use. | attend chat |
ways-audit | Reports on the compliance claims ways carry: coverage, control traces, provenance lint. | Governance |
Attend delivers a peer's message to a session two ways: a Monitor line wakes an idle session, and the Stop hook hands messages over at the end of a busy turn (delivery). Its keepwarm sensor, once armed, wakes an idle session before the prompt cache lapses (keepwarm). In attend chat you address agents by name or channel on the same bus:
<img src="docs/images/attend/attend-chat-fanout.png" alt="attend-chat on the merged tab: messages from the agents Elio and Lachlan and from the operator, each with its project and channel mark, and a draft addressed to @Elio @Lachlan with both recipients flagged under the input" width="720" />
Runs on Linux and macOS. The installer needs git, jq and make, and stops if any is missing.
| Tool | Purpose | Notes | |
|---|---|---|---|
| Claude Code | The agent this configures | `curl -fsSL https://claude.ai/install.sh \ | bash` |
git | Stages the app source, update checks | Usually pre-installed | |
jq | JSON in hook scripts and macros | Must install | |
make | Drives the build and the binary downloads | Usually pre-installed with a build toolchain | |
python3 | The adr and doc tools, chart-tool, the bash-bound guard | Stdlib only, no pip packages | |
gh, logged in | Downloads the prebuilt binaries; issue sync, repo macros | Run gh auth login. Without it every binary builds from source | |
cargo | Source build of a suite binary | When gh is missing or logged out, or no prebuilt fits your platform | |
cmake, C++ compiler | Source build of way-embed | Same as cargo; make deps installs them |
make setup acquires each suite binary in this order: keep a working one in bin/, download the prebuilt release through gh, build from source with cargo. Standard utilities (bash, awk, sed, grep, find, timeout, tr, sort, wc, date) are assumed present via coreutils.
make setup also fetches the embedding engine and its model. If that step fails, the install still completes, and Finishing an install walks through turning semantic matching on.
Platform install guides: macOS (Homebrew) · Arch Linux · Debian / Ubuntu · Fedora / RHEL
macOS note:
timeoutis a GNU coreutils command not present by default. Installcoreutilsvia Homebrew — see the macOS guide for PATH setup.
curl -sL https://raw.githubusercontent.com/aaronsb/agent-ways/main/scripts/install.sh | bash -s -- --bootstrap
The installer:
$XDG_DATA_HOME/agent-ways and builds the binaries, fetching the embedding model (~21MB);~/.local/bin;ways manage repair, which symlinks the projected roots (skills/, agents/, commands/, hooks/ways/, built binaries) into ~/.claude, merges its hook entries and permissions into settings.json, and registers the agent-ways MCP server in ~/.claude.json;Your existing ~/.claude (sessions, credentials, your own settings.json values, projects/) is preserved. If one of the projected root paths is already a real directory of yours, reconcile stops and names it; it never deletes it. The install guide covers that case, forks and previous installs. Restart Claude Code and the ways are active.
To customize, add your own ways under $XDG_CONFIG_HOME/agent-ways/ways/ (see Creating Ways). To replace the shipped corpus, fork the repo and install from your fork. To work on agent-ways itself, set up a development checkout.
To remove it, ways manage uninstall lists what it would do: withdraw from ~/.claude and your other targets, remove the MCP registration, unlink the commands, delete the app and its caches. ways manage uninstall --yes does it. Your own ways, API keys and event log stay unless you add --purge (ADR-198). To put the same version back cleanly, ways manage reinstall --yes fetches it first, then uninstalls and installs, keeping what is yours.
Stop and read this if you're letting an AI agent run the installer. You are about to let an agent modify
~/.claude/— the directory that controls how Claude Code behaves. The agent is editing its own configuration. Review the repo first. You are responsible for the result.
core.md loads at session start with behavioral guidance, operational rules, and a dynamic ways index. Then, as you work:
enforce mode a way it judges irrelevant is held back; in shadow mode it only logs its verdicts; off skips it.A way fires when matched, then re-discloses on its refire: cadence (a fraction of the context window, ADR-126) as its salience decays. A marker file per way and agent records the fire and drives that re-disclosure state machine; it does not block the way for good.
Matching has two channels: regex patterns for known keywords/commands/files, and sentence-embedding semantic scoring (all-MiniLM-L6-v2). See matching.md for the full strategy. The judge sends text from your conversation to a hosted model; the install guide says what and gives the commands.
ways session opens the session screen. Its timeline tab lists each way once per agent that fired it, with the epoch, the distance back, the trigger and the re-disclosure forecast, and marks what happened to each way: injected, re-disclosed, or kept out by the judge. ways session ways prints the current session's list as text.
<img src="docs/images/ways/session-timeline.png" alt="The ways session timeline at epoch 9 of a 200K-token session, matched view: an Agent column naming main, code-reviewer, a workflow member, two general-purpose subagents and one by id; ways marked as injected, re-disclosed, shadow-flagged, judge-blocked with P(yes) 0.050, held by the context cap and held in the refire window; a check line showing five fires and decay 0.17; and the context gauge at 29% with a forecast of re-disclosures between 58K and 104K tokens" width="800" />
The CLI reference explains each column and mark.
For the complete system guide — trigger flow, state machines, the pipeline from principle to implementation — see docs/hooks-and-ways/README.md.
ways settings on a terminal opens the settings screens: ways, matching, gate, install, attend, sensors and theme. An edit is queued, and nothing reaches a file until you review and apply it. On the gate tab, Enter on a profile's model opens a picker of the models the provider serves, once ways agent models has fetched them:
<img src="docs/images/ways/settings-model-picker.png" alt="The gate tab of ways settings: the anthropic profile's keys in a tree on the left, a picker over it listing claude-haiku-4-5 (current), claude-3-5-haiku-latest, claude-opus-4-5 and claude-sonnet-4-5, and the detail pane on the right giving the key's default and the file it writes, agent.yaml" width="720" />
The same keys work from the command line:
ways settings list # every key and its value
ways settings help ways.disabled_domains # what a key does, its type and default
ways settings set ways.disabled_domains itops,ea
ways settings set gate.mode shadow
User settings live in $XDG_CONFIG_HOME/agent-ways/config.yaml, except the gate.* keys, which live in agent.yaml beside it, and the attend.* keys, which live in $XDG_CONFIG_HOME/attend/config.yaml. A project overlays its own: ways keys in .claude/ways.yaml, attend keys in .claude/attend.yaml. ways settings help <key> names each key's file. The file form of the disabled_domains example:
disabled_domains:
- itops
- ea
A disabled domain is ignored completely: no matching, no output. The CLI reference lists the settings commands.
Ways load from three roots. When the same way id (its path under the root) exists in more than one, the higher root wins (ADR-143):
| Root | Path | Holds |
|---|---|---|
| Project | $PROJECT/.claude/ways/{domain}/{wayname}/ | Ways for one repository, checked in with it |
| User | $XDG_CONFIG_HOME/agent-ways/ways/{domain}/{wayname}/ | Your own ways, and overrides of shipped ways; updates never touch it |
| Core | the app's hooks/ways/, projected to ~/.claude/hooks/ways/ | The shipped corpus; change it in a fork, since a local edit can block ways manage update |
Each way is a {wayname}.md file with YAML frontmatter:
---
description: semantic text # embedding semantic matching (preferred)
vocabulary: domain keywords # space-separated terms augmenting the embedding
pattern: commit|push # regex on user prompts (supplementary)
commands: git\ commit # regex on bash commands
files: \.env$ # regex on file paths
macro: prepend # dynamic context via macro.sh
scope: agent, subagent # injection scope
---
Matching has two independent lanes. A way fires when the semantic probability g(s) clears the global threshold τ_s, or when a pattern:/commands:/files: regex matches and g(s) clears the lower keyword floor τ_k. The keyword lane is floor-gated — a regex hit can't drag in an unrelated prompt — except it fails open when no calibration is loaded, and pattern_strict: true bypasses the gate by design. See the engine reference for the exact fire rule.
Project macros are disabled by default. Trust a project with echo "/path/to/project" >> ~/.claude/trusted-project-macros.
For the full authoring guide: extending.md | For matching strategy: matching.md | For macros: macros.md
After creating or tuning a way, verify it matches what you expect — and doesn't match what it shouldn't.
# Score a way against sample prompts (inside Claude Code)
/ways-tests score security "how do i hash passwords with bcrypt"
# Rank all ways against a prompt
/ways-tests score-all "write some unit tests for this module"
# Validate frontmatter
ways author lint --global
# Vocabulary gap analysis
ways author suggest ~/.claude/hooks/ways/softwaredev/code/security/security.md
# How a prompt matches under the live matcher (ADR-160)
ways author match "pin lockfile versions"
# Sibling vocabulary overlap (Jaccard)
ways author tree softwaredev/code/supplychain/depscan --jaccard
# Way-vs-way embedding similarity
ways author siblings softwaredev/code/supplychain/depscan/node
# Session simulation tests (Rust integration tests)
make test-sim
# Interactive: full hook pipeline with subagent injection
# Start a fresh session, then: read and run tests/way-activation-test.md
Other test tools: scripts/doc-graph.sh --stats checks documentation link integrity; ways-audit lint validates provenance metadata. Full test guide: tests/README.md.
The shipped ways cover commits, security, testing, debugging, dependencies, architecture, documentation and more, across eleven domains: collaboration, data, documentation, ea, evaluative, itops, meta, research, softwaredev, workstation and writing. ways status prints the current count. The live index is generated at session start. Replace these entirely if your domain isn't software dev.
Also included:
aaronsb/agent-waysClaude Code ships two official features for injecting guidance: Rules (.claude/rules/*.md) and Skills (~/.claude/skills/). Ways solve problems that neither can.
The problem has a name in principal–agent theory: preference uncertainty. An agent that doesn't know its principal's norms has only two safe strategies — ask constantly, or hedge exhaustively. Remove the ways system and agents of every model tier revert to exactly that. Rules and skills each supply some norms; ways supply them situated — at the moment of action, wherever in the file tree that action happens.
Rules and ways both inject guidance conditionally — but their disclosure models are fundamentally different:
paths: src/api/**). The project's directory tree is the disclosure taxonomy. This works when concerns map cleanly to directories, but most concerns don't — security, testing conventions, commit standards, and performance patterns cut across every directory.hooks/register.tsx 954 lines1// attend's agent-facing driver in Claude Code (ADR-406).
2//
3// Dormant is plain Claude Code: one tool, `attend_listen`, and no polling,
4// delivery or row of this module's. A session is active from session start
5// when its project resolves `attend_mod.listening: active` through the ways
6// config, or from the agent's attend_listen call; it stays active until
7// Claude Code exits. While active the mod polls attend, delivers peer
8// messages as framed turns, serves the send/reply/join/peers tools, draws one
9// status row and drives keepwarm.
10//
11// The keepalive policy (ADR-414) runs in every interactive session with the
12// mod, dormant ones included, while autocompact is off: attend judges, and
13// this module supplies the operator's prompts and the autocompact gate,
14// submits the keepalive and wrap turns, and routes the wrap's reply. A
15// dormant session polls nothing, delivers nothing and joins nothing for it:
16// one timer, set again at each main-loop turn's end. Where the policy
17// applies, it drives every cache wake, ADR-182's armed windows included.
18//
19// Every message travels through attend's signal store by the attend CLI, as
20// an argv array with no shell. The mod never uses $.session.send.
21
22import { atom, read, update } from 'claude-code'
23import type { EngineInterface, ProcessRunResult, PromptSubmitInput, PromptSubmitResult, Register, Timer } from 'claude-code'
24
25import type { AttendStatus, KeepaliveRow } from '../types/index.d.ts'
26import {
27 ACTIVE_TOOLS,
28 DEFAULT_CONFIG,
29 DORMANT_REFUSAL,
30 OPERATOR_ORIGINS,
31 listenResult,
32 toolResult,
33 LISTEN_TOOL,
34 BEAT_ARGV,
35 PLAIN_BEAT_ARGV,
36 DRAIN_ARGV,
37 KEEPWARM_CHECK_ARGV,
38 LISTEN_OFF_ARGV,
39 LISTEN_ON_ARGV,
40 PEERS_ARGV,
41 UNREAD_ARGV,
42 WAKE_DRAIN_ARGV,
43 configArgv,
44 formatMessage,
45 frameInbound,
46 compactFocus,
47 firstCheckMs,
48 gateFrom,
49 gateIsFresh,
50 isWrapTurn,
51 maySubmit,
52 planCheck,
53 refusalDetail,
54 resetArgv,
55 saveArgv,
56 retryAtMs,
57 rowAfterPrompt,
58 rowAfterTurnEnd,
59 rowRefused,
60 rowText,
61 serialize,
62 wrapPlan,
63 isFullPreamble,
64 joinArgv,
65 keepaliveApplies,
66 keepaliveRowOf,
67 parseDecision,
68 policyArgv,
69 wrappedArgv,
70 keepwarmLine,
71 oneLine,
72 parseConfig,
73 parseDrain,
74 parsePeerCount,
75 parseUnread,
76 peerLines,
77 pollInterval,
78 replyArgv,
79 sendArgv,
80 shortId,
81} from './lib.ts'
82import type { Decision, Gate, Keepalive, Listed, Mod, ModConfig, WrapOutcome, WrapRoute } from './lib.ts'
83
84
85type $ = EngineInterface
86
87const DEFAULTS = {
88 mode: 'dormant',
89 listening: null,
90 peers: 0,
91 unread: 0,
92 channels: [],
93 deliveries: 0,
94 afterCompaction: false,
95 turnsSinceFull: 0,
96 awaitingOperator: false,
97 cacheLapsed: false,
98 keepalive: null,
99} satisfies AttendStatus
100
101const status = atom({ plugin: 'attend', key: 'status' } as const, DEFAULTS)
102
103// Tools are listed as mcp__attend__<name>; the matchers below spell them out,
104// so the validator can read them.
105const PLUGIN = 'attend'
106
107
108// A drain that finds messages waits this long and drains again, so a burst
109// arrives as one turn.
110const BATCH_MS = 2_000
111// The peer count and the keepwarm check run at most this often.
112const PEERS_EVERY_MS = 30_000
113const KEEPWARM_EVERY_MS = 60_000
114// The heartbeat is touched this often, a third of attend's 90 s liveness
115// grace (ADR-129), during a turn and at the slow interval alike.
116const BEAT_EVERY_MS = 30_000
117// Polls that retry a batch whose submit failed before it is let go.
118const SUBMIT_TRIES = 3
119// The $.store key naming the sessions that listened where the project's
120// config would not start them listening, newest last, so a resume of one
121// comes back listening (ADR-411).
122const LISTENED = 'listened'
123const LISTENED_KEPT = 50
124// A /clear or a /resume tells attend the id that goes on after this long.
125const RELISTEN_MS = 1_000
126// The keepalive policy: a gate read as on or unknown is read again after
127// this long, one read as off before every check, since a turn may follow
128// it; the check reads the transcript and the settings through ways; the
129// first check waits until session start has set the session listening or
130// dormant; a check that could not run asks again.
131const POLICY_TIMEOUT_MS = 30_000
132const POLICY_START_MS = 1_000
133// A submitted wrap whose turn has not started after this long, or has not
134// answered by the time the cache lapses, is reported unfinished.
135const WRAP_START_MS = 10 * 60_000
136const WRAP_LAPSE_MS = 60 * 60_000
137
138// agent-ways theme (tools/agent-theme/themes/agent-ways.theme).
139const SUBTLE = '#3a3a3c'
140const FG = '#d0d0d0'
141
142async function readStatus($: $): Promise<AttendStatus> {
143 return { ...DEFAULTS, ...(await read($, status)) }
144}
145
146async function updateStatus($: $, f: (s: AttendStatus) => AttendStatus) {
147 await update($, status, s => f({ ...DEFAULTS, ...s }))
148}
149
150/** One CLI call; a command that cannot start reads as exit 127. */
151async function run($: $, argv: readonly string[], timeoutMs = 15_000, stdin?: string): Promise<ProcessRunResult> {
152 try {
153 return await $.process.run(argv, stdin === undefined ? { timeoutMs } : { timeoutMs, stdin })
154 } catch (err) {
155 return { exitCode: 127, stdout: '', stderr: String(err), isStdoutTruncated: false, isStderrTruncated: false }
156 }
157}
158
159async function readConfig($: $, cwd: string): Promise<ModConfig> {
160 const r = await run($, configArgv(cwd), 10_000)
161 return r.exitCode === 0 ? parseConfig(r.stdout) : { ...DEFAULT_CONFIG }
162}
163
164async function registerListen($: $) {
165 await $.tool.register(LISTEN_TOOL)
166}
167
168/** The tools of an active session. Registering one again with the same
169 * spec changes nothing, so every path that finds the session active
170 * registers them, a reloaded module's session.start included. */
171async function registerActiveTools($: $) {
172 for (const tool of ACTIVE_TOOLS) await $.tool.register(tool)
173}
174
175/** Tells attend this session is listening, with the mod as its host; the
176 * next drain applies the cold-start rule: what was addressed to the project
177 * since this Claude Code process started arrives, and what `#open` and the
178 * channels carry from now on. */
179async function listenOn($: $): Promise<ProcessRunResult> {
180 const r = await run($, LISTEN_ON_ARGV)
181 await updateStatus($, s => ({ ...s, listening: r.exitCode === 0 }))
182 return r
183}
184
185/** Whether this process resumed its session (`claude --resume`). The
186 * SessionStart at process start names its source, so a fork or a fresh
187 * start reads as not resumed. Where none came, a session that starts with
188 * a transcript was resumed, and a read that fails counts as a new one. */
189async function isResumed($: $, mod: Mod): Promise<boolean> {
190 if (mod.startSource !== undefined) return mod.startSource === 'resume'
191 try {
192 const m = await $.session.messages()
193 return Array.isArray(m) && m.length > 0
194 } catch {
195 return false
196 }
197}
198
199/** The sessions that listened, newest last, as $.store holds them. */
200async function readListened($: $): Promise<string[]> {
201 const v = await $.store.get(LISTENED)
202 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
203}
204
205/** Whether the project's config starts this session listening on its own,
206 * so a resume of it needs no record. */
207function startsActive(mod: Mod): boolean {
208 return mod.config.listening === 'active' && mod.canPoll
209}
210
211/** Records that session `id` listened, keeping the newest LISTENED_KEPT,
212 * where the config would not reproduce it and the record does not list it
213 * already. $.store has no versioned write, so two processes recording at
214 * once can lose one id; writing this rarely keeps that window small. The
215 * record serves a later resume and never this session: a store that fails
216 * is logged and the session goes on. */
217async function rememberListening($: $, mod: Mod, id: string) {
218 if (startsActive(mod)) return
219 try {
220 const ids = await readListened($)
221 if (ids.includes(id)) return
222 ids.push(id)
223 await $.store.set(LISTENED, ids.slice(-LISTENED_KEPT))
224 } catch (err) {
225 $.ui.log(`attend: could not record this session for a resume: ${String(err)}`)
226 }
227}
228
229/** Makes session `id` active for the rest of the process: listening, the
230 * poll, and the record a resume reads; the caller has registered the
231 * tools. Idempotent. Returns attend's answer to `listen on`. */
232async function activate($: $, mod: Mod, id: string): Promise<ProcessRunResult> {
233 mod.active = true
234 let r: ProcessRunResult
235 try {
236 mod.config = await readConfig($, mod.cwd)
237 mod.canPoll = mod.canPoll || mod.config.headlessPoll
238 // The config read again may change whether the policy applies: one judge
239 // per ledger, the policy's timer or the poll's plain check.
240 const didApply = mod.ka.applies
241 mod.ka.applies = keepaliveApplies(mod.config, mod.isInteractive)
242 if (mod.ka.applies && !didApply) await armKeepalive($, mod, (await $.clock.now()) + POLICY_START_MS)
243 if (!mod.ka.applies) mod.ka.timer?.cancel()
244 mod.lastActivityMs = await $.clock.now()
245 await updateStatus($, s => ({ ...s, mode: 'active' as const }))
246 r = await listenOn($)
247 } finally {
248 mod.isRelistening = false
249 }
250 await rememberListening($, mod, id)
251 if (mod.canPoll) {
252 startTicker($, mod)
253 $.clock.after(0, () => void tick($, mod, true))
254 }
255 return r
256}
257
258function startTicker($: $, mod: Mod) {
259 mod.ticker?.cancel()
260 const every = Math.max(1, Math.min(mod.config.pollSeconds, 5)) * 1000
261 mod.ticker = $.clock.every(every, () => void tick($, mod, false))
262}
263
264/** One beat of the poll: touch the heartbeat, drain while idle, refresh the
265 * row, run the keepwarm check. `force` skips the poll's interval
266 * (activation, a turn's end). The heartbeat keeps its own interval, so a
267 * turn or a slow poll longer than attend's grace does not leave the session
268 * looking gone. */
269async function tick($: $, mod: Mod, force: boolean) {
270 if (mod.isTicking) return
271 if (!(await isOn($, mod))) return
272 mod.isTicking = true
273 try {
274 const now = await $.clock.now()
275 if (now - mod.lastBeatMs >= BEAT_EVERY_MS) {
276 mod.lastBeatMs = now
277 if (mod.isBeatPlain) {
278 await run($, PLAIN_BEAT_ARGV)
279 } else if ((await run($, BEAT_ARGV)).exitCode === 2) {
280 // An older attend: a usage error on --mod. Beat without it from now on.
281 mod.isBeatPlain = true
282 await run($, PLAIN_BEAT_ARGV)
283 }
284 }
285 const isTurnRunning = mod.running.size > 0
286 const interval = pollInterval(mod.config, now - mod.lastActivityMs, isTurnRunning)
287 if (!force && now - mod.lastPollMs < interval) return
288 mod.lastPollMs = now
289 if (!isTurnRunning && !mod.isRelistening && !mod.isAwaitingOperator) await poll($, mod)
290 await refreshUnread($, mod)
291 if (force || now - mod.lastPeersMs >= PEERS_EVERY_MS) await refreshPeers($, mod)
292 // Where the keepalive policy applies, its own timer drives every wake.
293 const isPlain = !mod.ka.applies
294 if (isPlain && mod.config.keepwarm === 'on' && mod.running.size === 0 && now - mod.lastKeepwarmMs >= KEEPWARM_EVERY_MS) {
295 mod.lastKeepwarmMs = now
296 await keepwarm($)
297 }
298 } finally {
299 mod.isTicking = false
300 }
301}
302
303/** One wake drain; on messages, a plain drain after BATCH_MS, then one
304 * prompt for the batch. The wake drain delivers only once something pending
305 * would wake the session (a message addressed to it, or a post in one of
306 * its named channels), and then delivers all that is pending. A
307 * turn is submitted only when the drain delivered at least one message: the
308 * cold start's held-back note and the session's name are fields of the
309 * drain's document, never a message.
310 *
311 * The drain marks what it delivers read before the prompt is submitted, so
312 * the batch waits in `mod.pending` until a submit takes it. A submit that
313 * fails, or that a hook drops, leaves the batch there and the next poll
314 * tries it again before draining more, up to SUBMIT_TRIES times. */
315async function poll($: $, mod: Mod) {
316 if (mod.isPolling || mod.isPendingCold) return
317 mod.isPolling = true
318 try {
319 if (mod.pending.length === 0) {
320 // Only what is addressed to the session, or posted in one of its
321 // named channels, starts a turn; other #open posts wait for its next
322 // one (ADR-406 item 2).
323 const first = parseDrain((await run($, WAKE_DRAIN_ARGV)).stdout)
324 if (!first) return
325 // Listening turned off elsewhere (attend listen off, a private scene),
326 // or an identity attend cannot resolve: the row says so.
327 const listening = first.status === 'drained'
328 const was = await readStatus($)
329 if (was.listening !== listening || was.cacheLapsed !== first.cacheLapsed) {
330 await updateStatus($, s => ({ ...s, listening, cacheLapsed: first.cacheLapsed }))
331 }
332 if (first.messages.length === 0) return
333 const msgs = [...first.messages]
334 await updateStatus($, s => ({ ...s, unread: Math.max(s.unread, msgs.length) }))
335 await new Promise<void>(resolve => $.clock.after(BATCH_MS, resolve))
336 const second = parseDrain((await run($, DRAIN_ARGV)).stdout)
337 if (second) msgs.push(...second.messages)
338 mod.pending = msgs
339 mod.submitTries = 0
340 }
341 if (await deliver($, mod, mod.pending)) {
342 mod.pending = []
343 } else if (++mod.submitTries >= SUBMIT_TRIES) {
344 // Read by attend and refused by the session: they stay readable with
345 // attend message inbox, and the poll goes on to new messages.
346 await $.ui.toast(`attend: ${mod.pending.length} message(s) could not be delivered; attend message inbox lists them`)
347 mod.pending = []
348 }
349 } finally {
350 mod.isPolling = false
351 }
352}
353
354/** Submits one framed batch, which starts a turn in an idle session.
355 * Resolves whether the prompt entered; the preamble counters move only
356 * when it did. */
357async function deliver($: $, mod: Mod, msgs: readonly Listed[]): Promise<boolean> {
358 // A check in flight submits nothing behind this turn.
359 mod.ka.epoch += 1
360 mod.lastActivityMs = await $.clock.now()
361 for (const m of msgs) {
362 mod.ids.set(shortId(m.id), m.id)
363 if (m.re) mod.ids.set(shortId(m.re), m.re)
364 }
365 const s = await readStatus($)
366 const full = isFullPreamble(s.deliveries, s.afterCompaction, s.turnsSinceFull, mod.config.preambleEveryTurns)
367 const entered = await $.prompt
368 .submit({ text: frameInbound(msgs.map(formatMessage), full), asUser: true })
369 .then(r => r.drop === undefined)
370 .catch(() => false)
371 if (!entered) return false
372 await updateStatus($, t => ({
373 ...t,
374 deliveries: t.deliveries + 1,
375 afterCompaction: false,
376 turnsSinceFull: full ? 0 : t.turnsSinceFull,
377 }))
378 return true
379}
380
381/** Holds, or stops holding, the session's turns until the operator's first
382 * prompt. The module's flag serves a /clear, which empties $.state; the
383 * copy in $.state serves a reload, which starts the module afresh. */
384async function setAwaiting($: $, mod: Mod, awaiting: boolean) {
385 mod.isAwaitingOperator = awaiting
386 await updateStatus($, s => ({ ...s, awaitingOperator: awaiting }))
387}
388
389/** The operator's first prompt in a session the config started listening:
390 * one drain, and what it delivered rides `e` as context, the cold start's
391 * held-back note with it, rather than as a turn of its own. A prompt that
392 * does not enter leaves the messages in `mod.pending`, which the poll
393 * submits as it does a batch whose submit failed. */
394async function enterWithPending($: $, mod: Mod, e: PromptSubmitInput, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>, isCold: boolean) {
395 // A batch an earlier cold prompt failed to carry goes first.
396 const carried = mod.isPendingCold ? mod.pending : []
397 const d = parseDrain((await run($, DRAIN_ARGV)).stdout)
398 if (!d && carried.length === 0) return next(e)
399 if (d) {
400 const listening = d.status === 'drained'
401 if ((await readStatus($)).listening !== listening) await updateStatus($, s => ({ ...s, listening }))
402 }
403 const msgs = [...carried, ...(d?.messages ?? [])]
404 if (carried.length > 0) {
405 mod.pending = []
406 mod.isPendingCold = false
407 }
408 for (const m of msgs) {
409 mod.ids.set(shortId(m.id), m.id)
410 if (m.re) mod.ids.set(shortId(m.re), m.re)
411 }
412 const note = d?.note ? `attend: ${oneLine(d.note)}` : undefined
413 const block = msgs.length > 0 ? frameInbound(msgs.map(formatMessage), true) : undefined
414 const attached = [block, note].filter((x): x is string => x !== undefined)
415 if (attached.length === 0) return next(e)
416 // Read by attend and not entered: they wait in `mod.pending`. Taken on a
417 // lapsed cache, they wait for the operator's next prompt, with the row
418 // still saying so; otherwise the poll submits them.
419 const keep = async () => {
420 if (msgs.length === 0) return
421 mod.pending = [...msgs]
422 mod.submitTries = 0
423 mod.isPendingCold = isCold
424 if (isCold) await updateStatus($, s => ({ ...s, cacheLapsed: true, unread: Math.max(s.unread, msgs.length) }))
425 }
426 let r: PromptSubmitResult
427 try {
428 r = await next({ ...e, context: [...(e.context ?? []), attached.join('\n')] })
429 } catch (err) {
430 await keep()
431 throw err
432 }
433 if (r.drop !== undefined) {
434 await keep()
435 } else if (msgs.length > 0) {
436 await updateStatus($, t => ({ ...t, deliveries: t.deliveries + 1, afterCompaction: false, turnsSinceFull: 0 }))
437 $.ui.toast(`attend: ${msgs.length} peer message(s) went to Claude with your prompt`)
438 }
439 return r
440}
441
442
443async function refreshUnread($: $, mod: Mod) {
444 const u = parseUnread((await run($, UNREAD_ARGV)).stdout)
445 if (!u) return
446 await updateStatus($, s => ({ ...s, unread: u.unread, channels: [...new Set([...u.channels, ...mod.joined])] }))
447}
448
449async function refreshPeers($: $, mod: Mod) {
450 mod.lastPeersMs = await $.clock.now()
451 const r = await run($, PEERS_ARGV)
452 if (r.exitCode === 0) await updateStatus($, s => ({ ...s, peers: parsePeerCount(r.stdout) }))
453}
454
455/** ADR-182's judgment, run by attend; a `keepwarm:` line becomes a turn. */
456async function keepwarm($: $) {
457 const line = keepwarmLine((await run($, KEEPWARM_CHECK_ARGV, 30_000)).stdout)
458 if (line) await $.prompt.submit({ text: line })
459}
460
461/** Whether this session is active. The module's own flag answers too, and
462 * puts the mode back in $.state should it be missing there. */
463async function isOn($: $, mod: Mod): Promise<boolean> {
464 if (isActive(await readStatus($))) return true
465 if (!mod.active) return false
466 await updateStatus($, s => ({ ...s, mode: 'active' as const }))
467 return true
468}
469
470function isActive(s: AttendStatus): boolean {
471 return s.mode === 'active'
472}
473
474function input<T>(e: unknown): Partial<T> {
475 return e as Partial<T>
476}
477
478// ── the keepalive policy (ADR-414) ──────────────────────────────────────
479
480async function setKeepaliveRow($: $, f: (k: KeepaliveRow | null) => KeepaliveRow | null) {
481 await updateStatus($, s => ({ ...s, keepalive: f(s.keepalive) }))
482}
483
484/** The autocompact gate (ADR-414 item 2): the usage breakdown, estimated
485 * locally with no request, else `autoCompactEnabled` in the merged
486 * settings, else unknown, which counts as on. `fresh` reads it whatever
487 * was kept, as before a submit. A gate that first reads off sends the
488 * operator's prompt held while it did not. */
489async function readGate($: $, ka: Keepalive, fresh = false): Promise<Gate> {
490 const now = await $.clock.now()
491 if (!fresh && gateIsFresh(ka.gate, ka.gateAtMs, now)) return ka.gate
492 let usage: unknown
493 try {
494 usage = await $.session.usage({ breakdown: 'summary' })
495 } catch {
496 usage = undefined
497 }
498 let settings: unknown
499 if (gateFrom(usage, undefined) === 'unknown') {
500 try {
501 settings = await $.settings.read()
502 } catch {
503 settings = undefined
504 }
505 }
506 ka.gate = gateFrom(usage, settings)
507 ka.gateAtMs = now
508 if (ka.gate === 'off' && ka.heldPromptMs !== undefined) {
509 const at = ka.heldPromptMs
510 ka.heldPromptMs = undefined
511 await run($, resetArgv(at / 1000))
512 }
513 return ka.gate
514}
515
516/** The policy's timer: a check at `atMs`. */
517async function armKeepalive($: $, mod: Mod, atMs: number) {
518 mod.ka.timer?.cancel()
519 const wait = Math.max(0, atMs - (await $.clock.now()))
520 mod.ka.timer = $.clock.after(wait, () => {
521 mod.ka.timer = undefined
522 void checkKeepalive($, mod)
523 }) as unknown as Timer
524}
525
526/** One check: read the gate, ask attend, submit what it returns, and set the
527 * timer for when attend says to ask again. Nothing is due while a main-loop
528 * turn runs, and a prompt or a turn that starts while attend judges (the
529 * epoch moves) cancels the submit. A dormant session with autocompact on
530 * stays plain Claude Code and asks nothing. */
531async function checkKeepalive($: $, mod: Mod) {
532 const ka = mod.ka
533 if (!ka.applies || mod.running.size > 0 || mod.config.keepwarm !== 'on') return
534 const epoch = ka.epoch
535 await serialize(ka, async () => {
536 const gate = await readGate($, ka)
537 const isListening = await isOn($, mod)
538 if (!isListening && gate !== 'off') return
539 const r = await run($, policyArgv(gate), POLICY_TIMEOUT_MS)
540 const d: Decision | undefined = r.exitCode === 0 ? parseDecision(r.stdout) : undefined
541 const plan = d ? planCheck(d, isListening) : undefined
542 if (!d || !plan || plan.retry) {
543 const at = retryAtMs(await $.clock.now(), isListening, gate, ka.retries)
544 ka.retries += 1
545 if (at !== null) await armKeepalive($, mod, at)
546 return
547 }
548 ka.retries = 0
549 await setKeepaliveRow($, k => keepaliveRowOf(d, k))
550 if (plan.submit) {
551 const isPolicy = d.policy
552 const gateNow = isPolicy ? await readGate($, ka, true) : gate
553 if (!maySubmit({ epochThen: epoch, epochNow: ka.epoch, running: mod.running.size, isPolicy, gate: gateNow })) {
554 if (plan.submit.kind === 'wrap') await run($, wrappedArgv('unfinished', 'a turn started before the wrap went in'))
555 return
556 }
557 const { text, route } = plan.submit
558 if (route) {
559 const wrap = { route, text, turnId: undefined, reply: undefined, sawTurn: false }
560 ka.wrap = wrap
561 // A wrap whose turn never starts, or never answers before the cache
562 // lapses, is reported rather than left pending.
563 $.clock.after(WRAP_START_MS, () => {
564 if (ka.wrap === wrap && wrap.turnId === undefined) {
565 void reportUnrouted($, ka, wrap.sawTurn ? 'the wrap\'s turn was not recognised' : 'the wrap\'s turn never started')
566 }
567 })
568 $.clock.after(WRAP_LAPSE_MS, () => {
569 if (ka.wrap === wrap) void reportUnrouted($, ka, 'the wrap did not answer before the cache lapsed')
570 })
571 }
572 const isEntered = await $.prompt
573 .submit({ text })
574 .then(x => x.drop === undefined)
575 .catch(() => false)
576 if (!isEntered && route) {
577 ka.wrap = undefined
578 await run($, wrappedArgv('unfinished', 'the wrap prompt did not enter'))
579 }
580 }
581 if (plan.armAtMs !== null) await armKeepalive($, mod, plan.armAtMs)
582 })
583}
584
585/** The operator is present: the count starts again, and a sent wrap and a
586 * stop are cleared (ADR-414 item 4). attend hears of it only while the
587 * gate last read off; otherwise the prompt is held, and sent when the gate
588 * first reads off, so a session with autocompact on runs nothing. */
589async function keepaliveOperator($: $, mod: Mod) {
590 const ka = mod.ka
591 if (!ka.applies) return
592 ka.epoch += 1
593 // Never awaited: the prompt must not wait on the chain a check holds.
594 if (ka.wrap) void reportUnrouted($, ka, 'the operator prompted before the wrap answered')
595 const now = await $.clock.now()
596 if ((await readGate($, ka, true)) === 'off') {
597 ka.heldPromptMs = undefined
598 void serialize(ka, () => run($, resetArgv(now / 1000)))
599 } else {
600 ka.heldPromptMs = now
601 }
602 await setKeepaliveRow($, rowAfterPrompt)
603}
604
605/** A main-loop turn ended, which restarts the clock: the timer is set for
606 * the first point a check could be due, and attend names the due point
607 * there. The wrap's turn routes its reply first. */
608async function keepaliveTurnEnded($: $, mod: Mod, e: { turnId: string; reason: string; answer?: string }) {
609 if (!mod.ka.applies) return
610 await routeWrap($, mod.ka, e.turnId, e.reason, e.answer ?? '')
611 if (mod.running.size > 0) return
612 mod.ka.retries = 0
613 const at = (await $.clock.now()) + firstCheckMs(mod.config)
614 await armKeepalive($, mod, at)
615 await setKeepaliveRow($, k => rowAfterTurnEnd(k, at))
616}
617
618/** The wrap's turn ended: its final reply goes where `wrap` says, and attend
619 * records where it went (ADR-414 item 6). Only the turn the wrap's own
620 * text started is routed; a turn that ended other than with an answer is
621 * written nowhere. */
622async function routeWrap($: $, ka: Keepalive, turnId: string, reason: string, answer: string) {
623 const w = ka.wrap
624 if (!w || w.turnId === undefined || w.turnId !== turnId) return
625 ka.wrap = undefined
626 // The wrap's own answer, from its first ending step, which stands even
627 // when a Stop-hook continuation then ends aborted or in error; the
628 // turn's final answer only where no step was seen.
629 const reply = w.reply ?? answer
630 const report = (o: WrapOutcome, detail = '') => serialize(ka, () => run($, wrappedArgv(o, detail)))
631 const plan = wrapPlan(w.route, w.reply !== undefined ? 'answer' : reason, reply)
632 if (plan.kind === 'unfinished') return void (await report('unfinished', plan.detail))
633 if (plan.kind === 'transcript') await report('transcript')
634 // attend writes the file and records the outcome, or why it could not.
635 if (plan.kind === 'file') await serialize(ka, () => run($, plan.argv, undefined, reply))
636 if (plan.kind === 'send') {
637 const r = await run($, plan.argv)
638 if (r.exitCode === 0) {
639 await report('sent', w.route.to)
640 } else {
641 // Refused (no live session by that name, a channel it cannot post
642 // to) or timed out: the reply goes to the file instead.
643 const detail = refusalDetail(w.route.to, r)
644 if (w.route.file === '') await report('refused', `${detail}; no safe wrap file to keep it in`)
645 else await serialize(ka, () => run($, saveArgv('refused', detail, w.route.file), undefined, reply))
646 await setKeepaliveRow($, rowRefused)
647 }
648 }
649 if (w.route.after === 'compact') {
650 const instructions = compactFocus(reply)
651 // Between turns: a compaction rejects while a turn runs.
652 $.clock.after(1_000, () => {
653 void $.session.compact({ instructions }).catch(err => $.ui.log(`attend: the compaction after the wrap failed: ${String(err)}`))
654 })
655 }
656}
657
658/** A wrap that went in and was never routed: its turn never started or
659 * was not recognised, or never ended with an answer before the cache
660 * lapsed, or the operator prompted first. attend records it as unfinished.
661 * The wrap is cleared before the first await, so a caller may fire it. */
662async function reportUnrouted($: $, ka: Keepalive, why: string) {
663 ka.wrap = undefined
664 await serialize(ka, () => run($, wrappedArgv('unfinished', why)))
665}
666
667export const register: Register = on => {
668 const mod: Mod = {
669 config: { ...DEFAULT_CONFIG },
670 canPoll: false,
671 isInteractive: false,
672 cwd: '.',
673 running: new Set(),
674 isPolling: false,
675 isTicking: false,
676 ticker: undefined,
677 lastPollMs: 0,
678 lastPeersMs: 0,
679 lastKeepwarmMs: 0,
680 lastBeatMs: 0,
681 isBeatPlain: false,
682 lastActivityMs: 0,
683 ids: new Map(),
684 active: false,
685 pending: [],
686 isPendingCold: false,
687 submitTries: 0,
688 joined: new Set(),
689 isStarted: false,
690 startSource: undefined,
691 isRelistening: false,
692 isAwaitingOperator: false,
693 ka: {
694 applies: false,
695 gate: 'unknown',
696 gateAtMs: -Infinity,
697 timer: undefined,
698 chain: Promise.resolve(),
699 epoch: 0,
700 heldPromptMs: undefined,
701 retries: 0,
702 wrap: undefined,
703 },
704 }
705
706 on('session.start', async ($, e, next) => {
707 mod.cwd = e.cwd
708 mod.config = await readConfig($, e.cwd)
709 // attend_mod.install false: the mod is unavailable, nothing is listed.
710 if (!mod.config.install) return next(e)
711 mod.canPoll = e.isInteractive || mod.config.headlessPoll
712 mod.isInteractive = e.isInteractive
713 mod.ka.applies = keepaliveApplies(mod.config, e.isInteractive)
714 // The first check reads where the transcript stands (a resumed session
715 // may be warm) and records the gate for the status card.
716 if (mod.ka.applies) await armKeepalive($, mod, (await $.clock.now()) + POLICY_START_MS)
717 await registerListen($)
718 mod.isStarted = true
719 // A reload finds the mode in $.state; a new process (`claude --resume`)
720 // finds the session id in the record of sessions that listened. The
721 // record leaves out a session its config started, so a resume of one
722 // goes on unheld, as it did before the process exited.
723 const id = await $.session.id()
724 const kept = await readStatus($)
725 const wasActive =
726 isActive(kept) || (await readListened($)).includes(id) || (startsActive(mod) && (await isResumed($, mod)))
727 if (wasActive || (mod.config.listening === 'active' && mod.canPoll)) {
728 // Listed by turn one; listening and the first drain follow the hook.
729 // Active from here, so a /clear before activate runs keeps it. A
730 // session the config starts listening has had no prompt yet: nothing
731 // it finds pending starts a turn before the operator's first prompt.
732 // A reload keeps whatever hold $.state records.
733 mod.active = true
734 await setAwaiting($, mod, isActive(kept) ? kept.awaitingOperator : !wasActive)
735 await registerActiveTools($)
736 $.clock.after(0, () => void activate($, mod, id))
737 } else {
738 // Dormant: attend's drain delivers nothing to this session either.
739 $.clock.after(0, () => void run($, LISTEN_OFF_ARGV))
740 }
741 return next(e)
742 })
743
744 // A /clear or an in-session /resume goes on under another session id with
745 // no session.start, and Claude Code empties this plugin's $.state. The
746 // settings SessionStart (source `clear` or `resume`, `session_id` the id
747 // that goes on) fires after that. An active session, or a /resume to a
748 // session that listened, puts the mode back at once, so a reload finds it
749 // active, records the id, and tells attend the id is listening. The poll
750 // holds its drain until then. The model's context changed as at a
751 // compaction, so the next delivery carries the preamble line. At process
752 // start this event comes before session.start, which handles that case.
753 on('classic.SessionStart', async ($, e, next) => {
754 // Read before the chain runs, so session.start cannot overtake it.
755 const isAtStart = !mod.isStarted
756 if (isAtStart) mod.startSource = e.source
757 const done = await next(e)
758 if (isAtStart || (e.source !== 'clear' && e.source !== 'resume')) return done
759 const id = e.session_id || (await $.session.id())
760 const goesOn = (await isOn($, mod)) || (e.source === 'resume' && (await readListened($)).includes(id))
761 if (!goesOn) return done
762 mod.isRelistening = true
763 try {
764 // The operator chose a conversation to go on with, as a first prompt
765 // would: the hold for one ends.
766 if (e.source === 'resume') mod.isAwaitingOperator = false
767 await updateStatus($, s => ({
768 ...s,
769 mode: 'active' as const,
770 afterCompaction: true,
771 turnsSinceFull: 0,
772 awaitingOperator: mod.isAwaitingOperator,
773 }))
774 await rememberListening($, mod, id)
775 if (mod.active) {
776 $.clock.after(RELISTEN_MS, () => void listenOn($).finally(() => (mod.isRelistening = false)))
777 } else {
778 mod.active = true
779 await registerActiveTools($)
780 $.clock.after(RELISTEN_MS, () => void activate($, mod, id))
781 }
782 } catch (err) {
783 // No timer will lower the guard, so the poll would stay held.
784 mod.isRelistening = false
785 throw err
786 }
787 return done
788 })
789
790 // A compaction drops the earlier preamble from the model's view, so the
791 // next delivery shows it in full. A precompute installs nothing.
792 on('session.compact', async ($, e, next) => {
793 const done = await next(e)
794 if (e.trigger !== 'precompute' && e.agentId === undefined && done.skip === undefined) {
795 await updateStatus($, s => ({ ...s, afterCompaction: true }))
796 }
797 return done
798 })
799
800 // The operator's first prompt in a session the config started listening,
801 // or their next prompt while a message waits on a lapsed cache: what is
802 // pending enters beside it, read in the same turn, rather than as a turn
803 // the operator never asked for.
804 on('prompt.submit', async ($, e, next) => {
805 // Any prompt (a schedule, a peer, a task notice) moves the epoch, so a
806 // check in flight queues nothing behind its turn; only the operator's
807 // starts the count again.
808 mod.ka.epoch += 1
809 if (!OPERATOR_ORIGINS.has(e.origin.kind)) return next(e)
810 await keepaliveOperator($, mod)
811 const isHeldCold = (await readStatus($)).cacheLapsed || mod.isPendingCold
812 if (!mod.isAwaitingOperator && !isHeldCold) return next(e)
813 await setAwaiting($, mod, false)
814 if (isHeldCold) await updateStatus($, s => ({ ...s, cacheLapsed: false }))
815 if (!(await isOn($, mod))) return next(e)
816 return enterWithPending($, mod, e, next, isHeldCold)
817 })
818
819 // The main loop's turns only: a subagent's or a background agent's run
820 // is a turn of its own loop and can outlast the operator's attention by
821 // half an hour, while the main loop idles and a message should wake it.
822 on('turn.start', async ($, e, next) => {
823 if (e.agentId !== undefined) return next(e)
824 mod.running.add(e.turnId)
825 // Nothing is due while a main-loop turn runs, and a check in flight
826 // submits nothing. The turn the wrap's own text started is the wrap's.
827 mod.ka.epoch += 1
828 mod.ka.timer?.cancel()
829 mod.ka.timer = undefined
830 const w = mod.ka.wrap
831 if (w && w.turnId === undefined) {
832 if (isWrapTurn(e.text ?? '', w.text)) w.turnId = e.turnId
833 else w.sawTurn = true
834 }
835 mod.lastActivityMs = await $.clock.now()
836 if (await isOn($, mod)) await updateStatus($, s => ({ ...s, turnsSinceFull: s.turnsSinceFull + 1 }))
837 return next(e)
838 })
839
840 // The wrap's own answer: its turn's first step that ended it. A peer
841 // message the Stop hook drains, or a notification, continues the turn
842 // with more steps, and their answer is not the wrap.
843 on('turn.step', async function* ($, e, next) {
844 const r = yield* next(e)
845 const w = mod.ka.wrap
846 if (e.agentId === undefined && w && w.turnId === e.turnId && w.reply === undefined && r.stopReason === 'end_turn') w.reply = r.answer
847 return r
848 })
849
850 on('turn.complete', async ($, e, next) => {
851 const done = await next(e)
852 if (e.agentId !== undefined) return done
853 mod.running.delete(e.turnId)
854 mod.lastActivityMs = await $.clock.now()
855 await keepaliveTurnEnded($, mod, e)
856 // Whatever the Stop hook left (or all of it, where no Stop hook runs).
857 if (mod.canPoll) $.clock.after(250, () => void tick($, mod, true))
858 return done
859 })
860
861 on('tool.call', { tool: 'mcp__attend__attend_listen' }, async $ => {
862 const was = isActive(await readStatus($))
863 await registerActiveTools($)
864 return listenResult(await activate($, mod, await $.session.id()), was, mod.canPoll)
865 })
866
867 on('tool.call', { tool: 'mcp__attend__attend_send' }, async ($, e) => {
868 if (!(await isOn($, mod))) return DORMANT_REFUSAL
869 const a = input<{ message: unknown; channel: unknown; to: unknown }>(e)
870 const message = typeof a.message === 'string' ? a.message : ''
871 if (message.trim() === '') return { result: 'message is empty; nothing sent', isError: true }
872 const channel = typeof a.channel === 'string' && a.channel.trim() !== '' ? a.channel : undefined
873 const to = typeof a.to === 'string' && a.to.trim() !== '' ? a.to : undefined
874 mod.lastActivityMs = await $.clock.now()
875 return toolResult(await run($, sendArgv(message, channel, to)), 'attend message send')
876 })
877
878 on('tool.call', { tool: 'mcp__attend__attend_reply' }, async ($, e) => {
879 if (!(await isOn($, mod))) return DORMANT_REFUSAL
880 const a = input<{ message: unknown; re: unknown }>(e)
881 const message = typeof a.message === 'string' ? a.message : ''
882 if (message.trim() === '') return { result: 'message is empty; nothing sent', isError: true }
883 // A handle this module delivered maps here; any other (one the Stop-hook
884 // drain printed) goes to attend as written, which resolves it.
885 const raw = typeof a.re === 'string' ? a.re.trim().replace(/^~/, '') : ''
886 const re = raw === '' ? undefined : mod.ids.get(raw) ?? raw
887 mod.lastActivityMs = await $.clock.now()
888 return toolResult(await run($, replyArgv(message, re)), 'attend message reply')
889 })
890
891 on('tool.call', { tool: 'mcp__attend__attend_join' }, async ($, e) => {
892 if (!(await isOn($, mod))) return DORMANT_REFUSAL
893 const a = input<{ channel: unknown }>(e)
894 const channel = typeof a.channel === 'string' ? a.channel.trim() : ''
895 if (channel === '') return { result: 'channel is empty; nothing joined', isError: true }
896 mod.lastActivityMs = await $.clock.now()
897 const r = await run($, joinArgv(channel))
898 if (r.exitCode === 0) {
899 const label = `#${channel.replace(/^[#@]/, '')}`
900 mod.joined.add(label)
901 await updateStatus($, s => ({ ...s, channels: [...new Set([...s.channels, label])] }))
902 }
903 return toolResult(r, 'attend channel join')
904 })
905
906 on('tool.call', { tool: 'mcp__attend__attend_peers' }, async $ => {
907 if (!(await isOn($, mod))) return DORMANT_REFUSAL
908 const r = await run($, PEERS_ARGV)
909 if (r.exitCode === 0) await updateStatus($, s => ({ ...s, peers: parsePeerCount(r.stdout) }))
910 return toolResult(r, 'attend peer list')
911 })
912
913 // One row while active, in the shape of the operator's statusline: peers,
914 // unread (or waiting, while the cache is cold), channels, and the keepalive
915 // policy's segment while it runs. Name and project are the statusline's.
916 // Dormant draws the keepalive segment alone while it shows, else nothing.
917 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
918 if (e.props.hasSurvey) return next(e)
919 const text = rowText(await readStatus($), await $.clock.now())
920 if (text === undefined) return next(e)
921 const { Box, Text } = $.ui.resolve(e)
922 return (
923 <Box flexDirection="row">
924 <Text color={SUBTLE}>{'\uE0B6'}</Text>
925 <Text color={FG} backgroundColor={SUBTLE}>
926 {' '}
927 {text}{' '}
928 </Text>
929 <Text color={SUBTLE}>{'\uE0B4'}</Text>
930 </Box>
931 )
932 })
933
934 // The human's row for a delivery this plugin submitted: one dim line per
935 // message. ctrl+o (isExpanded) and rows of any other origin keep the
936 // engine's drawing. The model still reads the full framed text.
937 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
938 const { origin, isExpanded, text } = e.props
939 if (isExpanded || origin.kind !== 'plugin' || origin.name !== PLUGIN) return next(e)
940 const lines = peerLines(text)
941 if (!lines) return next(e)
942 const { Box, Text } = $.ui.resolve(e)
943 return (
944 <Box flexDirection="column">
945 {lines.map((l, i) => (
946 <Text key={String(i)} dimColor wrap="truncate-end">
947 {l}
948 </Text>
949 ))}
950 </Box>
951 )
952 })
953}
954types/index.d.ts 56 lines1/** The keepalive policy as the row draws it (ADR-414 item 9). */
2export type KeepaliveRow = {
3 state: 'running' | 'wrapped' | 'stopped'
4 count: number
5 max: number
6 /** Epoch seconds. */
7 nextAt: number | null
8 wrapsAt: string | null
9 wrappedAt: string | null
10 /** Why a running policy has nothing due, from attend's decision. */
11 idle: string | null
12 /** A wrap sent over attend was refused and went to the file instead. */
13 isRefused: boolean
14 /** A dormant session draws the segment from its first keepalive until
15 * the operator's next prompt. */
16 isShown: boolean
17}
18
19export type AttendStatus = {
20 /** Dormant: plain Claude Code, nothing polled or drawn. Active: listening
21 * until Claude Code exits (ADR-406 item 2). */
22 mode: 'dormant' | 'active'
23 /** Whether attend has this session listening; false when `attend listen on`
24 * failed, null before it ran. */
25 listening: boolean | null
26 peers: number
27 /** Messages waiting for this session (`attend message unread`), or drained and not
28 * yet delivered where attend has no unread count. */
29 unread: number
30 /** The channels this session receives or joined, as `#name`. */
31 channels: string[]
32 /** Deliveries made so far; the preamble line shows on delivery 0. */
33 deliveries: number
34 /** A compaction happened since the last delivery: the next is full. */
35 afterCompaction: boolean
36 /** Turns since the preamble line last showed. */
37 turnsSinceFull: number
38 /** The config started this session listening and the operator has not
39 * prompted yet: no turn is submitted, and what is pending rides the first
40 * prompt. Kept here so a reloaded module keeps holding. */
41 awaitingOperator: boolean
42 /** A message that would wake the session waits because the prompt cache
43 * has lapsed: the session is not woken, and what waits rides the
44 * operator's next prompt (ADR-406 item 2). */
45 cacheLapsed: boolean
46 /** The keepalive policy's segment of the row (ADR-414 item 9); null
47 * where the policy does not run. */
48 keepalive: KeepaliveRow | null
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 attend: { status: AttendStatus }
54 }
55}
56hooks/lib.ts 863 lines1// Pure pieces of the attend mod (ADR-406): argv building, parsing what the
2// attend and ways CLIs print, framing a delivery, and the timing rules. No `$`
3// here, so the tests exercise them directly.
4
5import type { Timer } from 'claude-code'
6
7import type { AttendStatus, KeepaliveRow } from '../types/index.d.ts'
8
9export const ATTEND = 'attend'
10/** This mod's version, equal to .claude-plugin/plugin.json's. A mod cannot read
11 * its manifest, so scripts/check-plugins.sh compares the two and
12 * scripts/release.sh bump moves both. The beat sends it to attend, which
13 * tells the operator when the session runs an older mod than the installed
14 * one. */
15export const MOD_VERSION = '0.28.0'
16export const WAYS = 'ways'
17
18// ── argv ─────────────────────────────────────────────────────────────────
19// Every call is an argv array with no shell. A body is one element after
20// `--`, so no quoting rule applies to it and a body that starts with a dash
21// stays a body. attend sends a long body as one message.
22
23/** `attend message send [--channel C] [--to T] -- <message>`: `to` is a session's
24 * name, a person's member id or a project path, and makes it a direct
25 * message; with neither it posts to #open, as `channel` open does. */
26export function sendArgv(message: string, channel?: string, to?: string): string[] {
27 const argv = [ATTEND, 'message', 'send']
28 if (channel) argv.push('--channel', bareChannel(channel))
29 if (to) argv.push('--to', to)
30 argv.push('--', message)
31 return argv
32}
33
34/** `attend message reply [--re <id>] -- <message>`: threads to the newest message
35 * delivered, or to `re`, and goes to that message's sender as a direct
36 * message, or into its channel when it was said in a named channel. attend
37 * refuses a parent whose channel this session left, whose sender session
38 * has exited, or that it cannot find. */
39export function replyArgv(message: string, re?: string): string[] {
40 return re ? [ATTEND, 'message', 'reply', '--re', re, '--', message] : [ATTEND, 'message', 'reply', '--', message]
41}
42
43export function joinArgv(channel: string): string[] {
44 return [ATTEND, 'channel', 'join', bareChannel(channel)]
45}
46
47function bareChannel(channel: string): string {
48 return channel.trim().replace(/^[#@]/, '')
49}
50
51/** `listen on` names the mod as the host, so attend's Stop-hook drain speaks
52 * the mod's format and tools in this session. */
53export const LISTEN_ON_ARGV = [ATTEND, 'listen', 'on', '--host', 'mod'] as const
54export const LISTEN_OFF_ARGV = [ATTEND, 'listen', 'off'] as const
55export const DRAIN_ARGV = [ATTEND, 'message', 'inbox', '--drain', '--json'] as const
56/** The idle poll's drain: it takes nothing unless something pending is
57 * addressed to this session, sent to its project, a reply to a message it
58 * sent, or a post in one of its named channels (ADR-406 item 2). */
59export const WAKE_DRAIN_ARGV = [ATTEND, 'message', 'inbox', '--drain', '--json', '--wake'] as const
60export const UNREAD_ARGV = [ATTEND, 'message', 'unread', '--json'] as const
61/** Touches the session's heartbeat while it listens, and records the mod's
62 * version for the session. The drain touches the heartbeat too, but runs
63 * only between turns and at the poll's interval, which can be longer than
64 * attend's 90 s liveness grace. */
65export const BEAT_ARGV = [ATTEND, 'listen', 'beat', '--mod', MOD_VERSION] as const
66/** The beat for an attend that predates `--mod` and exits 2 on it (a usage
67 * error): the heartbeat without the version. */
68export const PLAIN_BEAT_ARGV = [ATTEND, 'listen', 'beat'] as const
69export const PEERS_ARGV = [ATTEND, 'peer', 'list'] as const
70export const KEEPWARM_CHECK_ARGV = [ATTEND, 'keepwarm', 'check'] as const
71/** The operator prompted at `atS` (epoch seconds): the keepalive count
72 * starts again (ADR-414 item 4). */
73export function resetArgv(atS: number): string[] {
74 return [ATTEND, 'keepwarm', 'reset', '--at', String(Math.floor(atS))]
75}
76
77/** The keepalive policy's judgment, once, with the autocompact gate the mod
78 * read (ADR-414 item 7). */
79export function policyArgv(gate: Gate): string[] {
80 return [ATTEND, 'keepwarm', 'check', '--policy', '--json', '--autocompact', gate]
81}
82
83/** Where the wrap's reply went, for the status card. */
84export function wrappedArgv(outcome: WrapOutcome, detail: string): string[] {
85 return [ATTEND, 'keepwarm', 'wrapped', outcome, '--detail', oneLine(detail)]
86}
87
88/** The wrap's reply sent over attend: one message, its first line the
89 * handoff line attend wrote, to `#name` as a channel post and to anything
90 * else as a direct message (ADR-414 item 6). */
91export function wrapSendArgv(route: WrapRoute, reply: string): string[] {
92 const body = `${oneLine(route.handoff)}\n${reply}`
93 return route.to.startsWith('#') ? sendArgv(body, route.to) : sendArgv(body, undefined, route.to)
94}
95
96/** Whether `to` names `#open`, which reaches every listening session on the
97 * host. attend refuses it as a wrap's recipient; the mod refuses it too. */
98export function isOpenChannel(to: string): boolean {
99 return /^#\s*open\s*$/i.test(to)
100}
101
102/** The resolved `attend_mod` section, through the ways config layers: the
103 * user file, the target's file, then the project's `.claude/ways.yaml`. */
104export function configArgv(project: string): string[] {
105 return [WAYS, 'settings', 'list', 'attend_mod', '--json', '--effective', '--project', project]
106}
107
108// ── the module's state ─────────────────────────────────────────────────
109
110/** What the module keeps between dispatches. A reload starts it afresh, so
111 * the mode a reload reads is the one in $.state: session.start restores it
112 * from there, and a /clear or a /resume, which empty $.state, has it put
113 * back. Across processes the record in $.store answers instead. */
114export type Mod = {
115 config: ModConfig
116 /** Whether this session may poll: interactive, or allowed by config. */
117 canPoll: boolean
118 isInteractive: boolean
119 cwd: string
120 /** The main loop's turns running now. The poll holds while one runs: the
121 * Stop-hook drain owns delivery at a turn's boundary. */
122 running: Set<string>
123 isPolling: boolean
124 isTicking: boolean
125 ticker: Timer | undefined
126 lastPollMs: number
127 lastPeersMs: number
128 lastKeepwarmMs: number
129 lastBeatMs: number
130 /** attend rejected `--mod`, so beats go without it. */
131 isBeatPlain: boolean
132 /** The last delivery, send, reply, join or turn. */
133 lastActivityMs: number
134 /** Short `~id` handles of delivered messages, to their full ids. */
135 ids: Map<string, string>
136 /** Set on activation; the mode in $.state is the copy a reload reads. */
137 active: boolean
138 /** A drained batch no submit has taken yet. */
139 pending: Listed[]
140 /** `pending` was taken from a prompt the operator made while the cache
141 * had lapsed, and that prompt did not enter. The poll leaves it for the
142 * operator's next prompt instead of starting a turn on the cold cache. */
143 isPendingCold: boolean
144 /** Submits of `pending` that failed. */
145 submitTries: number
146 /** Channels joined through attend_join, shown until attend reports rooms. */
147 joined: Set<string>
148 /** session.start has run; before it, at process start, the engine has not
149 * bound the session and refuses a tool registration. */
150 isStarted: boolean
151 /** The source of the SessionStart that came before session.start at
152 * process start (`startup`, `resume`, `fork`); undefined when none came. */
153 startSource: string | undefined
154 /** A /clear or a /resume moved the session to an id attend has not been
155 * told is listening yet; the poll holds its drain until it has. */
156 isRelistening: boolean
157 /** The session started listening from its project's config and the
158 * operator has not prompted yet. The poll submits no turn: what is
159 * pending rides as context on the operator's first prompt, and the row
160 * counts it meanwhile. The first prompt ends the hold, and so does a
161 * resume to another conversation. */
162 isAwaitingOperator: boolean
163 /** The keepalive policy's state (ADR-414). */
164 ka: Keepalive
165}
166
167export type Keepalive = {
168 /** keepaliveApplies: an interactive session, keepwarm on, the policy
169 * enabled. Never a -p run or an SDK session. */
170 applies: boolean
171 gate: Gate
172 gateAtMs: number
173 /** The one timer: the next check. */
174 timer: Timer | undefined
175 /** The reset, the check and the wrap's report run one at a time, so a
176 * prompt's reset is never overwritten by a check that read the ledger
177 * before it. */
178 chain: Promise<unknown>
179 /** Moves at each operator prompt and main-loop turn start: a check whose
180 * epoch moved while attend judged submits nothing. */
181 epoch: number
182 /** The operator's last prompt, held while the gate last read on or
183 * unknown; sent to attend when the gate first reads off. */
184 heldPromptMs: number | undefined
185 /** Checks that could not judge since the last turn's end. */
186 retries: number
187 /** A wrap submitted, its text, the main-loop turn that text started, and
188 * the answer of that turn's first step that ended it (`end_turn`): the
189 * wrap itself, whatever a Stop-hook continuation or a notification adds
190 * to the turn after it. */
191 wrap: { route: WrapRoute; text: string; turnId: string | undefined; reply: string | undefined; sawTurn: boolean } | undefined
192}
193
194// ── tools ────────────────────────────────────────────────────────────────
195
196/** The one tool of a dormant session. */
197export const LISTEN_TOOL = {
198 name: 'attend_listen',
199 description:
200 'Turn on attend (peer messaging between Claude sessions and people in attend-chat) for the rest of this session. Call it only when the user asks to use attend. It delivers messages addressed to this session, shows one status row, and adds attend_send, attend_reply, attend_join and attend_peers. It stays on until Claude Code exits.',
201 inputSchema: { type: 'object', properties: {} },
202 isDeferred: false,
203 } as const
204
205/** The tools of an active session. */
206export const ACTIVE_TOOLS = [
207 {
208 name: 'attend_send',
209 description:
210 'Start a new topic over attend. Name someone in `to` to send them a direct message. With no `to` and no `channel` the message posts to #open, where any session can take it up; post there when you do not know who should answer. #open reaches every listening session, in other projects too, so keep chatter about this project to a named channel or a direct message. `channel` posts to a named channel, which wakes its idle members; with `to` as well, the post stays in the channel addressed to them. To answer a message that arrived, use attend_reply. The message is passed as is: no quoting needed, and a long one goes out as one message.',
211 inputSchema: {
212 type: 'object',
213 properties: {
214 message: { type: 'string', description: 'The message body.' },
215 channel: { type: 'string', description: 'A channel name, with or without #. open or #open posts to #open.' },
216 to: {
217 type: 'string',
218 description: "Who the direct message is for: a session's name (such as Chaucer-beta), a person's member id (such as aaron), or a project path for every session there.",
219 },
220 },
221 required: ['message'],
222 },
223 isDeferred: false,
224 },
225 {
226 name: 'attend_reply',
227 description:
228 "Reply to a message that arrived over attend. The reply is a direct message to its sender, an answer to an #open post included; a reply to a post in a named channel stays in that channel. It threads to the newest inbound message, or to the one given as `re` (the `~id` at the end of its line, or a full id). Silence is a valid reply.",
229 inputSchema: {
230 type: 'object',
231 properties: {
232 message: { type: 'string', description: 'The reply body.' },
233 re: { type: 'string', description: 'The message replied to: the `~id` at the end of a delivery line.' },
234 },
235 required: ['message'],
236 },
237 isDeferred: false,
238 },
239 {
240 name: 'attend_join',
241 description: 'Join an attend channel (created if absent), so its messages reach this session.',
242 inputSchema: {
243 type: 'object',
244 properties: { channel: { type: 'string', description: 'The channel name, with or without #.' } },
245 required: ['channel'],
246 },
247 isDeferred: false,
248 },
249 {
250 name: 'attend_peers',
251 description: "List the active Claude Code sessions on this machine and their channels, and who is in each of this session's channels.",
252 inputSchema: { type: 'object', properties: {} },
253 isDeferred: false,
254 },
255] as const
256
257/** A CLI call as a tool's result: its output, or why it failed. */
258export function toolResult(r: { exitCode: number; stdout: string; stderr: string }, what: string) {
259 const text = r.stdout.trim() || r.stderr.trim()
260 return r.exitCode === 0
261 ? { result: text || 'done' }
262 : { result: `${what} failed (${r.exitCode}): ${r.stderr.trim() || r.stdout.trim()}`, isError: true as const }
263}
264
265export const DORMANT_REFUSAL = {
266 result: 'attend is not listening in this session. Call attend_listen first, and only when the user asked to use attend.',
267 isError: true as const,
268}
269
270/** attend_listen's answer, from attend's answer to `listen on`. */
271export function listenResult(r: { exitCode: number; stderr: string }, was: boolean, canPoll: boolean) {
272 const tools = ' attend_send, attend_reply, attend_join and attend_peers are available.'
273 if (r.exitCode !== 0) {
274 return {
275 result: `attend could not set this session listening, so no message will be delivered to it; the tools can still send.${tools}\nattend listen on failed (${r.exitCode}): ${r.stderr.trim()}`,
276 isError: true as const,
277 }
278 }
279 const lead = was ? 'attend was already listening in this session.' : 'attend is listening for the rest of this session.'
280 const poll = canPoll
281 ? ' Messages addressed to this session and posts in its channels arrive as peer-data turns; other #open posts arrive with your next turn.'
282 : ' This session does not poll (a -p or SDK run); messages arrive at the end of a turn.'
283 return { result: `${lead}${poll}${tools}` }
284}
285
286/** Origins that are the operator's own prompt: the terminal, Remote
287 * Control, an SDK host's turn, and the owner's ping from Slack. An
288 * unclassified one is not counted as the operator's. */
289export const OPERATOR_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'slack-ping'])
290
291// ── configuration ────────────────────────────────────────────────────────
292
293export type ModConfig = {
294 /** How a session in this project starts. */
295 listening: 'active' | 'dormant'
296 /** Whether an active session runs attend's keepwarm check (ADR-182). */
297 keepwarm: 'on' | 'off'
298 /** false: the mod is unavailable, nothing is listed. */
299 install: boolean
300 /** The poll interval while awake, in seconds. */
301 pollSeconds: number
302 /** The poll interval once the session has been quiet, in seconds. */
303 slowPollSeconds: number
304 /** Quiet this long and the poll slows, in minutes. */
305 quietMinutes: number
306 /** The preamble shows again once this many turns have passed. */
307 preambleEveryTurns: number
308 /** Whether a `-p` run or an SDK session may poll. */
309 headlessPoll: boolean
310 /** `attend_mod.keepalive.enabled`: the keepalive policy may run (ADR-414). */
311 keepalive: boolean
312 /** `attend_mod.keepalive.margin_minutes`: a keepalive is due this long
313 * before the cache lapses. attend judges; the mod uses it only to set a
314 * dormant session's timer. */
315 marginMinutes: number
316}
317
318/** What the mod runs under when ways cannot answer. The keepalive policy
319 * fails closed: off, as `keepwarm` and `install` fall to their quiet side
320 * in the registry (ADR-414 item 8). */
321export const DEFAULT_CONFIG: ModConfig = {
322 listening: 'dormant',
323 keepwarm: 'on',
324 install: true,
325 pollSeconds: 5,
326 slowPollSeconds: 60,
327 quietMinutes: 20,
328 preambleEveryTurns: 20,
329 headlessPoll: false,
330 keepalive: false,
331 marginMinutes: 10,
332}
333
334/** Reads `ways settings list attend_mod --json --effective`. A key that is
335 * absent or of the wrong type keeps its default, so a ways CLI that is
336 * missing or older than the section leaves the mod dormant. */
337export function parseConfig(stdout: string): ModConfig {
338 let keys: Record<string, { value?: unknown }> = {}
339 try {
340 const doc = JSON.parse(stdout) as { keys?: Record<string, { value?: unknown }> }
341 keys = doc.keys ?? {}
342 } catch {
343 return { ...DEFAULT_CONFIG }
344 }
345 const v = (k: string) => keys[`attend_mod.${k}`]?.value
346 const num = (k: string, d: number) => {
347 const x = v(k)
348 return typeof x === 'number' && Number.isFinite(x) && x > 0 ? x : d
349 }
350 const bool = (k: string, d: boolean) => {
351 const x = v(k)
352 return typeof x === 'boolean' ? x : d
353 }
354 return {
355 listening: v('listening') === 'active' ? 'active' : 'dormant',
356 keepwarm: v('keepwarm') === 'off' ? 'off' : 'on',
357 install: bool('install', DEFAULT_CONFIG.install),
358 pollSeconds: num('poll_seconds', DEFAULT_CONFIG.pollSeconds),
359 slowPollSeconds: num('slow_poll_seconds', DEFAULT_CONFIG.slowPollSeconds),
360 quietMinutes: num('quiet_minutes', DEFAULT_CONFIG.quietMinutes),
361 preambleEveryTurns: num('preamble_every_turns', DEFAULT_CONFIG.preambleEveryTurns),
362 headlessPoll: bool('headless_poll', DEFAULT_CONFIG.headlessPoll),
363 // Only an explicit true: a ways that does not know the key runs none.
364 keepalive: v('keepalive.enabled') === true,
365 marginMinutes: num('keepalive.margin_minutes', DEFAULT_CONFIG.marginMinutes),
366 }
367}
368
369// ── timing ───────────────────────────────────────────────────────────────
370
371/** The interval the next poll waits: the slow one once the session has had
372 * no delivery, no send of its own and no turn for the quiet period. */
373export function pollInterval(c: ModConfig, idleMs: number, isTurnRunning: boolean): number {
374 const quiet = !isTurnRunning && idleMs >= c.quietMinutes * 60_000
375 return (quiet ? c.slowPollSeconds : c.pollSeconds) * 1000
376}
377
378/** The preamble goes with delivery 0, the first after a compaction, and
379 * the first once `every` turns have passed since it last showed. */
380export function isFullPreamble(deliveries: number, afterCompaction: boolean, turnsSinceFull: number, every: number): boolean {
381 return deliveries === 0 || afterCompaction || turnsSinceFull >= every
382}
383
384/** Whether the keepalive policy may run in a session started with
385 * `isInteractive` under `c` (ADR-414 item 1): never a -p run or an SDK
386 * session, whatever `headless_poll` says; autocompact is judged at each
387 * check. Where it applies, keepalive.tsx drives every cache wake. */
388export function keepaliveApplies(c: ModConfig, isInteractive: boolean): boolean {
389 return isInteractive && c.install && c.keepwarm === 'on' && c.keepalive
390}
391
392/** When the policy's timer asks attend first after a main-loop turn's end: the margin before the hour tier's cache lapses, or ADR-182's
393 * 50 minutes when that comes sooner, which an armed window wakes at. attend
394 * answers with the due point it judged. */
395export function firstCheckMs(c: ModConfig): number {
396 return Math.min(60 - c.marginMinutes, 50) * 60_000
397}
398
399// ── the keepalive policy (ADR-414) ───────────────────────────────────────
400
401/** The autocompact gate: `off` lets the policy run; `unknown` counts as on,
402 * the side that starts no turn the operator did not type. */
403export type Gate = 'on' | 'off' | 'unknown'
404
405/** The gate from `$.session.usage({ breakdown: 'summary' })`, else from
406 * `autoCompactEnabled` in `$.settings.read()`, where only an explicit false
407 * is off and an absent key is Claude Code's default, on. Either read is
408 * undefined when it failed. */
409export function gateFrom(usage: unknown, settings: unknown): Gate {
410 const b = (usage as { context?: { breakdown?: { isAutoCompactEnabled?: unknown } } } | undefined)?.context?.breakdown
411 if (typeof b?.isAutoCompactEnabled === 'boolean') return b.isAutoCompactEnabled ? 'on' : 'off'
412 if (typeof settings !== 'object' || settings === null) return 'unknown'
413 return (settings as { autoCompactEnabled?: unknown }).autoCompactEnabled === false ? 'off' : 'on'
414}
415
416/** Where the wrap's reply goes, as attend resolved it. */
417export type WrapRoute = {
418 dest: 'transcript' | 'file' | 'send' | 'off'
419 /** The expanded `wrap_file`: `file`'s destination and a refused send's fallback. */
420 file: string
421 /** `send`'s recipient; `#name` is a channel. */
422 to: string
423 after: 'lapse' | 'compact'
424 handoff: string
425}
426
427/** What `attend keepwarm check --policy --json` decided. */
428export type Decision = {
429 action: 'none' | 'keepalive' | 'wrap'
430 text: string | null
431 /** When to ask again, in epoch seconds. */
432 nextAt: number | null
433 count: number
434 max: number
435 wrapsAt: string | null
436 wrappedAt: string | null
437 /** Whether the policy runs; false is ADR-182's armed windows alone. */
438 policy: boolean
439 state: 'running' | 'wrapped' | 'stopped' | 'off'
440 reason: string
441 wrap: WrapRoute | null
442 /** attend could not judge (the transcript was unreadable): ask again. */
443 retry: boolean
444 /** Why a running policy has nothing due: `under 50k`, `5m tier`. */
445 idle: string | null
446}
447
448export type WrapOutcome = 'transcript' | 'file' | 'sent' | 'refused' | 'unfinished'
449
450/** The decision; undefined where the output is not one (an attend without
451 * `--policy`, a build without the sensor). */
452export function parseDecision(stdout: string): Decision | undefined {
453 let d: Record<string, unknown>
454 try {
455 const doc = JSON.parse(stdout) as unknown
456 if (typeof doc !== 'object' || doc === null) return undefined
457 d = doc as Record<string, unknown>
458 } catch {
459 return undefined
460 }
461 const action = d.action
462 if (action !== 'none' && action !== 'keepalive' && action !== 'wrap') return undefined
463 const str = (v: unknown) => (typeof v === 'string' ? v : null)
464 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
465 const states = ['running', 'wrapped', 'stopped', 'off'] as const
466 const state = states.find(x => x === d.state) ?? 'off'
467 const w = d.wrap as Record<string, unknown> | null | undefined
468 const dests = ['transcript', 'file', 'send', 'off'] as const
469 const dest = dests.find(x => x === w?.dest)
470 const wrap: WrapRoute | null =
471 w && dest && typeof w.file === 'string' && typeof w.to === 'string'
472 ? { dest, file: w.file, to: w.to, after: w.after === 'compact' ? 'compact' : 'lapse', handoff: str(w.handoff) ?? 'handoff:' }
473 : null
474 return {
475 action,
476 text: str(d.text),
477 nextAt: num(d.next_at),
478 count: num(d.count) ?? 0,
479 max: num(d.max) ?? 0,
480 wrapsAt: str(d.wraps_at_hm),
481 wrappedAt: str(d.wrapped_at_hm),
482 policy: d.policy === true,
483 state,
484 reason: str(d.reason) ?? '',
485 wrap,
486 retry: d.retry === true,
487 idle: str(d.idle),
488 }
489}
490
491/** The focus a compaction after the wrap keeps: the wrap's closing
492 * `/compact <focus>` line, else the continuation prompt and the task list. */
493export const DEFAULT_COMPACT_FOCUS = 'keep the continuation prompt and the task list'
494
495export function compactFocus(reply: string): string {
496 const lines = reply.split('\n').map(l => l.trim().replace(/^`+|`+$/g, ''))
497 for (let i = lines.length - 1; i >= 0; i--) {
498 const m = /^\/compact\s+(.+)$/.exec(lines[i] ?? '')
499 if (m?.[1]) return m[1].trim()
500 }
501 return DEFAULT_COMPACT_FOCUS
502}
503
504
505/** The row's segment after a decision; null where the policy does not run.
506 * A dormant session shows it from the first turn the policy submits. */
507export function keepaliveRowOf(d: Decision, was: KeepaliveRow | null): KeepaliveRow | null {
508 if (!d.policy || d.state === 'off') return null
509 return {
510 state: d.state,
511 count: d.count,
512 max: d.max,
513 nextAt: d.state === 'running' ? d.nextAt : null,
514 wrapsAt: d.wrapsAt,
515 wrappedAt: d.wrappedAt,
516 idle: d.state === 'running' ? d.idle : null,
517 isRefused: d.state === 'wrapped' && (was?.isRefused ?? false),
518 isShown: (was?.isShown ?? false) || d.action !== 'none',
519 }
520}
521
522/** `⟳ 2/8 · next 41m · wrap ~21:40`, `⟳ 0/1 · idle: under 50k`,
523 * `⟳ wrapped 21:40`, `⟳ stopped`. */
524export function keepaliveSegment(k: KeepaliveRow, nowMs: number): string {
525 if (k.state === 'wrapped') return `⟳ wrapped ${k.wrappedAt ?? ''}`.trimEnd() + (k.isRefused ? ' · handoff refused' : '')
526 if (k.state === 'stopped') return '⟳ stopped'
527 const parts = [`⟳ ${k.count}/${k.max}`]
528 if (k.nextAt !== null) parts.push(`next ${Math.max(0, Math.round((k.nextAt * 1000 - nowMs) / 60_000))}m`)
529 if (k.wrapsAt !== null) parts.push(`wrap ~${k.wrapsAt}`)
530 if (k.idle) parts.push(`idle: ${k.idle}`)
531 return parts.join(' · ')
532}
533
534/** Runs `f` after every earlier call on `h` settled, one at a time. */
535export function serialize<T>(h: { chain: Promise<unknown> }, f: () => Promise<T>): Promise<T> {
536 const next = h.chain.then(f, f)
537 h.chain = next.catch(() => undefined)
538 return next
539}
540
541/** A gate read as on or unknown is reused this long; one read as off is
542 * read again before every check and every submit, since a turn may follow. */
543export const GATE_KEPT_MS = 5 * 60_000
544
545export function gateIsFresh(gate: Gate, gateAtMs: number, nowMs: number): boolean {
546 return gate !== 'off' && nowMs - gateAtMs < GATE_KEPT_MS
547}
548
549/** What one decision asks of the mod: a turn to submit, and when to ask
550 * again. A dormant session submits only the policy's own turns, never an
551 * armed window's wake, which stays a listening session's (ADR-406 item
552 * 13). A retry is the caller's to bound. */
553export type CheckPlan = {
554 submit: { kind: 'keepalive' | 'wrap'; text: string; route: WrapRoute | null } | null
555 armAtMs: number | null
556 retry: boolean
557}
558
559export function planCheck(d: Decision, isListening: boolean): CheckPlan {
560 let submit: CheckPlan['submit'] = null
561 if (d.action === 'keepalive' && d.text && (isListening || d.policy)) submit = { kind: 'keepalive', text: d.text, route: null }
562 if (d.action === 'wrap' && d.text && d.wrap && d.policy) submit = { kind: 'wrap', text: d.text, route: d.wrap }
563 const armAtMs = !d.retry && d.nextAt !== null && (d.policy || isListening) ? d.nextAt * 1000 : null
564 return { submit, armAtMs, retry: d.retry }
565}
566
567/** Whether a planned turn may still go in: no prompt or main-loop turn came
568 * since the check began (the epoch held), none runs now, and for the
569 * policy's own turn the gate, read again, is still off (ADR-414 item 2). */
570export function maySubmit(o: { epochThen: number; epochNow: number; running: number; isPolicy: boolean; gate: Gate }): boolean {
571 return o.epochThen === o.epochNow && o.running === 0 && (!o.isPolicy || o.gate === 'off')
572}
573
574/** A dormant session's retries after a check that could not judge, at most
575 * this many per stretch, this far apart; a listening session asks again on
576 * the plain cadence. */
577export const RETRY_MAX = 3
578export const RETRY_DORMANT_MS = 5 * 60_000
579export const RETRY_LISTENING_MS = 60_000
580
581export function retryAtMs(nowMs: number, isListening: boolean, gate: Gate, retries: number): number | null {
582 if (isListening) return nowMs + RETRY_LISTENING_MS
583 return gate === 'off' && retries < RETRY_MAX ? nowMs + RETRY_DORMANT_MS : null
584}
585
586/** The header Claude Code puts above a prompt a plugin submits without
587 * `asUser`, as `turn.start`'s text carries it (Claude Code 2.1.300):
588 * `The <plugin> plugin sent a message:`, then the prompt, then a closing
589 * paragraph. Only this mod's own name, the manifest's `attend`. */
590const PLUGIN_FRAME = 'The attend plugin sent a message:'
591
592/** Whether a main-loop turn that started with `turnText` is the turn a
593 * prompt the mod submitted as `submitted` started: the submitted first
594 * line as a whole line, either first (the bare prompt) or right under the
595 * engine's plugin header (the framed form). Never a line inside someone
596 * else's prose. */
597export function isSubmittedTurn(turnText: string, submitted: string): boolean {
598 const lines = turnText.split('\n').map(l => l.trim())
599 const want = submitted.split('\n')[0]?.trim() ?? ''
600 if (want === '') return false
601 if (lines[0] === want) return true
602 return lines[0] === PLUGIN_FRAME && lines[1] === want
603}
604
605/** Whether a main-loop turn is the wrap the mod submitted ([`isSubmittedTurn`]
606 * for a `wrap:` prompt). */
607export function isWrapTurn(turnText: string, submitted: string): boolean {
608 return submitted.startsWith('wrap:') && isSubmittedTurn(turnText, submitted)
609}
610
611/** The engine's framing of a prompt plugin `name` submits without
612 * `asUser`, as `turn.start` carries it; for tests and docs. */
613export function framedPluginPrompt(name: string, text: string): string {
614 return `The ${name} plugin sent a message:\n${text}\n\nThis is how Claude Code surfaces a prompt a plugin submits between turns — it starts this turn in the user's place. Address the message above.`
615}
616
617/** What the wrap's reply becomes once its turn ends (ADR-414 item 6). */
618export type WrapPlan =
619 | { kind: 'unfinished'; detail: string }
620 | { kind: 'transcript' }
621 | { kind: 'file'; argv: string[] }
622 | { kind: 'send'; argv: string[] }
623
624export function wrapPlan(route: WrapRoute, reason: string, reply: string): WrapPlan {
625 if (reason !== 'answer') return { kind: 'unfinished', detail: `the turn ended: ${reason}` }
626 if (reply.trim() === '') return { kind: 'unfinished', detail: 'the reply was empty' }
627 if (route.dest === 'file' && route.file === '') return { kind: 'unfinished', detail: 'no safe wrap file: see attend keepwarm status' }
628 if (route.dest === 'file') return { kind: 'file', argv: saveArgv('file', '', route.file) }
629 if (route.dest === 'send' && !isOpenChannel(route.to)) return { kind: 'send', argv: wrapSendArgv(route, reply) }
630 if (route.dest === 'send') return { kind: 'unfinished', detail: `${route.to} is refused: it reaches every listening session` }
631 return { kind: 'transcript' }
632}
633
634/** attend writes the wrap's reply, given on stdin, to `path` and records
635 * the outcome: it checks the path again and renames a temp file over it,
636 * so a link planted during the wrap turn is never followed (ADR-414 item
637 * 6). The mod never writes the file itself. */
638export function saveArgv(outcome: 'file' | 'refused', detail: string, path: string): string[] {
639 return [ATTEND, 'keepwarm', 'wrapped', outcome, '--detail', oneLine(detail), '--path', path, '--save']
640}
641
642/** The card's detail for a send attend refused or that timed out; attend
643 * adds where the reply went instead. */
644export function refusalDetail(to: string, r: { exitCode: number; stderr: string; stdout: string }): string {
645 const why = (r.stderr.trim() || r.stdout.trim() || `attend exited ${r.exitCode}`).split('\n')[0] ?? ''
646 return `${to}: ${why}`
647}
648
649/** The row's segment after the operator's prompt: the count starts again,
650 * and a dormant session stops drawing it. */
651export function rowAfterPrompt(k: KeepaliveRow | null): KeepaliveRow | null {
652 return k && { ...k, state: 'running', count: 0, wrappedAt: null, isRefused: false, isShown: false }
653}
654
655/** After a main-loop turn's end: the next check's time, while it runs. */
656export function rowAfterTurnEnd(k: KeepaliveRow | null, atMs: number): KeepaliveRow | null {
657 return k?.state === 'running' ? { ...k, nextAt: Math.round(atMs / 1000), idle: null } : k
658}
659
660export function rowRefused(k: KeepaliveRow | null): KeepaliveRow | null {
661 return k && { ...k, isRefused: true }
662}
663
664/** The row's text: an active session's peers, mail, channels and keepalive
665 * segment; a dormant session's keepalive segment alone, from its first
666 * keepalive until the operator's next prompt; otherwise none. */
667export function rowText(s: AttendStatus, nowMs: number): string | undefined {
668 const keepalive = s.keepalive ? keepaliveSegment(s.keepalive, nowMs) : undefined
669 if (s.mode !== 'active') return s.keepalive?.isShown ? keepalive : undefined
670 // A message held on a lapsed cache waits for the operator's prompt.
671 const mail = s.cacheLapsed && s.unread > 0 ? `${s.unread} waiting · cold` : `${s.unread} unread`
672 const state = s.listening === false ? '○ attend not listening' : `● ${s.peers} peers ✉ ${mail}`
673 const channels = s.channels.length > 0 ? ` ${s.channels.join(' ')}` : ''
674 return `${state}${channels}${keepalive ? ` ${keepalive}` : ''}`
675}
676
677// ── parsing ──────────────────────────────────────────────────────────────
678
679/** One drained message, as `attend message inbox --drain --json` gives it. */
680export type Listed = {
681 /** The drain's scope label: `#open`, `project`, or `@name` for a channel. */
682 scope: string
683 sender: string
684 /** attend's wire identity, `claude:<session>` or `external:<user>[@terminal]`;
685 * empty where absent. Self-reported by the writer, so never proof of who sent it. */
686 senderId: string
687 id: string
688 /** The thread parent's id, where the message is a reply; empty otherwise. */
689 re: string
690 /** When it was sent, as attend shows it; empty where absent. */
691 when: string
692 message: string
693}
694
695/** What one drain said. `messages` is empty when nothing was delivered: the
696 * cold start's held-back note and the session's name are fields of their
697 * own, never messages. `status` is `drained`, or why nothing was:
698 * `not_listening` (listening was turned off, here or elsewhere) or
699 * `unresolved` (attend cannot tell which session this is). `cacheLapsed`:
700 * a wake drain held a message that would wake the session because its
701 * prompt cache has lapsed (ADR-406 item 2). Undefined where the output is not a
702 * schema-1 document (an attend without `--drain --json`, or one whose
703 * document changed shape). */
704export type Drain = {
705 status: string
706 messages: Listed[]
707 heldBack: number
708 note: string | null
709 youAre: string | null
710 cacheLapsed: boolean
711}
712
713/** The `--drain --json` schema this mod reads. */
714export const DRAIN_SCHEMA = 1
715
716export function parseDrain(stdout: string): Drain | undefined {
717 let doc: unknown
718 try {
719 doc = JSON.parse(stdout)
720 } catch {
721 return undefined
722 }
723 if (typeof doc !== 'object' || doc === null) return undefined
724 const d = doc as Record<string, unknown>
725 if (d.schema !== DRAIN_SCHEMA || typeof d.status !== 'string' || !Array.isArray(d.messages)) return undefined
726 const str = (v: unknown) => (typeof v === 'string' ? v : '')
727 const messages = d.messages.flatMap((m): Listed[] => {
728 if (typeof m !== 'object' || m === null) return []
729 const x = m as Record<string, unknown>
730 if (typeof x.id !== 'string' || typeof x.message !== 'string') return []
731 return [{ scope: str(x.scope), sender: str(x.sender), senderId: str(x.sender_id), id: x.id, re: str(x.re), when: str(x.when), message: x.message }]
732 })
733 return {
734 status: d.status,
735 messages,
736 heldBack: typeof d.held_back === 'number' ? d.held_back : 0,
737 note: typeof d.note === 'string' ? d.note : null,
738 youAre: typeof d.you_are === 'string' ? d.you_are : null,
739 cacheLapsed: d.cache_lapsed === true,
740 }
741}
742
743/** The peer count from `attend peer list`: its footer `N agent(s)` less this
744 * session's own row. */
745export function parsePeerCount(stdout: string): number {
746 const m = /(\d+) agent\(s\)/.exec(stdout)
747 return m ? Math.max(0, Number(m[1]) - 1) : 0
748}
749
750/** `attend message unread --json`: the count and the channels this session receives.
751 * Undefined where the output is not that shape (an attend without the verb). */
752export function parseUnread(stdout: string): { unread: number; channels: string[] } | undefined {
753 try {
754 const doc = JSON.parse(stdout) as { unread?: unknown; rooms?: { label?: unknown; kind?: unknown }[] }
755 if (typeof doc.unread !== 'number') return undefined
756 const channels = (doc.rooms ?? [])
757 .filter(r => r.kind === 'channel' && typeof r.label === 'string')
758 .map(r => displayScope(String(r.label)))
759 return { unread: doc.unread, channels }
760 } catch {
761 return undefined
762 }
763}
764
765/** The `keepwarm:` wake line `attend keepwarm check` printed, if any. */
766export function keepwarmLine(stdout: string): string | undefined {
767 const line = stdout.split('\n').map(l => l.trim()).find(l => l.startsWith('keepwarm:'))
768 return line && line.length > 'keepwarm:'.length ? line : undefined
769}
770
771// ── the view ─────────────────────────────────────────────────────────────
772
773/** Short stable handle for a message id; `attend_reply`'s `re` takes it.
774 * attend computes the same handle (`short_id` in tools/attend/src/verbs/
775 * inbox.rs) for the lines its Stop-hook drain prints in a mod host, and
776 * `attend message reply --re` resolves one this module never mapped. */
777export function shortId(id: string): string {
778 let h = 0x811c9dc5
779 for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 0x01000193) >>> 0
780 return h.toString(36).padStart(5, '0').slice(-5)
781}
782
783/** A channel's drain label `@name` reads `#name` in the view. */
784export function displayScope(scope: string): string {
785 return scope.startsWith('@') ? `#${scope.slice(1)}` : scope
786}
787
788/** A field as one line: every run of whitespace, control or format
789 * characters (newlines, CR, NUL, U+2028, zero-width) becomes one space. Each
790 * field a store writer controls passes through it before it reaches the
791 * prompt, so no field can start a line of its own. */
792export function oneLine(s: string): string {
793 return s.replace(/[\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim()
794}
795
796/** `[#channel] Name (project) HH:MM: text ~id`, one line, ` (re ~parent)`
797 * added where attend says the message is a reply. Id, sender id and cwd are
798 * dropped, and the line makes no claim about where the message came from
799 * (ADR-406 item 4); `~id` is a short handle for `attend_reply`'s `re`. */
800export function formatMessage(m: Listed): string {
801 const when = m.when ? ` ${oneLine(m.when)}` : ''
802 const re = m.re ? ` (re ~${shortId(m.re)})` : ''
803 return oneLine(`[${oneLine(displayScope(m.scope))}] ${safeSender(m.sender)}${when}: ${oneLine(m.message)} ~${shortId(m.id)}${re}`)
804}
805
806/** A sender label in a safe charset: made one line, then every character
807 * but a letter, a digit, space and `_ . - @ ( )` becomes `-`, so a sender
808 * cannot write the line's `:` or `~` separators. `safe_sender` in
809 * tools/attend/src/cmd/inbox.rs applies the same rule to the Stop-hook
810 * drain's lines. */
811export function safeSender(s: string): string {
812 return oneLine(s).replace(/[^\p{Alphabetic}\p{N} _.\-@()]/gu, '-')
813}
814
815/** The frame the mod writes around every delivery, in place of the one Claude
816 * Code drops for a prompt submitted `asUser` (ADR-406 item 4). The silence
817 * line opens it, so the model weighs whether to answer before it reads the
818 * messages. The header names the transport and marks the block as data; it
819 * says nothing of who typed a message or where it came from. The preamble,
820 * on a cadence ({@link isFullPreamble}), adds one line of tool usage after
821 * the closing tag. */
822export const HEADER = 'The block below is message data relayed by attend.'
823export const SILENCE_FIRST = 'Silence is a valid reply to the messages below; answer only if useful.'
824export const PREAMBLE = 'attend_reply threads (re: ~id); attend_send starts a topic.'
825
826const OPEN = '<attend-peer-messages>'
827const CLOSE = '</attend-peer-messages>'
828
829/** A line with every `<` that could forge the frame made `‹`. A `<` is kept
830 * only when the next non-space character is ASCII `[A-Za-z0-9<=(-]` and the
831 * word there is not `attend` in any case; every other `<` is defused. The
832 * allowlist keeps code (`<cwd>`, `x < y`, `Vec<u8>`, `a <= b`) and defuses
833 * `</`, `<attend`, and anything a renderer could hide or draw as `/`
834 * between them (review of #949), at the cost of `</div>`, `<attendee>` and
835 * a trailing `<`. One pass, so each `<` is judged on the original text. The
836 * Stop-hook drain's `defuse_fence` in
837 * tools/attend/src/cmd/inbox.rs applies the same rule. */
838export function defuseFence(s: string): string {
839 return s.replace(/<(?! *(?!attend)[A-Za-z0-9<=(-])/gi, '‹')
840}
841
842/** The prompt one batch becomes: the silence line, the header, the lines
843 * inside the tags, and the preamble's tool line after them when `full`.
844 * Each line is made one line again and defused ({@link defuseFence}), so
845 * no line can open or end the block, whatever the tag's spacing or case. */
846export function frameInbound(lines: readonly string[], full: boolean): string {
847 const inner = lines.map(b => defuseFence(oneLine(b))).join('\n')
848 return [SILENCE_FIRST, HEADER, OPEN, inner, CLOSE, ...(full ? [PREAMBLE] : [])].join('\n')
849}
850
851/** The message lines inside the tags of a framed delivery; undefined where
852 * the text holds no frame. The human's view of a delivery. */
853export function peerLines(text: string): string[] | undefined {
854 const a = text.indexOf(OPEN)
855 const b = text.lastIndexOf(CLOSE)
856 if (a < 0 || b < a) return undefined
857 return text
858 .slice(a + OPEN.length, b)
859 .split('\n')
860 .map(l => l.trim())
861 .filter(l => l !== '')
862}
863