SLOPSHOPPER

Hermes Helmet

First-officer skills for Hermes Helmet: setup, single-issue delivery, dependent-issue coordination, and multi-part writing (Helmet Write), plus the Captain's…

panecommandpromptmodelprocesstimer
★ 2v1.0.1Apache-2.0updated 2026-10-25MachineWisdomAI/hermes-helmet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hermes-helmet
│ ┃ Captain's Bridge ✕ › fix the failing auth test and add an audit log call │ ┃ Captain's Bridge │ ┃ Captain's Bridge needs Claude Code 2.1.293 ⏺ Read(src/auth.ts) │ ┃ or newer; this is 2.1.289. Update Claude ⎿ Read 6 lines │ ┃ Code, then run /captains-bridge again. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /captains-bridge │ ⎿ hermes-helmet: Captain's Bridge needs Claude Code 2.1.293 or new │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Captain's Bridge
Captain's Bridge Captain's Bridge needs Claude Code 2.1.293 or newer; this is 2.1.289. Update Claude Code, then run /captains-bridge again.
README

Hermes Helmet

<img src="docs/assets/hermes-helmet-winged-mark-v1.png" width="320" alt="Hermes Helmet: a futuristic winged helmet with a glowing cyan visor and tether.">

Give Hermes the wings to carry your plan through, under its own identity

<a href="https://machine-wisdom.ai/"> <img src="docs/assets/machine-wisdom-ai/MachineWisdomAILogo.png" width="40" height="40" alt=""><br> <strong>By Machine Wisdom AI</strong> </a>

Hermes Helmet is an open-source software factory from Machine Wisdom AI for builders who already work with coding agents and have a project they cannot avoid finishing. It carries an accepted GitHub plan through implementation, review, repair, and authorized merge, with controlled autonomy and Hermes working under its own identity. You keep the plan that holds your intent, one accountable first officer you can question about what was built, and a record on GitHub that you can inspect and correct.

Named after the winged helmet of the Greek god Hermes, it adds a process for long-running autonomy, with connected tasks, review, and guardrails.

Put together a written plan with your coding agent, record the work as linked GitHub issues, and delegate authority to carry that plan through. Hermes implements under its own identity. Your coding agent coordinates the work, reviews the result, directs repairs, and handles authorized merges. You can follow progress through the Hermes Kanban board and GitHub without relaying every handoff yourself.

Why adopt Hermes Helmet

Read Hermes Helmet: giving your software factory wings for the story behind the project and examples of delivered work.

You set the direction; your first officer sees it through

You are the Captain. You agree on the goal, the plan, and the authority to act. Your coding agent is the first officer—Codex, Claude Code, or another Hermes instance. It runs under your OS account and uses your credentials to coordinate and accept work on your behalf. Hermes is the crew, running in Docker with its own GitHub account, credentials, and checkout to implement, test, and repair changes.

flowchart TB
    subgraph captain_side["Captain identity · your workstation"]
        captain["You · Captain"]
        officer["First officer<br/>Your OS account + GitHub credentials"]
        captain -->|"Written plan + delegated authority"| officer
    end

    github["GitHub<br/>Issues · pull requests · reviews · checks"]

    subgraph compose["Docker Compose · deploy/compose.yaml"]
        subgraph container["Hermes container · worker identity"]
            poller["Issue and review poller"] --> kanban["Hermes Kanban"]
            kanban --> worker["Hermes worker<br/>Own GitHub account + token"]
        end
        state[("Persistent worker volume · /opt/data<br/>Checkouts · credentials · task history")]
        worker --- state
    end

    officer <-->|"Assign · review · authorized merge"| github
    github -->|"Issues + review feedback"| poller
    worker -->|"Commits · pull requests · repairs"| github

    style captain_side fill:#F2EAF8,stroke:#6B3FA0,color:#241036
    style compose fill:none,stroke:#6A6A6A,color:#222222
    style container fill:#E5F5F1,stroke:#1A7A6D,color:#06332E
    style captain fill:#F8E6C4,stroke:#A56A12,color:#2C1A00
    style officer fill:#E6D4F5,stroke:#6B3FA0,color:#241036
    style worker fill:#C9EDE8,stroke:#1A7A6D,color:#06332E
    style github fill:#E8E8E8,stroke:#6A6A6A,color:#222222

The skills bundled with Hermes Helmet give the first officer its operating instructions. The helmet CLI supplies the checks, state transitions, and status commands those skills use. The first officer performs the review; Hermes does the implementation.

SkillWhat your first officer uses it for
setup-helmetConfigure identities, repositories, model access, and the worker runtime.
helmet-issueCarry one issue through implementation, review, repairs, and acceptance.
helmet-epicCoordinate dependent issues and run independent work in parallel.
helmet-reviewRequest a worker review of a pull request and read its formal verdict.
helmet-writeCoordinate multi-part writing: section writers, exact-revision review, and a separate editor.

Install those skills with helmet install-skills, or as a Claude Code / Codex plugin; see First-officer plugin. Choosing Claude as first officer does not change the worker model.

In configuration and command references, Captain-side means the side where your first officer operates with your delegated authority. The two GitHub identities are captain_github_login and worker_github_login. See the operating model for their responsibilities.

Bring your own planning system

Keep priorities, roadmap, ownership, and your mobile project workflow in Linear, Jira, GitHub Projects, or whichever tracker you prefer. Shape the next deliverable with your coding agent using the planning approach and skills you choose. Planning skills are intentionally outside the Hermes Helmet release.

Hermes Helmet begins at delegation. GitHub issues are its execution contract because they sit beside the repositories, branches, pull requests, checks, and reviews. The accepted work can be one bounded issue or a parent with bounded sub-issues and explicit dependencies. Once you accept the plan and grant authority, the same coding agent becomes your first officer and uses Hermes Helmet to carry that work through implementation, review, repair, and authorized merge.

See From project planning to delegated delivery for the handoff.

Start with one issue

New to Hermes Helmet? Read Becoming Captain first for who does what and how your first issue moves from delegation to acceptance, then follow the quickstart, or point your coding agent at the setup-helmet skill. You need Docker Compose, Python 3.11+, a separate worker GitHub account and token, and access to your chosen model provider.

The host CLI and the worker image are separate installations. Installing the CLI does not pull GHCR or start the worker.

Install the CLI from the current public source:

git clone https://github.com/MachineWisdomAI/hermes-helmet.git
cd hermes-helmet
python3 -m venv .venv
. .venv/bin/activate
python -m pip install .
helmet --help

Start the worker from the Hermes Helmet 1.0.0 runtime image, pinned by digest ghcr.io/machinewisdomai/hermes-helmet/runtime@sha256:04f772b48151813aec165ec81ee7323e316cababa686963fbc4064a40b283c2b (source 24876c7583f5dd3219e94e27b72a8952ba9752aa). The quickstart pulls that image and runs Compose with --no-build, then completes provider login and enables issue intake. Publishing a release does not install or restart a worker; confirm /opt/hermes-helmet/SOURCE_COMMIT in your own running container. Building from deploy/Dockerfile remains a development option. See the runtime image notes. Keep company-specific mounts in a private overlay.

Install the bundled skills into your coding-agent host, then give your first officer one GitHub issue to carry through the workflow. For example, after setup:

Use helmet-issue to implement https://github.com/example-org/demo-repo/issues/123. Follow the issue through review, necessary repairs, and acceptance under the agreed merge authority.

Hermes Helmet 1.0.0 is a stable GitHub release, not a PyPI publication. Its assets are the wheel, source distribution, Claude/Codex plugin archive, release-identity.json, runtime-image-identity.json and SHA256SUMS. Current source installs the helmet command used here and the compatible alias hermes-helmet. The September 29, 2026 public preview is historical; its CLI command is hermes-helmet, and its details are in the preview notes.

How the work moves

Hermes Helmet connects three existing surfaces: your coding-agent host, Hermes Agent and its Kanban executor, and GitHub. The worker runtime runs an issue poller on the configured schedule. That poller turns eligible issues and trusted review activity into Kanban assignments.

From issue to pull request

The first officer uses helmet-issue to check the configured identity and repository, adopt any existing task or PR, and dispatch eligible work. The poller selects open issues with the configured dispatch_label in allowlisted repositories. It records one root Kanban task per issue URL in a SQLite ledger, so later passes can find the existing assignment.

Hermes works in a Git worktree, implements the issue, runs tests, and opens a pull request under worker_github_login. It records the PR URL in its run metadata as published_pr. That links the issue, assignment, branch, and PR for the next stage.

Review and repair on the same change

The first officer reviews the current PR commit from a separate checkout, checks the result against the agreed outcome, and posts findings to GitHub. The poller accepts review activity from configured repository roles and explicitly trusted bots. It ignores approvals and the worker's own activity.

When trusted feedback arrives, the poller creates a dependent repair task that reuses the worker's branch and worktree. Hermes fetches the current GitHub review, comments, checks, and mergeability, makes the correction, and pushes to the same PR. Review text stays on GitHub; the assignment points the worker back to that record. While a repair is outstanding, the poller waits before creating another one.

The first officer then reviews the repaired commit. A failed CI check is input to that review; the poller starts repairs from trusted review activity. The accepted issue defines completion, so consequential defects are repaired without turning optional improvements into new release requirements.

Acceptance and authorized merge

The first officer's review is recorded against a specific commit SHA. Before an unattended merge, the merge gate checks that the live PR still has that reviewed head, an effective approval, passing required checks, and a mergeable state. The merge request includes the head SHA, and Hermes Helmet reads back GitHub's merge state afterward.

By default, the first officer merges after it has reviewed the exact current head, required checks pass, and GitHub reports the pull request mergeable. The installed command is helmet merge ISSUE_URL --expected-head SHA. An explicit whole-line Merge when clean: no directive on an issue or parent epic requires a separate Captain approval instead. A child can explicitly re-enable autonomous merge with Merge when clean: yes. Implementation and repair stay with the worker; merging stays on the Captain side. An installation deliberately configured for explicit Captain approval remains a hard ceiling. Interactive runs ask once before dispatch and persist that choice across waits and restarts. Scheduled runs honor a valid persisted choice and otherwise use the policy default without asking.

Resume from the existing record

The Docker volume at /opt/data persists worker configuration, credentials, checkouts, scheduler state, and task history. The poller's SQLite ledger keeps the issue-to-task links and review progress. On your workstation, checkpoints under ~/.hermes-helmet/checkpoints/ retain the task, PR, reviewed commit, repair count, and merge mode.

When the first officer resumes, it reconciles those records with current GitHub and worker state. helmet wait provides a bounded wait for changes; helmet status reports the linked task, PR, review state, and next action. This lets the first officer continue the existing work across sessions.

The control-loop guide and single-issue reference describe the interfaces and recovery behavior.

Carry a plan across dependent issues

For a larger body of work, helmet-epic reads a parent issue and its children, validates their dependencies, and dispatches the children whose prerequisites are satisfied. It uses GitHub sub-issues and dependency relationships where available, with explicit Parent and Blocked by links as the fallback.

Each child follows the same issue, review, repair, and merge workflow. max_epic_parallelism bounds concurrent work, while runtime and repair budgets bound each issue and the epic as a whole (defaults: 6-day Captain windows, 24-hour worker attempts). Independent children can continue when another is blocked. A changed dependency graph is presented for acceptance before new dispatch, and the parent remains open for your closeout. See the epic guide.

Give the worker its own identity and chosen access

One authority policy names the company, both GitHub identities, repositories, labels, trusted reviewers, model, merge rules, and budgets. Setup, preflight, and task instructions use that same document. The ExampleCo policy uses example-captain and example-agent; replace them with your two accounts.

The worker's GitHub personal access token determines what it can access and change. The repository allowlist determines where Hermes Helmet dispatches work. Configure each for its purpose; a dispatch list does not change the permissions of a token. Preflight checks that the live worker login matches worker_github_login and differs from captain_github_login.

Credentials live in owner-only runtime files, outside the policy and source. The Docker entrypoint prepares the worker token file and removes the token variables before starting the supervised services; the GitHub CLI wrapper loads the token for GitHub commands. The first officer retains your credentials on the Captain side. Company-specific configuration belongs in your private overlay.

Keep the workflow as models change

Choose the coding agent that acts as first officer separately from the model behind the Hermes worker. The policy's inference_provider and inference_model select the worker's model. Changing that selection preserves the worker's task records and GitHub identity, and the first officer keeps its review responsibilities. See provider and model configuration for hosted providers and compatible local endpoints, or Claude subscription workers to run the worker on a Claude Pro or Max subscription.

GitHub preserves the feedback and acceptance history for each change. Optional memory integrations make context and accepted lessons available across clients:

IntegrationWhat it adds
OpenVikingWorking context across sessions and clients.
FAVA TrailsGoverned decisions and observations, with approval, provenance, and supersession.
Company skill packsYour own instructions imported into Hermes-owned state.

Promotion from working context into FAVA Trails is explicit. These integrations can be added to the core issue-to-merge workflow when you need them.

Develop and contribute

Follow CONTRIBUTING.md for code and documentation changes and the security policy for vulnerability reports. Run the repository checks before submitting a change:

scripts/verify.sh

Build and smoke-test a local development image with scripts/build-dev-image.sh and scripts/smoke-dev-image.sh.

Documentation

License

Hermes Helmet is licensed under Apache-2.0. See LICENSE and NOTICE. OpenViking is an optional AGPL-3.0 service; enabling it is an adopter choice.

Source 2 files
hooks/register.tsx 1832 lines
1// Captain's Bridge: the Claude Code mod of the Hermes Helmet plugin.
2//
3// It reads the session's saved records with `helmet bridge read`, draws the
4// Changes First walkthrough (overview, full item detail, Back with its place
5// restored, Refresh records), and prepares a new explanation in the background
6// with one tool-less model request. The request never starts a turn, submits a
7// prompt, calls a tool, spawns a subagent, waits or polls; the first officer's
8// task is never interrupted. State follows the contract in ../types.
9//
10// The calls this module makes are checked in CI against the read-only
11// allowlist from issue #55 (scripts/check_mod_calls.py).
12import { atom, read, update } from 'claude-code'
13import type { Register } from 'claude-code'
14import type {
15  BridgeState,
16  Coverage,
17  RecordsSummary,
18  RequestStatus,
19  SessionRecord,
20  Walkthrough,
21  WalkthroughItem,
22} from '../types'
23
24type BridgeWalkthrough = NonNullable<BridgeState['walkthrough']>
25
26export const PANE_ID = 'captains-bridge'
27export const PANE_TITLE = "Captain's Bridge"
28export const COMMAND = 'captains-bridge'
29// The lowest Claude Code build this release is verified on (see
30// docs/first-officer-plugins.md). It is never lower than 2.1.293.
31export const MIN_VERSION = '2.1.293'
32export const CHANGED_MESSAGE =
33  'This conversation changed. Run /captains-bridge to open the Bridge for it.'
34export const READER_SCHEMA_MAJOR = 1
35export const READER_TIMEOUT_MS = 30000
36// Subdued, distinct backgrounds; the text labels carry the meaning too.
37export const REPORT_BACKGROUND = '#2f3b4c'
38export const DISPOSITION_BACKGROUND = '#3b3a2f'
39
40// One bounded, tool-less explanation request. The instructions and the schema
41// go first and are cache-marked; the record summary is the last block.
42export const MAX_TOKENS = 16000
43export const DIFFICULT_MESSAGE = "The explanation didn't match the records"
44export const EMPTY_MESSAGE = 'The explanation came back empty'
45export const WALKTHROUGH_CONFLICT_MESSAGE =
46  'The explanation changed while it was being saved; press Update to try again'
47export const TIMEOUT_MESSAGE = 'The explanation request hit its time limit'
48export const RELOAD_MESSAGE = 'Interrupted by a plugin reload'
49
50export const EXPLANATION_INSTRUCTIONS = [
51  'You explain one Claude Code session to its Captain from the records you are given.',
52  'Answer with one JSON object and nothing else: no prose, no code fences.',
53  '',
54  'Rules:',
55  '- Make only claims the cited records support. Every conclusion, step, comparison and link cites record refs from this request.',
56  '- Keep the record text and your interpretation distinct. Never restate a record as if it were your own conclusion.',
57  '- Keep a reported completion separate from its acceptance. A finished turn, a successful command or a green run is not delivery or acceptance.',
58  '- Assign actors by actual responsibility: Captain, First officer, Hermes, or Other agent. Use Hermes only where a cited record attributes the work to the Hermes worker.',
59  '- Never treat a quiet interval or a gap as a stall.',
60  '- Never invent a before state, a diff, a link, a parent-child execution tree, or runtime success.',
61  '- Use readable, descriptive titles. Never use planning codes, task ids or raw UUIDs as names.',
62  '- Say where coverage is incomplete, at the affected item.',
63  '',
64  'The walkthrough shape is given as a JSON schema; the records follow as JSON.',
65].join('\n')
66
67export const WALKTHROUGH_SCHEMA = {
68  type: 'object',
69  additionalProperties: false,
70  required: ['objective', 'summary', 'evidence', 'items'],
71  properties: {
72    objective: { type: 'string' },
73    summary: { type: 'string' },
74    evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
75    items: {
76      type: 'array',
77      minItems: 1,
78      maxItems: 24,
79      items: {
80        type: 'object',
81        additionalProperties: false,
82        required: ['id', 'title', 'group', 'status', 'summary', 'detail', 'evidence'],
83        properties: {
84          id: { type: 'string' },
85          title: { type: 'string' },
86          group: { enum: ['changed', 'unresolved', 'activity'] },
87          status: { type: 'string' },
88          summary: { type: 'string' },
89          detail: { type: 'string' },
90          evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
91          steps: {
92            type: 'array',
93            items: {
94              type: 'object',
95              additionalProperties: false,
96              required: ['actor', 'label', 'detail', 'evidence'],
97              properties: {
98                actor: { enum: ['Captain', 'First officer', 'Hermes', 'Other agent'] },
99                label: { type: 'string' },
100                detail: { type: 'string' },
101                evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
102              },
103            },
104          },
105          reported: { $ref: '#/$defs/note' },
106          disposition: { $ref: '#/$defs/note' },
107          change: {
108            type: 'object',
109            additionalProperties: false,
110            required: ['before', 'after', 'explanation', 'evidence'],
111            properties: {
112              before: { type: 'string' },
113              after: { type: 'string' },
114              explanation: { type: 'string' },
115              evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
116            },
117          },
118          links: {
119            type: 'array',
120            items: {
121              type: 'object',
122              additionalProperties: false,
123              required: ['label', 'url', 'evidence'],
124              properties: {
125                label: { type: 'string' },
126                url: { type: 'string' },
127                evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
128              },
129            },
130          },
131        },
132      },
133    },
134  },
135  $defs: {
136    note: {
137      type: 'object',
138      additionalProperties: false,
139      required: ['label', 'actor', 'evidence'],
140      properties: {
141        label: { type: 'string' },
142        actor: { enum: ['Captain', 'First officer', 'Hermes', 'Other agent'] },
143        evidence: { type: 'array', minItems: 1, items: { type: 'string' } },
144      },
145    },
146  },
147}
148
149export type ExplanationOptions = { model: string; timeoutMs: number }
150
151export function explanationOptions(options: unknown): ExplanationOptions {
152  const bag = (options ?? {}) as Record<string, unknown>
153  const model =
154    typeof bag.explanationModel === 'string' && bag.explanationModel.trim() !== ''
155      ? bag.explanationModel.trim()
156      : 'sonnet'
157  const seconds =
158    typeof bag.explanationTimeoutSeconds === 'number' &&
159    Number.isFinite(bag.explanationTimeoutSeconds) &&
160    bag.explanationTimeoutSeconds > 0
161      ? bag.explanationTimeoutSeconds
162      : 180
163  return { model, timeoutMs: Math.round(seconds * 1000) }
164}
165
166const binding = atom({ plugin: 'hermes-helmet', key: 'binding' } as const, null)
167const records = atom({ plugin: 'hermes-helmet', key: 'records' } as const, null)
168const walkthrough = atom(
169  { plugin: 'hermes-helmet', key: 'walkthrough' } as const,
170  null,
171)
172const request = atom(
173  { plugin: 'hermes-helmet', key: 'request' } as const,
174  { generation: 0, status: 'idle', retain: false },
175)
176const view = atom({ plugin: 'hermes-helmet', key: 'view' } as const, { stack: [] })
177const pending = atom({ plugin: 'hermes-helmet', key: 'pending' } as const, null)
178
179// One AbortController per live request, in module scope. A plugin reload
180// re-evaluates this module, so the controller is gone while `request.status`
181// stays `preparing` in state: that is the reload case below.
182let liveRequest: { generation: number; controller: AbortController } | null = null
183
184// Sources of a classic SessionStart that start a different conversation.
185const CHANGING_SOURCES = ['clear', 'resume', 'fork']
186
187export function parseVersion(text: string): number[] | null {
188  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text.trim())
189  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
190}
191
192export function isSupported(version: string): boolean {
193  const have = parseVersion(version)
194  const need = parseVersion(MIN_VERSION)
195  if (have === null || need === null) return false
196  for (let i = 0; i < 3; i++) {
197    if (have[i] !== need[i]) return have[i] > need[i]
198  }
199  return true
200}
201
202export function tooOldMessage(version: string): string {
203  return (
204    `Captain's Bridge needs Claude Code ${MIN_VERSION} or newer; this is ` +
205    `${version || 'an unknown version'}. Update Claude Code, then run ` +
206    '/captains-bridge again.'
207  )
208}
209
210const GROUPS: Array<[WalkthroughItem['group'], string]> = [
211  ['changed', 'What changed'],
212  ['unresolved', 'What remains unresolved'],
213  ['activity', 'Other recorded activity'],
214]
215
216export function overviewText(
217  body: Walkthrough | null,
218  counts: { recordsIncluded: number; recordsTotal: number } | null,
219): string {
220  if (body === null) {
221    const known =
222      counts === null
223        ? ''
224        : ` ${counts.recordsIncluded} of ${counts.recordsTotal} records are read.`
225    return `No walkthrough has been prepared for this conversation yet.${known}`
226  }
227  const lines = [`Objective: ${body.objective}`, `Summary: ${body.summary}`]
228  for (const [group, label] of GROUPS) {
229    const items = body.items.filter(item => item.group === group)
230    if (items.length === 0) continue
231    lines.push(`${label}:`)
232    for (const item of items) lines.push(`- ${item.title} (${item.status})`)
233  }
234  return lines.join('\n')
235}
236
237// ---------------------------------------------------------------------------
238// The record reader: `helmet bridge read`, run as a child process.
239
240export type ReaderOutcome =
241  | { ok: true; summary: RecordsSummary }
242  | { ok: false; text: string }
243
244export function readerArgv(
245  helmetCommand: string,
246  bound: { sessionId: string; transcriptPath?: string },
247): string[] {
248  const argv = [helmetCommand, 'bridge', 'read', '--session', bound.sessionId]
249  // An unknown transcript path leaves the lookup to the reader, by exact ID.
250  if (bound.transcriptPath) argv.push('--transcript', bound.transcriptPath)
251  return argv
252}
253
254function schemaMajor(schema: unknown): number | null {
255  const match =
256    typeof schema === 'string' ? /^hermes-helmet\.bridge\.records\/(\d+)$/.exec(schema) : null
257  return match ? Number(match[1]) : null
258}
259
260const FIXES: Record<string, string> = {
261  'session-not-found':
262    'No saved record file was found for this exact conversation. Run /captains-bridge again from the conversation you want, or check that Claude Code saves sessions.',
263  'ambiguous-session':
264    'More than one saved record file carries this conversation ID, and the Bridge never chooses one. Remove the duplicate or open the Bridge from a conversation with a single record file.',
265  'session-mismatch':
266    'The saved records belong to a different conversation than the one this Bridge is bound to. Run /captains-bridge again in the conversation you want.',
267  'unsupported-format':
268    'These saved records are in a format this release does not support. Update the hermes-helmet package and plugin together, then refresh.',
269  unreadable:
270    'The saved records could not be read. Check the file permissions and that the disk is available, then refresh.',
271}
272
273function firstLine(text: string): string {
274  const line = text.split('\n').find(part => part.trim() !== '') ?? ''
275  return line.trim().slice(0, 240)
276}
277
278function validSummary(value: any): value is RecordsSummary {
279  const c = value?.coverage
280  return (
281    value !== null &&
282    typeof value === 'object' &&
283    typeof value.sessionId === 'string' &&
284    typeof value.readAt === 'string' &&
285    typeof value.fingerprint === 'string' &&
286    Array.isArray(value.records) &&
287    value.records.every(
288      (r: any) =>
289        r !== null &&
290        typeof r === 'object' &&
291        typeof r.ref === 'string' &&
292        typeof r.timestamp === 'string' &&
293        typeof r.text === 'string',
294    ) &&
295    c !== null &&
296    typeof c === 'object' &&
297    typeof c.recordsTotal === 'number' &&
298    typeof c.recordsIncluded === 'number' &&
299    typeof c.bytesOmitted === 'number' &&
300    typeof c.unsupportedSkipped === 'number' &&
301    typeof c.pendingTail === 'boolean' &&
302    Array.isArray(value.warnings) &&
303    value.warnings.every(
304      (w: any) =>
305        typeof w === 'string' ||
306        (w !== null && typeof w === 'object' && typeof w.message === 'string'),
307    )
308  )
309}
310
311export async function runReader(
312  $: any,
313  helmetCommand: string,
314  bound: { sessionId: string; transcriptPath?: string },
315): Promise<ReaderOutcome> {
316  const shown = `${helmetCommand} bridge read`
317  let result: any
318  try {
319    result = await $.process.run(readerArgv(helmetCommand, bound), {
320      timeoutMs: READER_TIMEOUT_MS,
321    })
322  } catch (error) {
323    const reason = firstLine(String((error as { message?: unknown })?.message ?? error))
324    return {
325      ok: false,
326      text:
327        `Could not run \`${shown}\`${reason ? ` (${reason})` : ''}. ` +
328        'Install the helmet command so Claude Code can reach it, or set helmetCommand ' +
329        'in the plugin settings to its absolute path (Claude Desktop may not inherit your shell PATH). ' +
330        'The reader also stops after 30 seconds.',
331    }
332  }
333  if (result === null || typeof result !== 'object') {
334    return {
335      ok: false,
336      text: `\`${shown}\` returned nothing the Bridge can use. Check that helmetCommand names the helmet command.`,
337    }
338  }
339  const exitCode = typeof result.exitCode === 'number' ? result.exitCode : 1
340  const stdout = typeof result.stdout === 'string' ? result.stdout : ''
341  const stderr = typeof result.stderr === 'string' ? result.stderr : ''
342  let parsed: any = null
343  if (!result.isStdoutTruncated) {
344    try {
345      parsed = JSON.parse(stdout)
346    } catch {
347      parsed = null
348    }
349  }
350  const hasError =
351    parsed !== null &&
352    typeof parsed === 'object' &&
353    parsed.error !== null &&
354    typeof parsed.error === 'object' &&
355    typeof parsed.error.code === 'string'
356  if (parsed !== null && typeof parsed === 'object' && 'schema' in parsed) {
357    if (schemaMajor(parsed.schema) !== READER_SCHEMA_MAJOR) {
358      return {
359        ok: false,
360        text:
361          `\`${shown}\` answered with schema ${JSON.stringify(parsed.schema)}, but this plugin reads ` +
362          `hermes-helmet.bridge.records/${READER_SCHEMA_MAJOR}. Update the hermes-helmet package and the plugin ` +
363          'to matching releases, then refresh.',
364      }
365    }
366  }
367  if (hasError) {
368    const code: string = parsed.error.code
369    const message =
370      typeof parsed.error.message === 'string' ? parsed.error.message : 'no message'
371    const fix = FIXES[code] ?? 'Update the hermes-helmet package and plugin together, then refresh.'
372    return { ok: false, text: `The reader reported ${code}: ${message} ${fix}` }
373  }
374  if (exitCode !== 0) {
375    const tail = firstLine(stderr) || firstLine(stdout)
376    const old = /invalid choice|unrecognized arguments|no such command|unknown command|usage:/i.test(
377      `${stderr}\n${stdout}`,
378    )
379    return {
380      ok: false,
381      text:
382        `\`${shown}\` exited with code ${exitCode} without a reader error` +
383        `${tail ? ` (${tail})` : ''}. ` +
384        (old
385          ? 'This helmet is too old to read sessions: install hermes-helmet 0.7.0 or newer, or set helmetCommand to a newer one.'
386          : 'Check that helmetCommand names a current helmet command, then refresh.'),
387    }
388  }
389  if (result.isStdoutTruncated || parsed === null || typeof parsed !== 'object') {
390    return {
391      ok: false,
392      text:
393        `\`${shown}\` printed output the Bridge cannot parse` +
394        `${result.isStdoutTruncated ? ' (it was cut at 4 MiB)' : ''}. ` +
395        'Check that helmetCommand names the helmet command and that it is current.',
396    }
397  }
398  if (schemaMajor(parsed.schema) !== READER_SCHEMA_MAJOR) {
399    return {
400      ok: false,
401      text:
402        `\`${shown}\` answered with ${parsed.schema === undefined ? 'no schema' : `schema ${JSON.stringify(parsed.schema)}`}, ` +
403        `but this plugin reads hermes-helmet.bridge.records/${READER_SCHEMA_MAJOR}. ` +
404        'Update the hermes-helmet package and the plugin to matching releases, then refresh. The records already shown were kept.',
405    }
406  }
407  if (!validSummary(parsed)) {
408    return {
409      ok: false,
410      text: `\`${shown}\` answered in a shape this plugin does not understand (a record, the coverage counts or a warning is malformed). Update the hermes-helmet package and plugin together, then refresh. The records already shown were kept.`,
411    }
412  }
413  if (parsed.sessionId !== bound.sessionId) {
414    return {
415      ok: false,
416      text:
417        `The reader answered for a different conversation (${parsed.sessionId}) than the one this Bridge is bound to (${bound.sessionId}). ` +
418        'Nothing was adopted and the Bridge did not rebind. Run /captains-bridge again in the conversation you want.',
419    }
420  }
421  return { ok: true, summary: parsed }
422}
423
424function coverageOf(summary: RecordsSummary): Coverage {
425  return summary.coverage
426}
427
428// Rereads only the saved records. It never touches the walkthrough, the view
429// stack or the selection, and it calls no model and sends nothing.
430let reading = false
431export async function refreshRecords($: any, helmetCommand: string): Promise<void> {
432  if (reading) return
433  reading = true
434  try {
435    const bound = await read($, binding)
436    if (bound === null) return
437    const outcome = await runReader($, helmetCommand, bound)
438    // A conversation change while the reader ran: nothing is adopted.
439    const now = await read($, binding)
440    if (now === null || now.sessionId !== bound.sessionId) return
441    if (!outcome.ok) {
442      await update($, view, current => ({
443        ...current,
444        notice: { kind: 'error', text: outcome.text },
445      }))
446      return
447    }
448    const summary = outcome.summary
449    await update($, records, () => ({
450      fingerprint: summary.fingerprint,
451      readAt: summary.readAt,
452      coverage: coverageOf(summary),
453      summary,
454    }))
455    await update($, view, current => {
456      const { notice: _gone, ...rest } = current as any
457      return rest
458    })
459  } finally {
460    reading = false
461  }
462}
463
464// ---------------------------------------------------------------------------
465// Preparing an explanation. One bounded, tool-less model request over the
466// request's own immutable record snapshot. Nothing here submits a prompt,
467// starts a turn, calls a tool, spawns a subagent, waits, polls or retries.
468
469type RequestSnapshot = {
470  readAt: string
471  fingerprint: string
472  summary: RecordsSummary
473}
474
475// The source-bound validation ported from the approved spike: source is kept
476// separate from interpretation, support is required, and a link must appear
477// verbatim in a record the link itself cites.
478export function validateWalkthrough(value: any, summary: RecordsSummary): string | null {
479  if (value === null || typeof value !== 'object' || Array.isArray(value)) return 'shape'
480  const seen = new Set(summary.records.map(rec => rec.ref))
481  const textOf = new Map(summary.records.map(rec => [rec.ref, rec.text]))
482  const prose = (v: unknown): boolean => typeof v === 'string' && v.trim() !== ''
483  const ACTORS = ['Captain', 'First officer', 'Hermes', 'Other agent']
484  const evidence = (v: unknown): string[] | null => {
485    if (!Array.isArray(v) || v.length === 0) return null
486    const refs: string[] = []
487    for (const ref of v) {
488      if (typeof ref !== 'string' || !seen.has(ref)) return null
489      refs.push(ref)
490    }
491    return refs
492  }
493  if (!prose(value.objective) || !prose(value.summary)) return 'prose'
494  if (evidence(value.evidence) === null) return 'evidence'
495  if (!Array.isArray(value.items) || value.items.length === 0) return 'items'
496  const ids = new Set<string>()
497  for (const item of value.items) {
498    if (item === null || typeof item !== 'object' || Array.isArray(item)) return 'item'
499    if (
500      !prose(item.id) ||
501      !prose(item.title) ||
502      !prose(item.status) ||
503      !prose(item.summary) ||
504      !prose(item.detail)
505    ) {
506      return 'item-text'
507    }
508    if (!['changed', 'unresolved', 'activity'].includes(item.group)) return 'group'
509    if (ids.has(item.id)) return 'duplicate-id'
510    ids.add(item.id)
511    if (evidence(item.evidence) === null) return 'item-evidence'
512    if (item.steps !== undefined) {
513      if (!Array.isArray(item.steps)) return 'steps'
514      for (const step of item.steps) {
515        if (step === null || typeof step !== 'object') return 'step'
516        if (!prose(step.label) || !prose(step.detail)) return 'step-text'
517        if (!ACTORS.includes(step.actor)) return 'actor'
518        if (evidence(step.evidence) === null) return 'step-evidence'
519      }
520    }
521    for (const field of ['reported', 'disposition'] as const) {
522      const note = item[field]
523      if (note === undefined || note === null) continue
524      if (typeof note !== 'object') return field
525      if (!prose(note.label) || !ACTORS.includes(note.actor)) return field
526      if (evidence(note.evidence) === null) return `${field}-evidence`
527    }
528    if (item.change !== undefined && item.change !== null) {
529      const change = item.change
530      if (typeof change !== 'object') return 'change'
531      if (!prose(change.before) || !prose(change.after) || !prose(change.explanation)) {
532        return 'change-text'
533      }
534      if (evidence(change.evidence) === null) return 'change-evidence'
535    }
536    if (item.links !== undefined) {
537      if (!Array.isArray(item.links)) return 'links'
538      for (const link of item.links) {
539        if (link === null || typeof link !== 'object') return 'link'
540        if (!prose(link.label) || typeof link.url !== 'string') return 'link'
541        const source = evidence(link.evidence)
542        if (source === null) return 'link-evidence'
543        // The address must appear verbatim in a record the link cites.
544        if (!source.some(ref => (textOf.get(ref) ?? '').includes(link.url))) return 'link-source'
545      }
546    }
547  }
548  return null
549}
550
551export function statusLine(request: BridgeState['request']): string {
552  switch (request.status) {
553    case 'preparing':
554      return 'Preparing a new explanation in the background. The first officer keeps working.'
555    case 'cancelled':
556      return 'The explanation request was cancelled. The last explanation stays.'
557    case 'superseded':
558      return 'A new instruction superseded the explanation request. The last explanation stays.'
559    case 'timed-out':
560      return request.message ?? TIMEOUT_MESSAGE
561    case 'failed':
562      return request.message ?? 'The explanation request failed.'
563    default:
564      return request.message ?? ''
565  }
566}
567
568export function failureOf(error: unknown): { status: RequestStatus; message: string } {
569  const name =
570    error !== null && typeof error === 'object' && typeof (error as { name?: unknown }).name === 'string'
571      ? (error as { name: string }).name
572      : ''
573  const text = firstLine(String((error as { message?: unknown } | null)?.message ?? error ?? ''))
574  if (name === 'AbortError' || /deadline|time[ -]?out|abort/i.test(text)) {
575    return { status: 'timed-out', message: TIMEOUT_MESSAGE }
576  }
577  return {
578    status: 'failed',
579    message:
580      text === ''
581        ? 'The explanation request failed.'
582        : `The explanation request failed: ${text.slice(0, 160)}`,
583  }
584}
585
586// Compare-and-set the request only while the transition still owns it. A
587// competing write that lands between the read and the set (a Keep preparing
588// toggle, say) bumps the store version and makes the write miss without
589// changing the generation; the transition reads again and retries so a
590// settling request is never stranded. When the generation or status it
591// depends on no longer matches, a newer request or instruction owns the
592// request and the transition is discarded.
593async function settleRequest(
594  $: any,
595  next: (state: BridgeState['request']) => BridgeState['request'] | null,
596): Promise<boolean> {
597  for (let attempt = 0; attempt < 16; attempt++) {
598    const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
599    const state = current?.value as BridgeState['request'] | null | undefined
600    if (state === null || state === undefined) return false
601    const value = next(state)
602    if (value === null) return false
603    const outcome = await $.state.set(
604      { plugin: 'hermes-helmet', key: 'request' },
605      value,
606      { ifVersion: current.version },
607    )
608    if (outcome?.isSet === true) return true
609  }
610  return false
611}
612
613// Settles the request only if it is still the preparing generation: a late
614// result of a cancelled or superseded request is discarded silently and the
615// walkthrough is never touched.
616async function failRequest(
617  $: any,
618  generation: number,
619  status: RequestStatus,
620  message: string | undefined,
621): Promise<void> {
622  await settleRequest($, state =>
623    state.generation === generation && state.status === 'preparing'
624      ? message === undefined
625        ? { generation, status, retain: state.retain === true }
626        : { generation, status, retain: state.retain === true, message }
627      : null,
628  )
629}
630
631async function installWalkthrough(
632  $: any,
633  generation: number,
634  body: Walkthrough,
635  snapshot: RequestSnapshot,
636): Promise<void> {
637  const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
638  const state = current?.value as BridgeState['request'] | null | undefined
639  if (state === null || state === undefined) return
640  if (state.generation !== generation || state.status !== 'preparing') return
641  // Adopt while this generation still owns the preparing request: the request
642  // stays `preparing` until the walkthrough write has landed, so a duplicate
643  // or newer Update cannot start a second preparation and race this write.
644  // Only then does the request settle idle, and only while it is still the
645  // preparing generation: a Cancel or supersession that won during the write
646  // leaves it alone.
647  //
648  // The walkthrough is written under compare-and-set against the version read
649  // here, so an older adoption can never overwrite a newer accepted
650  // explanation: its write misses and is discarded. A Cancel or supersession
651  // that wins while the write is in flight cannot stop it landing, so the
652  // adoption rechecks the request afterwards and restores the last useful view
653  // it replaced, only while its own write is still the current one.
654  const key = { plugin: 'hermes-helmet', key: 'walkthrough' }
655  const prior = await $.state.get(key)
656  const adopted = await $.state.set(
657    key,
658    {
659      body,
660      readAt: snapshot.readAt,
661      fingerprint: snapshot.fingerprint,
662      preparedAt: new Date().toISOString(),
663    },
664    { ifVersion: prior?.version ?? 0 },
665  )
666  if (adopted?.isSet !== true) {
667    // The walkthrough moved since it was read (a newer write or a cleanup
668    // restore). This generation may still own the preparing request, so settle
669    // it as a generation-qualified failure that keeps the current view rather
670    // than leaving it preparing; a request that is no longer the preparing
671    // generation is left alone.
672    await failRequest($, generation, 'failed', WALKTHROUGH_CONFLICT_MESSAGE)
673    return
674  }
675  const after = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
676  const settled = after?.value as BridgeState['request'] | null | undefined
677  // Restore only while this adoption's own write is still the current one: the
678  // set is fenced to the version it returned, so a newer accepted explanation
679  // that landed since is never overwritten.
680  const restore = async () => {
681    await $.state.set(key, prior?.value ?? null, { ifVersion: adopted.version })
682  }
683  if (settled?.generation !== generation || settled?.status !== 'preparing') {
684    await restore()
685    return
686  }
687  const done = await settleRequest($, s =>
688    s.generation === generation && s.status === 'preparing'
689      ? { generation, status: 'idle', retain: s.retain === true }
690      : null,
691  )
692  // Cancellation or supersession won the final settlement: keep the last
693  // useful view rather than the replacement it cancelled.
694  if (!done) await restore()
695}
696
697// A resolved `api-error`: the engine carries the HTTP status and the kind it
698// classified the failure as, never the provider's own text.
699export function providerFailureMessage(result: any): string {
700  const kind = typeof result?.error === 'string' && result.error !== '' ? result.error : 'unknown'
701  const status = typeof result?.status === 'number' ? result.status : null
702  return status === null
703    ? `The explanation request failed: the provider answered ${kind} and no response arrived.`
704    : `The explanation request failed: the provider answered ${kind} (status ${status}).`
705}
706
707async function runPreparation(
708  $: any,
709  generation: number,
710  controller: AbortController,
711  snapshot: RequestSnapshot,
712  options: ExplanationOptions,
713): Promise<void> {
714  try {
715    // The AbortSignal is the call's own option (ModelCompleteOptions), not a
716    // request field: it is what actually cuts the in-flight call on Cancel.
717    // The engine never rejects over what the provider did; it resolves a
718    // result, and the request's own time limit arrives as `aborted`.
719    const result = await $.model.complete(
720      {
721        model: options.model,
722        prompt: [
723          { text: EXPLANATION_INSTRUCTIONS, cache: true },
724          { text: JSON.stringify(WALKTHROUGH_SCHEMA) },
725          { text: JSON.stringify(snapshot.summary) },
726        ],
727        maxTokens: MAX_TOKENS,
728        timeoutMs: options.timeoutMs,
729      },
730      { signal: controller.signal },
731    )
732    if (result?.isAnswered !== true) {
733      if (result?.reason === 'aborted') {
734        // Its time limit elapsed, or the call was cut: timed out.
735        await failRequest($, generation, 'timed-out', TIMEOUT_MESSAGE)
736      } else if (result?.reason === 'api-error') {
737        await failRequest($, generation, 'failed', providerFailureMessage(result))
738      } else {
739        // `empty-reply`, or a shape this release does not know.
740        await failRequest($, generation, 'failed', EMPTY_MESSAGE)
741      }
742      return
743    }
744    const text = typeof result.text === 'string' ? result.text.trim() : ''
745    if (text === '') {
746      await failRequest($, generation, 'failed', EMPTY_MESSAGE)
747      return
748    }
749    let parsed: unknown
750    try {
751      parsed = JSON.parse(text)
752    } catch {
753      await failRequest($, generation, 'failed', DIFFICULT_MESSAGE)
754      return
755    }
756    if (validateWalkthrough(parsed, snapshot.summary) !== null) {
757      await failRequest($, generation, 'failed', DIFFICULT_MESSAGE)
758      return
759    }
760    await installWalkthrough($, generation, parsed as Walkthrough, snapshot)
761  } catch (error) {
762    const failure = failureOf(error)
763    await failRequest($, generation, failure.status, failure.message)
764  } finally {
765    if (liveRequest !== null && liveRequest.generation === generation) liveRequest = null
766  }
767}
768
769// The Update press: compare-and-set the request to a new preparing generation,
770// take one record snapshot, then start exactly one model request off the press
771// dispatch. A duplicate press while preparing is ignored.
772export async function pressUpdate(
773  $: any,
774  helmetCommand: string,
775  options: ExplanationOptions,
776): Promise<void> {
777  const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
778  const state = current?.value as BridgeState['request'] | null | undefined
779  if (state !== null && state !== undefined && state.status === 'preparing') return
780  const generation = ((state?.generation ?? 0) as number) + 1
781  const claimed = await $.state.set(
782    { plugin: 'hermes-helmet', key: 'request' },
783    {
784      generation,
785      status: 'preparing',
786      retain: state?.retain === true,
787      startedAt: new Date().toISOString(),
788    },
789    { ifVersion: current?.version ?? 0 },
790  )
791  if (claimed?.isSet !== true) return
792
793  // The preparation claims its live request now, before the reader: Cancel and
794  // a superseding instruction end a preparation by clearing this token and
795  // aborting its controller, so every await below rechecks it locally and a
796  // preparation that lost ownership adopts nothing and launches nothing.
797  const controller = new AbortController()
798  liveRequest = { generation, controller }
799  const owns = () => liveRequest !== null && liveRequest.generation === generation
800  const release = () => {
801    if (owns()) liveRequest = null
802  }
803
804  const bound = await read($, binding)
805  if (bound === null) {
806    release()
807    await failRequest($, generation, 'failed', 'No conversation is bound.')
808    return
809  }
810  const outcome = await runReader($, helmetCommand, bound)
811  // A Cancel or a new instruction during the reader ends this preparation.
812  if (!owns()) return
813  const stillBound = await read($, binding)
814  if (stillBound === null || stillBound.sessionId !== bound.sessionId) {
815    release()
816    return
817  }
818  if (!outcome.ok) {
819    release()
820    await failRequest($, generation, 'failed', outcome.text)
821    return
822  }
823  const summary = outcome.summary
824  const snapshot: RequestSnapshot = {
825    readAt: summary.readAt,
826    fingerprint: summary.fingerprint,
827    summary,
828  }
829  await update($, records, () => ({
830    fingerprint: summary.fingerprint,
831    readAt: summary.readAt,
832    coverage: coverageOf(summary),
833    summary,
834  }))
835
836  // One request, started off the press dispatch so the press returns at once.
837  // A Cancel or a superseding instruction between scheduling and the deferred
838  // start launches no stale request and never clobbers a newer controller.
839  Promise.resolve(
840    $.clock.after(0, () => {
841      if (!owns()) return
842      // The preparation runs in the background: its own failure is already
843      // settled into the request state, and a settlement that arrives after
844      // this plugin's environment is gone (a reload, a teardown) has nowhere
845      // left to go, so it is discarded rather than left unhandled.
846      void runPreparation($, generation, controller, snapshot, options).catch(() => {})
847    }),
848  ).catch(() => {})
849}
850
851// Cancel: abort, advance the generation and mark cancelled. The walkthrough is
852// untouched, and the aborted request's later failure is discarded.
853export async function pressCancel($: any): Promise<void> {
854  const controller = liveRequest === null ? null : liveRequest.controller
855  liveRequest = null
856  const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
857  const state = current?.value as BridgeState['request'] | null | undefined
858  if (state !== null && state !== undefined && state.status === 'preparing') {
859    // Bind the cancel to the generation it saw: a collision retries only while
860    // that same preparing generation still owns the request, so a newer
861    // request is never cancelled by accident.
862    const generation = state.generation
863    await settleRequest($, s =>
864      s.status === 'preparing' && s.generation === generation
865        ? { generation: generation + 1, status: 'cancelled', retain: s.retain === true }
866        : null,
867    )
868  }
869  if (controller !== null) controller.abort()
870}
871
872export async function toggleKeepPreparing($: any): Promise<void> {
873  const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
874  const state = current?.value as BridgeState['request'] | null | undefined
875  if (state === null || state === undefined) return
876  await $.state.set(
877    { plugin: 'hermes-helmet', key: 'request' },
878    { ...state, retain: state.retain !== true },
879    { ifVersion: current.version },
880  )
881}
882
883// A person's prompt while a request is preparing supersedes it, unless the
884// Captain chose Keep preparing. The prompt itself is never rewritten, blocked
885// or replaced: this only settles the request.
886export async function notePrompt($: any, e: any): Promise<void> {
887  const origin = e?.origin
888  const kind = origin !== null && typeof origin === 'object' ? origin.kind : undefined
889  if (kind === 'plugin') return
890  const current = await $.state.get({ plugin: 'hermes-helmet', key: 'request' })
891  const state = current?.value as BridgeState['request'] | null | undefined
892  if (state === null || state === undefined) return
893  if (state.status !== 'preparing' || state.retain === true) return
894  const generation = state.generation
895  const controller = liveRequest === null ? null : liveRequest.controller
896  liveRequest = null
897  await settleRequest($, s =>
898    s.status === 'preparing' && s.retain !== true && s.generation === generation
899      ? { generation: generation + 1, status: 'superseded', retain: false }
900      : null,
901  )
902  if (controller !== null) controller.abort()
903}
904
905// After a reload, a preparing request with no live controller can never
906// finish: it becomes failed, and the last useful walkthrough stays.
907export async function recoverAfterReload(
908  $: any,
909  state: BridgeState['request'] | null | undefined,
910  version: number | undefined,
911): Promise<void> {
912  if (state === null || state === undefined || state.status !== 'preparing') return
913  if (liveRequest !== null) return
914  await $.state.set(
915    { plugin: 'hermes-helmet', key: 'request' },
916    { ...state, status: 'failed', message: RELOAD_MESSAGE },
917    version === undefined ? {} : { ifVersion: version },
918  )
919}
920
921// ---------------------------------------------------------------------------
922// Derived facts: time, actors, freshness. Everything comes from the saved
923// records; nothing here is a judgment about delivery or stalls.
924
925export function formatElapsed(ms: number): string {
926  const total = Math.max(0, Math.round(ms / 1000))
927  const hours = Math.floor(total / 3600)
928  const minutes = Math.floor((total % 3600) / 60)
929  const seconds = total % 60
930  if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
931  if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`
932  return `${seconds}s`
933}
934
935function byRef(summary: RecordsSummary | null): Map<string, SessionRecord> {
936  const map = new Map<string, SessionRecord>()
937  if (summary !== null) for (const rec of summary.records) map.set(rec.ref, rec)
938  return map
939}
940
941// Elapsed time from the first to the last cited record, or null when fewer
942// than two cited records carry a usable timestamp.
943export function elapsedOf(refs: string[], index: Map<string, SessionRecord>): string | null {
944  const times: number[] = []
945  for (const ref of refs) {
946    const rec = index.get(ref)
947    const at = rec === undefined ? NaN : Date.parse(rec.timestamp)
948    if (!Number.isNaN(at)) times.push(at)
949  }
950  if (times.length < 2) return null
951  return formatElapsed(Math.max(...times) - Math.min(...times))
952}
953
954export function actorOf(rec: SessionRecord): string {
955  if (rec.role === 'tool_result') return rec.tool ? `Tool result (${rec.tool})` : 'Tool result'
956  switch (rec.origin) {
957    case 'person':
958      return 'Captain'
959    case 'assistant':
960      return 'First officer'
961    case 'agent':
962      return 'Other agent'
963    case 'hook':
964      return 'Hook'
965    case 'plugin':
966      return 'Plugin'
967    default:
968      return 'Session metadata'
969  }
970}
971
972function clock(timestamp: string): string {
973  return timestamp.replace('T', ' ').replace(/\.\d+/, '').replace(/Z$/, ' UTC')
974}
975
976function excerpt(text: string): string {
977  const flat = text.replace(/\s+/g, ' ').trim()
978  return flat.length > 240 ? `${flat.slice(0, 239)}…` : flat
979}
980
981function totalOf(fingerprint: string): number | null {
982  const match = /^(\d+):/.exec(fingerprint)
983  return match ? Number(match[1]) : null
984}
985
986// How many records the saved records hold beyond those the explanation read.
987export function newerRecordCount(
988  explained: { fingerprint: string; readAt: string },
989  current: { fingerprint: string; summary: RecordsSummary },
990): number {
991  const was = totalOf(explained.fingerprint)
992  const now = totalOf(current.fingerprint)
993  if (was !== null && now !== null) return Math.max(0, now - was)
994  const since = Date.parse(explained.readAt)
995  return current.summary.records.filter(rec => Date.parse(rec.timestamp) > since).length
996}
997
998export function staleNote(
999  saved: { fingerprint: string; readAt: string } | null,
1000  current: { fingerprint: string; summary: RecordsSummary } | null,
1001): string | null {
1002  if (saved === null || current === null || saved.fingerprint === current.fingerprint) {
1003    return null
1004  }
1005  const n = newerRecordCount(saved, current)
1006  return `Explanation read records at ${saved.readAt}; ${n} newer records since.`
1007}
1008
1009export function coverageWarnings(summary: RecordsSummary | null): string[] {
1010  if (summary === null) return []
1011  const c = summary.coverage
1012  const out: string[] = []
1013  if (c.recordsIncluded < c.recordsTotal) {
1014    out.push(
1015      `Coverage is partial: ${c.recordsIncluded} of ${c.recordsTotal} records are included` +
1016        `${c.bytesOmitted > 0 ? `, ${c.bytesOmitted} bytes omitted` : ''}.`,
1017    )
1018  }
1019  if (c.unsupportedSkipped > 0) {
1020    out.push(`${c.unsupportedSkipped} unsupported records were skipped.`)
1021  }
1022  if (c.pendingTail) {
1023    out.push('The last line was still being written and was not read.')
1024  }
1025  for (const warning of summary.warnings) {
1026    out.push(typeof warning === 'string' ? warning : warning.message)
1027  }
1028  return out
1029}
1030
1031// A link is shown only when its address appears verbatim in a record that
1032// the item cites; a link without that evidence is never drawn.
1033export function evidenceLinks(
1034  item: WalkthroughItem,
1035  index: Map<string, SessionRecord>,
1036): Array<{ label: string; url: string }> {
1037  const out: Array<{ label: string; url: string }> = []
1038  for (const link of item.links ?? []) {
1039    if (!/^https?:\/\//.test(link.url)) continue
1040    const backed = link.evidence.some(ref => index.get(ref)?.text.includes(link.url))
1041    if (backed) out.push({ label: link.label, url: link.url })
1042  }
1043  return out
1044}
1045
1046// ---------------------------------------------------------------------------
1047// The view. One element tree from `$.ui.resolve(e)` serves the terminal and
1048// the desktop; the scroll window is the mod's own, in blocks, so Back can
1049// restore it exactly.
1050
1051let blockCount = 0
1052
1053// Press handlers are closures made in the render hook; `$` itself is never
1054// put in an object (the validator wants every call spelled out at its site).
1055type Actions = {
1056  open: (itemId: string) => Promise<void>
1057  back: () => Promise<void>
1058  toggle: (section: 'change' | 'sources') => Promise<void>
1059  refresh: () => Promise<void>
1060  run: (action: Action) => Promise<void>
1061  update: () => Promise<void>
1062  cancel: () => Promise<void>
1063  keep: () => Promise<void>
1064}
1065
1066type Ctx = {
1067  ui: any
1068  act: Actions
1069  body: Walkthrough | null
1070  index: Map<string, SessionRecord>
1071  stack: Array<{ itemId?: string; scroll?: number; open?: string[] }>
1072  names: ActionNames
1073  busy: Action | null
1074  request: BridgeState['request']
1075}
1076
1077// Long text is cut into word-wrapped pieces, each its own block, so the
1078// block-counted scroll window moves a line or so at a time and never skips
1079// a whole long explanation or source list.
1080const PIECE_WIDTH = 72
1081
1082export function textPieces(text: string): string[] {
1083  const pieces: string[] = []
1084  for (const line of text.split('\n')) {
1085    let current = ''
1086    for (const word of line.split(/\s+/).filter(w => w !== '')) {
1087      if (current !== '' && current.length + 1 + word.length > PIECE_WIDTH) {
1088        pieces.push(current)
1089        current = ''
1090      }
1091      current = current === '' ? word : `${current} ${word}`
1092      while (current.length > PIECE_WIDTH) {
1093        pieces.push(current.slice(0, PIECE_WIDTH))
1094        current = current.slice(PIECE_WIDTH)
1095      }
1096    }
1097    pieces.push(current)
1098  }
1099  return pieces
1100}
1101
1102function textBlocks(ui: any, key: string, text: string, dim = false): any[] {
1103  const { Text } = ui
1104  return textPieces(text).map((piece, i) => (
1105    <Text key={`${key}:${i}`} wrap="wrap" dimColor={dim ? true : undefined}>
1106      {piece === '' ? ' ' : piece}
1107    </Text>
1108  ))
1109}
1110
1111function pairBoxes(ui: any, item: WalkthroughItem, suffix: string): any {
1112  const { Box, Text } = ui
1113  const reported = item.reported
1114  const disposition = item.disposition
1115  return (
1116    <Box key={`pair${suffix}`} flexDirection="column" marginY={1}>
1117      <Box
1118        key={`report${suffix}`}
1119        flexDirection="column"
1120        paddingX={1}
1121        backgroundColor={REPORT_BACKGROUND}
1122      >
1123        <Text bold>Completion report</Text>
1124        <Text wrap="wrap">
1125          {reported === undefined
1126            ? 'No completion report is recorded for this item.'
1127            : `${reported.actor}: ${reported.label}`}
1128        </Text>
1129      </Box>
1130      <Box
1131        key={`disposition${suffix}`}
1132        flexDirection="column"
1133        paddingX={1}
1134        backgroundColor={DISPOSITION_BACKGROUND}
1135      >
1136        <Text bold>Disposition</Text>
1137        <Text wrap="wrap">
1138          {disposition === undefined
1139            ? 'No acceptance or rejection is recorded. A completed run is not acceptance.'
1140            : `${disposition.actor}: ${disposition.label}`}
1141        </Text>
1142      </Box>
1143    </Box>
1144  )
1145}
1146
1147function overviewBlocks(ctx: Ctx): any[] {
1148  const { Box, Text, Button } = ctx.ui
1149  const body = ctx.body as Walkthrough
1150  const blocks: any[] = []
1151  blocks.push(
1152    <Text key="objective-title" bold>
1153      Objective
1154    </Text>,
1155    ...textBlocks(ctx.ui, 'objective', body.objective),
1156    <Text key="outcome-title" bold>
1157      Outcome
1158    </Text>,
1159    ...textBlocks(ctx.ui, 'outcome', body.summary),
1160  )
1161  for (const [group, label] of GROUPS) {
1162    const items = body.items.filter(item => item.group === group)
1163    if (items.length === 0) continue
1164    blocks.push(
1165      <Text key={`group:${group}`} bold>
1166        {label}
1167      </Text>,
1168    )
1169    for (const item of items) {
1170      const paired = item.reported !== undefined || item.disposition !== undefined
1171      blocks.push(
1172        <Box key={`row:${item.id}`} flexDirection="column" marginBottom={1}>
1173          <Button
1174            key={`item:${item.id}`}
1175            plain
1176            onPress={() => ctx.act.open(item.id)}
1177          >
1178            {`${item.title} [${item.status}]`}
1179          </Button>
1180          <Text wrap="truncate-end" dimColor>
1181            {item.summary.replace(/\s+/g, ' ')}
1182          </Text>
1183          {paired ? pairBoxes(ctx.ui, item, `:${item.id}`) : null}
1184        </Box>,
1185      )
1186    }
1187  }
1188  return blocks
1189}
1190
1191function detailBlocks(ctx: Ctx, item: WalkthroughItem, open: string[]): any[] {
1192  const { Box, Text, Button, Link } = ctx.ui
1193  const blocks: any[] = []
1194  const elapsed = elapsedOf(item.evidence, ctx.index)
1195  blocks.push(
1196    <Box key="title" flexDirection="column">
1197      <Text bold>{item.title}</Text>
1198      <Text dimColor>
1199        {item.status}
1200        {elapsed === null ? '' : ` · elapsed ${elapsed}`}
types/index.d.ts 143 lines
1// Type contract for the Hermes Helmet Claude Code mod (Captain's Bridge).
2//
3// Self-contained on purpose: Claude Code checks that this file has no import,
4// export-from, require or reference, exports only types, and augments
5// 'claude-code' only through `declare module`. The state shape below is the
6// accepted design from issue #55; later slices add behavior, not fields.
7
8export type Ref = string
9
10export type Actor = 'Captain' | 'First officer' | 'Hermes' | 'Other agent'
11
12export type WalkthroughItem = {
13  id: string
14  title: string
15  group: 'changed' | 'unresolved' | 'activity'
16  status: string
17  summary: string
18  detail: string
19  evidence: Ref[]
20  steps?: Array<{
21    actor: Actor
22    label: string
23    detail: string
24    evidence: Ref[]
25  }>
26  reported?: { label: string; actor: Actor; evidence: Ref[] }
27  disposition?: { label: string; actor: Actor; evidence: Ref[] }
28  change?: {
29    before: string
30    after: string
31    explanation: string
32    evidence: Ref[]
33  }
34  links?: Array<{ label: string; url: string; evidence: Ref[] }>
35}
36
37export type Walkthrough = {
38  objective: string
39  summary: string
40  evidence: Ref[]
41  items: WalkthroughItem[]
42}
43
44export type Coverage = {
45  recordsTotal: number
46  recordsIncluded: number
47  bytesOmitted: number
48  unsupportedSkipped: number
49  pendingTail: boolean
50}
51
52export type RecordOrigin =
53  | 'person'
54  | 'hook'
55  | 'plugin'
56  | 'meta'
57  | 'assistant'
58  | 'agent'
59
60export type SessionRecord = {
61  ref: Ref
62  parentRef: Ref | null
63  agentId: string | null
64  timestamp: string
65  role: 'user' | 'assistant' | 'tool_result' | 'system'
66  origin: RecordOrigin
67  text: string
68  tool?: string
69  toolUseId?: string
70  isError?: boolean
71  truncated: boolean
72}
73
74export type RecordsSummary = {
75  schema: 'hermes-helmet.bridge.records/1'
76  helmetVersion: string
77  sessionId: string
78  readAt: string
79  fingerprint: string
80  records: SessionRecord[]
81  coverage: Coverage
82  // The reader reports `{ code, message }` objects; plain strings are
83  // tolerated by the view.
84  warnings: Array<string | { code: string; message: string }>
85}
86
87export type RequestStatus =
88  | 'idle'
89  | 'preparing'
90  | 'failed'
91  | 'cancelled'
92  | 'superseded'
93  | 'timed-out'
94
95export type BridgeState = {
96  binding: { sessionId: string; transcriptPath?: string } | null
97  records: {
98    fingerprint: string
99    readAt: string
100    coverage: Coverage
101    summary: RecordsSummary
102  } | null
103  walkthrough: {
104    body: Walkthrough
105    readAt: string
106    fingerprint: string
107    preparedAt: string
108  } | null
109  request: {
110    generation: number
111    status: RequestStatus
112    retain: boolean
113    startedAt?: string
114    message?: string
115  }
116  view: {
117    // The last entry is the current view; an entry without itemId is the
118    // overview. `scroll` is the window offset left behind when the next
119    // view was opened; `open` lists the expanded sections of an item.
120    stack: Array<{
121      itemId?: string
122      scroll?: number
123      open?: Array<'change' | 'sources'>
124    }>
125    // The last reader or action message; it never replaces a useful view.
126    notice?: { kind: 'error' | 'info'; text: string }
127  }
128  pending: { action: 'show-me' | 'retro'; since: string } | null
129}
130
131declare module 'claude-code' {
132  interface PluginState {
133    'hermes-helmet': {
134      binding: BridgeState['binding']
135      records: BridgeState['records']
136      walkthrough: BridgeState['walkthrough']
137      request: BridgeState['request']
138      view: BridgeState['view']
139      pending: BridgeState['pending']
140    }
141  }
142}
143