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

<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.
Read Hermes Helmet: giving your software factory wings for the story behind the project and examples of delivered work.
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.
| Skill | What your first officer uses it for |
|---|---|
setup-helmet | Configure identities, repositories, model access, and the worker runtime. |
helmet-issue | Carry one issue through implementation, review, repairs, and acceptance. |
helmet-epic | Coordinate dependent issues and run independent work in parallel. |
helmet-review | Request a worker review of a pull request and read its formal verdict. |
helmet-write | Coordinate 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Integration | What it adds |
|---|---|
| OpenViking | Working context across sessions and clients. |
| FAVA Trails | Governed decisions and observations, with approval, provenance, and supersession. |
| Company skill packs | Your 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.
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.
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.
hooks/register.tsx 1832 lines1// 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 lines1// 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