@lyupro/codex-bridge 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.md +16 -4
- package/cli/active-hosts.mjs +44 -0
- package/cli/command-registry.mjs +18 -2
- package/cli/dispatcher-model-check.mjs +61 -0
- package/cli/doctor-host-side.mjs +117 -110
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +19 -4
- package/cli/handback-witness-check.mjs +30 -20
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +7 -1
- package/cli/inventory-confirm.mjs +75 -0
- package/cli/inventory-transition.mjs +4 -2
- package/cli/manifest.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +25 -119
- package/src/agents/codex-build.md +15 -133
- package/src/agents/codex-review.md +10 -109
- package/src/agents/codex-scout.md +11 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +59 -40
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -56
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +55 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +2 -1
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/runner/args.mjs +140 -231
- package/src/home/lib/runner/attach.mjs +3 -3
- package/src/home/lib/runner/continuation.mjs +174 -192
- package/src/home/lib/runner/launcher.mjs +9 -9
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +2 -1
- package/src/home/lib/runner/pass-gate.mjs +11 -9
- package/src/home/lib/runner/preflight.mjs +4 -3
- package/src/home/lib/runner/scope-check.mjs +11 -10
- package/src/home/lib/runner/task-input.mjs +23 -10
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -127
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/required-inputs.mjs +0 -301
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,121 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.12] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]` lets the
|
|
12
|
+
operator declare that the recorded hosts are all the hosts using this home. An old installation
|
|
13
|
+
record migrated to format 2 without a terminal stays `inventory: incomplete`: later updates do
|
|
14
|
+
not ask again, and since 0.6.11 every dispatcher model observation leaves the pin undetermined.
|
|
15
|
+
The package cannot prove that no unrecorded host uses the home; only the operator can.
|
|
16
|
+
The command shows the home, recorded host roots and Codex rules registry hints, marking missing
|
|
17
|
+
folders because the registry can retain deleted scratchpads. Enroll an unlisted host with
|
|
18
|
+
`codex-bridge install --host "<path>"` before confirming. A terminal "yes" changes only
|
|
19
|
+
`inventory` to `complete` and removes `legacy`; owners, fingerprints and every other field are
|
|
20
|
+
left as read. This permits ordinary shared-image cleanup when the last recorded host uninstalls;
|
|
21
|
+
it does not record a model match or clear a violation. No, EOF or a non-TTY exits 1 without changes;
|
|
22
|
+
Ctrl+C exits 130; a valid dry run shows the declaration without writing and exits 0. Confirmation
|
|
23
|
+
and an already-complete inventory without `legacy` exit 0. The `doctor` owner rows and the
|
|
24
|
+
incomplete-inventory pin reason now name this command.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `doctor` judges handback witness and dispatcher model state for every host whose dispatchers
|
|
29
|
+
ran in the last 24 hours. On 2026-10-07 two VS Code windows ran 2.1.291 and 2.1.292: an observation
|
|
30
|
+
on the non-newest host appeared only as history, and a model violation there would have printed
|
|
31
|
+
`ok`. Active witness violations identify their host with `host <version>:`, and each active
|
|
32
|
+
host's model entries have their own rows with that prefix. A model `fail` on any active host
|
|
33
|
+
reaches exit code 1. Older unresolved entries remain in one history sentence, without active
|
|
34
|
+
warning rows. The newest-host selector still serves contracts, `otherHost:*` and install.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Dispatcher model rows no longer print a doubled period after a pin reason: the live `doctor`
|
|
39
|
+
output had added its own period to a reason that already ended with one.
|
|
40
|
+
|
|
41
|
+
## [0.6.11] - 2026-10-07
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Breaking for orchestrator prompts:** the order gate refuses an explicit `model` on a dispatcher
|
|
46
|
+
call, even one matching the installed pin or `inherit`. Migration cost: drop `model` from dispatcher
|
|
47
|
+
calls in orchestrator prompts; the installed agent definition owns that setting. It also refuses a
|
|
48
|
+
foreign-family `CLAUDE_CODE_SUBAGENT_MODEL` on hosts where the environment overrides the pin
|
|
49
|
+
(before 2.1.251, or with `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1` from 2.1.257). Unknown host versions or
|
|
50
|
+
unparseable families are left to the stop observation. These checks precede prompt and grant exits,
|
|
51
|
+
so a continuation cannot bypass them.
|
|
52
|
+
|
|
53
|
+
- **Breaking for orchestrator prompts:** a dispatcher call is exactly one line, `task file: <absolute
|
|
54
|
+
path>`. The order — `order id`, `scope`, `scope new`, `phase`, `continue`/`retry`, `slug`, `effort`,
|
|
55
|
+
`changeset`, `repository` — goes in the header at the top of that file, and the dispatcher runs
|
|
56
|
+
`codex-bridge run --agent <type> --task-file "<path>"`. Any former label in the call is refused for
|
|
57
|
+
free with "moved to the task-file header" and the agent's header template. Migration cost: move the
|
|
58
|
+
label lines from the call into the task file's first lines. A task-file path containing `$`, `\\` or a
|
|
59
|
+
final `\` is refused, because the shell would rewrite it inside the command's double quotes and the
|
|
60
|
+
runner would open a different file than the one the gate checked; write it with forward slashes.
|
|
61
|
+
|
|
62
|
+
- The four dispatcher prompts share one protocol section rendered at install from the same command
|
|
63
|
+
assembler the order gate uses: the call is `task file:` only and the command is
|
|
64
|
+
`codex-bridge run --agent <type> --task-file "<path>"`. The hand-written label-to-flag tables and the
|
|
65
|
+
instruction to add a continuation flag are gone; the header labels are listed in each agent's
|
|
66
|
+
description. Migration cost: none beyond `codex-bridge update`, which reinstalls the prompts. A
|
|
67
|
+
first call that prints no `RUN=` line (a refusal or `UNAVAILABLE`) is itself the dispatcher's answer.
|
|
68
|
+
|
|
69
|
+
- A dispatcher call contains only exact `label: value` lines from the agent's input registry, in any
|
|
70
|
+
order, with blank lines allowed. Prose, unknown or another agent's labels, decorated or hyphenated
|
|
71
|
+
spellings, `--flag value` lines, duplicates, empty values, missing required labels, placeholders,
|
|
72
|
+
relative task-file paths and shell-unsafe values are refused for free before the dispatcher starts.
|
|
73
|
+
The refusal names the line, the exact spelling and every label of the agent, and says "Free text
|
|
74
|
+
belongs in the task file." Of 405 measured past calls, 47 began with prose, 112 carried lines outside
|
|
75
|
+
the registry, 18 passed values as `--order-id x`, and 1 duplicated a label.
|
|
76
|
+
Migration cost: a call written in an older form gets one free refusal naming the fix.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- The reply guard observes `message.model` in the dispatcher's assistant transcript entries at
|
|
81
|
+
`SubagentStop`, before its handback exits, and compares model families against the installed
|
|
82
|
+
contract agreed by every recorded owner. On 2026-09-25 a hook's `(inherit)` label was mistaken for
|
|
83
|
+
the model used, and the VS Code panel showed the parent model while transcripts showed the pinned
|
|
84
|
+
family; neither display is model evidence. A mismatch raises an alarm in the same turn and enters
|
|
85
|
+
`state/dispatcher-model.json`.
|
|
86
|
+
The order gate warns on every later call of that dispatcher type until a later match on any host;
|
|
87
|
+
uncertainty does not clear the warning. It warns rather than refusing work for a leak whose cause
|
|
88
|
+
may be another hook or the host.
|
|
89
|
+
- `doctor` reports `dispatcherModel:<type>` rows for the current observed session host: the latest
|
|
90
|
+
violation is `fail` with exit 1, an undetermined observation is `warn`, and a match is `ok`, including
|
|
91
|
+
recovery after a violation. No observations is `ok`; recovered incidents and other hosts remain
|
|
92
|
+
history, without a permanent warning. An incomplete, unreadable or disagreeing installed contract
|
|
93
|
+
makes the comparison undetermined, rather than guessing which agent definition the host selected.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- The handback witness keeps per-cause observations and violation history without a permanent
|
|
98
|
+
warning. Only later evidence disproving that cause clears it, and only the current observed session
|
|
99
|
+
host contributes active warnings. Intercepted handbacks, including refused attempts, are not proof
|
|
100
|
+
of recovery. Existing witness files migrate on their next write: old untyped alarms become
|
|
101
|
+
`legacy-untyped-unverified` history, not confirmed violations; `doctor` reads them without rewriting
|
|
102
|
+
the file. This fixes the 2026-09-27 case where clean handbacks on a newer host still left an old alarm
|
|
103
|
+
as the only visible signal.
|
|
104
|
+
- A subagent stop without `agent_type` alarms only with dispatcher evidence bound to that stop:
|
|
105
|
+
healthy, active dispatcher-gate state or an assistant Bash call running the standalone runner command
|
|
106
|
+
for a registered role. The 13 older alarms across nine host versions lacked that evidence. Without
|
|
107
|
+
it, a stop is undetermined, neither an accusation nor recovery, and a command's `--agent` does not
|
|
108
|
+
invent the missing host type.
|
|
109
|
+
|
|
110
|
+
- The reply guard identifies a dispatcher's run by a receipt — the run folder the runner itself
|
|
111
|
+
printed to that dispatcher, recorded once by the dispatcher gate — instead of the order id in the
|
|
112
|
+
call or the newest run on disk. A reply naming another folder, a reply with a verdict but no
|
|
113
|
+
receipt, and two different folders reported to one dispatcher are blocked; the last one also turns
|
|
114
|
+
the handback into a `FAIL`.
|
|
115
|
+
- `changeset:` reaches the runner, so `base:<branch>` and `commit:<sha>` review the requested changes
|
|
116
|
+
instead of silently reviewing uncommitted work.
|
|
117
|
+
- `scope-new:` is refused with the exact `scope new:` spelling; it can no longer be dropped or read as
|
|
118
|
+
`scope`.
|
|
119
|
+
- A `Repository root:` line is refused before the haiku dispatcher starts. It used to make the
|
|
120
|
+
dispatcher prepend `cd … &&` and leave without delegating.
|
|
121
|
+
|
|
7
122
|
## [0.6.10] - 2026-10-04
|
|
8
123
|
|
|
9
124
|
### Added
|
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
|
|
|
62
62
|
- **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
|
|
63
63
|
- **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
|
|
64
64
|
|
|
65
|
-
The current suite contains **
|
|
65
|
+
The current suite contains **3267 automated tests: 3266 passing and 1 skipped**.
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
|
@@ -93,12 +93,23 @@ Output on a healthy host (trimmed):
|
|
|
93
93
|
|
|
94
94
|
`doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
|
|
95
95
|
|
|
96
|
-
`doctor` also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1.
|
|
96
|
+
`doctor` also reports `handbackWitness`, `dispatcherModel:<type>` rows, and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Witness and model checks cover every host whose dispatchers ran in the last 24 hours; two VS Code windows on 2.1.291 and 2.1.292 on 2026-10-07 showed why the newest host alone could hide an active host's violation in history. The witness warns for unresolved causes on any active host; later disproving evidence clears the warning while retaining history. Intercepted handbacks are not recovery evidence, and old untyped alarms without dispatcher evidence remain unverified history. A stop missing `agent_type` alarms only with dispatcher evidence for that stop. Each active host's model entries have their own rows prefixed `host <version>:`, comparing transcript-observed families with the installed contract: a latest violation on any active host is `fail` with exit 1, uncertainty is `warn`, and a match, including recovery, is `ok`; no model entries for active hosts is also `ok`. Hosts outside the 24-hour window and recovered incidents remain history, not permanent warnings. Contracts, `otherHost:*` and install still use the newest observed host selector. See [Response guard](docs/overview.md#response-guard) for the evidence rules. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
|
|
97
97
|
|
|
98
98
|
If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
|
|
99
99
|
|
|
100
|
+
The task file starts with the order header, a blank line, and the task text:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
order id: order-42
|
|
104
|
+
repository: /abs/path/to/repository
|
|
105
|
+
scope: src/auth/**,tests/auth/**
|
|
106
|
+
advice: mechanical
|
|
107
|
+
|
|
108
|
+
Implement the authentication change and verify it with the tests named in the task.
|
|
109
|
+
```
|
|
110
|
+
|
|
100
111
|
```bash
|
|
101
|
-
codex-bridge run --agent codex-build --
|
|
112
|
+
codex-bridge run --agent codex-build --task-file "/abs/path/to/task.md"
|
|
102
113
|
```
|
|
103
114
|
|
|
104
115
|
The trade-off is explicit: `order-gate` and `reply-guard` sit on the dispatcher call, so a direct run is unguarded and its result is judged by the worktree and the suite.
|
|
@@ -130,8 +141,9 @@ Transport files from runs at least **30 days** old are pruned automatically when
|
|
|
130
141
|
| `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
|
|
131
142
|
| `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
|
|
132
143
|
| `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
|
|
144
|
+
| `inventory confirm [--scope user\|project] [--host <path>] [--dry-run]` | Confirm in a terminal that the recorded hosts are all the hosts using this home; until then the dispatcher model pin stays undetermined and `doctor` names this command. Changes only the inventory mark of the installation record. |
|
|
133
145
|
| `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
|
|
134
|
-
| `run <
|
|
146
|
+
| `run --agent <type> --task-file "<abs path>" [--no-wait]` | Start or attach to a delegated run through the permission-stable package command. The task-file header carries the whole order; its body carries the statement, scout questions and verification command. Its path must be absolute and use forward slashes; `$`, doubled backslashes and a final backslash are refused. Every other runner flag is a free refusal; stdin is not an input channel. |
|
|
135
147
|
| `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
|
|
136
148
|
| `projects [<name>] [--json]` | List projects or runs in the run store. |
|
|
137
149
|
| `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]`<br>`prune --all-projects [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup, for one project or across the whole run store. |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Names the hosts whose dispatchers ran in the last 24 hours, the hosts whose witness and model state is active;
|
|
2
|
+
* Plan_67 D12: concurrent VS Code hosts 2.1.291 and 2.1.292 on 2026-10-07 must both be judged, regardless of session liveness.
|
|
3
|
+
*/
|
|
4
|
+
export const ACTIVE_HOST_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function activeHostVersions({ observations, witnessRecord, modelRecord, now = new Date() }) {
|
|
7
|
+
const currentTime = new Date(now).getTime();
|
|
8
|
+
const activity = new Map();
|
|
9
|
+
function recordActivity(version, at) {
|
|
10
|
+
if (typeof version !== 'string' || !version || version === 'unknown') return;
|
|
11
|
+
if (typeof at !== 'string'
|
|
12
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(at)) return;
|
|
13
|
+
const time = Date.parse(at);
|
|
14
|
+
const date = Date.parse(`${at.slice(0, 10)}T00:00:00Z`);
|
|
15
|
+
if (!Number.isFinite(time) || !Number.isFinite(date)
|
|
16
|
+
|| new Date(date).toISOString().slice(0, 10) !== at.slice(0, 10)) return;
|
|
17
|
+
// Plan_67 R2: future evidence must not hide a valid past source for the same host.
|
|
18
|
+
if (time > currentTime) return;
|
|
19
|
+
if (!activity.has(version) || time > activity.get(version)) activity.set(version, time);
|
|
20
|
+
}
|
|
21
|
+
function ledgerActivity(ledger) {
|
|
22
|
+
if (!ledger || ledger.corrupt) return;
|
|
23
|
+
for (const entry of Object.values(ledger.entries || {})) {
|
|
24
|
+
if (!entry || entry.corrupt || entry.lastObservation?.corrupt) continue;
|
|
25
|
+
const observation = entry.lastObservation;
|
|
26
|
+
recordActivity(observation?.data?.hostVersion, observation?.at);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (observations && !observations.corrupt) {
|
|
30
|
+
for (const [version, host] of Object.entries(observations.hosts || {})) {
|
|
31
|
+
if (host && !host.corrupt) recordActivity(version, host.lastSeen);
|
|
32
|
+
}
|
|
33
|
+
for (const session of Object.values(observations.sessions || {})) {
|
|
34
|
+
if (session && !session.corrupt) recordActivity(session.version, session.at);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (witnessRecord && !witnessRecord.corrupt) {
|
|
38
|
+
for (const [version, at] of Object.entries(witnessRecord.intercepted || {})) recordActivity(version, at);
|
|
39
|
+
ledgerActivity(witnessRecord.ledger);
|
|
40
|
+
}
|
|
41
|
+
ledgerActivity(modelRecord);
|
|
42
|
+
return [...activity].filter(([, at]) => currentTime - at >= 0 && currentTime - at <= ACTIVE_HOST_WINDOW_MS)
|
|
43
|
+
.sort((a, b) => b[1] - a[1]).map(([version]) => version);
|
|
44
|
+
}
|
package/cli/command-registry.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { resolveProbeTarget } from './probe-target.mjs';
|
|
|
9
9
|
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
10
10
|
import { hook } from './hook.mjs';
|
|
11
11
|
import { resolveHost } from './hosts.mjs';
|
|
12
|
-
import { install } from './install.mjs';
|
|
12
|
+
import { install } from './install.mjs';
|
|
13
|
+
import { inventoryConfirm } from './inventory-confirm.mjs';
|
|
13
14
|
import { model } from './model.mjs';
|
|
14
15
|
import { projects } from './projects.mjs';
|
|
15
16
|
import { read } from './read.mjs';
|
|
@@ -25,7 +26,8 @@ import { update } from './update.mjs';
|
|
|
25
26
|
export function commandOptions(command, argv) {
|
|
26
27
|
const options = {};
|
|
27
28
|
const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
|
|
28
|
-
: command === 'uninstall' ? new Set(['--dry-run', '--purge'])
|
|
29
|
+
: command === 'uninstall' ? new Set(['--dry-run', '--purge'])
|
|
30
|
+
: command === 'inventory' ? new Set(['--dry-run'])
|
|
29
31
|
: command === 'doctor' ? new Set(['--probe-contract']) : new Set();
|
|
30
32
|
const flagNames = new Map([
|
|
31
33
|
['--dry-run', 'dryRun'],
|
|
@@ -123,6 +125,20 @@ export const COMMANDS = [
|
|
|
123
125
|
},
|
|
124
126
|
},
|
|
125
127
|
{
|
|
128
|
+
name: 'inventory',
|
|
129
|
+
summary: 'Confirm that the recorded hosts are all the hosts using this home',
|
|
130
|
+
usage: ['codex-bridge inventory confirm [--scope user|project] [--host <path>] [--dry-run]'],
|
|
131
|
+
section: 'public',
|
|
132
|
+
async handler(argv, io) {
|
|
133
|
+
if (argv[0] !== 'confirm') throw new Error(`unknown inventory action "${argv[0] ?? ''}"`);
|
|
134
|
+
const options = commandOptions('inventory', argv.slice(1));
|
|
135
|
+
const host = resolveHost(options);
|
|
136
|
+
const result = await inventoryConfirm({ host, dryRun: options.dryRun });
|
|
137
|
+
io.log(result.output);
|
|
138
|
+
return result.exitCode;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
126
142
|
name: 'doctor',
|
|
127
143
|
summary: 'Diagnose the selected Claude Code host (--probe-contract measures on a live host)',
|
|
128
144
|
usage: ['codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]'],
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** turns the dispatcher-model ledger into doctor rows; Plan_67 D4/D8/D12 judge every recently active host. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DISPATCHER_MODEL_FILE } from '../src/home/lib/dispatcher-model.mjs';
|
|
4
|
+
import { entryState } from '../src/home/lib/observation-ledger.mjs';
|
|
5
|
+
import { check } from './doctor-format.mjs';
|
|
6
|
+
|
|
7
|
+
function observationSummary({ data, at }) {
|
|
8
|
+
return `${data.agentType}: observed families ${data.parsed.join(', ')}, pinned family ${data.pinFamily} at ${at}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function entryCheck(entry) {
|
|
12
|
+
const observation = entry.lastObservation;
|
|
13
|
+
const { data, verdict, at } = observation;
|
|
14
|
+
const key = `dispatcherModel:${data.agentType}`;
|
|
15
|
+
const prefix = `host ${data.hostVersion}: `;
|
|
16
|
+
const state = entryState(entry);
|
|
17
|
+
// D4: only the latest confirmed violation fails; D8 keeps uncertainty from erasing history.
|
|
18
|
+
if (verdict === 'violation') {
|
|
19
|
+
return check(key, 'fail', `${prefix}${observationSummary(observation)}; Claude quota was spent on this dispatcher; see Plan_67.`);
|
|
20
|
+
}
|
|
21
|
+
if (verdict === 'undetermined') {
|
|
22
|
+
const reason = data.pinReasons?.length
|
|
23
|
+
// Reasons are sentences ending in a period; the row adds its own (live doctor printed "record..").
|
|
24
|
+
? data.pinReasons.map((text) => text.replace(/\.$/, '')).join('; ') : 'no model family could be read from the transcript';
|
|
25
|
+
const unresolved = state === 'violation'
|
|
26
|
+
? `; earlier violation (${observationSummary(entry.lastViolation)}) is not yet disproved` : '';
|
|
27
|
+
return check(key, 'warn', `${prefix}${data.agentType}: model observation undetermined at ${at}: ${reason}${unresolved}.`);
|
|
28
|
+
}
|
|
29
|
+
const history = state === 'recovered'
|
|
30
|
+
? `; recovered from earlier violation (${observationSummary(entry.lastViolation)})` : '';
|
|
31
|
+
return check(key, 'ok', `${prefix}${observationSummary(observation)}; matches the installed contract${history}.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dispatcherModelChecks({ record, activeHosts, stateDir }) {
|
|
35
|
+
if (record?.corrupt) {
|
|
36
|
+
return [check('dispatcherModel', 'warn',
|
|
37
|
+
`The dispatcher model record is unreadable; delete ${path.join(stateDir, DISPATCHER_MODEL_FILE)} to reset it.`)];
|
|
38
|
+
}
|
|
39
|
+
const entries = Object.values(record?.entries || {});
|
|
40
|
+
const current = entries.filter((entry) => activeHosts.includes(entry.lastObservation.data.hostVersion));
|
|
41
|
+
const rows = current.map(entryCheck);
|
|
42
|
+
if (!rows.length) {
|
|
43
|
+
rows.push(check('dispatcherModel', 'ok', 'Not observed in the last 24 hours — recorded when a dispatcher stops.'));
|
|
44
|
+
}
|
|
45
|
+
const hosts = new Map();
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const version = entry.lastObservation.data.hostVersion;
|
|
48
|
+
if (activeHosts.includes(version)) continue;
|
|
49
|
+
const counts = hosts.get(version) || { entries: 0, violations: 0 };
|
|
50
|
+
counts.entries += 1;
|
|
51
|
+
if (entryState(entry) === 'violation') counts.violations += 1;
|
|
52
|
+
hosts.set(version, counts);
|
|
53
|
+
}
|
|
54
|
+
// D12: inactive hosts are one history sentence, never additional active warning rows.
|
|
55
|
+
if (hosts.size) {
|
|
56
|
+
const history = [...hosts].map(([version, counts]) =>
|
|
57
|
+
`${version ?? 'unknown host'}: ${counts.entries} entries, ${counts.violations} unresolved violations`);
|
|
58
|
+
rows[0].value += ` History on hosts without dispatcher activity in the last 24 hours (historical, not current): ${history.join('; ')}.`;
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
package/cli/doctor-host-side.mjs
CHANGED
|
@@ -1,110 +1,117 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
|
|
3
|
-
* comes from its marks, while WHO uses the home comes only from its owner rows. A single
|
|
4
|
-
* "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
|
|
5
|
-
* never said which hosts the home record names or whether its inventory is complete.
|
|
6
|
-
* Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
|
|
7
|
-
*/
|
|
8
|
-
import fs from 'node:fs/promises';
|
|
9
|
-
import path from 'node:path';
|
|
10
|
-
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
-
import { check } from './doctor-format.mjs';
|
|
12
|
-
import { hasPackageMarks } from './host-inspection.mjs';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'warn',
|
|
58
|
-
'
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`${
|
|
110
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
|
|
3
|
+
* comes from its marks, while WHO uses the home comes only from its owner rows. A single
|
|
4
|
+
* "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
|
|
5
|
+
* never said which hosts the home record names or whether its inventory is complete.
|
|
6
|
+
* Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
import { check } from './doctor-format.mjs';
|
|
12
|
+
import { hasPackageMarks } from './host-inspection.mjs';
|
|
13
|
+
import { isFormat2 } from '../src/home/lib/install-owner-roots.mjs';
|
|
14
|
+
import { imageFingerprint } from './install-owners.mjs';
|
|
15
|
+
import { INVENTORY_CONFIRM_COMMAND } from './inventory-confirm.mjs';
|
|
16
|
+
|
|
17
|
+
const inventoryRepair = `; run ${INVENTORY_CONFIRM_COMMAND} if the recorded hosts are all of them`;
|
|
18
|
+
|
|
19
|
+
const ownerRepair = '; install into each host first: run codex-bridge install --host "<path>"';
|
|
20
|
+
|
|
21
|
+
// Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
|
|
22
|
+
export function ownerEntry(rawRecord, host) {
|
|
23
|
+
if (rawRecord === null || !isFormat2(rawRecord)) return null;
|
|
24
|
+
return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hostSideCheck(host, inspection) {
|
|
28
|
+
if (inspection.settingsError) {
|
|
29
|
+
return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
|
|
30
|
+
}
|
|
31
|
+
if (!hasPackageMarks(inspection)) {
|
|
32
|
+
return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const kept = inspection.files.filter((file) => file.disposition === 'keep'
|
|
36
|
+
&& ['changed', 'unknown'].includes(file.reason));
|
|
37
|
+
const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
|
|
38
|
+
const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
|
|
39
|
+
return kept.length
|
|
40
|
+
? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
|
|
41
|
+
: check('host files', 'ok', counts);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function homeImageExists(host) {
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(path.join(host.brandRoot, 'lib'));
|
|
47
|
+
return true;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return false;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function homeOwnersCheck(host, rawRecord, inspection) {
|
|
55
|
+
if (rawRecord === null) {
|
|
56
|
+
return await homeImageExists(host)
|
|
57
|
+
? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
|
|
58
|
+
: check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
|
|
59
|
+
}
|
|
60
|
+
if (!isFormat2(rawRecord)) {
|
|
61
|
+
return check(
|
|
62
|
+
'home owners',
|
|
63
|
+
'warn',
|
|
64
|
+
'old record format; the hosts using this home are unknown until the next install or update',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
|
|
69
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
70
|
+
const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
|
|
71
|
+
const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
|
|
72
|
+
const parts = [
|
|
73
|
+
`recorded: ${roots.length ? roots.join(', ') : 'none'}`,
|
|
74
|
+
inventoryComplete ? 'inventory complete'
|
|
75
|
+
: `inventory incomplete: an old record did not name every host${roots.length ? inventoryRepair : ownerRepair}`,
|
|
76
|
+
...(unrecordedMarks
|
|
77
|
+
? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
|
|
78
|
+
: []),
|
|
79
|
+
];
|
|
80
|
+
return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ownersInSyncCheck(rawRecord) {
|
|
84
|
+
if (rawRecord === null || !isFormat2(rawRecord)) {
|
|
85
|
+
return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const owners = Object.values(rawRecord.owners);
|
|
89
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
90
|
+
if (!owners.length) {
|
|
91
|
+
return check('owners in sync', 'warn',
|
|
92
|
+
`no recorded owners${inventoryComplete ? '' : `; inventory incomplete${ownerRepair}`}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fingerprint = imageFingerprint(rawRecord.image);
|
|
96
|
+
const parts = [];
|
|
97
|
+
const unstamped = [];
|
|
98
|
+
for (const owner of owners) {
|
|
99
|
+
if (owner.imageFingerprint != null) {
|
|
100
|
+
if (owner.imageFingerprint === fingerprint) continue;
|
|
101
|
+
} else if (owner.version === rawRecord.image.version) {
|
|
102
|
+
unstamped.push(owner.root);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
|
|
106
|
+
const why = owner.version === rawRecord.image.version
|
|
107
|
+
? `verified against an earlier image of ${owner.version}`
|
|
108
|
+
: `recorded ${owner.version}, image ${rawRecord.image.version}`;
|
|
109
|
+
parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
|
|
110
|
+
}
|
|
111
|
+
if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
|
|
112
|
+
if (!inventoryComplete) parts.push(`inventory incomplete${inventoryRepair}`);
|
|
113
|
+
const warning = parts.length > 0;
|
|
114
|
+
if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
|
|
115
|
+
return check('owners in sync', warning ? 'warn' : 'ok',
|
|
116
|
+
`${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
|
|
117
|
+
}
|