SLOPSHOPPER

attend

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…

newbandrowsguardtoastprompt
★ 26v0.28.0AGPL-3.0updated 2026-10-16aaronsb/agent-ways/plugins/attend
A shopper browsing a rack in a slop shop
README

<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" />

Agent Ways

GitHub stars GitHub forks GitHub issues License Last commit Manual

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.

Session replay with ways session replay

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

<img src="docs/images/ways-introspect.gif" alt="ways session replay — each frame shows a way firing, guidance clusters near the attention cursor and packs like a compression pattern as context fills" width="800" />

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

  • Excel/Office productivity
  • AWS operations
  • Financial analysis
  • Research workflows
  • Anything with patterns your agent should know about

What's in the box

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.

ComponentWhat it doesDocs
waysThe 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-embedThe 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-agentA 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-mcpThe 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
attendThe awareness layer: sensors for git state, peer sessions and process activity, surfaced into a running session as notifications.Attend and Monitor
attend-chatA terminal chat that puts you on the same signal bus the agents use.attend chat
ways-auditReports 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" />

Prerequisites

Runs on Linux and macOS. The installer needs git, jq and make, and stops if any is missing.

ToolPurposeNotes
Claude CodeThe agent this configures`curl -fsSL https://claude.ai/install.sh \bash`
gitStages the app source, update checksUsually pre-installed
jqJSON in hook scripts and macrosMust install
makeDrives the build and the binary downloadsUsually pre-installed with a build toolchain
python3The adr and doc tools, chart-tool, the bash-bound guardStdlib only, no pip packages
gh, logged inDownloads the prebuilt binaries; issue sync, repo macrosRun gh auth login. Without it every binary builds from source
cargoSource build of a suite binaryWhen gh is missing or logged out, or no prebuilt fits your platform
cmake, C++ compilerSource build of way-embedSame 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: timeout is a GNU coreutils command not present by default. Install coreutils via Homebrew — see the macOS guide for PATH setup.

Quick Start

curl -sL https://raw.githubusercontent.com/aaronsb/agent-ways/main/scripts/install.sh | bash -s -- --bootstrap

The installer:

  • stages the app into $XDG_DATA_HOME/agent-ways and builds the binaries, fetching the embedding model (~21MB);
  • links the suite binaries into ~/.local/bin;
  • runs 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;
  • checks for a relevance-judge key and, on a terminal, offers to add one.

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.

How It Works

core.md loads at session start with behavioral guidance, operational rules, and a dynamic ways index. Then, as you work:

  1. UserPromptSubmit matches your message against every way, by keyword and by embedding.
  2. The relevance judge reviews those matches when you have stored a provider key. In enforce mode a way it judges irrelevant is held back; in shadow mode it only logs its verdicts; off skips it.
  3. The ways that pass are injected into the conversation.
  4. PreToolUse matches commands and file edits before they execute and injects the ways they trigger.
  5. SubagentStart injects the ways matched on the Agent tool's prompt into the subagent it spawns. From then on the subagent keeps its own firing state, with its token position read from its own transcript, so a way shown to a subagent does not silence it for the main agent.
  6. PostToolUse runs a way's postchecks after an edit or command, and matches any message you queued while Claude was working; those matches go through the judge like a prompt's. Stop records Claude's last reply so the next prompt is matched against it too.

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.

Configuration

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.

Creating Ways

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

RootPathHolds
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
Corethe 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

Testing Ways

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.

What's Included

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:

  • Agent teams — three-scope model (agent/teammate/subagent) with scope-gated governance and team telemetry. When one agent becomes a team, every teammate gets the same handbook.
  • 7 specialized subagents for requirements, architecture, planning, review, workflow, organization, and skeptical review of finished claims
  • Usage stats — way firing telemetry by scope, team, project, and trigger type
  • Update check — nudges a direct install when its app source is behind aaronsb/agent-ways

Why Ways? (Rules, Skills, and Ways)

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

The progressive disclosure problem

Rules and ways both inject guidance conditionally — but their disclosure models are fundamentally different:

  • Rules disclose based on file paths (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.
  • Ways disclose based on actions and intent — what you're doing (ru
Source 3 files
hooks/register.tsx 954 lines
1// 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}
954
types/index.d.ts 56 lines
1/** 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}
56
hooks/lib.ts 863 lines
1// 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