From c07857a1464b9082270e25a2716c383e83ae5c2f Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:12:27 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20update=20the=20Captain=E2=80=99s=20?= =?UTF-8?q?Bridge=20explanation=20in=20the=20background=20with=20cancellat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 10 ++ docs/first-officer-plugins.md | 5 +- mcp/captains-bridge/README.md | 39 +++++++- mcp/captains-bridge/preparation.py | 68 +++++++++++++ mcp/captains-bridge/server.py | 42 +++++++- mcp/captains-bridge/test_preparation.cjs | 114 ++++++++++++++++++++++ mcp/captains-bridge/test_preparation.py | 117 +++++++++++++++++++++++ mcp/captains-bridge/view.html | 43 ++++++++- pyproject.toml | 2 +- scripts/verify.sh | 2 +- skills/observe-chat/SKILL.md | 14 ++- src/hermes_helmet/__init__.py | 2 +- 14 files changed, 447 insertions(+), 15 deletions(-) create mode 100644 mcp/captains-bridge/preparation.py create mode 100644 mcp/captains-bridge/test_preparation.cjs create mode 100644 mcp/captains-bridge/test_preparation.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2d71fdb..75be1d7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.3.0", + "version": "0.4.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index fd161f3..e88f62d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.3.0", + "version": "0.4.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/CHANGELOG.md b/CHANGELOG.md index a35fd0c..13bdded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased +### 0.4.0 + +- Captain’s Bridge can prepare an updated walkthrough in the background. The + panel captures the originating chat and an immutable snapshot, asks the first + officer to dispatch one bounded read-only agent without waiting, and keeps the + current walkthrough visible. Cancel, supersession, timeout, failure, duplicate + submission and late completion preserve the last useful view. Verified with + synthetic protocol and panel tests only; acceptance in an installed Codex host + is recorded separately in the pull request. + ### 0.3.0 - Add Captain’s Bridge to the Codex plugin: a read-only panel for the invoking diff --git a/docs/first-officer-plugins.md b/docs/first-officer-plugins.md index ff2f441..05fb721 100644 --- a/docs/first-officer-plugins.md +++ b/docs/first-officer-plugins.md @@ -29,8 +29,9 @@ path. Do not delete unrelated host skills. Canonical behavior stays in The Codex plugin also carries Captain’s Bridge: a read-only panel that explains one recorded piece of work in the invoking chat and opens its supporting records. It is Codex-only and starts no second server beyond the -plugin's own MCP server. Background preparation and cancellation are not part of -this release. See the +plugin's own MCP server. From an open Bridge, “Update walkthrough” can ask the +first officer to start one read-only background preparation, cancellable from the +panel; see the lifecycle in the Bridge guide. See the [source and test guide](../mcp/captains-bridge/README.md). The Claude Code port is tracked in [issue #55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55); the diff --git a/mcp/captains-bridge/README.md b/mcp/captains-bridge/README.md index 11ecd8a..bf510c6 100644 --- a/mcp/captains-bridge/README.md +++ b/mcp/captains-bridge/README.md @@ -29,11 +29,45 @@ not load it. transcript export, resume or collection infrastructure. - Refresh rereads records and keeps the explanation with its original read time, flagging it as older when the fingerprint changed. “Update walkthrough” - sends a request to the first officer. Delegated background preparation and - cancellation are not implemented. + rereads only. See Background preparation below. - Show Me and Retro request separately installed skills and report if they are unavailable. +## Background preparation lifecycle + +“Update walkthrough” (and its stale notice) runs this lifecycle. Opening the +Bridge never starts it. + +1. Capture: the panel calls `request_walkthrough_update` with its portable view. + The server rereads that exact chat and returns a request: a random + `requestId`, the `threadId`, and a snapshot (source fingerprint, read time, + record count). No server-side state is kept, so nothing is process-local. +2. Dispatch: the panel sends one `ui/message` asking the first officer to start + exactly one read-only subagent, not wait or poll, and not pause project work. + The subagent reads only `threadId` with `read_chat_work`, never its own chat. + It must not run project tasks or repair the viewer. +3. Deliver: the subagent calls `deliver_walkthrough_update` once with the request + unchanged, or with `failure`. Citations are validated against the snapshot’s + records only; the result keeps the snapshot’s fingerprint and read time, so a + later chat change marks it older instead of fresh. A request naming no valid + chat, or a forged snapshot, is rejected. +4. Settle: the panel owns the single active request. It accepts a delivery only + when `requestId` matches and the request is still active, and ignores every + other result. Delivery, failure, cancel, supersession or the 10-minute timeout + end the request; each preserves the last useful view, and a second delivery + for a settled request is dropped. The panel never retries. +5. Cancel: “Cancel update” ends the request at once and asks the first officer to + interrupt the subagent. If the host cannot be told, the panel says so and still + discards any later result. A substantive new instruction from the Captain + supersedes the request the same way unless the Captain says to keep it. + +Unsupported boundaries, reported rather than hidden: the server cannot itself stop +a subagent or know the Captain issued a new instruction; the first officer does +both from the messages above. Whether a delivery reaches the panel when the parent +turn has already finished, and whether work continues during preparation, depend +on Codex host behavior that synthetic tests cannot establish; they require the +installed-host acceptance recorded in the pull request. + ## Checks Python 3.11 or newer and Node.js. The tests use synthetic records only and do @@ -44,6 +78,7 @@ python3 -B -m unittest discover -s . -p 'test_*.py' -v node test_view.cjs node test_delivery.cjs node test_actions.cjs +node test_preparation.cjs ``` `scripts/verify.sh` runs these. Protocol and fixture checks do not establish diff --git a/mcp/captains-bridge/preparation.py b/mcp/captains-bridge/preparation.py new file mode 100644 index 0000000..d3d279f --- /dev/null +++ b/mcp/captains-bridge/preparation.py @@ -0,0 +1,68 @@ +"""Background walkthrough preparation: portable request identity and settlement. + +The server keeps no request state. The panel owns the single active request and +discards any settlement that is not for it, so cancellation, supersession, timeout +and late completion need no cross-process coordination. Nothing is written. +""" +import copy +import re +import secrets +from walkthrough import fingerprint, validate + +OUTCOMES = ('delivered', 'failed', 'cancelled', 'superseded') +CHAT = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + + +def new_request(thread_id, data): + """Capture the originating chat and an immutable snapshot of what was read.""" + return {'requestId': secrets.token_urlsafe(18), 'threadId': thread_id, + 'snapshot': {'fingerprint': fingerprint(data), 'readAt': data['readAt'], + 'recordCount': len(data['records'])}} + + +def check_request(request): + """Return a normalized copy of an untrusted request, or raise ValueError.""" + if not isinstance(request, dict): + raise ValueError('The walkthrough request is missing.') + snapshot = request.get('snapshot') + request_id, thread_id = request.get('requestId'), request.get('threadId') + if not isinstance(request_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{16,64}', request_id): + raise ValueError('The walkthrough request has no valid identity.') + if not isinstance(thread_id, str) or not CHAT.fullmatch(thread_id): + raise ValueError('The walkthrough request has no valid originating chat.') + if not isinstance(snapshot, dict): + raise ValueError('The walkthrough request has no preparation snapshot.') + digest, read_at, count = snapshot.get('fingerprint'), snapshot.get('readAt'), snapshot.get('recordCount') + if (not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest) + or not isinstance(read_at, str) or not 0 < len(read_at) <= 80 + or not isinstance(count, int) or isinstance(count, bool) or count < 1): + raise ValueError('The walkthrough request snapshot is invalid.') + return {'requestId': request_id, 'threadId': thread_id, + 'snapshot': {'fingerprint': digest, 'readAt': read_at, 'recordCount': count}} + + +def prepared_walkthrough(request, walkthrough, data): + """Validate a delivered explanation against its snapshot, not the current read. + + Citations must belong to the records that existed at snapshot time. The result + keeps the snapshot's fingerprint and read time, so records read later flag it + as older instead of presenting it as freshly interpreted. + """ + snapshot = request['snapshot'] + if len(data['records']) < snapshot['recordCount']: + raise ValueError('The chat has fewer records than the preparation snapshot. Request a new update.') + bounded = dict(data, records=data['records'][:snapshot['recordCount']]) + checked = validate(copy.deepcopy(walkthrough), bounded) + checked.update(fingerprint=snapshot['fingerprint'], explainedAt=snapshot['readAt']) + return checked + + +def settlement(request, outcome, message=None): + if outcome not in OUTCOMES: + raise ValueError('Unknown settlement outcome.') + value = {'requestId': request['requestId'], 'outcome': outcome} + if message is not None: + if not isinstance(message, str) or len(message) > 500: + raise ValueError('A settlement message must be text, at most 500 characters.') + value['message'] = message + return value diff --git a/mcp/captains-bridge/server.py b/mcp/captains-bridge/server.py index 986a47f..dd41833 100644 --- a/mcp/captains-bridge/server.py +++ b/mcp/captains-bridge/server.py @@ -6,6 +6,7 @@ from pathlib import Path from chat_reader import read_chat from walkthrough import validate, fingerprint +from preparation import new_request, check_request, prepared_walkthrough, settlement ROOT = Path(__file__).resolve().parent URI = 'ui://hermes-helmet/captains-bridge-v1' @@ -42,6 +43,16 @@ def tool(name, title, description, properties, required=(), app=False, entry=Fal VIEW['_meta'] = {'ui': {'visibility': ['app']}} +REQUEST = tool('request_walkthrough_update', 'Capture walkthrough request', + 'Read the exact chat in the portable view and capture an immutable preparation snapshot and request identity. Starts no work.', + {'view': {'type': 'object'}}, ('view',)) +REQUEST['_meta'] = {'ui': {'visibility': ['app']}} +DELIVER = tool('deliver_walkthrough_update', 'Deliver prepared walkthrough', + 'For a read-only preparation agent: deliver the explanation for one captured request, or report failure. Pass the request unchanged. Cites record IDs from the request chat only. Delivery is discarded by the panel unless the request is still active. Never retry.', + {'request': {'type': 'object'}, 'walkthrough': {'type': 'object'}, + 'failure': {'type': 'string', 'maxLength': 500}}, ('request',), app=True) + + def bind(thread_id): data = read_chat(thread_id) key = secrets.token_urlsafe(24) @@ -90,13 +101,38 @@ def read_view(view): return {'thread_id': view['threadId'], 'data': data, 'walkthrough': account} +def capture_request(view): + state = read_view(view) + request = new_request(state['thread_id'], state['data']) + text = 'Captured walkthrough request ' + request['requestId'] + '. No work was started.' + return {'content': [{'type': 'text', 'text': text}], 'structuredContent': {'request': request}} + + +def deliver(args): + request = check_request(args.get('request')) + failure = args.get('failure') + if failure is not None or args.get('walkthrough') is None: + note = failure if isinstance(failure, str) and failure.strip() else 'The preparation agent returned no explanation.' + outcome = settlement(request, 'failed', note[:500]) + return {'content': [{'type': 'text', 'text': 'Reported failure for request ' + request['requestId'] + '.'}], + 'structuredContent': {'preparation': outcome}} + # The reader opens only the request's own chat; the child never picks one. + data = read_chat(request['threadId']) + account = prepared_walkthrough(request, args['walkthrough'], data) + state = {'thread_id': request['threadId'], 'data': data, 'walkthrough': account} + result = present(None, state) + result['structuredContent']['preparation'] = settlement(request, 'delivered') + result['_meta']['preparation'] = result['structuredContent']['preparation'] + return result + + def handle(method, params): if method == 'initialize': return {'protocolVersion': params.get('protocolVersion', '2025-06-18'), 'capabilities': {'tools': {}, 'resources': {}}, 'serverInfo': {'name': 'hermes-helmet-captains-bridge', 'version': VERSION}} if method == 'ping': return {} if method == 'tools/list': - return {'tools': [OPEN, READ, PRESENT, REFRESH, VIEW]} + return {'tools': [OPEN, READ, PRESENT, REFRESH, VIEW, REQUEST, DELIVER]} if method == 'resources/list': return {'resources': [{'uri': URI, 'name': 'Captain’s Bridge', 'mimeType': 'text/html;profile=mcp-app'}]} if method == 'resources/templates/list': @@ -106,6 +142,10 @@ def handle(method, params): if method != 'tools/call': raise ValueError('Unknown method or resource') args, name = params.get('arguments') or {}, params.get('name') + if name == REQUEST['name']: + return capture_request(args.get('view')) + if name == DELIVER['name']: + return deliver(args) if name in (VIEW['name'], REFRESH['name']): return present(None, read_view(args.get('view')), for_app=True) if name in (OPEN['name'], READ['name']) and args.get('thread_id'): diff --git a/mcp/captains-bridge/test_preparation.cjs b/mcp/captains-bridge/test_preparation.cjs new file mode 100644 index 0000000..8860b6e --- /dev/null +++ b/mcp/captains-bridge/test_preparation.cjs @@ -0,0 +1,114 @@ +const fs=require('fs'),vm=require('vm'),assert=require('node:assert/strict'); +class Element{ + constructor(tag){this.tagName=tag;this.children=[];this.textContent='';this.hidden=false;this.disabled=false;this.style={setProperty(){}}} + append(c){this.children.push(c)} prepend(c){this.children.unshift(c)} replaceChildren(...c){this.children=c} focus(){} +} +const roots=Object.fromEntries(['app','error','connect','connection','request-status'].map(k=>[k,new Element(k)])); +const document={getElementById:id=>roots[id],createElement:t=>new Element(t),documentElement:new Element('html')}; +let listener;const sent=[];const timers=[]; +const parent={postMessage(m){if(m.method)sent.push(m)}}; +const context=vm.createContext({document,parent,window:{scrollY:0,scrollTo(){}}, + setTimeout:(fn,ms)=>{timers.push({fn,ms});return timers.length},clearTimeout:id=>{if(timers[id-1])timers[id-1].fn=null}, + requestAnimationFrame:f=>f(),addEventListener:(t,fn)=>{if(t==='message')listener=fn},console}); +vm.runInContext(fs.readFileSync('view.html','utf8').match(/ diff --git a/pyproject.toml b/pyproject.toml index bbd147e..01bb9ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.3.0" +version = "0.4.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/verify.sh b/scripts/verify.sh index 3358769..c66c227 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -163,7 +163,7 @@ PYTHONPATH=src "$PYTHON_BIN" -m unittest discover -s tests -v cd mcp/captains-bridge "$PYTHON_BIN" -B -m unittest discover -s . -p 'test_*.py' -v if command -v node >/dev/null 2>&1; then - for check in test_view.cjs test_delivery.cjs test_actions.cjs; do + for check in test_view.cjs test_delivery.cjs test_actions.cjs test_preparation.cjs; do node "$check" done elif [ -n "${CI:-}" ]; then diff --git a/skills/observe-chat/SKILL.md b/skills/observe-chat/SKILL.md index 44ad422..41c9952 100644 --- a/skills/observe-chat/SKILL.md +++ b/skills/observe-chat/SKILL.md @@ -67,6 +67,18 @@ references, retaining consequential failures. Keep async handoffs and later wakeups related to the same work without inventing a parent-child execution tree. Do not assign turn timestamps to individual operations. Gaps are not proof of a stall. +## Background update (preparation agent) + +When the Captain’s panel requests an update, the first officer dispatches one +read-only subagent and continues authorized work without waiting or polling. The +subagent reads only the `threadId` in the request with `read_chat_work`, never its +own chat, runs no project task, does not repair the viewer, and never retries. It +cites only IDs from that read and calls `deliver_walkthrough_update` once with the +request unchanged, or with `failure`. On a cancel message or a substantive new +instruction from the Captain, the first officer interrupts the subagent and +discards its result unless the Captain keeps the request. Present a delivery +briefly at an available boundary; do not hold project delivery open for it. + ## Refresh and limitations The reader opens Codex’s existing local chat index in read-only mode and reads @@ -79,7 +91,7 @@ The prepared result carries an explicit chat reference and explanation so app calls do not depend on the model tool process’s memory. Each app read validates the citations again; it writes no transcript or standalone viewer file. If records changed it labels the explanation as older. “Update walkthrough” -requests this skill again in this same chat. No automatic monitoring is added. +starts the background update above in this same chat. No automatic monitoring is added. The extension does not directly collect Hermes Docker events: use recorded worker results only when explicitly connected to this chat, and disclose missing coverage at the affected item. A source-bound explanation is not independent diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 6cc2e83..7b99fba 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ "Policy", From 9e46f3a146ed9b8b70ea18974fdb2cb175f4dbf7 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:24:16 +0000 Subject: [PATCH 2/6] fix: verify the captured snapshot and scope Captain's Bridge preparation settlement to its attempt --- CHANGELOG.md | 6 ++- mcp/captains-bridge/README.md | 18 +++++++-- mcp/captains-bridge/preparation.py | 46 +++++++++++++++++++---- mcp/captains-bridge/test_preparation.cjs | 47 ++++++++++++++++++++++-- mcp/captains-bridge/test_preparation.py | 29 +++++++++++++++ mcp/captains-bridge/view.html | 29 +++++++++++---- 6 files changed, 151 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bdded..b339cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ All notable changes to Hermes Helmet are recorded here. The project follows ### 0.4.0 - Captain’s Bridge can prepare an updated walkthrough in the background. The - panel captures the originating chat and an immutable snapshot, asks the first + panel captures the originating chat and a verifiable snapshot (delivery is + rejected if captured records changed or digests are inconsistent), asks the first officer to dispatch one bounded read-only agent without waiting, and keeps the - current walkthrough visible. Cancel, supersession, timeout, failure, duplicate + current walkthrough visible. A timeout asks the first officer to stop the agent + and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate submission and late completion preserve the last useful view. Verified with synthetic protocol and panel tests only; acceptance in an installed Codex host is recorded separately in the pull request. diff --git a/mcp/captains-bridge/README.md b/mcp/captains-bridge/README.md index bf510c6..88a0068 100644 --- a/mcp/captains-bridge/README.md +++ b/mcp/captains-bridge/README.md @@ -49,8 +49,15 @@ Bridge never starts it. 3. Deliver: the subagent calls `deliver_walkthrough_update` once with the request unchanged, or with `failure`. Citations are validated against the snapshot’s records only; the result keeps the snapshot’s fingerprint and read time, so a - later chat change marks it older instead of fresh. A request naming no valid - chat, or a forged snapshot, is rejected. + later chat change marks it older instead of fresh. The snapshot also carries a + digest of the exact captured records and a seal over every snapshot field. On + delivery the server rereads the chat and rejects the request if a captured + record has changed, if the digests are inconsistent, or if the chat names no + valid chat; records appended later are allowed and only mark the result older. + The seal is a consistency check, not authentication: the server keeps no + secret or state, so it does not stop a caller who can read the chat from + building a consistent snapshot. It also does not preserve the captured text; + it detects that the text changed. 4. Settle: the panel owns the single active request. It accepts a delivery only when `requestId` matches and the request is still active, and ignores every other result. Delivery, failure, cancel, supersession or the 10-minute timeout @@ -58,7 +65,12 @@ Bridge never starts it. for a settled request is dropped. The panel never retries. 5. Cancel: “Cancel update” ends the request at once and asks the first officer to interrupt the subagent. If the host cannot be told, the panel says so and still - discards any later result. A substantive new instruction from the Captain + discards any later result. The 10-minute timeout does the same: it invalidates + the request at once, then asks the first officer to interrupt the subagent. The + panel says the stop was requested, not confirmed, and says so plainly if the + host could not be told. Each async effect (acknowledgement failure, timeout + handoff) applies only to the request that owns it, so a late failure of a + cancelled request never clears a newer one. A substantive new instruction from the Captain supersedes the request the same way unless the Captain says to keep it. Unsupported boundaries, reported rather than hidden: the server cannot itself stop diff --git a/mcp/captains-bridge/preparation.py b/mcp/captains-bridge/preparation.py index d3d279f..4e3f958 100644 --- a/mcp/captains-bridge/preparation.py +++ b/mcp/captains-bridge/preparation.py @@ -5,6 +5,8 @@ and late completion need no cross-process coordination. Nothing is written. """ import copy +import hashlib +import json import re import secrets from walkthrough import fingerprint, validate @@ -13,11 +15,30 @@ CHAT = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') +def records_digest(records): + """Digest of the exact captured records, in order.""" + return hashlib.sha256(json.dumps(records, sort_keys=True).encode()).hexdigest() + + +def seal(request_id, thread_id, snapshot): + """Bind every snapshot field so an edited or inconsistent field is detectable. + + This is a consistency check, not authentication: the server keeps no secret + and no state, so it detects corruption and inconsistency, not a deliberate + forger who can read the chat. The captured records are verified separately. + """ + fields = [request_id, thread_id, snapshot['fingerprint'], snapshot['readAt'], + snapshot['recordCount'], snapshot['evidence']] + return hashlib.sha256(json.dumps(fields).encode()).hexdigest() + + def new_request(thread_id, data): - """Capture the originating chat and an immutable snapshot of what was read.""" - return {'requestId': secrets.token_urlsafe(18), 'threadId': thread_id, - 'snapshot': {'fingerprint': fingerprint(data), 'readAt': data['readAt'], - 'recordCount': len(data['records'])}} + """Capture the originating chat and a verifiable snapshot of what was read.""" + request_id = secrets.token_urlsafe(18) + snapshot = {'fingerprint': fingerprint(data), 'readAt': data['readAt'], + 'recordCount': len(data['records']), 'evidence': records_digest(data['records'])} + snapshot['seal'] = seal(request_id, thread_id, snapshot) + return {'requestId': request_id, 'threadId': thread_id, 'snapshot': snapshot} def check_request(request): @@ -33,12 +54,16 @@ def check_request(request): if not isinstance(snapshot, dict): raise ValueError('The walkthrough request has no preparation snapshot.') digest, read_at, count = snapshot.get('fingerprint'), snapshot.get('readAt'), snapshot.get('recordCount') - if (not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest) + evidence, sealed = snapshot.get('evidence'), snapshot.get('seal') + if (not all(isinstance(h, str) and re.fullmatch(r'[0-9a-f]{64}', h) for h in (digest, evidence, sealed)) or not isinstance(read_at, str) or not 0 < len(read_at) <= 80 or not isinstance(count, int) or isinstance(count, bool) or count < 1): raise ValueError('The walkthrough request snapshot is invalid.') - return {'requestId': request_id, 'threadId': thread_id, - 'snapshot': {'fingerprint': digest, 'readAt': read_at, 'recordCount': count}} + clean = {'fingerprint': digest, 'readAt': read_at, 'recordCount': count, 'evidence': evidence} + if not secrets.compare_digest(sealed, seal(request_id, thread_id, clean)): + raise ValueError('The walkthrough request snapshot is inconsistent. Request a new update.') + clean['seal'] = sealed + return {'requestId': request_id, 'threadId': thread_id, 'snapshot': clean} def prepared_walkthrough(request, walkthrough, data): @@ -51,7 +76,12 @@ def prepared_walkthrough(request, walkthrough, data): snapshot = request['snapshot'] if len(data['records']) < snapshot['recordCount']: raise ValueError('The chat has fewer records than the preparation snapshot. Request a new update.') - bounded = dict(data, records=data['records'][:snapshot['recordCount']]) + captured = data['records'][:snapshot['recordCount']] + if not secrets.compare_digest(records_digest(captured), snapshot['evidence']): + raise ValueError('The captured records have changed since the preparation snapshot. Request a new update.') + if len(data['records']) == snapshot['recordCount'] and fingerprint(data) != snapshot['fingerprint']: + raise ValueError('The snapshot fingerprint does not match the captured chat. Request a new update.') + bounded = dict(data, records=captured) checked = validate(copy.deepcopy(walkthrough), bounded) checked.update(fingerprint=snapshot['fingerprint'], explainedAt=snapshot['readAt']) return checked diff --git a/mcp/captains-bridge/test_preparation.cjs b/mcp/captains-bridge/test_preparation.cjs index 8860b6e..bbf3e4f 100644 --- a/mcp/captains-bridge/test_preparation.cjs +++ b/mcp/captains-bridge/test_preparation.cjs @@ -22,13 +22,16 @@ const visible=e=>e.textContent+' '+e.children.map(visible).join(' '); const button=label=>walk(roots.app).find(e=>e.tagName==='button'&&e.textContent===label); const tick=()=>new Promise(r=>setImmediate(r)); const request=(id)=>({requestId:id,threadId:chat,snapshot:{fingerprint:'b'.repeat(64),readAt:'then',recordCount:3}}); -let capture=0,failCapture=false; +let capture=0,failCapture=false,failMessages=false,holdAcks=false;const pendingAcks=[]; const answer=(message,result)=>listener({source:parent,data:{jsonrpc:'2.0',id:message.id,result}}); const pump=async()=>{ // answer app tool calls and ui/message acknowledgements for(let i=0;i<20;i++){await tick();for(const m of sent.splice(0)){ if(m.method==='tools/call'&&m.params.name==='request_walkthrough_update'){ capture++;answer(m,failCapture?{isError:true,content:[{type:'text',text:'Capture failed.'}]}:{structuredContent:{request:request('request-identity-'+String(capture).padStart(4,'0'))}})} - else if(m.method==='ui/message'){messages.push(m);answer(m,{})} + else if(m.method==='ui/message'){messages.push(m); + if(holdAcks)pendingAcks.push(m); + else if(failMessages)listener({source:parent,data:{jsonrpc:'2.0',id:m.id,error:{message:'Host refused'}}}); + else answer(m,{})} else if(m.method==='tools/call')answer(m,{structuredContent:{}})}}}; const messages=[]; const deliver=(id,title,outcome='delivered',message)=>listener({source:parent,data:{jsonrpc:'2.0',method:'ui/notifications/tool-result',params: @@ -98,12 +101,48 @@ process.on('exit',()=>{if(!globalThis.__done){console.error('STALLED at step',gl const fifth=run('explain()');await pump();await fifth; const timer=timers.filter(t=>t.fn&&t.ms===600000).pop(); assert.ok(timer,'A bounded attempt needs a timeout.'); - timer.fn(); + const beforeTimeout=messages.length; + const timedOut=timer.fn(); + assert.equal(button('Cancel update'),undefined,'Timeout invalidates the request at once.'); + deliver('request-identity-0005','Too late');await pump();await timedOut; assert.match(roots.error.textContent,/took too long/); - deliver('request-identity-0005','Too late');await pump(); + assert.equal(messages.length,beforeTimeout+1,'Timeout must hand a stop request to the first officer.'); + assert.match(messages.at(-1).params.content[0].text,/Cancel .*request-identity-0005.*timed out/); + assert.match(messages.at(-1).params.content[0].text,/Interrupt its read-only subagent/); + assert.match(roots.error.textContent,/asked to stop it; the stop is not confirmed/,'A requested stop is not reported as confirmed.'); assert.doesNotMatch(visible(roots.app),/Too late/); assert.match(visible(roots.app),/Fresh/); + // A failed stop handoff after timeout is reported honestly and still discards the work. + const sixthA=run('explain()');await pump();await sixthA; + const timerB=timers.filter(t=>t.fn&&t.ms===600000).pop(); + assert.ok(timerB); + failMessages=true; + const timedOutB=timerB.fn();await pump();await timedOutB;failMessages=false; + assert.match(roots.error.textContent,/could not be told to stop it/); + assert.match(roots.error.textContent,/may still be running/); + deliver('request-identity-0006','Too late again');await pump(); + assert.doesNotMatch(visible(roots.app),/Too late again/); + + // Late acknowledgement failure for cancelled request A must not clear newer request B. + failMessages=false;holdAcks=true; + const A=run('explain()');await pump(); + assert.ok(pendingAcks.length>=1,'A dispatch acknowledgement is unresolved.'); + const ackA=pendingAcks.shift(); + const cancelA=run('cancelPrep()');await pump(); + const B=run('explain()');await pump(); + holdAcks=false; + for(const a of pendingAcks.splice(0))answer(a,{}); + await pump(); + assert.ok(button('Cancel update'),'B is active before the stale acknowledgement fails.'); + listener({source:parent,data:{jsonrpc:'2.0',id:ackA.id,error:{message:'Late A failure'}}}); + await pump();await A;await cancelA;await B; + assert.ok(button('Cancel update'),'A late failure of A must not clear B or its cancel control.'); + assert.doesNotMatch(roots.error.textContent,/Late A failure/); + const idB=run('prep.request.requestId'); + deliver(idB,'From B');await pump(); + assert.match(visible(roots.app),/From B/,'B still settles after A fails late.'); + // Capture failure sends nothing to the first officer. failCapture=true;const before=messages.length; const sixth=run('explain()');await pump();await sixth; diff --git a/mcp/captains-bridge/test_preparation.py b/mcp/captains-bridge/test_preparation.py index 5ac765d..21595f0 100644 --- a/mcp/captains-bridge/test_preparation.py +++ b/mcp/captains-bridge/test_preparation.py @@ -96,6 +96,35 @@ def test_request_cannot_name_another_chat_or_forged_snapshot(self): with self.assertRaises(ValueError): self.call('deliver_walkthrough_update', {'request': foreign, 'walkthrough': self.account}) + def test_changed_captured_record_is_rejected_even_with_same_ids_and_count(self): + request = self.request() + self.data['records'][0]['text'] = 'Rewritten after capture' + with self.assertRaisesRegex(ValueError, 'changed since'): + self.call('deliver_walkthrough_update', {'request': request, 'walkthrough': self.account}) + + def test_inconsistent_digest_is_rejected(self): + request = self.request() + for field in ('fingerprint', 'evidence', 'readAt', 'recordCount'): + forged = copy.deepcopy(request) + forged['snapshot'][field] = '0' * 64 if field in ('fingerprint', 'evidence') else ( + 'yesterday' if field == 'readAt' else request['snapshot']['recordCount']) + if field == 'recordCount': + forged['snapshot'][field] += 1 + with self.assertRaises(ValueError, msg=field): + self.call('deliver_walkthrough_update', {'request': forged, 'walkthrough': self.account}) + # A consistently re-sealed fingerprint that does not match the chat is still refused. + resealed = copy.deepcopy(request) + resealed['snapshot']['fingerprint'] = '0' * 64 + resealed['snapshot']['seal'] = preparation.seal(resealed['requestId'], resealed['threadId'], resealed['snapshot']) + with self.assertRaisesRegex(ValueError, 'fingerprint'): + self.call('deliver_walkthrough_update', {'request': resealed, 'walkthrough': self.account}) + + def test_appended_records_do_not_reject_unchanged_snapshot(self): + request = self.request() + self.data['records'].append({'id': 'later', 'kind': 'report', 'actor': 'First officer', 'text': 'More', 'turnId': 'turn-a'}) + result = self.call('deliver_walkthrough_update', {'request': request, 'walkthrough': self.account}) + self.assertEqual(result['structuredContent']['preparation']['outcome'], 'delivered') + def test_failure_report_delivers_no_explanation(self): request = self.request() for args in ({'failure': 'Could not read'}, {}): diff --git a/mcp/captains-bridge/view.html b/mcp/captains-bridge/view.html index d58b486..be67dbe 100644 --- a/mcp/captains-bridge/view.html +++ b/mcp/captains-bridge/view.html @@ -55,27 +55,42 @@ let prep=null,prepTimer=null,capturing=false; const PREP_MS=600000; function clearPrep(){clearTimeout(prepTimer);prep=null} -function prepText(request){return 'Prepare an updated Captain’s Bridge walkthrough in the background; this is optional and a new instruction from me ends it. Do not wait or poll for the result, and do not pause or change already-authorized project work. Dispatch exactly one read-only subagent, once, with no retry. The subagent reads only chat '+request.threadId+' (codex://threads/'+request.threadId+') and never substitutes its own chat. It must not run project tasks or repair the viewer. It uses the observe-chat skill and read_chat_work with that thread_id, cites only record IDs from that read, then calls deliver_walkthrough_update once with the request below unchanged (or with a failure message). Present its delivery briefly at the next available boundary. If I cancel this request, interrupt the subagent and discard any result.\n\nRequest:\n'+JSON.stringify(request)} +function prepText(request){return 'Prepare an updated Captain’s Bridge walkthrough in the background; this is optional and a new instruction from me ends it. Do not wait or poll for the result, and do not pause or change already-authorized project work. Dispatch exactly one read-only subagent, once, with no retry. The subagent reads only chat '+request.threadId+' (codex://threads/'+request.threadId+') and never substitutes its own chat. It must not run project tasks or repair the viewer. It uses the observe-chat skill and read_chat_work with that thread_id, cites only record IDs from that read, then calls deliver_walkthrough_update once with the request below unchanged (or with a failure message). Present its delivery briefly at the next available boundary. If I cancel this request or it times out, interrupt the subagent and discard any result.\n\nRequest:\n'+JSON.stringify(request)} async function explain(){ if(prep||capturing)return; if(!portableView?.threadId){error('This view has no chat reference. Open its walkthrough before requesting an update.');return} - try{ + let mine=null; + try{ error('');capturing=true; const captured=toolResult(await rpc('tools/call',{name:'request_walkthrough_update',arguments:{view:portableView}})); if(captured?.isError)throw Error(captured.content?.find(c=>c.type==='text')?.text||'The update could not be requested.'); const request=captured?.structuredContent?.request; if(!request?.requestId)throw Error('Codex returned no request identity. The current view is preserved.'); - capturing=false; + capturing=false;mine=request.requestId; prep={request,state:'sending'};render(); - const mine=request.requestId; const sent=await rpc('ui/message',{role:'user',content:[{type:'text',text:prepText(request)}]}); if(sent?.isError)throw Error(sent.content?.find(c=>c.type==='text')?.text||'The request could not be sent.'); - if(prep?.request.requestId===mine){prep.state='preparing';clearTimeout(prepTimer);prepTimer=setTimeout(()=>{if(prep?.request.requestId===mine){clearPrep();error('The update took too long and was stopped. Your current view is preserved.');render()}},PREP_MS);render()} - }catch(e){capturing=false;clearPrep();error(e.message);render()} + if(owns(mine)){prep.state='preparing';clearTimeout(prepTimer);prepTimer=setTimeout(()=>timeoutPrep(mine),PREP_MS);render()} + }catch(e){ + if(mine===null)capturing=false; // capture failed; no request exists yet + else if(!owns(mine))return; // cancelled, timed out or replaced: a late failure belongs to a dead attempt + else clearPrep(); + error(e.message);render()} } +const owns=id=>prep?.request.requestId===id; +async function stopRequest(id,why){ + try{await rpc('ui/message',{role:'user',content:[{type:'text',text:'Cancel Captain’s Bridge walkthrough request '+id+' ('+why+'). Interrupt its read-only subagent if it is still running and discard any result. Do not start another and do not change project work.'}]});return true}catch(e){return e.message||'unknown error'}} +async function timeoutPrep(id){ + if(!owns(id))return;clearPrep(); + error('The update took too long. Asking Codex to stop it. Your current view is preserved.');render(); + const stopped=await stopRequest(id,'timed out after 10 minutes'); + if(prep||capturing)return; // a newer attempt owns the panel + error(stopped===true?'The update took too long. Codex was asked to stop it; the stop is not confirmed, and any result is ignored. Your current view is preserved.' + :'The update took too long, and Codex could not be told to stop it ('+stopped+'). It may still be running; any result is ignored. Your current view is preserved.');render()} async function cancelPrep(){ if(!prep)return;const id=prep.request.requestId;clearPrep();render(); - try{await rpc('ui/message',{role:'user',content:[{type:'text',text:'Cancel Captain’s Bridge walkthrough request '+id+'. Interrupt its read-only subagent if it is still running and discard any result. Do not start another and do not change project work.'}]})}catch(e){error('Cancelled in this view. Codex could not be told: '+e.message)} + const stopped=await stopRequest(id,'cancelled'); + if(stopped!==true&&!prep&&!capturing){error('Cancelled in this view. Codex could not be told: '+stopped);render()} } function settle(preparation){ if(!prep||!preparation||preparation.requestId!==prep.request.requestId)return false; From fdcedbacdef12a616e5f4b01fae5954d87515622 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:56:26 +0000 Subject: [PATCH 3/6] chore: bump version to 0.5.0 after merging main --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 22 ++++++++++++---------- pyproject.toml | 2 +- src/hermes_helmet/__init__.py | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 75be1d7..8c9271f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.4.0", + "version": "0.5.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e88f62d..81ca114 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.4.0", + "version": "0.5.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/CHANGELOG.md b/CHANGELOG.md index 41deeb6..6e62852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased +### 0.5.0 + +- Captain’s Bridge can prepare an updated walkthrough in the background. The + panel captures the originating chat and a verifiable snapshot (delivery is + rejected if captured records changed or digests are inconsistent), asks the first + officer to dispatch one bounded read-only agent without waiting, and keeps the + current walkthrough visible. A timeout asks the first officer to stop the agent + and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate + submission and late completion preserve the last useful view. Verified with + synthetic protocol and panel tests only; acceptance in an installed Codex host + is recorded separately in the pull request. + ### 0.4.0 - Captain’s Bridge now presents the Changes First layout: purpose and outcome @@ -17,16 +29,6 @@ All notable changes to Hermes Helmet are recorded here. The project follows background and follows the host's light or dark theme, so headings, strong text and buttons stay readable in a dark Codex panel. -- Captain’s Bridge can prepare an updated walkthrough in the background. The - panel captures the originating chat and a verifiable snapshot (delivery is - rejected if captured records changed or digests are inconsistent), asks the first - officer to dispatch one bounded read-only agent without waiting, and keeps the - current walkthrough visible. A timeout asks the first officer to stop the agent - and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate - submission and late completion preserve the last useful view. Verified with - synthetic protocol and panel tests only; acceptance in an installed Codex host - is recorded separately in the pull request. - ### 0.3.0 - Add Captain’s Bridge to the Codex plugin: a read-only panel for the invoking diff --git a/pyproject.toml b/pyproject.toml index 01bb9ef..dabe017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.4.0" +version = "0.5.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 7b99fba..73063cf 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ "Policy", From c131de1102581476b38a8812337bfad1e90fb55d Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:08:21 +0000 Subject: [PATCH 4/6] chore: bump version to 0.6.0 after merging main --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- pyproject.toml | 2 +- src/hermes_helmet/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8c9271f..689d499 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.5.0", + "version": "0.6.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 81ca114..74ac5e7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.5.0", + "version": "0.6.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/pyproject.toml b/pyproject.toml index dabe017..a5466bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.5.0" +version = "0.6.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 73063cf..2adbd02 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = [ "Policy", From 24e7de9c0026e02c8c7eb4ef5274d6b791c69c09 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:41:36 +0000 Subject: [PATCH 5/6] fix(bridge): disclose Codex limit when a preparation result cannot reach a finished turn's panel --- CHANGELOG.md | 4 +++- mcp/captains-bridge/README.md | 13 +++++++++++++ mcp/captains-bridge/test_preparation.cjs | 1 + mcp/captains-bridge/view.html | 4 ++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d55fbe..53c6426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ All notable changes to Hermes Helmet are recorded here. The project follows and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate submission and late completion preserve the last useful view. Verified with synthetic protocol and panel tests only; acceptance in an installed Codex host - is recorded separately in the pull request. + is recorded separately in the pull request. Measured Codex limit: after the + parent turn finishes, a delivery reaches the server but the originating panel does + not render it, so the panel now says so instead of implying background success. ### 0.5.0 diff --git a/mcp/captains-bridge/README.md b/mcp/captains-bridge/README.md index 4be3680..a069a8e 100644 --- a/mcp/captains-bridge/README.md +++ b/mcp/captains-bridge/README.md @@ -76,6 +76,19 @@ Bridge never starts it. cancelled request never clears a newer one. A substantive new instruction from the Captain supersedes the request the same way unless the Captain says to keep it. +Measured host limit (installed Codex, parent turn already finished): the preparation +agent read the originating chat and `deliver_walkthrough_update` returned a validated +walkthrough with the captured fingerprint, read time and request identity, so delivery +to the server worked. The originating panel did not render it: it kept the old +explanation and its progress notice until its own 10-minute timeout, and interrupting +the agent reported it had already completed. This server keeps no state and cannot push +to a panel, so rendering in the original panel after the parent finishes is unsupported +by the host seam as measured; no background rendering is claimed. The panel therefore +says a result may not be shown if the chat turn has finished, offers Cancel to stop +waiting, and on timeout says a finished agent's result reached the server but cannot be +shown. A failure result is subject to the same boundary. Foreground delivery while the +parent turn is still active is unchanged. No second queue or observer was added. + Unsupported boundaries, reported rather than hidden: the server cannot itself stop a subagent or know the Captain issued a new instruction; the first officer does both from the messages above. Whether a delivery reaches the panel when the parent diff --git a/mcp/captains-bridge/test_preparation.cjs b/mcp/captains-bridge/test_preparation.cjs index bbf3e4f..bb0f3f1 100644 --- a/mcp/captains-bridge/test_preparation.cjs +++ b/mcp/captains-bridge/test_preparation.cjs @@ -110,6 +110,7 @@ process.on('exit',()=>{if(!globalThis.__done){console.error('STALLED at step',gl assert.match(messages.at(-1).params.content[0].text,/Cancel .*request-identity-0005.*timed out/); assert.match(messages.at(-1).params.content[0].text,/Interrupt its read-only subagent/); assert.match(roots.error.textContent,/asked to stop it; the stop is not confirmed/,'A requested stop is not reported as confirmed.'); + assert.match(roots.error.textContent,/cannot show it/,'The timeout names the undeliverable-result boundary.'); assert.doesNotMatch(visible(roots.app),/Too late/); assert.match(visible(roots.app),/Fresh/); diff --git a/mcp/captains-bridge/view.html b/mcp/captains-bridge/view.html index 64680bb..4491c6a 100644 --- a/mcp/captains-bridge/view.html +++ b/mcp/captains-bridge/view.html @@ -87,7 +87,7 @@ error('The update took too long. Asking Codex to stop it. Your current view is preserved.');render(); const stopped=await stopRequest(id,'timed out after 10 minutes'); if(prep||capturing)return; // a newer attempt owns the panel - error(stopped===true?'The update took too long. Codex was asked to stop it; the stop is not confirmed, and any result is ignored. Your current view is preserved.' + error(stopped===true?'The update took too long. Codex was asked to stop it; the stop is not confirmed, and any result is ignored. If the agent had already finished, its result reached the server but this panel cannot show it. Your current view is preserved.' :'The update took too long, and Codex could not be told to stop it ('+stopped+'). It may still be running; any result is ignored. Your current view is preserved.');render()} async function cancelPrep(){ if(!prep)return;const id=prep.request.requestId;clearPrep();render(); @@ -102,7 +102,7 @@ return true} function prepNotice(main){ if(!prep)return;const n=el('div',null,'notice'); - append(n,el('span',prep.state==='sending'?'Requesting an update…':'Updating the walkthrough in the background. This view stays as it is, and project work is not paused.'),button('Cancel update',cancelPrep)); + append(n,el('span',prep.state==='sending'?'Requesting an update…':'Update requested in the background. This view stays as it is, and project work is not paused. Codex may not show a result here if the chat turn has already finished; cancel to stop waiting.'),button('Cancel update',cancelPrep)); append(main,n)} let refreshSeq=0,refreshing=false; function newerOrSame(next){const a=Date.parse(next?.readAt),b=Date.parse(deck?.readAt);return !(Number.isFinite(a)&&Number.isFinite(b))||a>=b} From 058330c8d50ff3ad50036227c47f703a8433da49 Mon Sep 17 00:00:00 2001 From: yia-mw-agent <294638553+yia-mw-agent@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:05:23 +0000 Subject: [PATCH 6/6] fix(bridge): end unsupported background preparation on Codex without capture or dispatch --- CHANGELOG.md | 5 +++-- mcp/captains-bridge/README.md | 15 +++++++++------ mcp/captains-bridge/test_preparation.cjs | 17 ++++++++++++++++- mcp/captains-bridge/view.html | 10 ++++++---- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c6426..ac8a0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ All notable changes to Hermes Helmet are recorded here. The project follows submission and late completion preserve the last useful view. Verified with synthetic protocol and panel tests only; acceptance in an installed Codex host is recorded separately in the pull request. Measured Codex limit: after the - parent turn finishes, a delivery reaches the server but the originating panel does - not render it, so the panel now says so instead of implying background success. + parent turn finishes, the originating panel does not render a delivery, so a + request on the installed host now ends with an unsupported-boundary message + (no capture, dispatch or timer) unless the host advertises background delivery. ### 0.5.0 diff --git a/mcp/captains-bridge/README.md b/mcp/captains-bridge/README.md index a069a8e..259f551 100644 --- a/mcp/captains-bridge/README.md +++ b/mcp/captains-bridge/README.md @@ -82,12 +82,15 @@ walkthrough with the captured fingerprint, read time and request identity, so de to the server worked. The originating panel did not render it: it kept the old explanation and its progress notice until its own 10-minute timeout, and interrupting the agent reported it had already completed. This server keeps no state and cannot push -to a panel, so rendering in the original panel after the parent finishes is unsupported -by the host seam as measured; no background rendering is claimed. The panel therefore -says a result may not be shown if the chat turn has finished, offers Cancel to stop -waiting, and on timeout says a finished agent's result reached the server but cannot be -shown. A failure result is subject to the same boundary. Foreground delivery while the -parent turn is still active is unchanged. No second queue or observer was added. +to a panel, and the panel cannot tell whether the chat turn is still active, so +rendering in the original panel is unsupported on this route as measured. The panel +therefore ends the attempt at once: a request reports the boundary, captures no +snapshot, dispatches no agent and starts no timer, and the last view is kept. The +background lifecycle runs only when the host advertises +`hostCapabilities.backgroundWalkthroughDelivery`; the installed Codex host does not. +Agent completion alone does not establish a successful or validated delivery, so no +message claims one. The server-side delivery, provenance, ownership, cancellation and +Refresh behavior are unchanged, and no second queue, retry or observer was added. Unsupported boundaries, reported rather than hidden: the server cannot itself stop a subagent or know the Captain issued a new instruction; the first officer does diff --git a/mcp/captains-bridge/test_preparation.cjs b/mcp/captains-bridge/test_preparation.cjs index bb0f3f1..bf5af3c 100644 --- a/mcp/captains-bridge/test_preparation.cjs +++ b/mcp/captains-bridge/test_preparation.cjs @@ -44,6 +44,21 @@ process.on('exit',()=>{if(!globalThis.__done){console.error('STALLED at step',gl assert.equal(sent.length+messages.length,0,'Opening the Bridge must not start work.'); assert.match(visible(roots.app),/Current/); + globalThis.__step='unsupported'; + // Installed Codex does not advertise background delivery: the attempt ends before capture, dispatch or any timer. + const timersBefore=timers.length; + await run('explain()');await pump(); + assert.equal(capture,0,'The unsupported route must not capture a snapshot.'); + assert.equal(messages.length,0,'The unsupported route must not dispatch an agent.'); + assert.equal(timers.length,timersBefore,'The unsupported route must not start a progress timer.'); + assert.equal(button('Cancel update'),undefined,'No progress lifecycle on the unsupported route.'); + assert.match(roots.error.textContent,/not supported in this Codex chat/); + assert.doesNotMatch(roots.error.textContent,/reached the server/); + assert.match(visible(roots.app),/Current/,'The last view is preserved.'); + roots.error.textContent=''; + listener({source:parent,data:{id:1,result:{hostCapabilities:{backgroundWalkthroughDelivery:true}}}}); + sent.splice(0); + globalThis.__step='request'; // Request, then duplicate submission is ignored. const first=run('explain()');run('explain()');await pump();await first; @@ -110,7 +125,7 @@ process.on('exit',()=>{if(!globalThis.__done){console.error('STALLED at step',gl assert.match(messages.at(-1).params.content[0].text,/Cancel .*request-identity-0005.*timed out/); assert.match(messages.at(-1).params.content[0].text,/Interrupt its read-only subagent/); assert.match(roots.error.textContent,/asked to stop it; the stop is not confirmed/,'A requested stop is not reported as confirmed.'); - assert.match(roots.error.textContent,/cannot show it/,'The timeout names the undeliverable-result boundary.'); + assert.doesNotMatch(roots.error.textContent,/reached the server/,'Agent completion is not claimed to mean delivery.'); assert.doesNotMatch(visible(roots.app),/Too late/); assert.match(visible(roots.app),/Fresh/); diff --git a/mcp/captains-bridge/view.html b/mcp/captains-bridge/view.html index 4491c6a..fe269b6 100644 --- a/mcp/captains-bridge/view.html +++ b/mcp/captains-bridge/view.html @@ -54,12 +54,14 @@ function skillButton(skill,workItem){const b=button(skill==='retro'?'Retro':'Show Me',()=>requestSkill(skill,workItem,b),'button');b.title=skill==='retro'?'Suggest improvements for future sessions':'Ask for a visual explanation';b.disabled=messagePending;return b} function chatActions(main){append(main,append(el('div',null,'chat-actions'),skillButton('show-me'),skillButton('retro')),el('p','Visual explanation or session retrospective. The first officer responds in this chat.','action-context'))} async function connectChat(){try{await rpc('ui/message',{role:'user',content:[{type:'text',text:'Open Captain’s Bridge for this current chat with an updated source-grounded walkthrough. Use the observe-chat skill, read_chat_work, and present_chat_work. Preserve current-chat scope. Do not start or resume the underlying project work.'}]});error('')}catch(e){error(e.message)}} -let prep=null,prepTimer=null,capturing=false; +let prep=null,prepTimer=null,capturing=false,backgroundDelivery=false; const PREP_MS=600000; function clearPrep(){clearTimeout(prepTimer);prep=null} function prepText(request){return 'Prepare an updated Captain’s Bridge walkthrough in the background; this is optional and a new instruction from me ends it. Do not wait or poll for the result, and do not pause or change already-authorized project work. Dispatch exactly one read-only subagent, once, with no retry. The subagent reads only chat '+request.threadId+' (codex://threads/'+request.threadId+') and never substitutes its own chat. It must not run project tasks or repair the viewer. It uses the observe-chat skill and read_chat_work with that thread_id, cites only record IDs from that read, then calls deliver_walkthrough_update once with the request below unchanged (or with a failure message). Present its delivery briefly at the next available boundary. If I cancel this request or it times out, interrupt the subagent and discard any result.\n\nRequest:\n'+JSON.stringify(request)} async function explain(){ if(prep||capturing)return; + // Measured host boundary: installed Codex cannot render a result in this panel once the chat turn has finished, and the panel cannot tell whether the turn is still active. Unless the host advertises background delivery, end the attempt here: no capture, no dispatch, no timer. + if(!backgroundDelivery){error('Background updates are not supported in this Codex chat: a result prepared after the chat turn has finished cannot be shown in this panel. Nothing was requested, and your current view is preserved.');render();return} if(!portableView?.threadId){error('This view has no chat reference. Open its walkthrough before requesting an update.');return} let mine=null; try{ @@ -87,7 +89,7 @@ error('The update took too long. Asking Codex to stop it. Your current view is preserved.');render(); const stopped=await stopRequest(id,'timed out after 10 minutes'); if(prep||capturing)return; // a newer attempt owns the panel - error(stopped===true?'The update took too long. Codex was asked to stop it; the stop is not confirmed, and any result is ignored. If the agent had already finished, its result reached the server but this panel cannot show it. Your current view is preserved.' + error(stopped===true?'The update took too long. Codex was asked to stop it; the stop is not confirmed, and any result is ignored. Your current view is preserved.' :'The update took too long, and Codex could not be told to stop it ('+stopped+'). It may still be running; any result is ignored. Your current view is preserved.');render()} async function cancelPrep(){ if(!prep)return;const id=prep.request.requestId;clearPrep();render(); @@ -102,7 +104,7 @@ return true} function prepNotice(main){ if(!prep)return;const n=el('div',null,'notice'); - append(n,el('span',prep.state==='sending'?'Requesting an update…':'Update requested in the background. This view stays as it is, and project work is not paused. Codex may not show a result here if the chat turn has already finished; cancel to stop waiting.'),button('Cancel update',cancelPrep)); + append(n,el('span',prep.state==='sending'?'Requesting an update…':'Updating the walkthrough in the background. This view stays as it is, and project work is not paused.'),button('Cancel update',cancelPrep)); append(main,n)} let refreshSeq=0,refreshing=false; function newerOrSame(next){const a=Date.parse(next?.readAt),b=Date.parse(deck?.readAt);return !(Number.isFinite(a)&&Number.isFinite(b))||a>=b} @@ -154,6 +156,6 @@ function toolResult(result){for(let depth=0;depth<2;depth++){if(result?.isError||result?.structuredContent||result?._meta?.deck)return result;const blocks=result?.content;if(!Array.isArray(blocks)||blocks.length!==1||blocks[0]?.type!=='text')return result;let nested;try{nested=JSON.parse(blocks[0].text)}catch{return result}if(!nested||typeof nested!=='object'||!Array.isArray(nested.content)||!('structuredContent'in nested||'_meta'in nested||'isError'in nested))return result;result=nested}return result} function receive(raw){const result=toolResult(raw);if(result?.isError){error(result.content?.find(c=>c.type==='text')?.text||'The records could not be read.');return}const data=result?.structuredContent||{};const preparation=data.preparation||result?._meta?.preparation;if(preparation){if(!settle(preparation)){render();return}if(preparation.outcome!=='delivered'){render();return}}const payload=result?._meta?.deck?result._meta:data;if(payload.deck&&deck&&(!sameChat(payload.deck)||(!preparation&&!newerOrSame(payload.deck)))){error('An older or unrelated result was ignored; the current view is kept.')}else if(payload.deck){++loadVersion;deck=payload.deck;portableView=payload.view;error('');render()}else if(data.needsBinding){showConnection('Ask the first officer to connect this view to the current chat.')}else if(data.view){return loadPrepared(data.view)}else{error('The panel received a result without walkthrough data. Its explanation has not opened.');showConnection('No walkthrough data received.')}} $('connect').onclick=connectChat; -addEventListener('message',e=>{if(e.source!==parent)return;const m=e.data;if(m?.id===1&&m.result){host=true;send({method:'ui/notifications/initialized',params:{}});$('connect')&&($('connect').disabled=false);if($('connection'))$('connection').textContent='Connected to Codex. Waiting for this chat’s records.';for(const[k,v]of Object.entries(m.result.hostContext?.styles?.variables||{}))if(k.startsWith('--')&&!k.startsWith('--color-'))document.documentElement.style.setProperty(k,v);const hostTheme=m.result.hostContext?.theme;if(hostTheme==='light'||hostTheme==='dark')document.documentElement.setAttribute('data-theme',hostTheme)}else if(pending.has(m?.id)){const p=pending.get(m.id);clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result)}else if(m?.method==='ui/notifications/tool-result')receive(m.params);else if(m?.method==='ping'||m?.method==='ui/resource-teardown')send({id:m.id,result:{}})}); +addEventListener('message',e=>{if(e.source!==parent)return;const m=e.data;if(m?.id===1&&m.result){host=true;backgroundDelivery=m.result.hostCapabilities?.backgroundWalkthroughDelivery===true;send({method:'ui/notifications/initialized',params:{}});$('connect')&&($('connect').disabled=false);if($('connection'))$('connection').textContent='Connected to Codex. Waiting for this chat’s records.';for(const[k,v]of Object.entries(m.result.hostContext?.styles?.variables||{}))if(k.startsWith('--')&&!k.startsWith('--color-'))document.documentElement.style.setProperty(k,v);const hostTheme=m.result.hostContext?.theme;if(hostTheme==='light'||hostTheme==='dark')document.documentElement.setAttribute('data-theme',hostTheme)}else if(pending.has(m?.id)){const p=pending.get(m.id);clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result)}else if(m?.method==='ui/notifications/tool-result')receive(m.params);else if(m?.method==='ping'||m?.method==='ui/resource-teardown')send({id:m.id,result:{}})}); if(parent!==window)send({id:1,method:'ui/initialize',params:{protocolVersion:'2026-01-26',appInfo:{name:'hermes-helmet-captains-bridge',version:'1'},appCapabilities:{availableDisplayModes:['fullscreen']}}});else $('connection').textContent='Open Captain’s Bridge through the plugin in Codex.';