Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Opus 5.5 3bc66bf979 chore(web): regenerate agent docs for the integrated 0.10.3 batch
Ten independently reviewed slices (SEAM-2, REF-04, REF-08, first-run,
quota error, RU-15, release checklist, telemetry collector, Pet lockfile,
Rust 1.100.0 pin) merge onto main without conflict. The combined crate
surface changes the generated agent-docs index.

Validation: web check-facts and check-release-docs OK;
scripts/preflight.sh --check OK. Exact-SHA CI and RC dispatched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 04:56:42 -08:00
CodeWhale Bot 453a840e71 Merge commit 'e94fbf6e11' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot d865a7eaf1 Merge commit '00d3962085' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot 41e6a30894 Merge commit '705417fccd' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot 3471f45398 Merge commit '224764dc08' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot b0566ad706 Merge commit '64b7bfa4af' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot 4fbc0d394d Merge commit '4b1e691f3b' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot 995c39efae Merge commit '346cde38bd' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot af54e4e51e Merge commit '61d068842b' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale Bot 94406cd358 Merge commit 'db73296af6' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale Bot 0ed103ad5c Merge commit '0d8db3eb6b' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale BotandClaude Opus 5.5 e94fbf6e11 fix(toolchain): install rustfmt and clippy with the pinned 1.100.0 toolchain
Pinning channel = "1.100.0" made rustup install the pinned release with the
default component set on hosted runners, so rustfmt was absent in the Test jobs.
Five engine tests that format Rust through rustfmt then failed on all three OSes
with "rustfmt must be on PATH for the formatting tests" (CI 43387758446:
19419/19424 Linux and macOS, 18627/18632 Windows). The workflow contract forbids
per-workflow component adds, so the toolchain file owns the components.

Validation: .github/scripts/release-workflows.test.js OK; rustfmt resolves
under the pinned toolchain locally; scripts/preflight.sh --check OK. Exact-SHA
CI and RC dispatched. Refs CW-8205.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 04:54:52 -08:00
CodeWhale BotandClaude Sonnet 5.5 705417fccd chore(telemetry): qualify schema 6 collector compatibility
Preserve schema1 through5 validation and append bounded schema6 fields without changing the existing model-use columns. Schema6 remains first-party only. This prepares collector source; client emission stays schema4 until founder deployment and live v4/v6 endpoint acceptance.

Checks:225/225 tests in8files, TypeScript typecheck, localWrangler bundle dry-run passed. Independent correctness and privacy reviews approved. Actual Codewhale/OpenCodeGo deepseek-v4.1-flash prepared read-only source audit; coordinator rederived source and ordering. No deployment or live endpoint proof.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 00:14:03 -08:00
CodeWhale BotandClaude Sonnet 5.5 2d1ba71ed7 build: pin source toolchains and container recipes to Rust1.100.0
Carry the saved RU01 and RU08 work together so local builds, workflows,
Docker, Dev Containers and CNB agree. Keep the minimum supported Rust1.89
job isolated for its entire execution. Retain existing release source
pinning, macOS timeout and optional compiler-cache guards.

Harden the existing contract test against misplaced MSRV selectors,
unknown or expression-valued toolchains, and quoted stale image tags.
The old Tencent example now follows the same version.

Checks: full Node workflow contract passed (211 included Rust inputs,
11 wrapper event cases across12 steps,13 hermetic invocation checks);
22/22 dev-cache checks;13/13 independent negative probes rejected;
prebuild, generated-data freshness, formatting and preflight passed.
No native/image/hosted qualification is claimed by these source checks.

Original saved changes:09431b60c593 and2c12736202b2.
Worker: Codewhale/OpenCode Go deepseek-v4.1-flash, medium effort.
Independent Codex review approved the final25-path source.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:43:09 -08:00
CodeWhale BotandClaude Sonnet 5.5 00d3962085 Bring pet/tui/Cargo.lock in line with the workspace protocol crate versions
The lock still named codewhale-app-server-protocol 0.10.2 without its chrono
dependency, so cargo fetch --locked in the Pet conformance workflow refused.
Regenerated with cargo update --workspace; no other version changed.

Checks: current integrated cargo metadata --locked --offline passed;
201 packages resolved. Historical dedicated Pet workflow43216899528
passed4/4 jobs on the unchanged lockfile; current-parent Pet workflow
will be dispatched before landing. No native rebuild claimed here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:25:20 -08:00
CodeWhale Bot 64b7bfa4af test(web): keep installer fixture directories private across umasks
Create the happy-path install and home directories with mode0700 so a
host umask0002 does not turn them into destinations that the installer
correctly refuses. Production ownership and permission checks are unchanged.

Checks on Linux with the exact two-line fixture patch:33/33 installer
contracts at umask0002 and33/33 at0022;7/7 Node tests;1306/1306 Vitest
tests across92files. OpenNext/Cloudflare packaging completed and Wrangler
local dry-run exited0. TypeScript passed after the normal prebuild step.
These are source/local packaging receipts, not a deployment.

Independent Codex source review approved. Earlier Gemini Hydrogen
website dependency and adapter repairs are retained unchanged.
2026-11-12 23:20:49 -08:00
CodeWhale BotandClaude Sonnet 5.5 0d8db3eb6b feat(extension-host): gate service acts and retain sealed act receipts
Service acts use the session permission gate with child write/network
requirements. Reserve plugin management for the built-in creator module.
Record allowed and refused broker acts, and return those receipts when the
host throws, cancels or unregisters the tool. Seal the broker-act record
before returning so a late gate answer cannot authorize an unrecorded act.
Ordinary core-call receipts may remain in flight on cancellation; this does
not claim synchronous completion of all effects.

Preserves the reviewed SEAM-2 candidate and its real-host regression,
with complementary held-gate and successful-answer-after-seal tests.
Original candidate commits: 1d43f49bd41b55c287e2ced0702558cf2c10f3f9,
852332ad26 and 05c9de15c23394135129eedce9cc0f6f311e6fa6.

Checks on the frozen 22-file source over 622a493078:
- macOS mandatory Node selection: 277/277 passed; compiled Native: 2/2.
- Engine all-target Clippy with -D warnings: exit 0; fmt clean.
- Linux XL run 20261113T060722Z-seam2-xl-915835: 277/277 passed,
  6981 skipped, exit 0. The separate Large run is still pending.
The initial two missing-native-artifact failures were resolved by building
and explicitly supplying the supported fixture; original logs retained.
Integration adds mirrored changelog and regenerated docs; final candidate
full hosted CI and Windows qualification remain pending.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:15:37 -08:00
CodeWhale Bot 224764dc08 docs: align release checklist with exact-source CI
Remove stale Windows compiler-cache and main-push coverage claims. Keep release-candidate acceptance separate from normal CI and record confidential counts only in the private release record. Existing gate checkboxes remain unchecked.

Prepared by agy Gemini Hydrogen, then corrected and independently reviewed by Codex. Node release-doc check and diff check passed; this documentation change does not claim any release gate or publication.
2026-11-12 23:04:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 f2598ded67 docs: reword Windows smoke sccache checklist item as checkable (CW-7953)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:37 -08:00
CodeWhale BotandClaude Sonnet 5.5 5e1fa0877f docs: release checklist gains RC SHA, windows sccache, telemetry and security gates
Docs only; no CHANGELOG line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:36 -08:00
CodeWhale Bot 70fbe7aab6 fix(web): scope legacy lint allowances and retire the upstreamed adapter patch
Keep React lint errors enabled globally. Expose nine existing effect diagnostics as warnings on six exact component files and limit the server-clock purity exception to the revalidated home page. Preserve hydration, privacy and resource lifecycle behavior.

OpenNext 1.20.17 already includes server/preview-props.json, so remove the obsolete exact-byte monkey patch rather than rewriting the upstream loader. The old Linux packaging attempt failed its hash gate; a final clean packaging run is still pending.

Worker: agy Gemini Hydrogen, medium effort; independently reviewed by Codex. Independent ESLint checked 591 files with zero errors and nine warnings, and calculated configs prove exact exception scope. Pinned installed loader source/hash and its Next consumer match. Worker-reported test counts are not promoted to verified evidence without raw logs. No deploy or publication.
2026-11-12 23:04:34 -08:00
CodeWhale Bot c06ea6d11c chore(web): next 16.4.6, wrangler 4.161.0 and the OpenNext adapter update (CW-8205 RU-15)
Checks in web/:
- npm ci: exited 0 (880 packages)
- npm run build: exited 0 (Next.js 16.4.6 Turbopack, 979/979 static pages)
- npm run lint: exited 0 (0 errors, 0 warnings)
- npm test: 7/7 node --test (dependency-security.test.mjs), 92/92 vitest files (1306/1306 tests)
- npx tsc --noEmit: exited 0
- npm audit: 10 moderate before, 10 moderate after; npm run audit:dependencies exited 0 (10/10 mitigated, 0 blocked)

Worker: agy 1.5.7 with gemini-4-hydrogen-medium; reviewed and landed by the Codewhale coordinator.
2026-11-12 23:04:33 -08:00
CodeWhale Bot 4b1e691f3b fix(llm): reuse duration formatting and avoid repeated Go hints
Recover the saved quota-window fix using the existing command-contract duration formatter, so 70168 seconds reads as 19h29m and unknown resets omit the clause. Keep the OpenCode Go key guidance from repeating when an already-hinted error is displayed again. Extend the existing display regression and add mirrored release notes.

Worker: Codewhale on OpenCode Go deepseek-v4.1-flash, medium effort. Original saved Kimi-worker commit and author credit remain intact. Independent source review, rustfmt, changelog sync and diff checks passed. Focused exact-source Linux test is running; native and hosted gates remain required before landing.
2026-11-12 23:04:31 -08:00
CodeWhale BotandClaude Sonnet 5.5 2e2def655f fix(llm): print quota-window reset as plain text (CW-8209)
LlmError::QuotaWindow Display formatted retry_after with {:?}, leaking
Some(70168s)/None into user-visible text. Print the reset as plain text
("19 h 29 min", "H h", "N min", "N s") and omit the reset clause
entirely when no reset time is known. QuotaWindow and RateLimited gain an
OpenCode Go hint when the message names that provider.

Worker: Codewhale (kimi-k3 on OpenCode Go)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:30 -08:00
CodeWhale BotandClaude Sonnet 5.5 346cde38bd fix: make first-run download and trust choices clearer
Retry installer downloads up to three times, remove failed partials,
and use explicit digit choices for directory trust. Keep configured
provider errors while clarifying the fresh custom-route placeholder.
Align the Chinese trust keybindings and mirrored release notes.

Original author: CodeWhale Bot <[email protected]>
Carried only these three original commits:
  cb1505a37773270451cacd66de438221d7a08e1e
  20bfae466dbd131373f0d0e2a08f9e090779c654
  2c1e49c99901ff7a9a2f791e8e3f1ecd3a371c3e

The old gen-strings --check current claim is withdrawn: prior reviews
found the app check red on both base and candidate. No app-string
qualification is claimed here. Hard-link delivery and the original
saved-working-endpoint report remain unresolved separate acceptance.

Integrated-source checks: installer safety 76/76; website contract 33/33;
prebuild, fmt and diff checks pass. preflight passes. Rust gates pending.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 61d068842b refactor: retire unused APIs and the duplicate agent catalog
Preserve the reviewed REF-08 deletions and retained locale consumers.
Move the remaining static model admission into route_preferences while
retaining push18 live-catalog ownership and custom-route enumeration.
Regenerate web facts/docs and document removed crate APIs.

Original author: CodeWhale Bot <[email protected]>
Squashed from:
  2268c0d8a749c8794acc71af92c58e227186734e
  051cdc69126d8bbf8ed89ba371d3e605beda1820
  b4d6e16f196f1e72955140ff7d33960ee76a5863
  2a4c733b387aa1e8b2da10e4f6224f2fa99ad1d6
  c13c580ab7dfe5ed10b452fda2987eda960abc62
  3e6bb3616f377b9614fe17e7e4da7c0db5595ef4
  7620f050fbfd5cd47c48213fcfaa404e3d5f74d1
  459c0933eda34453ff739d292e1b1b42f049c308
  863fb512eb4ba6f49fe987287b2db36589711f91

Checks on this integrated tree: fmt, preflight, provider-registry and
diff checks passed; native workspace/all-target/all-feature Clippy is running; tests pending. Preflight: 109 dead-code
suppressions, 666 blocking-call sites within budget, 244 runtime routes.
Historical exact-tip evidence at 863fb512eb: 147/147 small-crate tests
and engine/core/hooks/localization check passed. Earlier full-suite
counts belong to prior trees and are not claimed for this integration.
Independent integrated-source review approved; exact-candidate native and
hosted CI gates remain required before landing. Corrected retained schema
documentation and the mirrored API-removal count after review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:26 -08:00
CodeWhale BotandClaude Opus 5.5 db73296af6 fix(subagent): remove the workspace-wide coordination lock
The v3 transaction lock and per-writer boot leases already own coordination.
Remove the lifetime process lock and its read-only fallback so one stale
session cannot keep later sessions read-only. Retry failed store admission
on later delegated writes, preserving image validation and lease ownership.

Pin an admitted Windows workspace directory without delete sharing. Keep
ordinary directory handles unchanged for plugin staging. The pin protects
the workspace and its ancestors, not children under .codewhale; that residual
boundary remains explicit in the review and release acceptance.

Integrated from 9a1e8e3b9f993a6adad30ad26d17a6d16e90737c; preserve the
original candidate and historical evidence. Updated lifecycle/architecture
docs, conformance fixtures, call sites and mirrored release notes together.

Validation on the integrated source: formatting, preflight and diff checks
passed (121 dead-code suppressions, 665 blocking sites, 244 runtime routes).
Independent source review approved. Focused Linux tests are running; exact
Windows convergence PASS and app-server compilation on all three platforms
remain required before landing. Earlier test counts are historical and are
not claimed for this integrated candidate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:23 -08:00
CodeWhale Bot 0002f88ca2 fix(api): keep repeated hook labels distinct in plugin index
Use the existing per-event ordinal for hook row identity. Hook names are optional labels and validation deliberately retains repeated names. The route regression covers repeated explicit labels and a label matching the unnamed fallback.

Validation: plugin-index route tests: 2 passed, 0 failed, 844 filtered out; cargo fmt --all -- --check and git diff --check passed; scripts/preflight.sh --check passed. macOS linker reported the existing __eh_frame size warning. Refs CW-7875.
2026-11-12 23:01:25 -08:00
CodeWhale BotandClaude Sonnet 5.5 fb43ff028a fix(config): show the telemetry setting default as on, matching the runtime
The settings schema declared telemetry default false while the resolver
(telemetry_consent_from_env) treats an unset value as on, so the settings
API reported default false for a default-on policy.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line the review wrote
(CW-7987 REVIEW.md finding 2; the packet's line was not used). This is the
committed part only: CW-7987 stays open for the active-version readback and
the ingest bundle rebuilt from the frozen candidate. Review: farm runs
20261112T110021Z-gtelrev-config-37102e (codewhale-config, 21 run, 21 passed,
823 skipped) and 20261112T110024Z-gtelrev-telem-751b20 (codewhale-telemetry,
53 run, 53 passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 23:01:24 -08:00
CodeWhale BotandClaude Sonnet 5.5 f8c3a015a4 feat(router): preserve explicit per-question decision abstention
An explicit abstained:true on a System One answer now survives decoding and the
routing receipt, and can never authorize Auto (tier), the thinking effort, a
shadow noul read or a shadow chat intent, even at a zero confidence floor. A
non-boolean abstained value fails answer decoding and is refused like any other
malformed answer. Provider usage evidence and the declared fallback are
unchanged. The custom decision endpoint half of the original change is not
included.

Receipt field abstained_questions touches crates/models, cost_status and the
tui activity_detail fixture outside the slice owner list; it is additive with a
serde default.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line from the review (CW-7992 REVIEW.md finding 1). Review: farm run 20261112T112213Z-leg01review-tip-faf9ea, 42 run, 42 passed, 7181 skipped; counterfactual run 20261112T112313Z-leg01review-cf-032c60 fails 2 of 3 with the guards removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 23:01:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 973d48e35c test(engine): expire the dropped-dispatch deadline only after the client call is entered (CW-8127)
The dropped-dispatch test gave the nested call a real 100 ms deadline. On a
slow runner that deadline could expire before bind_dispatch settled the
unknown-usage record, so the biased wall-clock select won with zero records
(left: 0, right: 1). Give the call a 600 s deadline, wait until the fixture
client is entered (the record is settled before the client is called), then
pause tokio time and advance 601 s so the deadline always fires after dispatch.
The wait selects on entered against the dispatch with a panic if the dispatch
ends first, inside a 10 s real-time timeout, so a regression that stops the
dispatch before the client fails at once instead of hanging. Every assertion is
unchanged. Product code is unchanged.

nextest stress 40/40 passed, 6 tests per iteration, target test 40/40 (farm run
20261112T113538Z-cw8127r1-new-33edc8); the old test under the same stress failed
1 of 20 at left: 0 (farm run 20261112T104528Z-cw8127-old-7569e0).
clippy -p codewhale-engine --all-targets --all-features -D warnings clean (farm
run 20261112T113538Z-cw8127r1-clippy-373b1e); cargo fmt --check and
scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 5be2da2a69 fix(app-server): plugin index names plugins by id, labels tools, signals cut name lists (CW-8049 PL-02 repair 1)
/v1/commands rows carry plugin_id (not the name) and workspace_sourced; the
index projects an active plugin's commands from those rows, labels native and
language-server plugins as tool, sources project commands as project, and
reports a cut or control-character name list under problems. Doc comment
removed.

Checks (Boat farm, Linux x86_64, at c3bc310042): nextest -p codewhale-app-server
-p codewhale-tui -E 'test(/plugin_index|command_catalog|get_v1_commands|user_registry|commands::discovery/)':
51 run, 51 passed, 6713 skipped (run 20261112T080110Z-plidx4-test-26f07e);
clippy -D warnings for engine, app-server and tui: clean
(run 20261112T080110Z-plidx4-clippy-124ba8). fmt, runtime-routes and facts
were the worker's local checks; preflight was not run on that tip.

Landing (push 18): rebased onto push 17; agent-docs.generated.json conflicted
with push 17's regeneration and was regenerated with derive-facts at each of
the two picks. docs/RUNTIME_ROUTES.md: check-runtime-routes PASS, no change.
CHANGELOG line from the round-2 review wording.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 9ca1c1c076 feat(app-server): one read route lists every plugin kind (CW-8049 PL-02)
GET /v1/apps/plugin-index projects the plugin registry, the built-in
switch catalog, skills, connectors, hooks and hand-written commands into
one list with kind, source and state. Each item embeds the row its owner's
route returns by calling that owner's handler, so a field an owner adds
appears here without a second edit; an owner that fails is named under
unavailable and the rest still list. A plugin item carries the names of the
skills, connectors, hooks, commands and profiles it adds, and those are
not listed again as items. Replaces nothing; the owners keep their routes.

A user command row now says which plugin added it (UserCommand.plugin,
CommandCatalogEntry.plugin, filled from the registry's plugin authority),
so a plugin's markdown command is not shown as hand-written. The wiring
in runtime_api.rs (mod line, route line, that field) and the
command_catalog and tui hunks are separable from plugin_index.rs.

Checks: farm nextest 6 run, 6 passed; farm clippy -D warnings clean for
engine, app-server, tui; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:19 -08:00
CodeWhale BotandClaude Fable 5.2 7179ae8d17 refactor(catalog): one model listing for both commands, and the inventory stops judging windows by name
With the readings proved equal on one captured catalog, the second copies
go:

- `route_preferences::list_models` no longer assembles its own roster
  (catalog rows, saved default, sort, dedup). It calls
  `provider_lake::listed_models_for_route`, the same function `models`
  prints.
- The routing inventory no longer asks the bundled catalog whether a model
  name has a context window to decide if a number is verified. The
  resolved route's own source decides; a route that does not resolve is
  unverified.

Farm (Linux x86_64), this commit: catalog_parity 1 test run: 1 passed;
config and engine lib tests filtered
`catalog model pricing cost_status route`: 1316 passed, 0 failed; clippy
-D warnings for config and engine (all targets, all features) and for cli
and app-server (all targets): clean.

Landing (push 18): CHANGELOG line for the pair (REF-05, CW-7971) from the
round-2 review, added in this commit.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:01:18 -08:00
CodeWhale BotandClaude Fable 5.2 117a1fa6ff fix(catalog): both model commands, the Runtime API, routing and cost read one captured catalog the same way
The new check loads one captured Models.dev catalog (the bundled seed,
sha256 7c48830c…d256) and an id-only Anthropic roster into a sealed home,
then reads three routes through `model list` / `model resolve`, `models`,
`GET /v1/providers/{id}/models`, the routing inventory and the cost
envelope: the official Anthropic route, a custom endpoint serving a
same-named model, and the Z.ai plan endpoint. It prints every reading and
fails on any disagreement.

On the previous source it found three:

- `model list` skipped a custom endpoint unless its table was literally
  named `custom`. It now lists every configured custom route under the
  route's own name.
- `model resolve` refused a custom endpoint's saved model ("no registered
  model") because the legacy registry was asked about an endpoint that owns
  its own roster. The registry is no longer consulted for those routes; the
  route resolver and a fetched roster still decide.
- The routing inventory gave a custom endpoint the context window and
  output limit of a same-named model and marked the window verified. It
  now takes the window, its source and the output limit from the resolved
  route, so a borrowed number is marked unverified and an unpublished
  output limit stays unknown.

`provider_lake::listed_models_for_route` is the list body of `models`
moved out unchanged so the check can read it.

One spelling difference is left and reported, not asserted: the inventory
normalises a listed `glm-5.1` to `GLM-5.1`.

One disagreement is also named, not hidden: a custom endpoint has no
published window, so routing still reports 200,000 for a saved model whose
name matches a known one (the router's long-context tag reads it), marked
unverified, while the Runtime API reports unknown. The check records the
Runtime reading as unknown and allows exactly that pair as a known
difference; any other window disagreement fails.

The check also pins the charge side: a custom endpoint gets no charge
(`UnknownBillingBasis`); the Z.ai plan route gets no charge
(`NotMoneyMetered`) even for a model that has a list rate, and both
surfaces show that same list rate; an id-only first-party model with no
price row reads unknown on both surfaces and produces no charge.

Farm (Linux x86_64): config, engine and app-server lib tests filtered
`catalog_parity catalog model pricing cost_status route`: 1444 passed,
0 failed; clippy -D warnings for config, engine, cli and app-server (all
targets, all features): clean.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 7e225f05ef fix(acp): a plugin tool's nested approval reaches the editor as a permission request that names the nested tool (CW-8050)
When a plugin tool asks the core to run a tool that needs approval, the ACP
projection resolves the nested execution id to the admitted plugin call and
shows the request on it. The request is built from the approval.required
payload (nested tool name, input, summary) so title, kind and rawInput show
what is being approved, and it carries no status so the running plugin call is
not reset to pending. The gates test and journey test live in acp_journey and
assert the second request names write and carries nested.txt.

Docs (RUNTIME_API, EXTENSIONS, zh_hans) and web/data/agent-docs.generated.json
updated.

Checks: nextest -p codewhale-app-server -E 'test(acp)' on the build farm,
48 run, 48 passed, 815 skipped; cargo fmt --check clean; preflight --check OK.

Landing (push 18): rebased onto push 17. The zh_hans EXTENSIONS and
RUNTIME_API paragraphs conflicted with push 17's CW-8071 wording of the same
ACP paragraph; kept push 17's text and added this change's sentences where
the English copies have them. agent-docs.generated.json regenerated with
derive-facts. CHANGELOG line from the round-2 review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:15 -08:00
CodeWhale Bot 9ddd373849 test(ci): validate bounded macOS artifact timeout branches
The release workflow now has a platform-specific bound, but three existing helper checks accepted numeric literals only. Parse only a numeric cap or the supported macOS conditional, require both branches to stay within 1–360 minutes, and assert macOS 360 versus other platforms 210. Keep nightly at 210 and verify compiler-cache installation and activation remain Linux-only.

Validation: full Node workflow contract passed (211 consumed inputs, 11 wrapper event cases with 12 steps each, 13 hermetic invocation checks). Independent review repeated it, rejected eight in-memory workflow regressions and checked eight parser edge cases. Prior Version drift CI failures were this stale contract, not a version mismatch. Exact new-SHA CI/RC are still required before landing.
2026-11-12 22:58:22 -08:00
CodeWhale Bot dc56a2c3be ci: give macOS artifact builds room to finish without sccache
Both macOS builds in RC 43354633748 on cbc255813d exhausted the
210-minute job limit. Their logs recorded repeated sccache server
shutdowns and local fallback without a Rust compiler diagnostic.
The cache failure is observed; its share of the elapsed time is unknown.

Restrict the optional compiler cache to Linux and allow macOS artifact
jobs 360 minutes. Other platforms keep 210 minutes. Preserve the dist
profile, source SHA pinning, import checks and downstream artifact gates.
Remove the obsolete timeout comment.

Validation: actionlint with ShellCheck disabled and git diff --check
passed, independently repeated by the reviewer. No Rust source changed.
The prior full CI passed at cbc255813d; exact new-SHA CI and RC remain
required before landing. No release or publication was performed.
2026-11-12 22:27:51 -08:00
CodeWhale Bot cbc255813d fix(tui): find the codewhale executable from a per-unit build directory
Cargo 1.100 places a test executable in <profile>/build/<package>/<hash>/out,
so the acceptance lookup took out as the profile directory and searched
target/out/codewhale, which failed 146 integration and 64 cucumber tests in the
release parity gate. A test executable under an out directory now resolves the
profile directory above the nearest build directory, and one test covers the
plain and cross-target layouts.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 3950ad2001)
2026-11-12 18:47:39 -08:00
CodeWhale Bot c8be6163e5 fix(release): qualify the two kernel32 imports Rust 1.100 adds to the Windows executables
Release candidate run 43320217667 built both 64-bit Windows targets for the
first time on the 1.100.0 stable toolchain and the import guard stopped on
kernel32.dll GetLogicalDrives and QueryDosDeviceW in codewhale, codew and
codewhale-tui. The pairs come from the Rust 1.100.0 standard library's Windows
file system code, not from a Codewhale call site.

ARM64: windows-arm64-export-probe.yml run 43330905781 on Windows 11 ARM64
26200.9725 resolved 449/449 imports of that candidate's executables (sha256
4d7dfba5...). The 2 pairs were added (473 -> 475) and the probe receipt replaces
the previous qualification fields, which move under supersedes.

x64: no native x64 export probe exists. The 2 pairs are namedApiContracts with
the Microsoft Learn page for each function (minimum supported client Windows XP
on both, read from the pages), below the unchanged 14393 floor and labelled
apart from the reporter-observed exports (471 -> 473). Floors, guard and x86
profile are unchanged.

Verified: the checker's own parse_imports over the retained llvm-objdump outputs
of the run's receipts leaves zero unqualified pairs for all six executables.
The executables were not kept, so the checker was not rerun on the bytes. Not a
14393, installer or startup run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 6d91b04a4b)
2026-11-12 18:47:37 -08:00
CodeWhale Bot 911e70dfdb fix(ci): guard npm wrapper smoke sccache with matrix.os
The wrapper-guard contract test evaluates step conditions without a runner
context, so use matrix.os, which it supplies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit d0da1d2c5e)
2026-11-12 18:47:34 -08:00
CodeWhale Bot 55ca20bc85 fix(ci): drop unused engine arboard and keep sccache off Windows legs
Cargo 1.100 lays build artifacts out per unit, so a rustc call for
codewhale-runtime carries about 330 search-path arguments (45K characters).
sccache expands the argument file and spawns rustc with the full line, which
Windows refuses past 32767 characters. The release-artifacts, nightly and npm
wrapper smoke Windows legs no longer enable sccache. codewhale-engine declared
arboard but never uses it; the TUI keeps its own.

Picked from ci/rc-fix b97e38645f (without its terminal.rs hunk).

Co-Authored-By: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 199ab3179d)
2026-11-12 18:47:29 -08:00
CodeWhale BotandClaude Opus 5.5 622a493078 feat(tui-kit): animated data displays an agent can send as JSON (CW-8202)
The vendored terminal kit could move things (MotionStep, MotionPolicy) but
had nothing that shows numbers. This adds three displays and a data
description of them:

- DataSparkline: one row, newest values on the right, with now/min/max in
  words. DataBars: a titled list of bars in eighth-cell steps that grow
  with a stagger and glide from previous values. DataTrend: a braille line
  chart with its range on the axis; it draws in from the left with a
  body-ink pen and falls back to a sparkline when too small.
- Every value is in the words, so colour and ink never carry a fact alone,
  and ASCII and NoColor profiles keep the rules. Draw-in reads a caller
  progress from the new MotionStep::drawn, so reduced and still motion
  paint the end state and the kit still never reads a clock.
- DataDisplay is the same vocabulary as serde JSON, borrowing the UI SDK
  idea (vercel-labs/ui) that a component says what it is and holds as data
  so an agent reads and writes facts, not pixels. from_json validates at
  the trust boundary (finite numbers, <=1024 points, <=24 bars, labels
  <=120 chars, trend rows 3..=16); describe() gives the plain-text facts;
  DATA_DISPLAY_SCHEMA is the tool schema.
- Gallery entries (6), examples/data.rs (live dashboard, --json FILE for an
  agent-sent display, --text, --svg DIR), CHANGELOG and COMPONENTS rows.

Not in this slice: an Engine tool or transcript event that lets the model
emit a DataDisplay. README's gallery count (199) and rendered previews are
not regenerated for the 6 new entries.

Checks (kit crate, standalone target): cargo test --lib 137 passed, 0
failed (13 new in components::chart, incl. rule checks over 9 profiles x
40/80/120 cols and an insta snapshot); cargo test --doc 11 passed; cargo
clippy --all-targets -D warnings clean; cargo fmt --check clean. No Engine
crate glob-imports the kit or uses the new names; Engine build not run
locally, CI covers it on landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 18:11:16 -08:00
CodeWhale BotandClaude Opus 5.5 8aa7707d04 fix(web): list MannXo with the post-0.10.3 contributors and drop the duplicate Qwen Code line (CW-8181)
5d7ca06a5a added @MannXo to RELEASE_CONTRIBUTORS, the website's 0.10.3
credits, but #7293 landed after 0.10.3: docs/CONTRIBUTORS.md lists MannXo in
"Unreleased — landed after v0.10.3", and the 0.10.3 "### Contributors" ledger
in CHANGELOG.md does not name them. public-copy.test.ts ("keeps
current-release website credits in exact changelog parity") then failed, which
failed the Lint & Type Check job of web.yml run 43344178264 and skipped the
Cloudflare deploy. MannXo moves to UNRELEASED_CONTRIBUTORS beside
SamhandsomeLee, matching the contributor doc.

The same commit meant to rewrite the Qwen Code changelog line with (#7293) but
added a second copy; the old "(PR 7293)" line is removed from CHANGELOG.md and
crates/tui/CHANGELOG.md, keeping the (#7293) form the release-note receipt
check looks for.

Evidence (web/, worktree claude-CW-8181b, on 97c1ad923d):
- public-copy.test.ts: passes (part of 5 files, 109/110; the one failure is
  ratatui/catalogue "Test timed out in 5000ms" with load average ~60)
- npm run check: exit 0 (facts OK, docs PASS, runtime routes 243 PASS,
  next build 979/979 static pages)
- npm test: 1303/1306; the 3 failures are 5 s timeouts in
  public-installer-contract and ratatui/catalogue that move between runs
  under that load, in files this change does not touch
- scripts/check-contributor-credit.py: "Every contributor in the window is
  credited on all three surfaces."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:37:28 -08:00
CodeWhale BotandClaude Opus 5.5 97c1ad923d feat(web): first-time guide for people who have never used a terminal (CW-8181)
New docs page /docs/first-time ("Use Codewhale for the first time", en + zh)
on the existing DocArticle renderer and docs-map registry. It covers opening a
terminal on macOS, Windows and Linux; installing and checking the version;
choosing the folder Codewhale works in and when that choice matters; opening a
terminal in a folder from Finder (Services > New Terminal at Folder),
File Explorer (Open in Terminal / Open PowerShell window here), `cd` + drag,
or `codewhale -C <dir>`; first run and provider choice; a first task; and
Esc, /undo, /diff, /receipts, /exit and `codewhale --continue`.

Every claim was checked against the v0.10.2 release, not main:
- workspace = current directory or -C/--workspace (resolve_workspace)
- file tools stay in the workspace unless /trust add, /trust on or Full Access
- Seatbelt on macOS, bubblewrap on Linux only when installed, no Windows sandbox
- snapshots and /undo are off in /, home, and ~/Desktop, Documents,
  Downloads, Library, Movies, Music and Pictures themselves
  (unsafe_workspace_snapshot_reason); snapshots do not need git
- no-git folders ask before edits ("No Git repository here, so edits ask
  first." in the v0.10.2 locale); the page avoids Accept Edits and the
  0.10.3 permission dial, and is now in the docs-release guard that keeps
  them out of every docs body while v0.10.2 is the published release
- Finder service names read from Terminal.app's Info.plist and its zh_CN
  ServicesMenu.strings on macOS 26.1

The install page gets a short "New to the terminal?" block for macOS and
Windows that links to the guide; llms.txt and the install markdown carry the
same steps (llms-txt contract test). The page also adds the topic to the
docs search index, the fallback topic list, and the GT catalog registry.

Regenerated web/data/agent-docs.* with npm run prebuild. That also picks up
docs/RUNTIME_API.md drift from 2c9174012e, which had left check:facts
failing with "generated agent docs are stale".

The new copy assumes the main web/public/install.sh (CW-8036), which adds the
PATH block itself; the live site serves the older installer until the next
web.yml dispatch, which ships this page and that installer together.

Evidence (web/, this worktree):
- npm test: vitest 92 files, 1306 passed, 0 failed
- npm run check:facts OK; check:docs PASS; check:locales PASS; gt-site check OK
- eslint (touched files) clean; tsc --noEmit exit 0
- next build: compiled, 979/979 static pages, including /{en,zh,ja}/docs/first-time
  and /docs-markdown/first-time.md
- next dev, built-in browser: /en/install and /en/docs/first-time at 583px,
  /zh/install and /zh/docs/first-time at 375px; no console errors, no
  horizontal page overflow, sidebar link and all internal links resolve
- main's installer in a throwaway HOME (zsh, default macOS PATH): installed
  v0.10.2, appended the marked block to ~/.zshrc, new `zsh -i` found
  codewhale 0.10.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:16:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 00cf9f002d fix(tui): satisfy clippy 1.100 needless_range_loop and unreachable_code
Iterate with enumerate in the context menu rows and two transcript tests, and
match the uninhabited backend error in the probe test instead of formatting it.
Behaviour unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 2cd5969e31 chore(web): regenerate agent docs for the CONTRIBUTORS entry and RUNTIME_API
npm run prebuild; the generated snapshot was stale for RUNTIME_API.md on main
and now also carries the CONTRIBUTORS.md change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 5d7ca06a5a chore(credit): credit MannXo for the Qwen Code registry change and cite the issue number
Map the commit author's name and email to the MannXo account, list them in
CONTRIBUTORS and the website release credits, and write the existing Qwen Code
line as (#7293) so the feature release-note receipt check finds it. check-
contributor-credit.py and check-versions.sh --range-audit-advisory pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 31178aec57 fix(engine): collapse four needless_bool tails flagged by clippy 1.100
Return the condition directly in auto_review tag-like check, fleet start_leased
result, DSH overlay_granted (cfg split) and ledger contract overlap. Behaviour
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:08 -08:00
CodeWhale BotandClaude Opus 5.5 5500fee639 docs(changelog): MCP and LSP clients answer server requests (PR 7319)
Merged as its author after two independent reviews (Opus 5.5, high;
correctness and attacker lenses, both "merge" at 8e8cd373ad) with every
check green on the PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 15:17:22 -08:00
落尘 30952497ca fix(mcp,lsp): answer server requests instead of taking them as replies (#7319)
* fix(mcp,lsp): answer server requests instead of taking them as replies

A JSON-RPC message with a `method` is a request or a notification, never
a reply, but both clients matched any message carrying an `id` against
their own pending requests. Servers number their requests independently,
so a server `ping` (MCP) or `workspace/configuration` (LSP) that shared an
id with an in-flight request resolved that request with no result, the
real reply was then dropped, and the server's request was never answered.

Both clients now skip messages with a `method` when matching replies and
answer server requests: MCP replies to `ping` with an empty result and to
anything else with -32601; LSP replies -32601 through the outbound queue
without blocking the dispatcher.

Tests: cargo test -p codewhale-lsp (65 passed); cargo test -p
codewhale-engine --lib mcp:: (371 passed). The two new tests fail on main.

Fixes #7306

* fix(mcp): keep the in-flight reply when answering a server request fails

The reply to a server request (`ping`, or -32601 for anything else) was
sent with `self.send(reply).await?`. A failed send failed the
`tools/call` it interrupted, even though the server's real answer was
still on its way, and marked the connection disconnected. Over HTTP it
was worse: a 404/405/406/415/501 on that POST made the transport fall
back to legacy SSE, dropping the Streamable HTTP messages still queued
for the request in flight.

The reply now goes through a new `McpTransport::send_reply`, which
defaults to `send`; HttpTransport overrides it to report a refusal
without changing mode. A failure is logged at debug level and not
propagated, and the connection state is left alone.

Tests: cargo test -p codewhale-engine --lib mcp:: (373 passed); clippy
--all-targets -D warnings clean. The two new tests fail without this
change.
2026-11-13 07:16:25 +08:00
CodeWhale BotandClaude Opus 5.5 9c10aabf78 docs(changelog): five contributor fixes merged today
Session titles count characters (PR 7322), non-ASCII paths shown as written
(PR 7317), session index entries after a torn line and snapshot retention
(PR 7316), blank environment variables no longer hide their aliases
(PR 7315), and Qwen Code as an external agent (PR 7293). Each merged as its
author after an independent review (Opus 5.5, high; two lenses on PR 7293)
with green required checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:53:22 -08:00
Parman Mohammadalizadeh 2c9174012e feat(engine): add Qwen Code to the external agent registry (#7293)
Qwen Code starts in ACP mode with `qwen --acp`, the flag that replaced
`--experimental-acp` in 0.6.1, so that is the version floor. Sign-in is
`/auth` inside an interactive session (`qwen auth` was removed), so the
row has no login command.
2026-11-13 05:52:26 +08:00
落尘 898c192257 fix(config): read every env alias chain through env_non_blank (#7315)
* fix(config): read every env alias chain through env_non_blank

env_non_blank exists so that a variable exported empty does not shadow
its legacy alias, but most of EnvRuntimeOverrides::load still read
`env::var(A).or_else(|_| env::var(B)).ok().filter(non_blank)`. An empty
A returns Ok(""), the or_else never runs, the filter then drops the
empty value, and a set B is ignored. CODEWHALE_MODEL= with
DEEPSEEK_MODEL set resolved to the default model; the same held for
CODEWHALE_BASE_URL, CODEWHALE_HTTP_HEADERS, the NVIDIA, Volcengine,
Wanjie, MiMo, Moonshot/Kimi, Hugging Face, Codex, Z.ai and StepFun
alias chains, and CODEWHALE_FORCE_HTTP1 / CODEWHALE_STREAM_OPEN_TIMEOUT_SECS.

Route all of them through env_non_blank, which also replaces about 240
lines of repeated filter chains. CODEWHALE_TELEMETRY_ENDPOINT keeps
reading an empty value on purpose, and CODEWHALE_YOLO is unchanged
because falling through to DEEPSEEK_YOLO would widen approval.

The new test fails on main (model None instead of legacy-model) and
passes here: codewhale-config lib 833 passed, 0 failed.

Refs #7297

* chore: keep changelog edits out of the branch

* fix(ci): accept env_non_blank chains in the provider registry env check

The provider registry drift check looked for the Hugging Face base URL
and model aliases only as literal std::env::var("NAME") calls, so it
failed once those chains read through env_non_blank. An
env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]) list states the
same lookup order, so it now satisfies the check; any other spelling
still falls back to the ordered std::env::var search.

python3 scripts/check-provider-registry.py: passed (failed before this
change on this branch).
2026-11-13 05:52:18 +08:00
落尘 3f2a019444 fix(state,snapshot): keep index entries after a torn line and clamp prune age (#7316)
* fix(state,snapshot): keep index entries after a torn line and clamp prune age

Session index: a crash mid-append leaves a final line with no trailing
newline. The next append_thread_name wrote straight after it, so the new
entry joined the fragment, failed to parse and was skipped, and the name
the caller had just saved was lost. Start the entry on a new line when
the file does not end in one. The existing torn-line test wrote its
fragment with writeln!, so it never covered the unterminated case.

Snapshot prune: cutoff was `now - max_age.as_secs() as i64`. A
max_age_days above about 1.07e14 (for example i64::MAX as "keep
forever") gives more than i64::MAX seconds after saturating_mul, the
cast wraps negative, the cutoff lands in the future and every snapshot
is pruned at boot. Clamp to i64::MAX and use saturating_sub.

Both new tests fail on main (thread-3 lookup None; 1 snapshot removed
instead of 0) and pass here: codewhale-state 28 passed,
codewhale-snapshot 92 passed.

Refs #7298

* chore: keep changelog edits out of the branch
2026-11-13 05:52:10 +08:00
落尘 eef19b9d30 fix: show non-ASCII paths as written in /diff and git mentions (#7317)
* fix: show non-ASCII paths as written in /diff and git mentions

Git quotes paths with non-ASCII bytes as octal escapes by default
(core.quotePath=true). /diff, the @git and @diff mentions and the
Runtime API workspace diff passed that output on unchanged, so a file
named `中文 目录/笔记 📝.md` reached the user and the model as
"\344\270\255...". Git::review_base now sets core.quotePath=false next
to its other runtime overrides, and the two /diff readers that do not
go through it (the snapshot work-tree diff and the workspace git
fallback) pass -c core.quotePath=false.

The working set also recorded the workspace root as an empty
"Active paths" entry ("-  (dir)") when a message carried the bare
workspace path, which an @-mention of a non-ASCII file name does
because the path regex stops at the first non-ASCII segment. An empty
relative path is no longer recorded.

Tests: codewhale-snapshot work_tree_changes_since (2 passed),
codewhale-runtime working_set (34 passed), codewhale-tui git_mention
(15 passed), codewhale-dependencies (55 passed). The new snapshot and
working-set tests fail without the fix.

Refs #7303

* chore: keep changelog edits out of the branch
2026-11-13 05:52:02 +08:00
落尘 f9236ad8f7 fix(tui): count characters, not bytes, for the session title limit (#7322)
Renaming a session rejected a 36-character Chinese title as too long because the check used byte length against 100. Use MAX_SESSION_TITLE_CHARS with a character count, matching the 1–100 characters message.
2026-11-13 05:51:54 +08:00
CodeWhale BotandClaude Opus 5.5 5dac4e1797 fix(credit): say what GitHub actually counts, and fail when a harvest, trailer or noreply id would leave someone uncounted
GitHub's contributor graph takes commit authors and the Co-authored-by
trailers in a commit's final trailer block whose address is linked to an
account. Accounts such as czf0718, baendlorel, OctoBored, PurplePulse and
RinZ27 appear in it with no authored commit on main, so the old wording
("counts commit authors only") in AGENTS.md, cw-land, AUTHOR_MAP and the
wall script was wrong, and it hid the honest fix for lost credit: a later
commit with a well-formed trailer.

The audit behind this found three ways credit was lost that nothing caught:
a harvest line with no trailer (seven people harvested in the v0.8 releases), a
trailer separated from the trailer block by a blank line (dmitri-0 in
d19a6eaa75), and a noreply address whose numeric id belongs to someone else
(ddlw's 17 commits use 13579959+ddlw, which GitHub credits to another
account). check-contributor-credit.py now reads every commit's trailers in one git log
call with %(trailers:key=Co-authored-by,unfold), the parser git
interpret-trailers uses, and, across the release window, fails on each of
these unless some commit in the window credits that person in a way GitHub
links: a noreply address whose numeric id matches AUTHOR_MAP, or a raw
email AUTHOR_MAP lists for them. A bare name, or an address the map does not
vouch for, credits nobody. A commit whose subject starts with chore(credit):
and whose author is a maintainer identity restores credit for older work, so
it counts toward GitHub credit but is not treated as work landing in the
release; the subject alone, or the same words in a body line, exempts
nothing.

What this does not do: a contributor whose own git config carries a wrong
noreply id fails the check in every release window where they commit, until
a chore(credit): commit in that window names them correctly (ddlw is still
active, so the next window needs either that or their own fix). Harvest
lines in a maintainer's chore(credit): commit are not checked, so real work
must not land under that subject. A Co-authored-by line quoted in a commit
body outside its trailer block is reported for the rest of the window. A raw
email listed in AUTHOR_MAP waives even if the person never linked it (the
map carries some local machine addresses for history), so an alias belongs
there only once it is linked. A harvested contributor with no AUTHOR_MAP line
fails the check until one is added, which cw-land now says.

AUTHOR_MAP: the header describes trailers and the numeric id; wdw8276 and
angus-guo now point at their renamed accounts (mars-base, imgusguo, same ids);
bengao168 and 894876246, the handles two harvest lines used, map to gaord and
idling11. Four identities GitHub already counts are added so the stricter
waiver still sees them: the commit emails [email protected] and
[email protected] (LING71671), and the logins BX166 and c020627, each with
the id from gh api users/<login>. .mailmap unifies the commit identities found in the audit for
git shortlog (296 to 294 names); GitHub reads neither file.

Checks: python3 scripts/check-contributor-credit.py exits 0 on the v0.10.2
window with the credit commit (16 contributors, all credited); on origin/main
without it, the same script exits 1 and lists 17 ddlw commits with a foreign
noreply id. On the v0.8.40 window (13,286 commits) the "Credit GitHub
cannot see" section lists only mky's 147e407464, whose trailer sits outside
the trailer block. With the same AUTHOR_MAP, output on both windows is
identical to the earlier version of this check apart from the added hint
lines, in 1.19 s and 0.33 s. Synthetic commits in an isolated clone of
f5a3dda975 with this script and map: a forged
"Co-authored-by: ddlw <[email protected]>" trailer, and a commit authored
as ddlw with an unlisted email, both still exit 1 with 17 ddlw lines; a
contributor commit with chore(credit): as its subject or as a body line stays
in the owed set (17 to 18); a maintainer chore(credit): commit with ddlw's
own noreply address exits 0; a harvest without a trailer, a trailer cut off
by a blank line and a foreign id for a mapped login each exit 1.
scripts/preflight.sh --check: OK. git diff --check: clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:17 -08:00
+21 2a0658b907 chore(credit): restore GitHub credit for 29 contributors whose work reached main unlinked
Each person below wrote code that is on main, through the pull request
named beside them, but GitHub's contributor count never saw them: the
commit used an email not linked to their account, a maintainer re-landed
or squashed the work under a maintainer identity, or the trailer was
malformed. History is not rewritten. This commit records their GitHub
identities in .github/AUTHOR_MAP and carries one Co-authored-by trailer
per person, with the numeric noreply address from the GitHub users API,
so the count can include them.

- AiurArtanis: Solarized Light theme, PR #2270 (commit c4a2f40723 used an unlinked email)
- Apeiron0w0: FocusGained recovery debounce, PR #1560 (9038e70bcb, unlinked local email)
- ChaceLyee2101: reasoning tokens in cost and CNY on zh-Hans, PR #1505; zh-CN README sync, PR #1504 (946c12b4d3, 13bfcf9e39, unlinked email)
- ddlw: installer download dedupe, PR #7107; mcp formatting, PR #7108; DSH receipt bytes on Windows, PR #7109 (commits used a noreply address whose numeric id belongs to another account)
- dmitri-0: phase strip cache hit rate, PR #4474 (a376f88ecf used a local email; the trailer in d19a6eaa75 sits outside the final trailer block)
- merchloubna70-dot: thirteen merged pull requests, PRs #597, #646, #648, #649, #652, #653, #654, #655, #658, #659, #661, #668, #669, plus re-landed work from #600, #601, #602, #645, #656, #657, #660, #671 (their commits used unlinked emails, and the squash commits are authored by a maintainer)
- mo-vic: /purge context pruning command, PR #2387 (a21e9e5c83, unlinked email)
- Jianfengwu2024: MSVC toolchain variables for Windows exec_shell, harvested from PR #1487 (383ed44fe8)
- alphapku-ux: toast stack no longer covers the composer, harvested from PR #1485 (58c39ebc91); alphapku-ux authored the pull request's one commit, and the MeAiRobot account that opened it, which may be the same person, is not credited a second time
- MMMarcinho: image_analyze tool, harvested from PR #1467 (3aaf0ad95e)
- NorethSea: update refreshes the sibling TUI binary, harvested from PR #1492 (23d3a71126)
- sandofree: Tavily and Bocha web search backends, harvested from PR #1294 (40df46c73d)
- tiger-dog: identifier underscores and the approval banner, harvested from PR #1455 (02f889f193, a57cb4dfa7)
- YaYII: opt-in /translate command, harvested from PR #1462 (d62facafac)
- wplll: cache-aware prompt diagnostics, PR #1196 (54ca5718d2); cache inspect, warmup keys, pack ordering and dedup tests, harvested from PRs #2390, #2391, #2392, #2393
- xulongzhe: ACP conversation history across turns, PR #3372 (9d2950fe59, unlinked email)
- ZhulongNT: NSIS installer and classroom admin checklist, PR #2045 (c2c36cca11, unlinked email)
- xiluoduyu: reject no-op edit_file replacements, PR #1460 (1352a65ac2, unlinked email)
- wlon: pager G/End overshoot fix, PR #1706 (v0.8.39); LSP diagnostics analysis update, PR #1130 (ad31d2bcec, trailer used an unlinked email)
- krisclarkdev: compaction keeps the user text query, PR #1704 (v0.8.39)
- THatch26: terminal resize paging fix, PR #1724 (v0.8.39)
- jeoor: installer response streams pause before parsing, PR #1860 (b94ccd3897)
- harvey2011888: loop guard halt reports Failed, PR #1859 (236ad4137d)
- duanchao-lab: terminal mode cleanup guard on early exit, PR #1630 (f23c9828b7)
- dumbjack: null bytes rejected in shell commands, PR #706 (1122bb0333)
- adity982: qualified MCP tool calls run once, PR #4756 (cccaa4f3f)
- AquaZhang: npm wrapper -v passthrough and its test, PR #959 (593ed2f900)
- disappear00: panic-prone edge cases, PR #957 (063d5d7d99)
- namezzb: cost currency persists from /config, PR #956 (d2e9f58756)

Co-authored-by: AiurArtanis <[email protected]>
Co-authored-by: Apeiron <[email protected]>
Co-authored-by: ChaceLyee2101 <[email protected]>
Co-authored-by: ddlw <[email protected]>
Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: merchloubna70-dot <[email protected]>
Co-authored-by: movic <[email protected]>
Co-authored-by: Jianfengwu2024 <[email protected]>
Co-authored-by: Alpha Wu <[email protected]>
Co-authored-by: MMMarcinho <[email protected]>
Co-authored-by: NorethSea <[email protected]>
Co-authored-by: Ye Wang <[email protected]>
Co-authored-by: tiger-dog <[email protected]>
Co-authored-by: YaYII <[email protected]>
Co-authored-by: wplll <[email protected]>
Co-authored-by: luola <[email protected]>
Co-authored-by: SONG ZHENRUI <[email protected]>
Co-authored-by: xiluoduyu <[email protected]>
Co-authored-by: Stephen Xu <[email protected]>
Co-authored-by: Kristopher Clark <[email protected]>
Co-authored-by: THatch26 <[email protected]>
Co-authored-by: Kayro <[email protected]>
Co-authored-by: harvey2011888 <[email protected]>
Co-authored-by: DC <[email protected]>
Co-authored-by: dumbjack <[email protected]>
Co-authored-by: ADITYA <[email protected]>
Co-authored-by: IceTear <[email protected]>
Co-authored-by: idontknow <[email protected]>
Co-authored-by: sailorZ <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 08c1abd4ea fix(release): qualify 3 SID accessor Windows imports for 0.10.3
Release candidate run 43186389246 on f5a3dda975 stopped the Windows import
guard on advapi32.dll GetSidIdentifierAuthority, GetSidSubAuthority and
GetSidSubAuthorityCount in codewhale, codew and codewhale-tui on both 64-bit
targets (from crates/config private_directory.rs, b3e874b8fb).

ARM64: existing windows-arm64-export-probe.yml run 43197611105 on Windows 11
ARM64 26200.9725 resolved 447/447 imports of that candidate's executables
(sha256 c8c6f97d...). The 3 pairs were added (470 -> 473) and the probe receipt
replaces the 2026-11-07 qualification fields, which move under supersedes.

x64: no native x64 export probe exists. The 3 pairs are namedApiContracts with
the Microsoft Learn page for each function (minimum supported client Windows XP
on all three, read from the pages), below the unchanged 14393 floor and labelled
apart from the reporter-observed exports (468 -> 471). Floors, guard and x86
profile are unchanged.

Verified: the checker's own parse_imports over the retained llvm-objdump outputs
of the run's receipts leaves zero unqualified pairs for all six executables
(3 x64 at 455 pairs, 3 ARM64 at 447), where before it left 3 each. The
executables were not kept, so the checker was not rerun on the bytes; the next
release candidate run on the landed SHA is the real check. Not a 14393,
installer or startup run. json.tool and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:15:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 63a964429c fix(web): Plugins page names what each permission mode does with an agent-written plugin and the real skill command (CW-8087 PL-04 repair 1)
Auto-Review's reviewer model judges and Plan refuses, per the recorded ruling, so the
page no longer says approval is always asked outside Full Access. /skill install is
the command that adds a skill. The coming lead says the rest of the page is the
released version, and the meta description no longer carries a sentence that outlives
the 0.10.3 release.

Checks: vitest 92 files / 1303 tests passed, tsc exit 0, eslint exit 0, check:facts exit 0,
next build exit 0; built /en and /zh plugins pages fetched and show the new text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 8f1ef4bfef feat(web): the Plugins page says plugin for what a person adds and labels what 0.10.3 brings as coming (CW-8087 PL-04)
The page described "everything is a plugin" as if the released terminal
already listed skills, connectors and hooks as plugins. It does not: a plugin
bundle carries them, and /skills, /mcp and /hooks add them one at a time. The
title, lead and meta now say that, and the heading "One format for every
extension" becomes "What a plugin can add", since one format is not released.

What 0.10.3 adds goes in a new section, "Coming in 0.10.3, not in the current
release": one Plugins place with kind labels, Standard and Creator mode per
conversation, the approval rule for a plugin the agent wrote, and the old
Plan/Work control renamed Permissions. The section renders only while the
published release is older than 0.10.3 (pluginsComingApplies) and is in the
site search under the same condition. English and Simplified Chinese carry the
text; the other 16 languages show the English page body through the existing
fallback, and no dictionary key was added or removed, so the 18-language
parity checks are unchanged.

Also: the authoring button opens the site's own plugin-authoring page (written
from the released tag) instead of the main-branch document; the DeepSeek
Harness compatibility link says it describes current source, because that
document is not in the v0.10.2 tag; the desktop app page names Plugins as one
noun with skills, connectors and hooks inside it; docs map descriptions say a
connector is an MCP server, a hook is added, and a skill can come inside a
plugin; the unrendered product copy says a skill is shared inside a plugin.

Checks run in web/: tsc --noEmit, eslint, check:facts, check:locales,
check:docs, vitest (91 files, 1301 tests plus 2 new), next build; the built
/en, /zh and /de plugins pages were fetched and the coming section checked.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:15:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 08f1797b26 docs(plugins): repair round 2 for review of CW-8071
plugin-creator step 7 says to write the Full Access plugin in an ordinary
project folder, because install refuses a name already used by the bundle
step 1 creates. English RUNTIME_API gets the same after-0.10.2 label as zh.
Agent docs regenerated; preflight --check OK.

At landing: the skills and plugins rows in docs/features.toml are
shortened to the registry's 120-character summary limit (they were 122
and 153, which failed feature_registry_matches_code); features JSON
regenerated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 a2264f350c docs(plugins): repair round 1 for review of CW-8071
Docs no longer claim a single Plugins list exists: hand-made skills, servers
and hooks are plugins of a kind, still managed in Extensions and Customize,
and the one list is stated as planned for 0.10.3. plugin-creator step 7 now
gives the install, trust with review token and enable steps that the Full
Access exception accepts. Drop the generation 22 comment. zh RUNTIME_API says
ACP plugin and MCP tools are main after 0.10.2.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 451c78c637 docs(plugins): the docs say plugin, in English and Simplified Chinese (CW-8071)
PLUGINS, SKILLS, MCP and HOOKS open by saying everything a person adds is a plugin;
a skill, an MCP connector and a hook are each a plugin or part of one and keep their
kind as a label, with their files, trust rules and commands unchanged. Connection
stays the word for a model provider or another agent and nothing else. PLUGINS no
longer says DSH plugin UI is unsupported. features.toml names the three kinds as
plugin kinds (ids unchanged); agent docs and feature data regenerated.

Checks: scripts/preflight.sh --check (see the review packet).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 b43716b7fc docs(plugins): correct the stale creator texts of ruling R8 (CW-8071)
plugin-creator no longer tells an agent never to automate trust: in Full Access an
agent may trust and enable a plugin it wrote and installed from its own project,
through the session's approval, recorded as the agent's (section 142.4), and code or
screen plugins point at cordis-plugin-development instead of a second format. The
0.10.2 body (generation 19) is retained and listed by hash so untouched copies
upgrade; the bundled generation is 22. DSH_PLUGIN_COMPAT now says imported client
UIs are served on the admitted seats instead of unsupported. The Simplified Chinese
EXTENSIONS and RUNTIME_API say ACP sessions get the extension host, plugin tools and
configured MCP servers, as the English does. The vocabulary script says /pod answers
Unknown command and codewhale pod is prompt text.

Checks: codewhale-skills nextest 188 passed 0 skipped; clippy -D warnings clean;
scripts/check-tui-product-vocabulary.sh exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:27 -08:00
CodeWhale BotandClaude Sonnet 5.5 4aca7d114a fix(extension-host): cordisInspect commands.register states the alias and refusal rules the registry enforces (CR-06a, CW-8073)
A built-in clash gets plugin-<plugin-slug>-<command> and is refused if invalid or taken; a name another plugin registered is refused. Catalog regenerated; --check exit 0; inspect tests 17/17 on Node and Bun; typecheck clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:26 -08:00
CodeWhale BotandClaude Sonnet 5.5 ba9b5a2642 fix(extension-host): cordisInspect catalog states the persona section and activation result contracts the host enforces (CR-06a, CW-8073)
systemPrompt.section is named deployment:persona-prefix with the order getSectionOrder returns; subagents result is a getter resolving to stopReason and output blocks. Command, avatar and hook limits corrected; non-JSON provider output is refused as DSH does. The method-existence test now checks each method on the class that serves it; a renamed shim method fails it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:24 -08:00
CodeWhale BotandClaude Sonnet 5.5 cc0b29971f feat(extension-host): cordisInspect data that describes this host (CR-06a, CW-8073)
A dependency-light cordisInspect service with list() and query() in the
shape DSH's cordis_inspect_list/query tools call. Host providers: Service,
Event, Config (Tool only when a source is supplied); client providers:
Slots and Theme. The catalog is generated from compatibility.json, the
host's refused-event list in root.ts, the shell-hook event map and the
Engine seat table, plus one hand-kept method table; manifests pass DSH's
own supported-schema check and inputs its validator. Not wired to the live
host (CR-06b); dist/ is not regenerated.

Checks: node --test test/inspect.test.mjs 15 pass 0 fail; bun test 15 pass
0 fail; npm run typecheck clean; generator --check clean; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:22 -08:00
CodeWhale BotandClaude Opus 5.5 eee770b34b docs(sandbox): Fleet workers keep the managed variable; named files fail closed; serve --mcp refuses shell (CW-8052)
CHANGELOG: the Sandbox bullet's managed-configuration sentence now says what
the four CW-8052 commits do.

Checks: round 0 nextest 987/987 passed and clippy clean on runtime, engine,
app-server and TUI; repair round on the rebased tip nextest 1058/1058 passed
(runtime lib 922, engine fleet::host 26, TUI config_stay 110); preflight OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:21 -08:00
CodeWhale BotandClaude Opus 5.5 e758fb0d98 fix(mcp): serve --mcp does not expose shell tools under a tool launcher (CW-8052)
The stdio MCP server builds its own ToolContext, whose shell manager has
no launcher, deny list or prefer_bwrap. When the loaded config carries a
managed tool_launcher, run_mcp_server now removes every expose_tools entry
that maps to exec_shell (shell, exec_shell) before the registry is built,
so no shell tool is registered, and names each one on stderr. Wiring the
launcher would also need the deny list and read guard; refusing is the
closed form the review allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:19 -08:00
CodeWhale BotandClaude Opus 5.5 f0bd96633b fix(fleet): local workers keep the parent's managed config variable (CW-8052)
A Fleet local worker is spawned with a cleaned environment rebuilt from
the child allow-list, which has no CODEWHALE_* names, so it lost
CODEWHALE_MANAGED_CONFIG_PATH and read the user config's
managed_config_path instead. base_env_from now carries both managed-config
names from the parent for every local worker (not only when a sessions
directory is set), and worker_env drops them from the request so an
allow-listed request cannot replace or add them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:18 -08:00
CodeWhale BotandClaude Opus 5.5 2561e2924f fix(config): a named managed file that cannot be read is a load error (CW-8052)
One helper, managed_config_file, now resolves the managed path for the
loader and for the three "who controls this" readers: the variable first
(an empty CODEWHALE_MANAGED_CONFIG_PATH defers to the legacy alias instead
of hiding it), then the config file's managed_config_path, then the
default path. The variable is read through codewhale_env_var, under the
test environment lock.

A file named by the variable or by managed_config_path that is missing,
a dangling link, a directory or unreadable used to load with no managed
layer; it now fails the load. Only a missing file at the default path
means no managed layer, and a config file's managed_config_path can no
longer point away from an existing default-path file. The readers report
Ambiguous where the loader would fail.

A provider that only a plugin declares used to fail under a managed file
with the saved-session message; it now says that plugin providers are not
usable under a managed configuration and what to do.

Tests that isolated themselves with an absent managed file now name an
empty one (no_managed_config writes it). New test covers missing,
directory, config-key and blank-variable cases; the late-provider test
now pins the error instead of passing on any Err.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 8676887110 fix(usage-reporting): installers force the kill switch for their own run; doctor names a saved opt-out as config (CW-8033)
The installers (web/public/install.sh, scripts/release/install.sh,
scripts/release/install.ps1) now run their closing `doctor --installs --brief`
with CODEWHALE_TELEMETRY=0 and CODEWHALE_NO_UPDATE_CHECK=1 for that command
only, and print one line saying usage reporting is on by default and how to
turn it off. v0.10.2 already honours both variables, so nothing needs the next
release. `doctor` is classed read-only and does not arm reporting at v0.10.2 or
main, so this is a guard that does not depend on that classification.

The dispatcher hands the child CODEWHALE_TELEMETRY with the resolved value, so
a saved opt-out read as an environment kill switch and doctor printed "(env)".
The dispatcher now also passes CODEWHALE_TELEMETRY_SOURCE; the child uses it
only to label the source, only next to the dispatcher's floor statement, and
never to change the decision. A hand-set CODEWHALE_TELEMETRY=0 still reads
"(env)".

Wording: the two clap help strings and the English TUI strings say "usage
reporting".

Checks: node install-safety.test.js 74 passed; farm nextest of the four new or
touched tests 4 passed, and a wider telemetry sweep (config, cli, tui,
localization) 206 passed, 1 failed, the failure being a test expectation fixed
in this commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 9fe5e64377 fix(engine): withhold tools whose encoded name exceeds the route limit; make decode exact (CW-8041)
to_api_tool_name expands every character outside [A-Za-z0-9_] (a dot is
nine characters, a hyphen two), so a long qualified MCP or plugin tool name
could encode past the provider limit and fail every retry with a 400 on
tools.N.name. A shortened name could not be decoded back, so no shortening
is attempted: the encoder stays injective and nothing is truncated.

Policy, applied in the one encoder module (client/wire.rs) and used by the
chat, responses and Anthropic builders:
- A tool whose encoded name exceeds the limit is withheld through the
  existing omitted-tools path. Forcing it with tool_choice fails with an
  error giving the tool name, its encoded length, the limit and what to
  change.
- A tool name already present in message history that exceeds a documented
  limit (Anthropic, OpenAI and Codex, DeepSeek) refuses the request with
  its own message: the tool was called earlier in the session, so switch to
  a route with a larger limit or start a new session. Routes with no
  documented limit send history unchanged. History is never rewritten.
- The omitted-tools warning (core/events.rs, one message) names the tools
  and states the limits and what to do. The exact encoded length is not in
  it: the warning carries names already cut to 64 characters; adding it
  needs a new field on the warning event.

Limit rule, in tool_name_limit(provider, wire): where a provider documents
its own limit it is used; otherwise the default is the limit of the wire
the route speaks, 128 on the Anthropic Messages wire and 64 on the OpenAI
chat and responses wires, except Ollama, Ollama Cloud, vLLM, SGLang and
custom routes, which have no name limit and use 128.
- Documented, read 2026-11-10: Anthropic 128 (define-tools page,
  ^[a-zA-Z0-9_-]{1,128}$); DeepSeek 128 on its chat and Anthropic wires
  (chat-completion API reference); OpenAI 64 (OpenAPI spec, function name
  description).
- Wire default, no documented limit found: MiniMax and ModelStudio on the
  Anthropic wire (128; their Anthropic-compatible references, read
  2026-11-10, state no tool-name limit), every other Anthropic-wire route
  (128), and every other chat or responses provider including Gemini (64;
  a wire default, not a documented provider limit).

Decode: from_api_tool_name ran a lenient second pass that rewrote bare
x00002E-style text, so a tool literally named ax00002Eb decoded to a.b, a
different tool. A name that is the canonical encoding of its own exact
decode now returns that decode and skips the lenient pass, so a legal tool
name can no longer be turned into another. The cost: a model that strips
both dashes from an encoded name is no longer recovered; the old
foo_x00002Ebar expectation is changed accordingly.

Tests: encoding collision and round trip over hostile names, limit counting,
limit per wire and provider, a 100-character name offered on the
MiniMax Anthropic wire and withheld on a chat wire, over-long MCP-style
names withheld per provider in the Anthropic, chat and responses builders,
forced-tool and history refusals, and a 65 to 128 character history name sent unchanged on Ollama, Custom and MiniMax chat routes, and the warning wording. Farm: nextest -p codewhale-engine client:: and core::events:: 664 run, 664 passed;
workspace clippy (ci.yml flags) clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 14b8e1d315 fix(engine): encode the tool name in a chat function-object tool_choice (CW-8040)
map_tool_choice_for_chat encoded only the internal {"type":"tool","name"}
shape. A choice already in chat-completions form,
{"type":"function","function":{"name":"web.run"}}, fell into the
passthrough arm and named a tool the request never declared, because the
declaration is sent as web-x00002E-run. The same held for the flat
{"type":"function","name":...} form and for a bare tool-name string, both
of which forced_tool_name already treats as a forced tool.

All of these now go out as {"type":"function","function":{"name":
to_api_tool_name(name)}}. Reserved strings (auto, none, required) and a
function choice with no name are unchanged.

Shape per OpenAI's published OpenAPI spec (ChatCompletionNamedToolChoice),
read 2026-11-10. The regression test covers web.run in all four shapes and
checks the built request body against the declared tool name.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 be4d539891 fix(app-server): match a gone store spelled through a symlink or another letter case when retiring a dead owner receipt (CW-8054)
The dead-owner discard compared the lexically normalised selected store with the
receipt's data_dir, so a home spelled through a symlinked ancestor (for example
/tmp on macOS) or in another letter case never matched and startup stayed
refused. Both sides now resolve the deepest existing ancestor and re-append the
missing tail, and on macOS and Windows the final comparison folds letter case.
Adds a symlinked-home test (unix) and a case-spelling test (macOS).

The one new canonicalize is admitted to the blocking-calls budget; it runs
inside owner_work, off the async runtime.

Checks: nextest -p codewhale-app-server --test daemon_socket 18/18 passed;
clippy -p codewhale-app-server --all-targets --all-features 0 warnings;
blocking-calls budget within budget; fmt clean.

Squashed from bf3c88bde3 and 6cdf29d72c.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:09 -08:00
CodeWhale BotandClaude Sonnet 5.5 2bc050dac1 test(engine): settle the queue fixture and bound idle reads by measured time (CW-8070)
The settled-nap test waited a fixed 2.3s for a fresh queue file to age and
bounded fingerprint reads by the nominal 3s window. On a slow runner the queue
could still be unsettled when counting began, or the sleep could overshoot and
fit more 1s naps than the bound allowed. Settle the stamp through the existing
fixture, wait for every worker to report a settled schedule, and derive the
bound from measured elapsed time. Product code is unchanged.

nextest stress 20/20 passed (farm run 20261112T061942Z-nap-stress-90898e);
clippy -p codewhale-engine --all-targets --all-features clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:06 -08:00
CodeWhale BotandClaude Fable 5.2 207144cdcb docs(changelog): the README front page, translated READMEs and the contributor count
The three commits beneath this one were reviewed in three rounds by an independent
reviewer (verdict land); preflight OK, 18 README translations in sync, 1766 links
resolve, web tests 1301 passed. This commit adds only their changelog line.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 01:35:59 -08:00
CodeWhale BotandClaude Opus 5.5 419270f2b5 docs(readme): the front page says what the website's first screen says
README.md now opens the way the rebuilt home page does: the official
black mark (white on dark themes), the locked line "The model can be
anyone's. The agent should be yours." as the heading, one plain sentence
on what Codewhale is, the install command, and the terminal capture the
website shows. That capture comes from a 0.10.3 development build, so its
caption now says "A development build of 0.10.3, which is not released
yet"; the old caption called it a 0.10.2 build. Then the shortest path: Install,
Quickstart, What it does, How it stays safe, Where it is going (the
website's labelled map: Released, In development, Invite-only, Being
built), Coming from another agent, Documentation, Contributing and
History and license. The top section names no other coding agent; the
import section lower down names the tools it imports from.

The README describes the released v0.10.2. Where the source on main has
already moved on, the sentence says "Coming in 0.10.3":
- Permissions: v0.10.2 has Plan, Work and Operate on Tab and Ask,
  Auto-Review and Full Access on Shift+Tab, and Ask applies edits inside
  the repository without asking. The 0.10.3 dial (Ask, Accept Edits,
  Plan, Auto-Review, Full Access), Tab no longer changing the mode,
  Operate's retirement, Ask asking before every edit, and Accept Edits as
  the default are one labelled paragraph. The details link goes to the
  v0.10.2 guide at the tag, and the current guide is linked as 0.10.3.
- Computer Use: the plugin is bundled with v0.10.2 (guide at the tag);
  the separately released plugin is labelled as 0.10.3.

Moved, not deleted:
- The "New in 0.10.2" list is in the CHANGELOG's 0.10.2 section and on
  the release page; README links both instead of keeping a third copy.
  The DeepSeek V4.1 Flash switch date is in docs/PROVIDERS.md, linked.
- The `exec` flag detail and the exec receipt edge cases already live in
  docs/INSTALL.md#headless-scripts-ci and docs/RECEIPTS.md#surfaces;
  README links there.
- The modes table becomes one paragraph; docs/MODES.md holds the rest.

The Documentation section says the linked guides follow the main branch
and can describe changes that are not in the latest release yet.

Badges: release (generated by shields from GitHub Releases), CI, license
and Discord. The crates.io and npm badges went: they repeated the release
badge and Cargo can trail it.

The OrcaRouter offer sits below "What it does", with the campaign
disclosure in the words decided for featured placements ("This link is
part of a referral campaign; Codewhale may earn a referral reward on
top-ups made through it.") and its end date, November 21, 2026. That
replaces the line saying Codewhale "earns a referral fee from
OrcaRouter". Its links are HTML with rel="sponsored", and like the
website it appears in English only.

Contributors are featured as on the website: the generated wall image
links the full list, and the text says which script regenerates it.

Image fixes: the terminal capture's alt text says what it shows, the
mark has explicit width and height, the offer image is smaller than the
capture, and the wall image keeps its dark ground in both themes.

All 18 translations follow the new structure, generated from one
template so code blocks, links and section counts match. Each carries
the website footer's machine-draft sentence for its language; the
headline, intro and map rows reuse the website's own translations. They
leave out the OrcaRouter block, as the website does. Like the other 16,
zh-CN and zh-TW now name the permissions Ask, Auto-Review and Full
Access, as the terminal shows them.

Checks: scripts/preflight.sh --check OK; check-readme-translations.py
OK, 18 translations in sync (sha256:fd76d718e3a2); check-readme-locales.sh
PASS; web check:docs PASS; web vitest public-surface-contract, faq-source
and the facts tests, 6 files, 60 passed; links in 22 files, 1816 links,
no missing file or anchor, 23 external URLs all HTTP 200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:13:32 -08:00
CodeWhale BotandClaude Opus 5.5 1a274c6135 chore: fewer files at the repository root; a support page; CODEOWNERS says only what is true
- DEPENDENCY_SECURITY.md described one thing, the VS Code extension
  packager's MSAL override. It is now the "VS Code extension packager"
  section of docs/dependency-maintenance.md, word for word. Nothing read
  the file; the one reference was a comment in security-audit.yml, which
  also still said an advisory was "admitted by name" although
  `extensions/vscode/scripts/audit-dependencies.mjs` blocks every reported
  vulnerability. The comment now points at the new section and says that.
  The file was not moved into extensions/vscode/ because `vsce package`
  would ship it inside the VSIX.
- .github/SUPPORT.md: where questions, bugs, ideas and security reports
  go. GitHub links it from the new-issue page. `codewhale doctor` exists
  in v0.10.2 and the bug template already asks for it.
- .github/CODEOWNERS kept the owner line and dropped a comment that named
  a `claude-review.yml` workflow that does not exist and a review
  integration that is not enabled.

Every root entry was inventoried with its references; the other moves
need a founder decision or touch release, packaging or test paths and are
proposed separately.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:56:36 -08:00
CodeWhale BotandClaude Opus 5.5 a37c6d0850 docs: the contributor wall counts 433 people, and the website counts the same people
`scripts/contributor-wall.py refresh` then `render` from git history and
GitHub: 407 accounts (was 393) and 26 more people from commit names not
linked to an account, 433 in all (was 421). Fifteen accounts are new,
among them the contributors whose fixes merged this week. `angus-guo` is
now `imgusguo` (the same GitHub user id, resolved by the script), and the
unlinked name `z27014` folds into `zhuowp` through `.github/AUTHOR_MAP`,
so nobody left the wall. GitHub's own contributor list sees 212 of them.

"LeHaiDang" and "Lê Hải Đăng" are one person: both names sign commits
with the same email. `unlinked_aliases` in docs/contributors.json now maps
the second to the first, so the wall shows one tile, not two.

The website counted only the linked accounts while the wall it links to
said 421 people. `deriveContributorCount` in `web/scripts/facts-lib.mjs`
now adds the unlinked commit names exactly as the wall's renderer does,
and rejects a malformed `unlinked_commit_names` list as it already
rejected malformed accounts. `node web/scripts/derive-facts.mjs` wrote
`contributorCount: 433`; `web/data/agent-docs.generated.json` picks up the
new CONTRIBUTORS.md.

Checks: `npx vitest run lib/facts lib/check-facts lib/facts-invariants
lib/facts-drift` 4 files, 47 passed, 0 failed; `node
web/scripts/check-facts.mjs` (through preflight) OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:56:35 -08:00
CodeWhale BotandClaude Fable 5.2 f5a3dda975 docs(changelog): context window labels and the REPL byte limit, two contributor fixes
Lines for PRs 7295 and 7296 by Aboysky, merged as themselves after independent review;
their Lint check first failed on a package-mirror network error and passed on rerun.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 00:29:59 -08:00
Boyce 7f7c238b87 fix(repl): truncate stdout by bytes, not chars (#7296)
truncate_stdout kept limit-80 characters, so multibyte output exceeded
the byte limit by up to 3x: 2000 CJK characters with a 1024-byte limit
returned 2886 bytes. The cut now walks back to the nearest char
boundary within limit-80 bytes, the same shape push_bounded above
already uses.
Regression test: truncate_caps_multibyte_output_by_bytes fails on main
with len: 2886 and passes with this change.

Refs #7274 (partial: the REPL byte limit item)
2026-11-12 16:29:43 +08:00
Boyce 07faa85a2f fix(util): drop the trailing zero from context window labels (#7295)
format_context_window appended M before trimming, so the trailing-zero
trim never matched: 1_500_000 printed as 1.50M and 2_100_000 as 2.10M.
The trims now run on the two-decimal number before M is appended.
Regression test: format_context_window_drops_trailing_zeros fails on
main with left: "1.50M" and passes with this change.

Refs #7274 (partial: the context window labels item)
2026-11-12 16:29:39 +08:00
CodeWhale BotandClaude Fable 5.2 1632fc5edd docs(changelog): ten contributor fixes merged today, and one Creator skills line instead of two
Lines for PRs 7310, 7311, 7312, 7313, 7287, 7288, 7289, 7290, 7291 and 7314 by luochen211,
merged as themselves after independent review (reports under the coordinator's
contributor-prs artifacts; farm runs on Linux, hosted checks green on each head).
The older of the two Creator skills lines is removed; the kept one says the skills
stay hidden until Creator mode.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 23:30:42 -08:00
落尘 b9ceca8a52 fix(tui): zh-Hans full-width punctuation and /status alignment under CJK labels (#7314)
* fix(l10n): use full-width punctuation in zh-Hans router and label strings

The zh-Hans pack writes 540 full-width colons, but 45 strings, mostly
the /router setup and test screens plus "标签: " labels and usage
lines, used ASCII `:` `;` `,` `(` `)` next to Chinese text, for example
"费用:${cost} (提供商报告)" and "删除该表(或运行 /router off),每个".

Switch them to `:;,()`. "标签: " and "标签:" are both two columns
wide, so aligned labels do not move. Key-hint footers and short
parenthesized row suffixes such as "(默认)" are left alone.

Refs #7299

* fix(tui): align /status values under CJK labels

push_row padded each label with `{label:<16}`, which counts chars. A
CJK char is two terminal columns, so in zh-Hans the values started at
columns 21, 23 and 24 depending on the label. Pad by display width
with the existing ui_text::pad_to_display_width. ASCII output is
unchanged.

Tests: codewhale-tui config_policy_status (20 passed). The new test
fails without the fix (value columns [21, 23, 24]).

Refs #7304

* fix(tui): measure /status labels with unicode-width in the portable config group

The config command group also compiles as codewhale-portable-config,
which may not depend on codewhale-tui-kit, so the previous commit broke
that build. Pad the label by its unicode-width display width directly
and add unicode-width to the portable proof crate.

Tests: codewhale-portable-config lib 36 passed and the config proof
passes; codewhale-tui lib (config_policy_status, core, debug_change_host,
zh-Hans approval) 87 passed; clippy -D warnings clean.
2026-11-12 15:29:25 +08:00
落尘 93d833c996 fix(engine): decode HTML entities once in web fetch and web.run (#7291)
Both chained str::replace calls with &amp; first, so '&amp;lt;br&amp;gt;' was decoded twice into '<br>'. Use the single-pass scrape::decode_html_entities in both places.
2026-11-12 15:29:20 +08:00
落尘 c38abdd64a fix(engine): keep non-ASCII letters in derived RLM session names (#7290)
derive_session_name dropped every non-ASCII character, so docs/报告.txt and docs/总结.txt both became '_txt' and collided. Keep Unicode letters and digits and lowercase them.
2026-11-12 15:29:16 +08:00
落尘 ed4ce895a6 fix(engine): keep timeline summaries that exactly fit the limit (#7289)
summarize_text appended '...' as soon as limit-3 characters were emitted, so an 8-character text with a limit of 10 came back as 'abcdefg...'. Filter control characters first, then truncate only when the text is longer than the limit. task_manager now uses the same function instead of its own copy of the bug.
2026-11-12 15:29:12 +08:00
落尘 dfaab78d13 fix(engine): honor env -S shebangs when resolving plugin interpreters (#7288)
A plugin script starting with '#!/usr/bin/env -S python3 -u' resolved the interpreter to '-S', so the tool failed to launch. Skip the -S/--split-string flag before taking the program name.
2026-11-12 15:29:08 +08:00
落尘 a7076f988d fix(tools): read every hunk line instead of stopping at twice the header counts (#7287)
parse_hunk_header capped the hunk body at 2 * (old + new) lines. Callers
skip lines that do not start with a header, so body lines past the cap
were dropped without an error: `@@ -1 +1 @@` followed by seven body
lines kept four, deleting a context line and never applying the rest.
`@@ -1,18446744073709551615 +1,1 @@` also overflowed the addition
(panic in debug, wrap to a zero-length hunk in release).

The body loop now runs until the next `@@`, `diff `, file header or end
of input; every arm already consumes a line or breaks, so it ends. An
under-counted hunk with prose after it now surfaces as a context
mismatch instead of a silent partial parse. The comment that described
the cap is removed.

Tests: parse_unified_diff_keeps_lines_past_an_undercounted_header
(engine apply_patch) fails on main. cargo test -p codewhale-engine
--lib apply_patch: 58 passed; cargo test -p codewhale-tools: 26 + 3
passed. clippy -p codewhale-engine -p codewhale-tools clean.
2026-11-12 15:29:04 +08:00
落尘 9991fab608 fix(sanitize): strip whole escape sequences and keep the text after them (#7313)
strip_ansi_impl skipped exactly two bytes for every ESC sequence that is
not CSI/OSC/DCS. Sequences with intermediates are longer: `tput sgr0` on
xterm-256color prints `ESC ( B`, so tool output showed a stray `B`, and
`ESC ESC [31m` printed `[31m`. A CSI also scanned through any byte until
one in 0x40..=0x7E, so after a stray `ESC [` the following CJK text was
dropped up to the next ASCII letter, and keep_sgr passed a span holding a
raw ESC through as SGR.

Other ESC sequences now consume their intermediates (0x20..=0x2F) and one
final byte (0x30..=0x7E), and drop only the ESC otherwise. A CSI stops at
the first byte outside 0x20..=0x7E without a final byte, so nothing is
emitted for it and the byte is handled as ordinary text.

Fixes #7300.

Tests: strip_ansi_consumes_whole_escape_sequences_and_keeps_following_text
fails on main ("Bok"). cargo test -p codewhale-sanitize --lib: 12 passed;
cargo test -p codewhale-tui-kit: 216 passed; cargo test -p codewhale-notify:
31 passed; cargo test -p codewhale-tui --lib ansi: 44 passed. clippy -p
codewhale-sanitize --all-targets clean.
2026-11-12 15:29:00 +08:00
落尘 1af27bbc0e fix(workflow): match the construct deny list on code, unquote fleet TOML strings, hide slot_dropped (#7312)
reject_unsupported_constructs searched the whole source, string literals
included, so a goal or prompt mentioning "async", "import " or "open("
rejected the workflow, including structured plans the engine wraps in
workflow({...}). String contents are now blanked before the search;
comments and template literals are still checked.

The minimal fleet TOML parser stripped only double quotes, so literal
strings kept their single quotes and basic strings kept their escapes.

The prelude deleted every captured __workflow_* host binding except
__workflow_slot_dropped, which let a script emit FanoutSlotDropped events.

Tests: cargo test -p codewhale-workflow (240 + 16 passed); cargo test -p
codewhale-workflow-js (12 + 82 passed). The three new or extended tests
fail on main.

Fixes #7308
2026-11-12 15:28:54 +08:00
落尘 4c569934c2 fix(skills): detect the archive prefix from every entry and import hidden files (#7311)
scan_tarball took the archive prefix from the first tar entry only. A
GitHub tarball starts with pax_global_header or the bare `repo-main/`
directory, so the prefix was empty: `<name>/SKILL.md` repositories failed
with MissingSkillMd and a nested skills/<x>/SKILL.md outranked the
repo-root SKILL.md. A flat tarball whose first entry was `scripts/run.sh`
took `scripts` as the prefix and installed without its SKILL.md.

The prefix is now the first component only when every entry (global PAX
headers excluded) shares it as a directory, and SKILL.md candidates are
ranked after the prefix is known.

Import copied packages without `.`-prefixed files, while the package
digest includes them, so any skill with a dotfile failed with "imported
content digest mismatch". The copy now keeps hidden files and skips the
receipt markers only at the package root, matching the digest.

Tests: cargo test -p codewhale-skill-install (47 passed); cargo test -p
codewhale-skills (186 + 3 passed). The two new tests fail on main.

Fixes #7307
2026-11-12 15:28:50 +08:00
落尘 4c8ab9a48f fix(app-server): read the original path of worktree-side renames in git status (#7310)
parse_porcelain consumed the ORIG_PATH record only for an index-side
rename or copy. A worktree-side rename (" R new\0old\0", e.g. after
`git add -N` on a moved file) left the original path to be parsed as a
row of its own: a phantom entry for ASCII names, and a panic from
`record[3..]` when the name starts with multibyte text such as "ab说明.md".

Tests: cargo test -p codewhale-app-server --lib runtime_api::git (29
passed with RUST_MIN_STACK=16777216; without it the unrelated
git_push_sends_only_the_named_branch_at_the_expected_head overflows the
default debug test-thread stack). The new test fails on main with
"start byte index 3 is not a char boundary".

Fixes #7309
2026-11-12 15:28:47 +08:00
CodeWhale BotandClaude Opus 5.5 db1e303779 docs(changelog): keep one home page line; two superseded copies came in with the repair commits (CW-8048)
Each HOME-2 repair commit added its revised line beside the previous one
instead of replacing it, so the Unreleased section listed the home page
three times. The last revision (f86aac6275) stays; the TUI slice is
regenerated with scripts/sync-changelog.sh (--check: up to date).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:52:41 -08:00
CodeWhale BotandClaude Opus 5.5 585ea26ec3 feat(plugins): trust, enable, disable and revoke plugins from a shell, recorded by who approved (CW-7891)
`codewhale plugins trust <name>` prints the same capability review the
terminal app shows (one renderer) with its bundle review token, and changes
nothing; in a terminal it then asks once. `--review-token <token> --yes`
trusts without a prompt, only while the token
(`<content-hash>.<capability-hash>`) still matches the installed content and
capability set. enable, disable and revoke go through the existing registry
transitions, each with --json. The token ties trust to exact content; it is
not evidence that anyone read the review, and there is no separate
--content-hash flag because the token's first half is that hash.

Who approved:
- The Engine sets CODEWHALE_AGENT_PROJECT, the session's project, on every
  command the model runs, whatever the sandbox, backend or permission mode:
  the shell tool (exec_shell, Bash, bash, and the task tools that use it),
  ShellManager::prepare_runner (persistent terminal/run and terminal/send
  sessions, and every sandboxed_runner_command: foreground verifiers, test
  runner, JavaScript execution), the background verifier path, the Python
  kernel behind repl blocks, rlm and RLM turns (PythonRuntime::new and
  spawn_with_context now take the session project), and stdio MCP servers
  the model starts with start_mcp_server. Hooks and MCP servers from
  the person's configuration do not get it. It is a new name:
  CODEWHALE_SANDBOX still means only "an OS sandbox is applied".
- With that mark the plugin command opens the mark's project, and a trust or
  enable is the agent's (agent cohort, that project only), including a
  prompt answered through a pseudo-terminal; it is never recorded as the
  person. Before a marked trust the command reads the bundle's
  .installed-from record and refuses unless the agent installed the plugin
  from a local or DSH folder inside that project (R21's caller condition).
- Without the mark, a --yes trust is recorded as the command line and an
  answered prompt as the person.
- Every trust goes through trust_reviewed. An unmarked trust passes no
  host-service token, so it grants no service; a marked one passes the token
  of the review composed for the agent. --review-token is never passed to
  the registry. Output, help, JSON (bundle_review_token) and docs keep the
  bundle review token and the host-service review token apart.
- plugins status and each command's report show approved_by and enabled_by.

The agent's shell commands that run these go through the session's approval
like any other shell command (Ask asks, Auto-Review's reviewer judges, Full
Access runs them); no feature-specific gate. disable and revoke are ungated.
The mark is a label the agent could remove in Full Access; the docs (English
and Chinese) say so, and that the install record shows where files came
from, not who wrote them.

The std::fs reads in headless.rs run inside the command's spawn_blocking,
so the blocking-calls budget is raised for that file.

Checks (Linux build farm):
- cargo nextest run -p codewhale-cli -p codewhale-tui --lib --test integration
  --test help_dispatch -E 'test(commands::groups::plugins) |
  test(plugin_trust_cli) | binary(help_dispatch)': 37 passed, 0 failed,
  6222 skipped.
- cargo nextest run -p codewhale-plugins -p codewhale-engine --lib
  -E 'package(codewhale-plugins) + test(authority::auto_review) +
  test(tools::shell::) + test(tools::terminal_session::) +
  test(tools::verifier::) + test(tools::runtime_mcp::)': 542 passed,
  0 failed, 6891 skipped.
- cargo nextest run -p codewhale-repl: 37 passed, 0 failed.
- The mark tests fail on the code before each fix: the shell tool test
  ("unset"), the terminal/run and terminal/send test and the verifier test
  ("MARK=unset"), the terminal-tools prompt test (recorded "person"), and
  the Python kernel test ("MARK=unset").
- cargo clippy --all-targets -D warnings on codewhale-engine, on
  codewhale-repl with codewhale-engine, and on codewhale-tui with
  codewhale-cli: clean.
- cargo fmt --all -- --check and scripts/preflight.sh --check: pass.
Windows read, not compiled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:25:32 -08:00
CodeWhale BotandClaude Sonnet 5.5 af2ac21b36 feat(runtime): conversation modes standard and creator, agent_preset on threads (CW-8049)
GET /v1/modes lists the built-in table; agent_preset is stored on the thread
record and the terminal session file and returned in the thread summary.
Absent means standard. Set on POST /v1/threads or PATCH while the thread has
no turn; unknown, unavailable, locked and executor cases are typed refusals
(unknown_mode, mode_unavailable, mode_locked,
mode_not_supported_with_executor). Creator is gated on host-runtime
availability only (no plugin_creator key exists). Stores and exposes the mode;
what Creator loads is CR-01/CR-04. One-line test-literal fix in
crates/tui/src/tui/ui/tests.rs for the new SavedSession field.

Checks (farm, Linux x86_64): nextest engine+app-server filtered, 12 run, 12 passed, 0 failed
(7 of them the new mode tests); clippy -D warnings --all-targets on engine,
app-server, tui: exit 0. Local: cargo fmt --check and preflight --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 11:56:43 -08:00
CodeWhale BotandClaude Sonnet 5.5 f86aac6275 fix(web): home posture note names the frames that show the model and Ask setting; the OrcaRouter offer returns below the first screen; changelog gating sentence corrected (CW-8048 HOME-2 version B, repair 1)
The note under the hero picture said the frames before and after show the
model name and the Ask setting; the Edit frame next to Approval shows
neither. It now says the Prompt and Result frames show them, which the
capture's flags confirm at both widths, in all 18 locales and the GT catalog.

Founder decision 156: the dated OrcaRouter offer strip from CW-7933 is
restored unchanged (partner-promo.ts, markup and styles from cc0d42bf4a,
English only, referral disclosure, days left computed at render, hidden
after 2026-11-21), placed after the model section, so the first screen is
as accepted.

The CHANGELOG said the page names several agents only while the release has
it; only the facts row is gated. Corrected, and it now says the offer strip
stays below the first screen. TUI changelog resynced.

Checks: npm test 1301 passed + 7 node, tsc, lint, check:locales, check:tokens
and build all exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:25 -08:00
CodeWhale BotandClaude Sonnet 5.5 c390b981b7 docs(changelog): the home page line says what the rebuild removed (CW-8048 HOME-2 version B)
The earlier home reorganization (CW-7933) is replaced by version B, which no
longer carries the dated OrcaRouter offer strip or the browser-filled GitHub
community panel; that entry described a page that no longer exists, so it is
folded into the rebuild's line. TUI changelog slice resynced.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:21 -08:00
CodeWhale BotandClaude Sonnet 5.5 fef5762aac fix(web): Arabic home opening sentence reads right to left; changelog line for the home page (CW-8048 HOME-2 version B)
The site sets unicode-bidi: plaintext on RTL paragraphs, so a paragraph
that opens with the Latin brand name took a left-to-right base and the
Arabic intro came out reversed around "Codewhale" and "Rust." Wrapping the
brand in bdi isolates it and the paragraph takes the Arabic direction.
Measured by word positions in the rendered /ar page before the fix.

Adds the CHANGELOG line for the rebuilt home page and syncs the TUI
changelog slice.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:17 -08:00
CodeWhale BotandClaude Opus 5.5 35e5e150e3 feat(web): home first screen says what Codewhale is, with the line kept as the headline (CW-8048 HOME-2 version B)
The first build put about a dozen things on the first screen. This keeps
five: the founder's line, one sentence that says what Codewhale is, the
install command, the released terminal session as one wide picture, and a
row of facts from data (Rust, licence, provider routes and models of the
published release, many agents when released). The route moves to one
smaller section lower down and carries the whale; the belief paragraph and
the text-only agents section are removed.

New home strings in 17 locales are machine drafts. Not yet run: npm test,
lint, build (version comparison commit; checks run on the recommended one).
tsc --noEmit: exit 0. check-locales: PASS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:34:13 -08:00
CodeWhale BotandClaude Fable 5.2 abcdd8ec3c fix(web): label the Chinese pages as having no native review yet (CW-8048 HOME)
Chinese ships with the same footer line as the other translated locales
until a native reader has reviewed it. English stays the reference.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 10:34:09 -08:00
CodeWhale BotandClaude Fable 5.2 cdb2679825 feat(web): the home page draws the whole product as one route (CW-8048 HOME)
The home is rebuilt on the chosen direction. Under the two-sentence
headline the product is one line on the horizon: solid through the parts
that are released, dashed and dotted through the ones that are not, and off
the edge of the page. Line style, station shape and a word carry each state,
from product-map.ts; colour never does alone. The whale rests where the solid
line ends and is never drawn over a part that is not released. The drawing is
hidden from assistive technology and an ordered list beside it says the same
thing.

The terminal that hangs from the Terminal app station is the released
v0.10.2 capture as selectable text. It opens on the approval step, with a
real tablist (Prompt, Edit, Approval, Result) that arrow keys, Home and End
move through. Its caption comes from capture-caption.ts, and frames that do
not show the model name or the Ask setting say so. Section 3 carries no
frame: no released capture shows several agents, and a development-build
frame is not true of the released version.

On a phone the first screen holds the headline, a compact route with the
whale, and the install box; the full list of parts appears once, in section
4, as a printed tree. The install box puts Copy in the choice row so the
command has the whole line, and on a phone the command breaks at the pipe.

Every visible word comes from home.ts. Eight keys nothing renders are removed
in all 18 locales; six frame summaries and a plural-aware contributor label
are added. The translations of the new keys are machine drafts, like the
rest of those locales. The merged list shows titles and faces and no times;
the date the count was read stays a title and screen-reader text.

The footer sits on the page's own ground under one hairline, as four columns
that fold into four disclosures on a phone, and the mirror links leave it for
/install#mirrors. With the navy band gone the stage roles and sea tokens go
too. The home's sky is the page ground itself, so the header paints the same
water and there is no seam under it. export-design-tokens.py now exports the
design kit's surface light, which the sky uses.

Removed with the old home: Strata, LivingHeroWhale and its light lattice,
the model chips, newest list, work-pattern grid, plugins heading, install
band and stats renderers, and the partner strip with its expiry helper. The
home reads only the facts and the committed activity snapshot, so it renders
with no request to GitHub and revalidates like /install.

Whale B's geometry is vendored unchanged with its provenance.

Checks, from web/: npm test 7 + 1291 passed (90 files), 0 failed; lint, tsc,
check:locales, check:tokens, check:docs, check:facts and build all pass.
Measured on the rendered page in en, zh and ar, light and dark, at 1440 and
375: no horizontal overflow from 320 to 1920 px in all 18 locales; site text
contrast at least 5.08:1; no animation under reduced motion.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 10:34:05 -08:00
CodeWhale BotandClaude Sonnet 5.5 4b8a300097 fix(git): pass GIT_NO_LAZY_FETCH instead of --no-lazy-fetch (CW-8055)
The Git tool's review_scope action passed --no-lazy-fetch on every
command; git older than 2.44 rejects that option, so the action failed on
older git. The same protection is available as the GIT_NO_LAZY_FETCH
environment variable, which newer git honors and older git ignores. The
argument and environment are built in apply_review_git_options.

Checks: farm nextest -p codewhale-engine --lib git_tool: 8 run, 8 passed;
farm clippy -p codewhale-engine --all-targets -D warnings: clean;
cargo fmt --all -- --check and scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 07:23:37 -08:00
CodeWhale BotandClaude Opus 5.5 99eb5c7769 feat(extension-host): fill the dsh/broker workspace arm over the existing file and Git tools (CW-7970)
The workspace arm serves four typed methods: read, list, write and exec.
Any other method is an unknown_method refusal and unknown fields are
invalid_input. Nothing is passed through.

Each act is one nested core call through the invocation's own context:
read_file, list_dir, write_file, or the Git tool's review_scope. So the
session's permission mode decides it like any other agent action (Ask
asks, Auto-Review judges, Full Access proceeds, Plan and deny lists
refuse), the per-invocation cap of 50 nested calls is the runaway
ceiling, and every act is receipted on the tool result.

Before the card the arm refuses when the live posture's deny list or a
child's grant would refuse the direct tool, and confines every path with
the existing resolve_strict_authority_path: relative, or absolute under
the workspace; no parent traversal; no symlink at any component, even
one pointing inside. The core tool receives the canonical path.
Confinement runs on the blocking pool behind a four-permit gate and stops
waiting on cancel.

exec takes a literal argv. argv[0] must be exactly git, and the rest
(after an optional --no-optional-locks) must equal one of the six
read-only metadata shapes the Git tool already runs, with cwd at the
workspace root if given. No shell, program path, -c or extra flag. The
Git tool runs it process-tree contained under a deadline and the call's
cancel token.

write is the read-write overlay. It is refused unless the plugin holds
the reviewed overlay grant, checked before any path is read; there is
no production source for that grant yet, so it is refused outside
tests. In the overlay, the workspace root and any .git or .codewhale
segment are refused. Writes are bounded to 1 MiB.

Refusals tell the plugin only what it needs; paths and gate messages go
to the log. The arm holds no state beyond the call.

src/dsh/workspace.ts is the matching facade, with bounds checked locally.
It is not registered and nothing is advertised: no service is granted.

Two seam tests that expected every unfilled stub to answer not_available
now skip the workspace arm, as they skip the network, remote and terminal
arms. The blocking-call budget records the two canonicalize calls in the
sync helpers that run only under spawn_blocking.

Known limits: a path is checked by the arm and resolved again by the core
tool, so a symlink swapped in between by a process that can write the
workspace is not caught here. Unmodified synchronous node:fs and
child_process calls stay refused by the Native sandbox; no OS sandbox
grant is added. The Git tool passes --no-lazy-fetch, which git older
than 2.44 rejects (exit 129), so exec fails on such hosts.

Checks (Linux farm): cargo nextest run --locked -p codewhale-engine --lib
-E 'test(extension_host)': 214 run, 214 passed, 6989 skipped, including
the seven new dsh_workspace unit tests and the new workspace broker test.
cargo clippy --locked -p codewhale-engine --all-targets -- -D warnings:
clean. Both on the tree rebased onto the grant slice. Extension host:
typecheck clean, build exit 0, node 455 tests 448 pass 7 skipped, bun 455
tests 449 pass 6 skip. cargo fmt --check and scripts/preflight.sh --check
pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 06:47:26 -08:00
CodeWhale BotandClaude Opus 5.5 0e425e0f8e fix(plugins): the agent's review is decided on the record at write time, and an agent's record belongs to its project (CW-8053)
Repair round 2.

- trust_reviewed now compares, inside the locked state closure, the
  generation on disk with the one the registry read, for an agent
  approver, and refuses with "state or review changed" when they differ.
  check_approver's "no review held for every project" rule read the
  registry's in-memory copy, so a registry read before a person's review
  (a long-lived terminal session, or a race) let the agent replace that
  review (re-review probe E). A plugin with no entry is generation 0 on
  both sides, so a first approval after an install still passes.
- Ruling R22: check_approver refuses the agent when the plugin's valid
  record is an agent's of another project (outside_reach); that agent
  asks the person. Before, the agent of B could take over A's agent
  record and turn the plugin off in A.

Tests: the_agent_cannot_replace_a_review_made_after_its_registry_was_read
and the_agent_of_another_project_cannot_take_over_an_agents_record. On
343a721580 both fail (farm run 20261111T132431Z-gaps-r2before-2bc61b:
15 run, 13 passed, 2 failed, unwrap_err on Ok). After: farm run
20261111T132426Z-gaps-r2test-b0f9bb, 438 run, 438 passed, 6990 skipped
(codewhale-plugins 231, engine extension_host 207). Clippy -D warnings
(20261111T132428Z-gaps-r2clippy-cadf9e) exit 0. preflight --check OK,
cargo fmt --check clean. Docs sentence names the other project's agent;
agent-docs regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:38 -08:00
CodeWhale BotandClaude Opus 5.5 cf6b8dc2af fix(plugins): the agent never replaces a review held for every project, and a linked project folder is not the agent's (CW-8053)
Repair round 1, per coordinator ruling R21 (amends R4's "a folder in the
workspace"). An install lands in the user's plugin folder, so the scope
test of dec4b298c1 refused exactly the plugin R4 is about, and it let a
linked <project>/.codewhale reach outside the project. check_approver now
holds what the crate can know:

- no workspace-folder scope test;
- refuses the agent for a built-in plugin;
- refuses the agent when the plugin's current record is valid and belongs
  to the reviewed cohort (a person, the command line, or a review from
  before 0.10.3): ask the person. This also closes the review's finding 3,
  where the agent's trust_reviewed replaced a person's record and walked
  around enable_by's refusal;
- for a Workspace-scoped plugin, requires its canonical folder to sit
  inside the canonicalized project; a canonicalize failure refuses.

enable_by keeps its record test (a record the agent made for this
project, not outside_reach) ahead of check_approver. CR-03 and the command
line check that the agent wrote and installed the plugin.

Tests: the_agent_never_replaces_a_review_held_for_every_project (probe D,
the command line, a pre-0.10.3 receipt, a built-in) and, on unix,
a_project_plugin_folder_that_resolves_outside_the_project_is_not_the_agents
(symlinked .codewhale refused; a real project folder accepted). The test
fixtures write plugins to the user folder again; the engine fixture
revokes the person's review before the agent reviews. Docs sentence
rewritten to R21; agent-docs regenerated.

Boat farm run 20261111T120302Z-gaps-r1test-7bff0d: 436 run, 436 passed,
6990 skipped (codewhale-plugins 229, engine extension_host 207).
scripts/preflight.sh --check OK; cargo fmt --check clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:34 -08:00
CodeWhale BotandClaude Opus 5.5 d22a678c31 docs(plugins): say which plugins the agent may approve or turn on, and that the command line grants no service (CW-8053)
web/data/agent-docs.generated.json regenerated with derive-facts.
scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:30 -08:00
CodeWhale BotandClaude Opus 5.5 d69d83ad27 fix(runtime-api): record a review or turn-on from the app as the app, not the terminal (CW-8053)
The plugins route called trust and enable, which record
{person, via: terminal}. It now calls trust_reviewed and enable_by with
PersonVia::App. The receipt stays in review_history, so the label is fixed
before the tag. The native-UI route test reads the record back.

Boat farm run 20261111T102948Z-gaps-test-4768b2: app-server plugin tests
11 run, 11 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:27 -08:00
CodeWhale BotandClaude Opus 5.5 52521af86a fix(extension-host): a plugin whose review record cannot be read at the scan is not loaded (CW-8053)
desired_owners read the record with .ok(), so a read error between
active_component_sources and this read inserted the owner with an empty
grant and its cohort unknown (G6 fail-open). A read error now keeps the
plugin out with a diagnostic naming the reason, and the scan also refuses
when the in-memory snapshot says the agent approved it. The loop is split
into desired_owners_from so a test can break the record between the two
reads.

Test: a_record_that_cannot_be_read_at_the_scan_loads_no_owner (turns the
plugin off from another registry between the reads). Boat farm run
20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:23 -08:00
CodeWhale BotandClaude Opus 5.5 6118b9c097 fix(plugins): the agent approves and turns on only a plugin folder in its own project, and a command line approval grants nothing (CW-8053)
Closes three gaps the grant slice's review found in the crate itself, so
they hold whatever a caller does:

- enable_by now checks an agent approver as trust_reviewed does (its own
  project, the thread, turn and tool call) and refuses when the plugin's
  record was approved for every project (by a person or the command line)
  or by the agent of another project. Before, an agent of project B could
  turn on a plugin limited to project A, and an agent with no call identity
  could turn on a plugin a person reviewed, which then ran everywhere.
  R4 and section 148.5: outside the agent's scope the caller asks the person
  through the normal card, so the approver it passes is then a person.
- check_approver refuses an agent approval unless the plugin was found in
  the workspace's own plugin folder (scope Workspace), R4's "a folder in the
  workspace". Before, the agent's review of a user-scope plugin a person had
  reviewed replaced that review.
- PluginApprover::CommandLine with a review token is refused: the command
  line shows no review, so it cannot record a host-service grant. Without a
  token it records trust as before. A command line process carrying the
  agent's mark is the agent (R14) and is checked as the agent.

The engine's grant test fixture writes its plugins into the workspace
plugin folder, because its agent-approved plugin must now live there.

Tests: four new or changed in host_service_tests (the three gaps, and the
command line test, whose agent now turns on only its own record). Boat farm
run 20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed, 7837 skipped
(codewhale-plugins 228, engine extension_host 207, app-server plugin 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 d48d4b0883 fix(acp): a transport failure mid-turn still settles the turn and saves its checkpoint (CW-8043 repair 1)
A failed output write (including the 30 s write limit), a non-EOF read error and
an over-8 MiB non-response frame used to end the connection and drop the prompt
task, so the interrupted turn never reached drive_prompt's settle and
checkpoint save. Each now records the failure, sends the prompt Eof and
notifies shutdown exactly as a clean EOF does; the output pump keeps draining
and discarding, the read error ends the input stream, and the oversize sink
frame is dropped. serve_connection returns the recorded error after the prompt
has settled.

Tests: the guard that used a failing emit closure (unreachable in production)
is restored to a real failing writer through serve_connection, with a failing
reader, a failing writer at the permission request, and an oversize-update sink
test. The three end-to-end tests fail on the previous commit (0 passed, 3
failed) and pass now.

Farm: lib acp_server 43 passed, acp_journey 2, acp_conformance 1,
single_turn_loop 14, clippy exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 00e3327004 refactor(acp): serve ACP on the official SDK agent connection (CW-8043 ACP-SDK-S1)
CapturedAcpFrontend::serve keeps its signature and now drives an
agent-client-protocol v1 agent over our own bounded Lines transport: the SDK
owns framing, ids, ordering and error envelopes. Replaces the handwritten
read loop (AcpServer::serve), AcpDispatch, the write_* JSON helpers,
JsonRpcResponseIdPolicy, is_jsonrpc_response and the permission id counter.

- Inbound: BoundedLines stream, blank lines skipped, an over-limit line is
  answered -32700 with the connection kept; daemon/input_closed (sent without
  a jsonrpc member) ends the stream as EOF before the SDK sees it.
- Outbound: channel sink drained into the borrowed writer; an over-8 MiB
  response becomes the same -32603, anything else ends the connection.
- Handlers are catch-alls over Value params, so today's validators, session
  cap, posture checks and Codewhale methods are unchanged and requests before
  initialize are still served. A prompt runs in cx.spawn behind a
  synchronously set busy flag; cancel, permission answers and EOF reach
  project_turn through an inbox. Every spawned task and callback returns
  Ok, prompt failures answer -32603 through the responder.
- Frames are built from the official v1 types. modelSelection and models are
  gone, list createdAt/messageCount move to _meta.codewhale, root sessionId
  stays on session/load. Terminal auth methods only for clients that enabled
  them, a permission request always carries text, notifications are never
  answered, session/list, config setters and provider methods are served
  while a turn runs, and the unused session cursor is deleted.

Tests changed: initialize_* (typed builder; modelSelection asserted absent),
the four wire-helper tests (replaced by one connection test), the image
update test (typed update), Rig::prompt and permission cases (run the real
agent over a duplex), status lists (omitted pending default counts as
pending). Added: oversize inbound, oversize outbound, session cap, mid-turn
requests. Conformance allowlist shrinks to the load sessionId row.

Farm (Linux): acp_journey 2 passed, acp_conformance 1 passed, lib acp_server
40 passed, core single_turn_loop 14 passed, clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:07 -08:00
CodeWhale BotandClaude Sonnet 5.5 29e9f0835c feat(skills): bundled skills can carry files; ship the four DSH creator skills (CR-01)
A bundled skill was one text body. It can now carry files beside SKILL.md
(references/, templates/): install_companion_files writes each one only when
absent, so a file the person edited is never overwritten on upgrade, and an
edited SKILL.md still stops that skill's install as before. The four DSH
creator skills ship as generation 21 system skills, with every file equal to
the CR-00 pin; skills-catalog-matrix.json gains the four rows.

load_skill prints DSH's body unchanged, then 'Base directory for this skill',
then a 'Host notes: Codewhale' block, for an unedited bundled copy that
carries files or notes (bundled_skill_load_context). The notes are
Codewhale's own text, one short file per skill, and hold only what section 2.4
of the design says the agent is told instead of DSH's sentence.

Gating: the plugin_creator switch does not exist yet. The skills are visible
now; CREATOR_SKILL_NAMES lets the switch's slice hide them by adding the names
to the effective bundled 'disabled' list, with no file moved (a test proves
the policy hides them). Not met here: reading a template with the read tool
in Ask without approval needs the owned skills dir in
ToolContext.trusted_external_paths (engine.rs, not mine); requested in the
review packet.

Checks (farm, Linux): codewhale-skills nextest 188/188 passed; clippy
-D warnings clean on codewhale-skills (all targets) and codewhale-engine
(lib, tests); codewhale-engine tools::skill nextest 16/16 passed. One
existing test updated: the marker for a customised copy now moves to 21
because the new skills install. Local: cargo fmt --check, preflight --check.

Squashed with repair round 1 (03e4bfd7cd) at landing, per the second review (CW/artifacts/creator-mode-20261111/CR-00-01/REVIEW-r2.md): the four creator skills are hidden by default behind set_creator_skills_enabled (process-wide, off, no caller; MODE-01 replaces it with a per-thread flag); every installed skill folder carries DeepSeek's MIT LICENSE; no new code comments; CHANGELOG corrected. Review farm run on 1e2106e63a: 204 of 204.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:51:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 99b55300d2 feat(creator): pin DeepSeek Harness creator files at 0.2.2-rc.1 with notices (CR-00)
Byte-for-byte copies, so Codewhale hosts DSH's own Creator text and tool code
rather than a rewrite: the 33 files of the four dsh-agent-preset skills, the
dsh-plugin-manager tools.js and dsh-tool-cordis index.js. Each pin has an
UPSTREAM.json naming package, version, upstream path and SHA-256 with
verbatim: true, plus the MIT licence text and a THIRD_PARTY_NOTICES section
naming the four packages. .gitattributes keeps LF so Windows checkouts hash
the same.

The dsh-sandbox approveEscalation excerpt is recorded as pending in
UPSTREAM.json: that package is not in the unpacked app and needs the
published tarball.

Checks: node --test creator-pin.test.mjs 3/3; cargo nextest run -p
codewhale-skills (farm) includes dsh_creator_pin 3/3, whole crate 188/188;
diff -r against the source skills folder empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 04:51:32 -08:00
CodeWhale BotandClaude Fable 5.2 1e2106e63a docs(plugins): say how a host service is asked for, granted, shared and refused, and who approved (CW-8012)
EXTENSIONS.md gains the host services section with who may approve, the
reach of each approver, that the agent's mark is a label it can remove
in Full Access, and the per-kind sharing rule. The CHANGELOG line says
plainly that Codewhale 0.10.2 turns plugins off on a state file in which
a plugin was reviewed or turned on by this release.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:41 -08:00
CodeWhale BotandClaude Fable 5.2 d1ea4bcecc feat(extension-host): a broker service is given only while every loaded plugin holds the same reviewed grant (CW-8012)
The global granted list is gone. A broker ticket is minted, and
redeemed, only when the service is offered by this build, the caller is
the current plugin-tier owner with a reviewed authority, no plugin
activation loaded into the shared host process has since left it, and
every live plugin-tier owner holds the service in its review record.

Plugins share one host process and loaded code cannot be unloaded, so
the rule counts what the process has loaded: once an owner is revoked,
superseded, failed or faulted, no service is given until that process
exits. The host is then retired by the existing planned restart when
idle, and reconcile loads only what is turned on. A built-in module
never gets a broker ticket.

Mint reads the owner record, which carries the grant read from disk when
the owner began. Redeem checks the rule again and re-reads the persisted
record of every loaded plugin, repeats that every 50 ms while the arm
runs, and once more before the answer is released, so a plugin turned
off, revoked or reviewed again from any process loses the service for
tickets already minted and for a call in flight.

A plugin that is refused gets one answer, not_granted, whether its own
review lacks the service or another plugin's does. The plugin that
withholds a service is named in host diagnostics only. A spent broker
ticket is typed limit instead of ticket_refused.

Limits shared by the host: a ticket revoked in the last 10 seconds is
refused but no longer counts toward the burst that ends the host, and
one cap of 50 nested core calls per tool call now covers core/call and
broker arms together.

The reconcile scan reads the reviewed record once per plugin (approver
and grant). An owner whose record says the agent approved it is not
loaded into the reviewed host: it fails closed with one diagnostic that
asks for a person's review. So the owner registry holds only the
reviewed cohort and its sharing rule, departed flag and withheld report
are that cohort's; agent-approved plugins get their own host later.

Checks (Boat farm, on this tree): nextest plugins + extension_host 427
run, 427 passed, 6981 skipped; clippy plugins and engine --all-targets
-D warnings exit 0; check app-server and tui --all-targets exit 0.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:37 -08:00
CodeWhale BotandClaude Fable 5.2 bb07a62382 feat(plugins): a review records the host services a plugin may use, who approved and who turned it on (CW-8012)
A plugin asks for host services in its manifest
(capabilities.host_services, the seven broker service names). The list
is part of the inventory and enters the capability hash only when it is
not empty, so every bundle that asks for nothing keeps its hash and its
review, and a bundle that changes what it asks for needs a new review.

A review grants: TrustReceipt gains version, approved_by and
host_services. trust_reviewed is the one writer. It takes the token of
the service review that was shown and recomputes that review under the
state lock; a different token refuses and records nothing. trust(name)
records a person in the terminal with no grant.

approved_by is one tagged shape: a person (via terminal, app or an
approval card), the agent (posture full_access, auto_review or
command_line, with the project and, for the first two, the thread, turn
and tool call), or the command line. PluginApprover::command_line gives
the agent's record when the calling process carries the agent's mark and
the command line's otherwise. An agent approver is refused without a
review token, without its call identity, or for a project other than
the registry's own, before anything is staged. enabled_by beside
enabled records who turned a plugin on and is cleared when it is turned
off or reviewed again. No per-service state is stored.

Cohorts: a record approved by the agent belongs to that project's agent
cohort, every other record to the reviewed cohort. The review lists as
peers only records of the same cohort, and its token covers the cohort.
A record approved by the agent is active only in the project it names
(LoadedPlugin::outside_reach, one more term in active()).

A grant is read back only for the exact reviewed content: enabled, same
generation and hashes, receipt version 2, every granted name among the
asked names, and the capability hash recomputed from the recorded
inventory. A receipt written before this change grants nothing. A
built-in review carried across an upgrade keeps the older receipt shape
when its prior review recorded no approver.

HOST_SERVICE_INJECTS is the one table from a DSH inject name to a host
service, and it is empty: this build offers nothing.

v0.10.2 refuses unknown fields in the state file. A test reads this
build's files through copies of v0.10.2's types: readable until a plugin
is reviewed or turned on here, then refused (unknown field version or
enabled_by).

Checks (Boat farm, Linux x86_64, on this tree): cargo nextest run -p
codewhale-plugins -p codewhale-engine --lib with the plugins package and
extension_host tests: 427 run, 427 passed (225 plugins, 202 engine),
6981 skipped. cargo clippy -p codewhale-plugins -p codewhale-engine
--all-targets -D warnings: exit 0. cargo check -p codewhale-app-server
-p codewhale-tui --all-targets: exit 0. Local: cargo fmt --check and
scripts/preflight.sh --check pass.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:32 -08:00
CodeWhale BotandClaude Opus 5.5 63acfcc807 docs(sandbox): say what a Fleet worker reads and when the launcher check holds; changelog line for the launcher (CW-8021)
From the third review (artifacts/queue-dispatch-20261111/CW-8021-S1/REVIEW-r3.md,
verdict land). Finding 1: docs/SANDBOX.md said a Fleet local worker reads only
the default managed path; it reads the user config's managed_config_path and
then the default path, so a user config can still point a worker at a missing
file (forwarding the variable to workers is a follow-up before slice 2).
Finding 4: the runtime info route reads the live configuration; a check made
once at start does not cover a later reload. Finding 2: the CHANGELOG line now
says a managed configuration named by the variable applies on every load and
a plugin-declared provider fails to load under it.

Checks: scripts/preflight.sh --check OK; agent docs regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:27:34 -08:00
CodeWhale BotandClaude Sonnet 5.5 abc81f25a6 fix(config): read the host-named managed file before the user file's path (CW-8021)
apply_managed_overrides took its path from config.managed_config_path, which
the environment override sets only after the provider identity resolves. A
user file whose provider is registered later by a provider plugin returned
early, so CODEWHALE_MANAGED_CONFIG_PATH was skipped and the launcher vanished
without an error. The variable is now read first (managed_path_from_env),
then the user file's path, then the default. Test: the late-provider load
either keeps the launcher or fails, never succeeds without it.

Also asserts sandbox.kind and sandbox.tool_launcher in the runtime-info test
and documents the sandbox object in RUNTIME_API.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:24:25 -08:00
CodeWhale BotandClaude Fable 5.2 c4db3b438f chore(sandbox): record the launcher mask checks in the blocking-call budget (CW-8021)
The bubblewrap command builder gained two synchronous filesystem reads
for the tool launcher: one `metadata` per ancestor to decide whether the
launcher's user can search a directory, and one `canonicalize` per denied
path before lifting its mask. Both run only when a launcher is configured
and sit in the same synchronous builder that already canonicalizes its
roots, so the budget for crates/sandbox/src/bwrap.rs rises from 1 to 3
`std::fs` sites instead of moving the builder off the calling thread.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:24:18 -08:00
CodeWhale BotandClaude Fable 5.2 ee32ec12be fix(sandbox): accept tool_launcher only from the host-named managed file (CW-8021)
Review repair for the tool launcher key.

- The launcher is read only when the managed file is the one named by
  CODEWHALE_MANAGED_CONFIG_PATH or the default managed path. A sealed file
  that a user config reaches through `managed_config_path` is refused, so
  the code now matches the error text and docs/SANDBOX.md.
- The sandboxed `sandbox_mode` that must sit beside the launcher is read
  from the managed file itself. A user config's `sandbox_mode` no longer
  satisfies it.
- A project config that sets `tool_launcher` stops the launch with a
  project-config error instead of being silently dropped by the overlay
  whitelist.
- docs/SANDBOX.md states all three, and tells a host that relies on the
  launcher to set CODEWHALE_MANAGED_CONFIG_PATH and check
  `sandbox.tool_launcher` in runtime info: without the variable a user
  config can point `managed_config_path` at a missing file and the Engine
  starts with no launcher.

Tests: one runtime test for the path refusal and the managed-only
sandbox_mode, one TUI test for the project refusal.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:24:16 -08:00
CodeWhale BotandClaude Opus 5.5 2652f60607 feat(sandbox): run tool commands as another user through a managed-only launcher (CW-8021)
A host that keeps tool commands away from the Engine's own files can now
name a `tool_launcher` argv prefix (for example `sudo -n -u <user> --
<exec-wrapper>`) in its managed configuration. The sandbox manager puts it
in front of bubblewrap, so the command becomes
`<launcher...> /usr/bin/bwrap ... -- <program>`: the uid change happens
before confinement, which is the only order that works, because every
unprivileged confinement sets no-new-privileges and a setuid launcher
inside it cannot switch user. Without the key nothing changes.

Config (crates/runtime): the key is `#[serde(skip)]`, so no config
document can set it. `apply_managed_overrides` reads it from the raw
managed file, beside `strip_external_credential_consent`, and refuses it
on non-Linux, in a managed profile, when it is not an absolute-program
argv, when the Engine's uid can replace the file through it or any
non-sticky writable ancestor, or when the managed `sandbox_mode` is not
`workspace-write` or `read-only`. A user config or user profile naming it
is a load error that points at the managed layer.

Sandbox (crates/sandbox), per the P0 review's conditions:
- Fail closed. With a launcher, `try_prepare` refuses instead of running
  unwrapped: a policy that skips the sandbox (`danger-full-access`,
  `external-sandbox`), `prefer_bwrap = false`, a failed launcher probe or a
  non-Linux host. `prepare` keeps its infallible shape for callers that
  cannot return an error (prepare_runner, the extension host) and turns a
  refusal into a process that prints the reason and exits 126.
- Probe through the launcher. `bwrap::probe_launcher` runs the launcher
  once for its uid and groups, then one wrapped `/bin/true` with the deny
  masks, and caches success only.
- Masks for the launcher's user, on every command. bwrap running as that
  user cannot create a mask under a directory it cannot search ("Can't
  mkdir ... Permission denied"), and the built-in deny list names such
  paths on a multi-user host. Each denied path is masked at the highest ancestor the
  launcher's user cannot search (the P0 review's R4 rule); writable roots,
  protected descendants, extra roots, device roots and readable paths it
  cannot reach are dropped. Deny exceptions are not applied.
- Private /tmp. Under a launcher the host /tmp and TMPDIR are not bound;
  each command gets a fresh tmpfs, so the tool user cannot leave files for
  the Engine's user.
- PTY: `spawn_pty` finds the bwrap element and the first `--` after it,
  because the launcher carries its own `--`.

Escalation clamp (crates/engine/src/tools/shell.rs): before this change
an approved `danger-full-access` escalation turned a managed
`workspace-write` call into DangerFullAccess, which the manager runs
unwrapped. Under a launcher the Bash tool now refuses that escalation
before the approval prompt, and the manager refuses it at execution.

Wiring: the key travels with the deny-list wiring in
runtime_threads/manager.rs, core/engine.rs (EngineConfig), the TUI
`sandbox run` command, exec_agent.rs and frame.rs (one EngineConfig line
needed to compile). `GET /v1/runtime/info` gains
`sandbox: { kind, tool_launcher }`.

Network (D2-A, Engine side only): a managed `sandbox_network_access =
true` with managed `sandbox_mode = "workspace-write"` already yields a
networked workspace-write policy under Ask; a test now pins that. No
default changes.

Known limits are written beside `SandboxManager::set_tool_launcher` and
in docs/SANDBOX.md "Running tools as another user". A real uid change is
not proved here: the farm runs as one user. The `/usr/bin/env` launcher
test proves the prefix, NoNewPrivs and the workspace write.

Checks (farm, Linux x86_64):
- cargo test --locked -p codewhale-sandbox -p codewhale-runtime:
  runtime 918 passed, 0 failed, 2 ignored; sandbox 106 passed, 0 failed
  (rerun on the final sandbox source: 106 passed; the /usr/bin/env
  launcher test ran, not skipped).
- cargo test --locked -p codewhale-engine --lib (authority tests, sandbox
  hint tests, launcher clamp): 22 passed, 0 failed.
- cargo test --locked -p codewhale-app-server --lib runtime_info, after
  rebasing onto 34a29a0f2a: 8 passed, 0 failed.
- cargo clippy --locked -p codewhale-sandbox -p codewhale-runtime
  -p codewhale-engine -p codewhale-app-server -p codewhale-tui
  --all-targets -D warnings: clean (before the rebase, which applied
  without conflicts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:24:10 -08:00
CodeWhale BotandClaude Opus 5.5 3791637a39 chore(changelog): record receipt token counts and the terminal cost line, tokens only for threads, and sync the TUI changelog slice (CW-8032)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:08:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 cdf6dff9cf fix(receipts): a thread receipt carries tokens only, with no provider-call count (CW-8032)
A Runtime thread's receipt counted turns and printed them as provider calls,
which they are not. thread_tokens no longer sets the count, and both
docs/RECEIPTS.md samples drop it. The opening sentence now says what is true
for this change: tokens on every receipt, the cost line on terminal sessions
only; a thread receipt's cost follows in a later slice.

Regenerated web/data/agent-docs.generated.json.

Checks, rebased on origin/main fc78dc86d8 (Boat farm, Linux x86_64, local-test
evidence only): receipts::tests, metrics::, debug:: lib tests passed 43, 3, 28,
23 and 106 across the five crates, 0 failed, 0 ignored; clippy --all-targets
-D warnings on the same five crates exit 0. Locally: cargo fmt --all --check
and scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:08:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 735469ffc1 feat(receipts): carry token counts and the /cost line; count saved-session usage in metrics (CW-8032)
A receipt gains an additive usage section on codewhale.receipt/v1: provider
token counts and the report /cost prints, built by the same formatter
(cost_report) so wording and numbers cannot drift. An unpriced route reads
"Estimated total: unknown" with its reason; an amount appears only when a
turn had an authoritative price. A single-turn receipt leaves usage out and
says so; a Runtime thread receipt carries tokens and points to the usage
endpoint for cost.

codewhale metrics now counts per-call usage saved in terminal sessions on its
own line instead of saying none was recorded.

The /cost key map and replacement helper move from the TUI into the engine so
the CLI receipt renders the same English text. docs/RECEIPTS.md (and zh_hans)
say where cost lives and name the terminal-session gaps next to the example.

Checks (Boat farm, Linux x86_64, local-test evidence only): receipts and
metrics lib tests passed 43, 3, 28, 23 and 106 across the five crates, 0 failed.
web check-facts OK after regenerating agent-docs.generated.json.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:08:04 -08:00
CodeWhale BotandClaude Fable 5.2 3020f4306b feat(extension-host): fill the dsh/broker terminal arm over the existing terminal tools (CW-7994)
The terminal arm serves six typed methods: list, run, send, wait, cancel
and reset. Any other method is an unknown_method refusal and unknown fields
are invalid_input. Nothing is passed through.

The owner stays tools/terminal_session. run, send, wait, cancel and reset
are each one nested core call on the existing terminal/* tool through the
invocation's own context, so planning, deny lists, the approval card, the
live posture, a child agent's grant, the per-invocation call cap and the
receipt on the tool result are the ones a core/call already has. list is a
read of the same registry, scoped to the invocation's workspace and thread.

A plugin's terminals are named dsh:<plugin id>:<name> inside the thread's
own owner, so a plugin cannot name the model's shells or another plugin's,
while the thread's existing terminal routes, resize, kill, delete-thread
cleanup and the restart instance id keep working on them unchanged.

Before the card, the arm refuses when the caller's shell policy or deny
list would refuse the direct tool (re-read from the live posture), when the
named terminal is not this plugin's, and when the plugin already holds 8
terminals in the session. Names, commands, input and timeouts are bounded.
Registry reads run on the blocking pool behind a four-permit gate and stop
waiting when the call is cancelled. A call whose cancel has fired does
nothing. Gate refusals reach the plugin as a short typed refusal; the
detail goes to the log. On platforms without the terminal owner every
method answers not_available after decoding.

Not reachable in production: no service is granted, the adapter
(src/dsh/terminal.ts) is not registered and nothing is advertised.

Known limits: list leaves a log line, not a receipt on the tool result
(arms cannot add one). A plugin's shells are not ended when the plugin is
disabled or revoked; they end with the thread. A failed command returns
its output without the exit code. No resize, kill or non-consuming read
for the plugin itself.

The two seam tests that expected every stub to answer not_available now
skip the terminal arm as they skip the network arm.

Checks (Linux farm): cargo nextest run --locked -p codewhale-engine --lib
extension_host: 191 run, 191 passed, 6979 skipped, including the four new
terminal tests. cargo clippy --locked -p codewhale-engine --all-targets
-- -D warnings: clean (run before a two-line test-only change). Extension
host: typecheck and build clean, node 451 tests 444 pass 7 skipped, bun 451
tests 445 pass 6 skip.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:04:55 -08:00
CodeWhale BotandClaude Opus 5.5 5a352597ad fix(deps): move rustix to 1.1.6 for RUSTSEC-2026-0486 (xattr functions mark unwritten bytes as initialized)
cargo-deny's advisories job failed on ci/coord-push6 (run 42991087859) with
RUSTSEC-2026-0486, unsound, against rustix 1.1.4, which main also pins. The
advisory's solution is rustix >= 1.1.6. `cargo update -p rustix --precise 1.1.6`
also re-resolved windows-sys for nine crates that share it (colored, errno,
fd-lock, nu-ansi-term, terminal_size and others), each within its declared
range. Lockfile only.

Checks: hosted CI on this exact commit (cargo-deny, Windows, macOS, Linux).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:00:48 -08:00
CodeWhale BotandClaude Opus 5.5 0fb6595178 chore(changelog): record the ACP client port onto the official SDK and sync the TUI changelog slice (CW-8044)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:54:41 -08:00
CodeWhale BotandClaude Fable 5.2 8b55f157a4 test(acp): the conformance check names the kept session/set_model path (CW-8044 ACP-SDK-S2)
The client still sends session/set_model to an agent that offers only the
pre-v1 models object. The check never saw that frame, so the deviation was
kept without being listed. A third leg drives the fixture agent with a plan
flag that offers the old models object, applies the model through it, and
allowlists the two frames that result: the client's session/set_model request
and the fixture's models field in its session/new response.

The two existing entries stay: the options fixture sends two fields that
exist only behind unstable schema features, and each entry names its feature.

Check on Linux x86_64: the_acp_client_and_its_fixture_agents_speak_official_v1_shapes
passed in the same 27-test run as the port.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:54:24 -08:00
CodeWhale BotandClaude Fable 5.2 f194324c1e refactor(acp): drive external agents through the official ACP SDK (CW-8044 ACP-SDK-S2)
The Engine's one ACP client keeps its whole public API and replaces only the
wire. Each client now runs one named connection thread with a current-thread
runtime that drives the official agent-client-protocol v1 client over the
worker's pipes, behind the shared bounded line reader. The fleet host still
owns the process; the SDK is given pipes only, and no process, stdio or
unstable feature is enabled. The caller's thread keeps its loop: one request
at a time, the same deadline and the same 25 ms startup-abort poll, now over
an event channel instead of raw lines.

What we send is built from the official types (initialize, session/new, load,
resume, set_config_option, set_mode, prompt, session/cancel, the permission
outcome and the elicitation decline). session/set_model stays hand-built: it
is a pre-v1 method with no official type, sent only to an agent that offers
the old models object. What an agent sends is still read as JSON, so legacy
fields keep working.

Every handler and response callback on the connection thread returns Ok and
reports failure to the caller as an event or through the responder, because
an Err there ends the whole connection. A request is consumed by its response
callback and never dropped, so a missed deadline sends nothing to the agent.

Wire changes: request ids are UUID strings; fields at their defaults may now
appear; session/resume no longer sends an empty mcpServers; a malformed
response from an agent is dropped, so its request fails at the deadline; a
malformed line that is not a response is answered with an id-null -32600.

Behavior changes beyond the wire:
- Sign-in is detected by the -32000 error code only, no longer by any error
  whose text contains "auth".
- A permission request the agent withdraws with $/cancel_request is answered
  cancelled, never with a selection. The handler is not interrupted; that
  needs a change to the handler interface.
- An oversized or non-UTF-8 line ends the connection with the shared reader's
  refusal text.
- An agent's error text now includes its string data, bounded.

Tests. invalid_acp_envelopes_and_protocol_versions_are_refused changes: the
case with no jsonrpc member and the case with neither result nor error now
fail at a 3 s deadline instead of at once, because the SDK drops a malformed
response; the protocol version 2 case is unchanged. Added: a line over 8 MiB
ends the connection unparsed; another session's update is ignored and its
permission request answered cancelled; a withdrawn permission request is
answered cancelled; a missed deadline sends no $/cancel_request and ids are
strings; and a runtime test that an agent ignoring session/cancel is stopped
after the 10 s grace with the killed reason, using a new fixture plan flag
(the runtime launches the fixture with an empty environment, so the flag is
read from the plan file like the fixture's other switches).

Checks on Linux x86_64: cargo nextest run -p codewhale-engine --lib --test
acp_client_conformance fleet::acp external_agent the_acp_client: 27 run,
27 passed. cargo clippy -p codewhale-engine -p codewhale-app-server --tests:
clean. Windows was not compiled.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:54:20 -08:00
CodeWhale BotandClaude Opus 5.5 fabd6bebc4 docs(plugins): correct the Windows state publish comment the grant fix made false (CW-7956)
The comment above the retry loop in publish_private_state_file still said
every holder of the stable file lets go quickly and that only the rename is
re-attempted. Since fc78dc86d8 a plugin UI grant can hold the file for up to
an hour, and access denied falls back to std::fs::rename with a flush through
a handle opened beforehand. Wording from the review in
artifacts/win-plugin-ui-20261111/REVIEW.md, question 8.

Known limit, recorded here since the fix's commit message omitted it: Windows
10 before 1709 and Server 2016 lack POSIX rename, so the 409 remains there for
as long as a grant lives.

Checks: rustfmt --check on the file. Comment-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:16:25 -08:00
CodeWhale BotandClaude Fable 5.2 5bf5035dd0 chore(changelog): record the released-tag docs for agents and sync the TUI changelog slice (CW-8048, CW-8047)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:08:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 26137d8fa2 fix(web): keep one copy of the release snapshot in the worker, carry it through the release workflow (CW-8048 DOCS-AT-TAG, repair 1)
- The snapshot and the topic builder move to lib/release-docs.ts, imported
  only by /llms-full.txt and /docs-markdown. llms.txt reads a small
  agent-docs.release-index.json (tag, present and omitted slugs) that the same
  script writes and the check compares with the snapshot. agent-docs.ts is
  back to the main-branch file only, so the docs pages no longer bundle it.
- release.yml, job sync-release-record only: run derive-release-docs after
  derive-facts, check-release-docs in the prove step, and carry the snapshot
  and its index in the proposed paths.
- derive-release-docs now exits 1 when the tag is not in the checkout and the
  committed snapshot is for a different release than the published one.
- web/maintainer/derived.txt no longer lists the snapshot: that job checks out
  without tags and cannot regenerate it.
- The zh runtime-reference description drops the words for "main branch".
- A source missing at the tag reads "<path> was added after <tag> and is not
  part of this release; the document below is what <tag> has."

Checks: npm test 91 files / 1304 tests, lint, tsc, check:locales, check:docs,
check:facts, build, and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:08:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 1c13f0bf5a feat(web): serve /llms-full.txt and /docs-markdown from the documents at the released tag (CW-8048 DOCS-AT-TAG)
The machine-readable docs embedded main-branch documents under a label that
said so, so they could describe unreleased work. They now serve a committed
snapshot of the documents as they were at the published release tag.

- web/data/agent-docs.release.json is produced by derive-release-docs.mjs from
  `git show <tag>:<path>` for every registered docs source, and records the tag
  it was made from. Run by prebuild; with no tag in the checkout it keeps the
  committed file and warns, as the release baseline does.
- check-release-docs.mjs (in check:facts and preflight --check) fails when the
  snapshot tag differs from the published release read by facts-lib, when a
  hash does not match, when a registered topic is missing, and, if the tag
  resolves, when the snapshot differs from the tagged source.
- data/agent-docs.generated.json and its check are unchanged and still feed
  the docs pages.
- A topic with no source at the tag is omitted from the routes and the index.
  A topic with some sources keeps them and says which file does not exist at
  the tag: today only docs/RUNTIME_ROUTES.md.
- The runtime reference description no longer says "main branch".

Checks: npm test 91 files / 1302 tests, lint, tsc, check:locales, build, and
scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:08:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 1606a4bccf fix(app-server): discard a dead socket owner's record only when the store this start selected is gone (CW-8047)
After a hard kill, the Engine's socket owner record can outlive both the
process and its data directory. Every later start then failed with
"Failed to resolve ... tasks/runtime".

Host startup now passes its selected store into the owner probe. The
record is retired only when all of these hold: the owning process is
dead, the record's data directory equals the selected store's root in
the form RuntimeStoreBinding::for_store_dir records
(checked_runtime_store_root, which creates nothing), and that root is
definitively NotFound. The only path inspected is the selected store's
own; the recorded path is compared, never followed. A record naming
another store, or the selected store while it still exists, keeps the
existing refusal and stays on disk, which is what the test
stale_publication_cannot_select_another_store_or_execution_scope
protects.

admit_runtime_host passes the store it already holds: one call-site line
in runtime_api.rs.

The regression probes first with a different selected store while the
named store is gone and requires the record to stay, then with the named
store and requires the discard.

Known limits. For a store that is gone, the root can only be normalised
lexically, while a host records the symlink-resolved root. A selected
store spelled through a symlink (a home under macOS /tmp or
/var/folders), one spelled in another letter case, and a Windows
verbatim root do not match, so those starts keep the existing refusal.
A store that is only temporarily absent, such as an unmounted volume,
now gets a first start with an empty store, as it already does after a
clean stop; no store is deleted.

This squashes the first change and its repair after review.

Checks (Linux x86_64 build farm, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608):
- run 20261111T063638Z-stalefix-nextest-fc68fe: 58 passed, 0 failed
- control with the previous probe behaviour, run
  20261111T064458Z-stalefix-control-523e53: 16 passed, 1 failed (the
  regression, as intended)
- clippy for the crate with CI's flags, run
  20261111T070931Z-stalefix-clippy2-ba438c: exit 0
- independent review rerun on a later base, run
  20261111T073145Z-rvstale2-nextest-216f38: 58 run, 58 passed
Not run: Windows, macOS, hosted CI, an installed binary.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:45:37 -08:00
CodeWhale BotandClaude Fable 5.2 b2ef69d52f chore(changelog): record the extension host re-review and sync the TUI changelog slice (CW-8000, CW-7941)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:26 -08:00
CodeWhale BotandClaude Fable 5.2 ac3f62967e docs(acp): the runtime API reference says which tools an ACP session can use (CW-7941)
The trusted local ACP profile now offers reviewed plugin tools and the tools
of MCP servers configured in Codewhale, under the same permission setting.
The reference still said MCP was unavailable on this transport, which
contradicted docs/EXTENSIONS.md. Client-supplied MCP servers stay unavailable.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:25 -08:00
CodeWhale BotandClaude Fable 5.2 5b034e9e39 test(acp): read the plugin's workspace from its tool result in the ACP journey (CW-7941)
The journey looked for the workspace path as a substring of the frames
re-serialized as JSON. A Windows path contains backslashes, which JSON
escapes, so that substring can never match there, and the three-OS suite runs
this test on every landing. The test now parses the plugin's completed tool
result, takes its workspace value and compares it with the session's
workspace after canonicalizing both, so it also holds where the temporary
directory is reached through a symlink or a short name.

Checks on the stack tip (farm, Linux x86_64, cargo nextest; run
20261111T062016Z-acpplugland-tests-74e101: 97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
Not run: Windows and macOS, which are the platforms this change is for.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:24 -08:00
CodeWhale BotandClaude Fable 5.2 aa3d423546 fix(acp): a child engine gets no MCP tools from the ACP tool build (CW-7941)
The ordinary tool build drops MCP tools for a child engine. The ACP build now
does the same: for a child it adds no MCP tool to the catalog or to the turn's
allow list. No child engine runs an ACP turn today (children use the Child
profile and ACP turns offer no spawn tool), so this closes a path that cannot
be reached yet and keeps the two builds from drifting apart.

acp_tool_build_with_mcp becomes pub(super) so the child-host tests can call
it. The new test child_engine_gets_no_mcp_tools_from_the_acp_tool_build hands
the same live MCP tool to a root ACP engine (in the catalog, on the allow
list) and to a child engine (in neither).

Checks on the stack tip (farm, Linux x86_64, cargo nextest; run
20261111T062016Z-acpplugland-tests-74e101: 97 run, 97 passed, 0 failed):
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed, the new test among them
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
Not run: the new test against the build without this change. It asserts on
the three values the change alters (the MCP names, the catalog and the allow
list), and the root half shows they are set when the engine is not a child.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 3ab0c48182 feat(acp): lift the ACP profile's blocks on extension and MCP tools (CW-7941)
- engine.rs: the ACP branch calls the async acp_turn_tool_build.
- runtime_threads/manager.rs: an ACP host no longer force-disables the mcp feature.
- tools/registry.rs: enforce_tool_authority admits extension-host tools.
- turn_loop.rs, tool_execution.rs: MCP tools are not refused for lacking a registry spec in an ACP turn.

Checks on the stack tip (farm, Linux x86_64, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608; run 20261111T062016Z-acpplugland-tests-74e101:
97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed
- single_turn_loop: 14 passed, 0 failed
- codewhale-app-server tests matching acp_server: 39 passed, 0 failed
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
Not run: Windows, macOS, hosted CI, a real editor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:21 -08:00
CodeWhale BotandClaude Sonnet 5.5 02f1734ecd feat(acp): attach the extension host and configured MCP servers to ACP sessions (CW-7941)
The ACP tool build now installs the same extension-host plugin tools and the
session's connected MCP tools that an ordinary turn gets, through the same
host attachment and MCP pool; nothing new loads or registers tools. Plugin and
MCP calls reach the editor as ACP tool calls and ask through the permission
setting. initialize advertises the real state under agentCapabilities._meta
(plugins and configuredMcpServers follow the extension_host and mcp flags;
client-supplied mcpServers stay refused). acp_journey drives one fixture plugin
tool and one fixture MCP tool from an ACP prompt.

Landing: the journey test connects through the shared ACP test client
(tests/acp_support) that the schema check introduced, the ACP line in
CHANGELOG.md says what is now attached, and the agent docs are regenerated
for the EXTENSIONS.md sentence. This commit does not build alone: the next
one calls acp_turn_tool_build.

Checks on the stack tip (farm, Linux x86_64, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608; run 20261111T062016Z-acpplugland-tests-74e101:
97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
  (editor_drives_a_session_through_permission_cancel_and_load,
  plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial)
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed
- single_turn_loop: 14 passed, 0 failed
- codewhale-app-server tests matching acp_server: 39 passed, 0 failed
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
- Mac: cargo fmt --all -- --check clean; scripts/preflight.sh --check OK
Not run: Windows, macOS, hosted CI, a real editor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:20 -08:00
CodeWhale BotandClaude Opus 5.5 9c7cd07bfe feat(extension-host): fill the remote DSH broker with host-presented pairing grants bound to plugin and session (CW-8000)
DSH-13. The remote arm decodes four typed methods (status, pair, clients,
revoke) and refuses any other method as unknown_method. A plugin never
receives a pairing secret or a client token: pair returns only a grant id,
the host reads the secret through RemoteGrants::awaiting_host to present
it to the person, a remote client redeems it once (a replay is refused),
and RemoteGrants::authorize admits that client only for the session the
grant was made in. Grants are bound to plugin id and session, so another
plugin or another session cannot list or revoke them, and they outlive the
invocation that asked, so a later invocation in the same session can
still revoke them. Pairings expire after 10 minutes, clients after 30, and
at most 4 pairings may await the host per plugin and session. No public
listener or tunnel exists: status says so and listen/tunnel are unknown
methods.

Known limits: the Runtime API does not yet read or redeem these grants
(exact request in the review packet), and whether a plugin may use the
remote service at all is not checked before the arm (SEAM-CONTEXT).

The TypeScript side adds the remote adapter (ctx.remote) that forwards
each method through host.broker with a typed payload, plus its
compatibility name and regenerated dist.

Checks, on the landing tip (this commit with the test commit after it):
- Linux x86_64: sh scripts/with-hermetic-test-home.sh cargo nextest run
  --locked --no-fail-fast -p codewhale-engine -E 'test(extension_host)'
  191 tests run: 191 passed (1 slow), 6981 skipped
- Linux x86_64: cargo clippy --workspace --all-targets --all-features
  --locked -- -D warnings -A clippy::uninlined_format_args
  -A clippy::too_many_arguments -A clippy::unnecessary_map_or
  exit 0, no warnings
- extension-host: npm run typecheck exit 0; npm run build exit 0, tree
  unchanged; npm test 451 tests, 444 pass, 0 fail, 7 skipped

Tests under the broker grant check (folded in from the landing):

The remote arm lands on top of the one grant check: broker tickets are
minted only for granted services, and no service is granted outside
tests. Two tests have to say so:

- the remote journey test grants workspace and remote for its own thread
  with TestGrants, as the network arm's test does, so it gets the remote
  ticket it pairs with and the workspace ticket it presents as the wrong
  service;
- the grant test's "the landed stub still refuses" assertion skips remote
  as it skips network: the filled arm decodes first and answers
  unknown_method for the probe method. The remote journey test asserts
  that refusal through the real table.

No production change. The remote service is granted to no plugin, so no
remote ticket is minted and the arm cannot be reached until a plugin's
reviewed record grants it.

Checks, on this commit (Linux x86_64): the engine's extension_host tests
under cargo nextest, 191 tests run: 191 passed (1 slow), 6981 skipped.
The remote journey test, the grant test and the ticket test passed by
name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:18 -08:00
CodeWhale BotandClaude Fable 5.2 fc78dc86d8 fix(plugins): replace plugin state on Windows while a plugin UI grant holds the file open (CW-7956)
On Windows, disabling or revoking a plugin whose UI had just been opened was
refused with 409:

    disabled failed for 'panel': failed to atomically and durably persist
    ...\runtime\plugins\state.json: Access is denied. (0x80070005)

A plugin UI grant keeps the plugin state file open for up to an hour, so that
every asset request can prove the state file is still the one the grant was
minted against. The Windows publication replaced the file with MoveFileExW, and
Windows refuses that replacement for as long as any handle is open on the
destination, whatever its share mode. The bounded retry around it was sized for
a scanner or an ACL pass that lets go within about a second, so it ran out and
the refusal surfaced. While any grant was live, every state change of every
plugin was refused, from this process or another: trust, enable, disable,
revoke and the state prune of an uninstall.

When MoveFileExW answers access denied, publication now opens the hardened
temporary and falls back to std::fs::rename, which replaces the destination
with POSIX rename semantics. A reader that shares delete access keeps its
handle on the superseded file and no longer blocks the replacement, which is
what Unix already did. That path does not write through, so the published file
is flushed through the handle opened before the rename, and a flush failure is
reported. A holder that denies delete sharing still gets the bounded retry and
then the refusal, with the stable file untouched.

The grant code is unchanged. After a replacement the grant's handle identifies
the superseded file, its identity check fails, and the grant is stale, as on
Unix.

Not covered: a volume without POSIX rename (FAT, exFAT, some network shares)
keeps the old refusal while a grant is open.

The plugin UI tests now print the response body when disable, enable or revoke
is refused; a failing run used to show only the status.

No released version is affected: the grant arrived after v0.10.2, so there is
no CHANGELOG line.

Evidence
- Hosted Windows before the fix (run 42972872196, the two test changes alone):
  the new registry test failed with "failed to atomically and durably persist
  ...\state.json: Access is denied. (0x80070005)" (3 run, 2 passed, 1 failed),
  and plugin_ui ran 3, passed 1, failed 2 with the 409 above for disable and
  the same error for revoke.
- Hosted Windows with this change on main ecfbdbccaa (run 42978481553):
  codewhale-plugins --lib 200 run, 200 passed, 1 skipped; plugin_ui 3 run, 3
  passed under nextest in the hermetic test home, and "test result: ok. 3
  passed; 0 failed" under cargo test. Run 42975064244 gave the same result on
  main 8ddd9025c0.
- Linux build farm with this change on main ecfbdbccaa: nextest 214 run, 214
  passed (3 plugin_ui, 211 codewhale-plugins). On main 8ddd9025c0, clippy for
  both crates with all targets and all features was clean.
- cargo fmt --all -- --check and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:35:19 -08:00
CodeWhale BotandClaude Fable 5.2 ecfbdbccaa chore(changelog): record the website text for agents and sync the TUI changelog slice (CW-8048)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:14 -08:00
CodeWhale BotandClaude Fable 5.2 74ebae88db fix(web): label the main-branch documents in /docs-markdown and /llms-full.txt with the published tag (CW-8048 SHARE-TEXT)
Every /docs-markdown topic except install embeds the repository's
documents as they are on main, and /llms-full.txt carries all of them after
the llms.txt text. Those documents describe behaviour the published release
does not have (a mode set without Operate, and commands and settings that
exist only in source), and nothing in the served text said so: the modes
topic was introduced with the released description and then contradicted
by the document under it.

Each of those documents now starts with the sentence the reference pages
already show for a main-branch document, naming the published tag, and a
link to the repository at that tag. The sentence is the existing
REFERENCE_GUIDE_COPY.scope string; nothing is added to a dictionary. It is
left out when the release is unknown, as on the reference pages.

This labels the statements; it does not remove them. Serving the documents
as they are at the release tag needs the generator in
scripts/docs-sources-lib.mjs to read the tag, which this change does not
touch. A link per document at the tag is not printed because a document can
exist on main and not at the tag.

Test: one more case in lib/llms-txt.test.ts counts the label in the full
text: once for every topic except install. Removing the label fails it.

Checks from web/: tsc --noEmit and eslint are clean; vitest on the four
files that read these texts, 403 of 403.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:10 -08:00
CodeWhale BotandClaude Fable 5.2 9f5241604d feat(web): llms.txt gives the release, the install steps and each part's state from the pages' own content (CW-8048 SHARE-TEXT)
An agent asked how to install Codewhale and what it can do today reads
/llms.txt. The file now opens with the locked headline and subhead and says
"The latest release is v{version}." from the published-release object.

The install text is rendered from lib/content/install.ts, the module the
/install page renders: one command per platform and channel, what the
installer adds to PATH and the opt-out, the verify command and its output,
the three first steps, usage counting and how to turn it off, update,
removal, known problems, the other channels and the mirrors. The page's own
conditions pick the wording: the uninstall command appears only when the
published release has it, and the version-free strings are used when the
release is unknown.

The six parts of the product are printed with their state words from
lib/content/product-map.ts and the locked map strings. A row whose computed
state no longer matches the state its sentence was written for prints the
state without the sentence, so a desktop release object flips that line to
"Released" and drops "no public download yet".

/docs-markdown/install.md serves the same install text as a page, in place
of the main-branch install guide, with the facts stamp at its end. Like
/install, it links the full guide at the release tag. /llms-full.txt
carries the new head and that page.

Removed from llms.txt because they contradict the released version or the
copy rules:
- the old identity line and summary sentence;
- the publish date on the release line (the changelog carries it); the date
  the release was read stays, inside the provenance stamp;
- the "Unreleased on main" list of changelog headlines;
- the entries for pages that now redirect (product, runtime, constitution,
  roadmap, feed, digest, community) and for /signin, which the sitemap no
  longer lists;
- the hand-typed states on the /app entry, which now prints the typed
  surface states the page prints.
The install entries use the install page's own description.

Not changed: the routes stay static and read the build's facts, while
/install revalidates against the runtime facts snapshot, so after a new
release the page can name it before the text does, until the next deploy.
The licence, default-model and sandbox lines still read the source tree.

Tests: lib/llms-txt.test.ts is rewritten around invariants (9 tests): the
head and the index, no link to a redirected route, the version equals the
release object and the source version never appears, every statement the
rendered /install page makes from the install content is in both texts,
each map row with its computed state and a fixture desktop release flipping
only the desktop line, no price, plan or date outside the stamp, unknown
stays unknown, and the routes. The three assertions in
lib/facts-invariants.test.ts that quoted the old release line now quote the
new one. Nine single-line mutations of the builders each fail one of these
tests.

Checks from web/: prebuild, check:facts, check:docs (29 topics) and
check:locales pass; tsc --noEmit and eslint are clean; node --test 7 of 7;
vitest 90 files, 1294 of 1294; next build succeeds.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:05 -08:00
584 changed files with 36654 additions and 16529 deletions

No files matched your search

+3 -3
View File
@@ -48,7 +48,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
# codewhale-cnb-bridge GitHub App credentials from the CNB KeyStore
# (codewhale.net/codewhale-ci-secrets, github-bridge.yml), injected as
# environment variables. Values are never printed.
@@ -112,7 +112,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
stages:
- name: install release dependencies
script: |
@@ -163,7 +163,7 @@ main:
$:
tag_push:
- docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
stages:
- name: build linux x64 release assets (static)
# The static full-LTO link can also outlive CNB's default no-output
+1 -1
View File
@@ -1,4 +1,4 @@
ARG RUST_VERSION=1.88
ARG RUST_VERSION=1.100.0
FROM rust:${RUST_VERSION}-slim-bookworm
# Native DBus headers and pkg-config are required by libdbus-sys during
+1 -1
View File
@@ -4,7 +4,7 @@
"context": "..",
"dockerfile": "Dockerfile",
"args": {
"RUST_VERSION": "1.88"
"RUST_VERSION": "1.100.0"
}
},
"customizations": {
+6
View File
@@ -65,6 +65,12 @@ crates/engine/extension-host/dist/** text eol=lf
crates/engine/tests/fixtures/extension_host/** text eol=lf
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
# DeepSeek Harness creator pins are compared byte for byte against recorded
# SHA-256 values (UPSTREAM.json) and embedded with include_bytes!(); a CRLF
# Windows checkout must not rewrite any of them.
crates/skills/assets/dsh-creator/** text eol=lf
crates/engine/extension-host/src/dsh/upstream/creator/** text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
+51 -8
View File
@@ -4,11 +4,12 @@
# alias = Display Name <[email protected]>
#
# The right-hand side must use GitHub's numeric noreply address so a harvested
# commit authored with it links to the contributor's account. Only commit
# authorship puts someone in the repository's contributor list; a co-author
# trailer shows on their profile but not there. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
# commit authored with it, or a Co-authored-by trailer in the commit's final
# trailer block, links to the contributor's account. GitHub resolves the
# address by its numeric id, so the id must be the contributor's own
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
# old-style noreply address, raw email from a contributor commit, or local
# machine email seen in older harvested history.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
@@ -50,6 +51,7 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
894876246 = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
@@ -79,19 +81,24 @@ Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
mars-base = kitty <[email protected]>
[email protected] = kitty <[email protected]>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
[email protected] = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <217034332+angus-[email protected]>
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
angus-guo = gus <217034332+im[email protected]>
imgusguo = gus <217034332+im[email protected]>
[email protected] = gus <[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
bengao168 = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
@@ -247,6 +254,7 @@ [email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
[email protected] = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
@@ -278,3 +286,38 @@ Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
[email protected] = luochen211 <[email protected]>
AiurArtanis = AiurArtanis <[email protected]>
[email protected] = AiurArtanis <[email protected]>
Apeiron0w0 = Apeiron <[email protected]>
[email protected] = Apeiron <[email protected]>
[email protected] = ChaceLyee2101 <[email protected]>
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
ddlw = ddlw <[email protected]>
mo-vic = movic <[email protected]>
[email protected] = movic <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
xulongzhe = luola <[email protected]>
[email protected] = luola <[email protected]>
ZhulongNT = SONG ZHENRUI <[email protected]>
[email protected] = SONG ZHENRUI <[email protected]>
xiluoduyu = xiluoduyu <[email protected]>
[email protected] = xiluoduyu <[email protected]>
wlon = Stephen Xu <[email protected]>
[email protected] = Stephen Xu <[email protected]>
krisclarkdev = Kristopher Clark <[email protected]>
THatch26 = THatch26 <[email protected]>
jeoor = Kayro <[email protected]>
harvey2011888 = harvey2011888 <[email protected]>
duanchao-lab = DC <[email protected]>
dumbjack = dumbjack <[email protected]>
[email protected] = dumbjack <[email protected]>
AquaZhang = IceTear <[email protected]>
disappear00 = idontknow <[email protected]>
namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
-6
View File
@@ -1,8 +1,2 @@
# Default owner for everything in the repo.
* @Hmbown
# AI code review is advisory and not wired through CODEOWNERS: GitHub CODEOWNERS
# only accepts users and teams, not bots. @Hmbown stays the human code owner.
# - Claude: .github/workflows/claude-review.yml (GitHub Actions).
# - Codex/ChatGPT: the ChatGPT Codex cloud integration (chatgpt.com/codex ->
# connect GitHub -> enable Code review), authed by the ChatGPT subscription.
+19
View File
@@ -0,0 +1,19 @@
# Getting help with Codewhale
- **A question about using Codewhale:** ask in
[Discussions Q&A](https://github.com/codewhale-hq/Codewhale/discussions/categories/q-a)
or on [Discord](https://discord.gg/37gfS3ksug). The
[user guide](../docs/GUIDE.md) and the [documentation index](../docs/README.md)
answer many questions already.
- **A bug, a crash or behavior that surprised you:** open a
[bug report](https://github.com/codewhale-hq/Codewhale/issues/new?template=bug_report.yml).
`codewhale doctor` prints the version and setup details a report needs.
- **An idea or feedback:** open an
[issue](https://github.com/codewhale-hq/Codewhale/issues/new/choose), or talk it
through first in
[Ideas](https://github.com/codewhale-hq/Codewhale/discussions/categories/ideas).
- **A security problem:** report it privately as described in the
[security policy](SECURITY.md), never in a public issue.
The [contributing guide](../CONTRIBUTING.md) explains where each kind of help
goes and how a pull request lands.
+119 -19
View File
@@ -248,7 +248,7 @@ assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch ==
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
assert.match(nightlyArmMuslSetup, /rustup target add aarch64-unknown-linux-musl/);
const nightlyArmStaticSmoke = namedStep(
nightly,
"Verify static Linux ARM64 binary and launch",
@@ -373,7 +373,7 @@ assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expecte
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
assert.match(releaseMuslBuild, /rustup target add \$\{\{ matrix\.target \}\}/);
assert.match(
releaseMuslBuild,
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
@@ -810,6 +810,20 @@ assert.doesNotMatch(
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
// bounds live in release-artifacts.yml rather than in its callers.
function timeoutMinutes(value) {
const literal = /^\d+$/.test(value);
const conditional = value.match(
/^\$\{\{\s*startsWith\(matrix\.os,\s*'macos-'\)\s*&&\s*(\d+)\s*\|\|\s*(\d+)\s*\}\}$/,
);
if (!literal && !conditional) return null;
const macos = Number(literal ? value : conditional[1]);
const other = Number(literal ? value : conditional[2]);
if (![macos, other].every((cap) => Number.isInteger(cap) && cap > 0 && cap <= 360)) {
return null;
}
return { macos, other };
}
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
@@ -823,7 +837,8 @@ function jobsWithoutTimeout(source) {
for (let j = i + 1; j < lines.length; j += 1) {
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
if (/^ uses:/.test(lines[j])) reusable = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
const timeout = lines[j].match(/^ timeout-minutes:\s*(.+?)\s*$/);
if (timeout && timeoutMinutes(timeout[1])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
@@ -862,26 +877,41 @@ for (const [name, source] of [
);
}
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
// keep real margin — a tight bound here fails healthy releases.
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
assert.ok(buildTimeout, "release-artifacts build job must be capped");
assert.ok(
Number(buildTimeout[1]) >= 60,
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
);
function jobTimeout(source, job) {
function jobTimeout(source, job, platform = "other") {
const match = source.match(
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (.+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
return Number(match[1]);
const caps = timeoutMinutes(match[1].trim());
assert.ok(caps, `${job} must declare a bounded timeout`);
return caps[platform];
}
// Fast setup and packaging fail quickly. Native builds need the same finite
// cold-build allowance as full CI: both 0.10.1 macOS artifacts hit the old
// 90m compilation cap before reaching their launch and inventory checks.
const macosTimeout = "${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}";
assert.deepEqual(timeoutMinutes(macosTimeout), { macos: 360, other: 210 });
for (const invalid of [
"0", "361", "Infinity", "${{ matrix.timeout }}",
"${{ startsWith(matrix.os, 'macos-') && 361 || 210 }}",
"${{ startsWith(matrix.os, 'macos-') && 360 || 0 }}",
]) {
assert.equal(timeoutMinutes(invalid), null, `${invalid} must not count as a bounded cap`);
}
assert.deepEqual(
jobsWithoutTimeout(`jobs:\n capped:\n timeout-minutes: ${macosTimeout}\n`),
[],
);
assert.equal(jobTimeout(artifacts, "build", "macos"), 360);
assert.equal(jobTimeout(artifacts, "build"), 210);
const artifactCacheSetup = artifacts.match(
/ - uses: mozilla-actions\/sccache-action@[^\n]+\n(?:(?! - ).*\n)*/,
);
assert.ok(artifactCacheSetup, "artifact compiler cache setup must be explicit");
assert.match(artifactCacheSetup[0], /^ if: runner\.os == 'Linux'$/m);
assert.match(
namedStep(artifacts, "Enable sccache"),
/^ if: steps\.sccache\.outcome == 'success' && runner\.os == 'Linux'$/m,
);
assert.equal(jobTimeout(candidate, "resolve"), 10);
assert.equal(jobTimeout(candidate, "web"), 15);
assert.equal(jobTimeout(artifacts, "pin"), 10);
@@ -894,7 +924,7 @@ for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
}
assert.equal(
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
"nightly and release must share the native cold-build allowance",
"nightly and non-macOS release builds must share the cold-build allowance",
);
assert.equal(jobTimeout(release, "resolve"), 10);
// The 0.10.1 cold parity build exhausted 45 minutes before tests started.
@@ -904,6 +934,76 @@ assert.ok(
"release parity must allow the full CI suite's cold-build budget",
);
const pinnedToolchain = read("rust-toolchain.toml").match(/^channel\s*=\s*"([^"]+)"/m)[1];
assert.match(pinnedToolchain, /^\d+\.\d+\.\d+$/, "rust-toolchain.toml must pin an exact Rust release");
const msrv = read("Cargo.toml").match(/^rust-version\s*=\s*"([^"]+)"/m)[1];
assert.match(msrv, /^\d+\.\d+$/, "Cargo.toml must declare a minor MSRV");
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const stripWorkflowComments = (source) =>
source
.split("\n")
.map((line) => line.replace(/(^|\s)#.*$/, ""))
.join("\n");
const workflowDir = path.join(repoRoot, ".github", "workflows");
const workflowSources = new Map();
for (const file of fs.readdirSync(workflowDir).filter((name) => /\.ya?ml$/.test(name)).sort()) {
workflowSources.set(file, stripWorkflowComments(read(`.github/workflows/${file}`)));
}
const ciSource = workflowSources.get("ci.yml");
const msrvHeader = /^ msrv:[ \t]*$/m.exec(ciSource);
assert.ok(msrvHeader, "ci.yml must keep an MSRV job");
const msrvStart = msrvHeader.index;
const afterMsrvHeader = msrvStart + msrvHeader[0].length;
const nextJob = /^ \S/m.exec(ciSource.slice(afterMsrvHeader));
const msrvEnd = nextJob ? afterMsrvHeader + nextJob.index : ciSource.length;
const selectorValue = (value) => value.trim().replace(/^(["'])(.*)\1$/, "$2");
const inMsrvJob = (index) => index >= msrvStart && index < msrvEnd;
for (const [file, source] of workflowSources) {
const selectors = [
...[...source.matchAll(/dtolnay\/rust-toolchain@([^\s]+)/g)].map((m) => ["action ref", m[1], m.index]),
...[...source.matchAll(/^[ \t]+toolchain:[ \t]*([^\r\n]*)$/gm)].map((m) => ["toolchain input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+rust-version:[ \t]*([^\r\n]*)$/gm)].map((m) => ["rust-version input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+RUSTUP_TOOLCHAIN:[ \t]*([^\r\n]*)$/gm)].map((m) => ["RUSTUP_TOOLCHAIN", selectorValue(m[1]), m.index]),
...[...source.matchAll(/\bcargo\s+\+\s*["']?([^\s"']+)/g)].map((m) => ["cargo + selector", m[1], m.index]),
];
for (const [kind, value, index] of selectors) {
if (kind === "action ref" && (/^[0-9a-f]{40}$/.test(value) || value === "master")) continue;
const expected = file === "ci.yml" && inMsrvJob(index) ? msrv : pinnedToolchain;
assert.equal(value, expected, `${file}: ${kind} ${value} must equal ${expected}`);
}
assert.doesNotMatch(source, /rustup (?:target|component) add --toolchain/, `${file}: rustup adds must follow rust-toolchain.toml`);
}
const msrvJob = ciSource.slice(msrvStart, msrvEnd);
assert.match(msrvJob, new RegExp(`RUSTUP_TOOLCHAIN: "${escapeRegExp(msrv)}"`), "the MSRV job must override rust-toolchain.toml for every step");
assert.match(msrvJob, new RegExp(`toolchain: "?${escapeRegExp(msrv)}"?`));
assert.match(msrvJob, new RegExp(`cargo \\+${escapeRegExp(msrv)} check --workspace --locked`));
assert.doesNotMatch(msrvJob, new RegExp(`\\b${escapeRegExp(pinnedToolchain)}\\b`), "the MSRV job must not select the pinned release toolchain");
const rootDockerfile = read("Dockerfile");
const devcontainerDockerfile = read(".devcontainer/Dockerfile");
const devcontainerConfig = read(".devcontainer/devcontainer.json");
const tencentCnb = read("deploy/tencent-lighthouse/cnb/cnb.yml.example");
const pinnedTag = `${pinnedToolchain}-bookworm`;
for (const [label, source] of [
["Dockerfile", rootDockerfile],
[".devcontainer/Dockerfile", devcontainerDockerfile],
]) {
assert.match(source, new RegExp(`^ARG RUST_VERSION=${escapeRegExp(pinnedToolchain)}$`, "m"), `${label} must default RUST_VERSION to the pinned toolchain`);
assert.match(source, /^FROM .*\brust:\$\{RUST_VERSION\}/m, `${label} must build from the RUST_VERSION image`);
}
assert.match(devcontainerConfig, new RegExp(`"RUST_VERSION":\\s*"${escapeRegExp(pinnedToolchain)}"`), ".devcontainer/devcontainer.json must pass the pinned RUST_VERSION build arg");
for (const [label, source] of [
[".cnb.yml", cnb],
["deploy/tencent-lighthouse/cnb/cnb.yml.example", tencentCnb],
]) {
const tags = [...stripWorkflowComments(source).matchAll(/^[ \t]*image:[ \t]*([^\r\n]*)$/gm)]
.map((m) => selectorValue(m[1]))
.filter((value) => value.startsWith("rust:"))
.map((value) => value.slice("rust:".length));
assert.ok(tags.length > 0, `${label} must declare a Rust build image`);
assert.deepEqual([...new Set(tags)], [pinnedTag], `${label}: Rust build images must match rust-toolchain.toml`);
}
console.log(
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
);
+5 -5
View File
@@ -23,17 +23,17 @@ jobs:
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
# rust-toolchain.toml pins `channel = "stable"` with no target, so
# rust-toolchain.toml pins `channel = "1.100.0"` with no target, so
# inside the action's alpine (musl) container rustup resolves it to
# `stable-x86_64-unknown-linux-musl` — which the container does not
# `1.100.0-x86_64-unknown-linux-musl`, which the container does not
# have. Without provisioning, the action's `rustup show` step prints
# "error: override toolchain 'stable-x86_64-unknown-linux-musl' is not
# "error: override toolchain '1.100.0-x86_64-unknown-linux-musl' is not
# installed" and then auto-installs mid-run (network-dependent ~12s
# detour, hard failure if the download stalls). `rust-version` makes the
# entrypoint run `rustup default stable` (same musl host triple) before
# entrypoint run `rustup default 1.100.0` (same musl host triple) before
# cargo-deny touches the workspace, so the toolchain file resolves to an
# already-installed toolchain on every run.
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
rust-version: stable
rust-version: 1.100.0
+14 -12
View File
@@ -203,7 +203,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: actions/[email protected]
with:
node-version: 22
@@ -428,7 +428,7 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# The GitHub Actions cache backend is main-only, mirroring
@@ -487,7 +487,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
if: needs.changes.outputs.heavy == 'true'
with:
toolchain: stable
toolchain: 1.100.0
components: rustfmt, clippy
- uses: mozilla-actions/[email protected]
id: sccache
@@ -728,6 +728,8 @@ jobs:
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: "1.89"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
@@ -751,7 +753,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -825,11 +827,11 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
shell: pwsh
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- name: Install macOS LLVM tools
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: rustup component add llvm-tools --toolchain stable
run: rustup component add llvm-tools
- name: Omit macOS build debug metadata
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
shell: bash
@@ -1222,7 +1224,7 @@ jobs:
if: needs.changes.outputs.heavy == 'true'
shell: pwsh
run: ./scripts/installer/update-user-path.tests.ps1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true'
- name: Check every workspace target compiles on Windows
if: needs.changes.outputs.heavy == 'true'
@@ -1267,7 +1269,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
@@ -1305,7 +1307,7 @@ jobs:
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: mozilla-actions/[email protected]
id: sccache
@@ -1313,7 +1315,7 @@ jobs:
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' && matrix.os != 'windows-latest'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
@@ -1364,7 +1366,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -1423,7 +1425,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
components: rustfmt
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+3 -3
View File
@@ -67,13 +67,13 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
targets: ${{ matrix.target }}
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
shell: bash
run: |
{
@@ -99,7 +99,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable aarch64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Install Rust target
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.100.0
with:
targets: aarch64-unknown-linux-ohos
- name: Install OpenHarmony native SDK
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- run: npm --prefix pet run sync
@@ -60,7 +60,7 @@ jobs:
cache-dependency-path: pet/package-lock.json
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: runner.os == 'macOS'
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
if: runner.os == 'macOS'
+5 -6
View File
@@ -88,9 +88,7 @@ jobs:
build:
name: Build ${{ matrix.platform }}
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
# compilation. Keep the full CI build budget; packaging stays capped below.
timeout-minutes: 210
timeout-minutes: ${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}
# FreeBSD is a source-build target with no prebuilt asset, no npm binary and no CI check
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd).
strategy:
@@ -172,13 +170,14 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
if: runner.os == 'Linux'
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success' && runner.os == 'Linux'
shell: bash
run: |
{
@@ -216,7 +215,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable ${{ matrix.target }}
rustup target add ${{ matrix.target }}
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
package-manager-cache: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
- name: Match dispatch to the requested commit
id: source
shell: bash
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
+4
View File
@@ -612,6 +612,7 @@ jobs:
set -euo pipefail
node web/scripts/sync-latest-release.mjs
node web/scripts/derive-facts.mjs
node web/scripts/derive-release-docs.mjs
- name: Prove the record passes the web fact gates
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -620,6 +621,7 @@ jobs:
node web/scripts/sync-latest-release.mjs --check
node web/scripts/check-cloud-facts.mjs
node web/scripts/check-facts.mjs
node web/scripts/check-release-docs.mjs
- name: Propose the record to the default branch
env:
TAG: ${{ needs.resolve.outputs.tag }}
@@ -633,6 +635,8 @@ jobs:
docs/cloud-facts/stable.json
web/lib/facts.generated.ts
web/data/agent-docs.generated.json
web/data/agent-docs.release.json
web/data/agent-docs.release-index.json
web/data/features.generated.json
)
if git diff --quiet -- "${paths[@]}"; then
+3 -4
View File
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
@@ -76,9 +76,8 @@ jobs:
- name: Audit website with verified build dependency mitigation
if: matrix.name == 'web'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
# The VS Code extension's packager toolchain carries one unfixed high
# advisory that never runs for users (DEPENDENCY_SECURITY.md at the repo
# root). It is admitted by name; anything else fails the job.
# The VS Code extension's packager dependency override is recorded in
# docs/dependency-maintenance.md; any reported vulnerability fails the job.
- name: Audit extension with reviewed packager advisory acceptance
if: matrix.name == 'vscode'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
ref: ${{ inputs.expected_sha }}
fetch-depth: 1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: taiki-e/install-action@nextest
- name: Run focused tests
shell: bash
+14
View File
@@ -14,3 +14,17 @@ Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
ddlw <[email protected]> <[email protected]>
dmitri-0 <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
AiurArtanis <[email protected]> <[email protected]>
Apeiron <[email protected]> <[email protected]>
ChaceLyee2101 <[email protected]> <[email protected]>
movic <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
luola <[email protected]> <[email protected]>
SONG ZHENRUI <[email protected]> <[email protected]>
xiluoduyu <[email protected]> <[email protected]>
+10 -3
View File
@@ -259,9 +259,16 @@ prefer merging the contributor's PR itself). When a contributor's work
lands as our commit, the contributor is that commit's **author** (`git commit
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
list counts commit authors only: about 200 harvested commits authored by
maintainer identities left their contributors off it, which is why
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
count takes commit authors and the `Co-authored-by` trailers in a commit's final
trailer block, and only addresses linked to an account: a trailer cut off by a
blank line, an unlinked email, or a noreply address carrying someone else's
numeric id credits nobody or the wrong person. Harvested commits authored by
maintainer identities without such a trailer left their contributors off it,
which is why `docs/CONTRIBUTORS.md` carries the full wall
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
restored by a later commit with one trailer per person, naming each pull
request; `scripts/check-contributor-credit.py` fails the release window when a
harvest, a trailer or a noreply id would leave someone uncounted.
A maintainer closes their PR by hand with a thank-you naming the commit;
nothing closes a PR automatically. Canonical human identities come from
`.github/AUTHOR_MAP`.
+60 -14
View File
@@ -7,7 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
- **Skill install and import:** `/skill install` now works out an archive's wrapper directory from every entry instead of the first one, so a GitHub repository laid out as `<name>/SKILL.md` installs instead of failing with "missing SKILL.md in archive", a repository-root `SKILL.md` is chosen ahead of a nested `skills/<name>/SKILL.md`, and a flat tarball that lists `scripts/run.sh` before `SKILL.md` no longer installs without its `SKILL.md`. Importing an external skill that contains dotfiles such as `.DS_Store`, `.gitignore` or `.config/` no longer fails with "imported content digest mismatch"; the dotfiles are copied, and the source's own `.trusted` and `.installed-from` are still left behind. Thanks [@luochen211](https://github.com/luochen211) (PR 7311).
- **Workflow authoring:** a declarative Workflow or structured `workflow` plan whose goal or prompt mentions words such as "async", "import" or "open(" is no longer rejected as an unsupported construct. Fleet TOML values written as 'single-quoted' strings, or with escapes such as `\"`, are now read without their quotes. Workflow scripts can no longer reach the internal `__workflow_slot_dropped` binding, which had let a script mark a clean run as Degraded. Thanks [@luochen211](https://github.com/luochen211) (PR 7312).
- **Tool output escape stripping:** tool output no longer shows a stray character after a character-set escape such as the `ESC ( B` that `tput sgr0` prints, and a stray `ESC [` no longer hides the Chinese, Japanese or Korean text after it. Coloured tool output now keeps only well-formed colour sequences. Thanks [@luochen211](https://github.com/luochen211) (PR 7313).
- **Patches with short hunk headers:** `apply_patch` now reads every line of a hunk when the `@@` header counts are too small. Lines past twice the header counts used to be dropped without an error, so a long hunk, or a new file written under `@@ -0,0 +1 @@`, could lose its later lines. A header with an enormous count no longer crashes the parser, and stray text after such a hunk now fails as a context mismatch. Thanks [@luochen211](https://github.com/luochen211) (PR 7287).
- **Script tools with `env -S` shebangs:** a script tool whose first line is `#!/usr/bin/env -S python3 -u` now starts `python3 -u` instead of failing to launch a program named `-S`. Other `env` options, the attached `-Sprogram` form and `env -S` quoting are still not interpreted. Thanks [@luochen211](https://github.com/luochen211) (PR 7288).
- **Timeline summaries:** a thread or task timeline summary that already fits its length limit is now shown in full, instead of having its last characters replaced by `...`. Thanks [@luochen211](https://github.com/luochen211) (PR 7289).
- **RLM context names:** `rlm_open` without a `name` now keeps non-ASCII letters and digits from the file name, so `docs/报告.txt` and `docs/总结.txt` open as `报告_txt` and `总结_txt` instead of both becoming `txt` and the second open failing with "already exists". Names derived from ASCII file names are unchanged. Thanks [@luochen211](https://github.com/luochen211) (PR 7290).
- **Web fetch and `web.run` entities:** text a page escaped on purpose now stays escaped. A page that writes `&amp;lt;br&amp;gt;` to show a literal `&lt;br&gt;` now reads as `&lt;br&gt;` in fetched text, `web.run` lines, page titles and meta descriptions; before, it was decoded twice into `<br>`. Both tools now use the search scraper's single-pass decoder, so numeric references such as `&#8212;` and names such as `&mdash;` are decoded there too. Thanks [@luochen211](https://github.com/luochen211) (PR 7291).
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
@@ -38,11 +91,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Docs for agents match the release:** `/llms-full.txt` and `/docs-markdown/*` now serve the documents as they were at the published release tag, from a committed snapshot that is checked against the published release.
- **Extension host (experimental): trusted plugins ask for review once more.** With `extension_host` turned on, plugins you already trusted are shown for review again after this update, because the capability table they were reviewed against gained the `remote` service. No plugin can use that service yet: none is granted it.
- **Website text for agents:** llms.txt now gives agents the install steps for the released version, the current release and the state of each part of Codewhale, from the same content as the pages. `/docs-markdown/install.md` serves those install steps as a page, and the main-branch documents beside it now say which release they can run ahead of.
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Plugins, DSH and MCP tools are not yet attached to ACP-driven sessions. A real-editor journey is not yet proved.
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
@@ -53,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
@@ -633,7 +691,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
- **Sessions in one folder no longer leave each other's sub-agents read-only or private:** a second session works beside another one, stale or suspended included, the older sub-agent store upgrades right away, and a store that cannot be opened is retried on the next sub-agent change instead of needing a restart. While another Codewhale session, a suspended one included, is open in a folder the desktop app or `serve` was asked to work in (other than the folder it started in), that Runtime still refuses sub-agents that would edit the folder directly; isolated worktrees are unaffected. A sub-agent store that cannot be opened shows its refusal once instead of repeating cleanup warnings on every sidebar refresh. Restart any session started with Codewhale 0.10.2 or older: once the folder upgrades it can no longer save sub-agent state (Refs #7232).
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
@@ -670,12 +728,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
composer. Real Pinyin input on macOS and Windows is not yet confirmed.
Thanks [@cmdcorp6534](https://github.com/cmdcorp6534) for the report
(Refs #2323).
- **Sub-agents beside an older session:** while a Codewhale 0.10.2 session
still uses a folder's older delegated-agent store, a 0.10.3 session in the
same folder keeps working sub-agents with private state that is not saved,
instead of refusing every sub-agent action. The older session's files are
left untouched, and the store upgrades when a session starts after the older
one exits (Refs #7232).
- **Fleet members:** a replayed member start now matches only within its own
session, so two sessions that reuse an operation key no longer receive each
other's agent.
@@ -704,12 +756,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
bare self-package exports resolve inside the selected package directory.
Invalid or incompatible ranges and paths outside the reviewed package still
refuse. Real published-plugin activation remains unqualified.
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
identify an owner PID. A refused legacy admission keeps its reason in Work
and shows one notice per cause instead of repeating cleanup warnings on
every sidebar refresh. Legacy migration still requires the actual holder to
exit and the refused owning Runtime to restart; locks and retained state
remain protected.
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
Generated
+18 -39
View File
@@ -912,14 +912,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "codewhale-agent"
version = "0.10.3"
dependencies = [
"codewhale-config",
"serde",
]
[[package]]
name = "codewhale-agent-contract"
version = "0.10.3"
@@ -943,7 +935,6 @@ dependencies = [
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1049,6 +1040,7 @@ dependencies = [
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-state",
"codewhale-telemetry",
@@ -1118,6 +1110,7 @@ dependencies = [
"codewhale-execpolicy",
"codewhale-paths",
"codewhale-protocol",
"codewhale-sanitize",
"codewhale-secrets",
"fd-lock",
"libc",
@@ -1141,7 +1134,6 @@ name = "codewhale-core"
version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-hooks",
"codewhale-protocol",
"codewhale-state",
"regex",
@@ -1150,7 +1142,6 @@ dependencies = [
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
"uuid",
]
@@ -1179,14 +1170,12 @@ dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1221,7 +1210,6 @@ dependencies = [
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"cucumber",
"dirs 7.0.0",
"encoding_rs",
"fd-lock",
@@ -1240,12 +1228,10 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"portable-pty",
"pretty_assertions",
"proc-macro2",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rusqlite",
"rustls",
@@ -1261,9 +1247,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1307,14 +1291,11 @@ name = "codewhale-hooks"
version = "0.10.3"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codewhale-app-server-protocol",
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"reqwest",
"serde",
"serde_json",
"tempfile",
"tokio",
@@ -1434,7 +1415,7 @@ version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-runtime",
"codewhale-secrets",
"codewhale-sanitize",
"dirs 7.0.0",
"regex",
"tempfile",
@@ -1509,6 +1490,7 @@ dependencies = [
"codewhale-protocol",
"codewhale-sanitize",
"serde_json",
"unicode-width",
]
[[package]]
@@ -1610,12 +1592,12 @@ dependencies = [
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-util",
"colored",
"dirs 7.0.0",
"fd-lock",
"futures-util",
"ignore",
"jsonwebtoken",
"libc",
@@ -1883,9 +1865,7 @@ dependencies = [
"reqwest",
"rio-vt",
"rquickjs",
"rusqlite",
"rustls",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1895,7 +1875,6 @@ dependencies = [
"tempfile",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1919,7 +1898,7 @@ version = "0.10.3"
dependencies = [
"codewhale-palette",
"codewhale-ratatui",
"codewhale-secrets",
"codewhale-sanitize",
"crossterm",
"ratatui",
"similar",
@@ -1999,7 +1978,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2526,7 +2505,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2667,7 +2646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2770,7 +2749,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4993,7 +4972,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -6339,15 +6318,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6403,7 +6382,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -7213,7 +7192,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -7245,7 +7224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -8364,7 +8343,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
-1
View File
@@ -1,7 +1,6 @@
[workspace]
members = [
"crates/account-cli",
"crates/agent",
"crates/agent-contract",
"crates/app-server",
"crates/app-server-protocol",
-20
View File
@@ -1,20 +0,0 @@
# Extension packager dependency security
The VS Code extension's packaging toolchain uses `@vscode/vsce` →
`@azure/identity` → `@azure/msal-node`. MSAL Node versions before 7.1.0 are
affected by [GHSA-xcx6-jx8p-x3h5](https://github.com/AzureAD/microsoft-authentication-library-for-js/security/advisories/GHSA-xcx6-jx8p-x3h5).
The extension manifest overrides Azure Identity's MSAL Node dependency to
`^7.1.2`; its lockfile resolves 7.1.2. Keep the override until Azure Identity
requires a patched version itself.
`npm run audit:dependencies -- --report <path>` audits the complete lockfile
and keeps the raw report. Every reported vulnerability blocks the check.
Incomplete reports, count mismatches and critical findings also fail. There
is no advisory exception.
After changing this dependency, materialize the lockfile with `npm ci`, run
the extension's existing `npm test` checks and build a local package with
`npm run package`. Packaging uses `vsce package --no-dependencies`, which
excludes development dependencies from the VSIX. A local package build does
not establish that a publishing authentication flow works; verify that flow
only when publication is separately authorized.
+1 -1
View File
@@ -12,7 +12,7 @@
# Or mount an env file:
# docker run --rm -it --env-file .env codewhale
ARG RUST_VERSION=1.88
ARG RUST_VERSION=1.100.0
# ── Stage 1: Build ────────────────────────────────────────────────────
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
+188 -186
View File
@@ -1,21 +1,25 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark-inverted.svg">
<img src="brand/wordmark.svg" alt="Codewhale" width="320">
<source media="(prefers-color-scheme: dark)" srcset="brand/mark-reversed.svg">
<img src="brand/mark-mono.svg" alt="Codewhale" width="72" height="72">
</picture>
**An open-source coding agent for your terminal that works with any model.**
<h1>The model can be anyone's. The agent should be yours.</h1>
Give Codewhale a task and it reads the repository, edits files, and runs
commands until the task is done or it needs input from you. It works with more
than 40 built-in providers, DeepSeek V4.1 Flash by default, and with models running on
your own machine. `/receipts` lists the files it changed, the commands it ran,
and the web and MCP calls it made.
Codewhale is an open-source coding agent for your terminal, written in Rust.
It works with any model, can split one task across several agents, and every
line of it is yours to read.
[![CI](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
```bash
curl -fsSL https://codewhale.net/install.sh | sh
```
This installs the latest release on macOS or Linux, and it needs no account.
[Windows, npm and Cargo](#install) are below.
[![Latest release](https://img.shields.io/github/v/release/codewhale-hq/Codewhale?label=release)](https://github.com/codewhale-hq/Codewhale/releases/latest)
[![CI](https://github.com/codewhale-hq/Codewhale/actions/workflows/ci.yml/badge.svg)](https://github.com/codewhale-hq/Codewhale/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
@@ -23,63 +27,41 @@ and the web and MCP calls it made.
[简体中文](docs/i18n/README.zh-CN.md) · [日本語](docs/i18n/README.ja-JP.md) · [Tiếng Việt](docs/i18n/README.vi.md) · [Bahasa Indonesia](docs/i18n/README.id.md) · [한국어](docs/i18n/README.ko-KR.md) · [Español](docs/i18n/README.es-419.md) · [Português](docs/i18n/README.pt-BR.md) · [Русский](docs/i18n/README.ru.md) · [Українська](docs/i18n/README.uk.md) · [Français](docs/i18n/README.fr.md) · [Deutsch](docs/i18n/README.de.md) · [繁體中文](docs/i18n/README.zh-TW.md) · [हिन्दी](docs/i18n/README.hi.md) · [Türkçe](docs/i18n/README.tr.md) · [Italiano](docs/i18n/README.it.md) · [Polski](docs/i18n/README.pl.md) · [العربية](docs/i18n/README.ar.md) · [Català](docs/i18n/README.ca.md)
<img src="web/public/codewhale-tui-0102-demo.png" alt="A Codewhale terminal session" width="760">
<img src="web/public/codewhale-tui-0102-demo.png" width="760" alt="Codewhale in a terminal after a demo task. It reports that it added a --json flag to export.py, that all 3 local tests passed and that a delegated review found no issues. The task list below shows three completed steps, and the status line shows Full Access and the local website-demo provider.">
<sub>A 0.10.2 development build with a scripted test model, in Full Access inside a throwaway workspace. The edit, three tests and a sub-agent review ran for real.</sub>
<sub>A development build of 0.10.3, which is not released yet, with a scripted test model, in Full Access inside a throwaway workspace. The edit, three tests and a sub-agent review ran for real.</sub>
</div>
<p align="center">
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">
<img src="web/public/codewhale-x-orcarouter.webp" alt="Codewhale × OrcaRouter — +30% DeepSeek v4.1 Flash usage" width="760">
</a>
</p>
[View the Codewhale × OrcaRouter offer](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67). Codewhale earns a referral fee from OrcaRouter.
## Install
macOS and Linux:
The command above puts checksum-verified `codewhale` and its short alias
`codew` in `~/.local/bin`. If your shell then says `command not found`,
follow the PATH note the installer prints
([details](docs/INSTALL.md#put-it-on-your-path)). `codewhale update` upgrades
it.
```bash
curl -fsSL https://codewhale.net/install.sh | sh
winget install --id HunterBown.CodeWhale --exact --source winget # Windows x64
npm install -g codewhale # wraps the same release binaries
cargo install codewhale-cli --locked # builds from source; can trail GitHub Releases
```
The installer puts checksum-verified `codewhale` and its short alias `codew`
in `~/.local/bin`. If your shell then says `command not found`, run the PATH
line the installer prints ([details](docs/INSTALL.md#put-it-on-your-path)).
`codewhale update` upgrades it. These commands install the latest release.
This page follows `main`, so a feature marked with a version arrives in that
release; the [changelog](CHANGELOG.md) lists what each release adds.
<details>
<summary><b>Windows, npm, Cargo, and other ways to install</b></summary>
```bash
winget install HunterBown.CodeWhale # Windows x64 (or Scoop, or the installer from GitHub Releases)
npm install -g codewhale # wraps the same release binaries
cargo install codewhale-cli --locked # crates.io; can trail GitHub Releases
```
Prebuilt binaries cover Linux, macOS, and Windows on x64 and arm64. Nix,
Homebrew on Linux, Android/Termux (preview), and manual downloads are in the
[installation guide](docs/INSTALL.md), and containers in
[Docker](docs/DOCKER.md). Stick to one install method per machine; otherwise
two copies end up on `PATH` and whichever comes first wins.
</details>
Prebuilt binaries cover Linux, macOS and Windows on x64 and arm64. Scoop, Nix,
Homebrew on Linux, Android and Termux (preview) and manual downloads are in the
[installation guide](docs/INSTALL.md), and containers are in
[Docker](docs/DOCKER.md). Use one install method per machine; otherwise two
copies end up on your `PATH` and whichever comes first wins.
## Quickstart
1. Commit or stash your work, then run `codewhale` from the root of a git
repository. By default (**Accept Edits**) it edits files in the repository
without asking and asks before each command; `Shift+Tab` steps the
permission dial when you want it to ask more or less.
2. Run `/provider` (or press `F3`), pick a provider, paste its API key, and
choose a model. DeepSeek is the default provider. With no key, pick a local
runtime instead. If no provider is configured and Ollama is already serving
a chat model, Codewhale uses it automatically; from 0.10.2 it offers it when
you send your first message.
repository. By default it applies file edits there without asking and asks
before each shell command that is not read-only.
2. Run `/provider` (or press `F3`), pick a provider, paste its API key and
choose a model. DeepSeek is the default provider. On a first launch with no
key, Codewhale asks for a provider when you send your first message, and it
offers a local model when a local Ollama server answers.
3. Type a task:
```text
@@ -88,105 +70,48 @@ Fix the failing tests and explain what changed.
Read-only commands run straight away. For any other shell command Codewhale
asks first: `y` runs it once, `a` allows commands like it for the rest of the
conversation, `n` refuses. `/undo` takes back your last request and its file
changes. `/help` lists commands and keys.
conversation, and `n` refuses. `/undo` takes back your last request and its
file changes, and `/help` lists commands and keys.
For scripts and CI, `codewhale exec` runs one task without the interface. By
default, `--auto` uses Full Access: tools run without asking, shell commands
have no Codewhale sandbox, and trust mode lets file tools write outside the
workspace. Add `--sandbox workspace-write` to keep the shell sandbox and
file-tool workspace restrictions, with trust mode off:
```bash
codewhale exec --auto --sandbox workspace-write "fix the failing tests and explain what changed"
```
## Modes and permissions
A fresh install starts in Work mode with the Accept Edits permission. One dial
switches both: `Shift+Tab` steps Ask → Accept Edits → Plan → Auto-Review →
Full Access.
| | Switch with | Options |
| --- | --- | --- |
| Mode | the Plan stop on `Shift+Tab`, or `/mode` | **Plan** reads and searches but changes nothing. **Work** (every other stop) edits files and runs commands; start a goal with `/goal` when a request needs more than one turn. |
| Permission | `Shift+Tab` | **Ask** asks before every file edit and every shell command that is not read-only. **Accept Edits** applies file edits in your git repository without asking and still asks before commands. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks, and commands can reach anything on this computer; repository rules and managed policy still apply. |
See the [modes and permissions guide](docs/MODES.md) for the details.
For scripts and CI, `codewhale exec` runs one task without the interface. Read
[headless runs](docs/INSTALL.md#headless-scripts-ci) before you give it tools
with `--auto`, because `--auto` uses Full Access unless you add a sandbox.
## What it does
### New in 0.10.2
Give Codewhale a task and it reads the repository, edits files and runs
commands until the task is done or it needs your input. The
[latest release notes](https://github.com/codewhale-hq/Codewhale/releases/latest)
list the newest changes, and the [changelog](CHANGELOG.md) has every
version.
- `/pet on` turns your terminal into an animated whale that shows what the
agent is doing, while the message box stays live. Its 980 particles spell
the current step in English, Chinese or Japanese.
- `/workbar terminal` shows the shells the agent is running as they run. Type
into one, or press `Ctrl+N` for a shell of your own (macOS and Linux).
- `/undo` takes back your whole last request in one step, including every file
it changed, and `/diff` works in folders without Git.
- When a plan is ready, Codewhale asks whether to start Work with Ask or
Auto-Review, or keep planning.
- `/color` gives each chat its own composer color, saved with the session.
- `web_search` can use DeepSeek's new Search API: set
`[search] provider = "deepseek"` with a DeepSeek key.
- Outside Full Access, a `curl` or `wget` that would upload a credential never
runs without your approval, and a command that hides invisible Unicode is
refused. `write` no longer replaces a file the agent has not read, or one
that changed after it read it.
### Models
### Any model
More than 40 providers are built in, including Anthropic, OpenAI, DeepSeek,
Google, Mistral, Moonshot, OpenRouter, and xAI. Any OpenAI-compatible endpoint
works, and so do local models through Ollama, vLLM, or SGLang. The default is
DeepSeek's `deepseek-flash`, which DeepSeek moved to DeepSeek-V4.1-Flash-1017
on 2026-10-17; 0.10.1 runs it without an update
Google, Mistral, Moonshot, OpenRouter and xAI. Any OpenAI-compatible endpoint
works, and so do local models through Ollama, vLLM or SGLang. The default
model is DeepSeek's `deepseek-flash`, which now serves DeepSeek V4.1 Flash
([details](docs/PROVIDERS.md#deepseek-v41-flash)).
`/model` switches models between turns, and `/model auto` picks one for each
turn (`/router` sets up how). `/turn inspect` shows which model a turn used,
what it cost and how long it took. `codewhale auth chatgpt` lets you use a
ChatGPT plan instead of an API key (experimental). See
[Providers](docs/PROVIDERS.md).
what it cost and how long it took. See [Providers](docs/PROVIDERS.md).
### Receipts and undo
`/receipts`, or `codewhale receipts --last` after you quit, lists the files
changed, commands run, web and MCP calls, agents started, approvals and who
gave them, and failures. `/undo` takes back your last request and the files it
changed, in one step and without trust mode. `/restore`
rolls the whole workspace back to an earlier snapshot; because that overwrites
later edits, it needs `/trust on` or Full Access. See [Receipts](docs/RECEIPTS.md).
`/receipts`, or `codewhale receipts --last` after you quit, lists the files a
session changed, the commands it ran, its web and MCP calls, the agents it
started, each approval and who gave it, and what failed. `/undo` takes back
your last request and the files it changed in one step. `/restore` rolls the
whole workspace back to an earlier snapshot, and because that overwrites later
edits it needs `/trust on` or Full Access. See [Receipts](docs/RECEIPTS.md).
Exec runs that offer tools are saved in every output format, so
`codewhale receipts --last` works after `exec --auto`, including `--json`.
Answer-only text and `--json` runs are saved only when resuming or when
conversation compaction starts; use `--output-format stream-json` to always
save them for receipts or `exec --continue`.
### Longer work
### Several agents on one task
`/goal` sets an objective that carries across turns. Codewhale can split work
across [sub-agents](docs/SUBAGENTS.md), run a [fleet](docs/FLEET.md) of agents,
or follow a [workflow](docs/WORKFLOW_AUTHORING.md) checked into the
repository.
### Coming from another agent
Codewhale reads `AGENTS.md` on its own. Instruction files written for other
tools, such as `CLAUDE.md`, `.cursorrules`, or
`.github/copilot-instructions.md`, load only when you opt in with
`project_instruction_imports = ["claude"]` (or `"cursor"`, `"cline"`,
`"windsurf"`, `"copilot"`, `"all"`) in your config; see
[config.example.toml](config.example.toml). Codewhale warns when it finds one
it did not load.
`/import-claude` prepares an import of your Claude Code settings and MCP
servers for you to review. `/plugin install` accepts Claude Code plugins
containing skills, commands, agent profiles, and MCP servers, and refuses
plugins that need hooks or LSP servers
([details](docs/CLAUDE_PLUGIN_COMPAT.md)).
across [sub-agents](docs/SUBAGENTS.md), run a [Fleet](docs/FLEET.md) of agents
with their own roles and models, or follow a
[workflow](docs/WORKFLOW_AUTHORING.md) checked into the repository.
### Extensions
@@ -194,79 +119,158 @@ Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
events. Code mode (experimental, on by default) lets the model write one
`execute_tools` program that calls several MCP and plugin tools; each call
still meets your approval rules and keeps its receipt. The independently installed Computer
Use plugin (experimental, macOS only) can observe
and operate other apps. It stays off until you review its access and enable it
([guide](https://github.com/codewhale-hq/codewhale-cu-plugin)).
still meets your approval rules and keeps its receipt. The Computer Use plugin
bundled with v0.10.2 (experimental, macOS only) can observe and operate other
apps, and it stays off until you review its access and enable it
([guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)).
Coming in 0.10.3: the plugin is released
[separately](https://github.com/codewhale-hq/codewhale-cu-plugin).
## Ways to run it
### Ways to run it
| Command | What it does |
| --- | --- |
| `codewhale` | The interactive terminal app |
| `codewhale exec "…"` | One task without the interface. On its own it answers without using tools; `--auto` enables tools with Full Access by default, `--sandbox workspace-write` restricts them, and `--output-format stream-json` streams events as JSON |
| `codewhale web` | A local web UI on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request. The [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) generates a pinned workflow for automatic reviews and `@codewhale review`, using your own model key and optional App identity |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals, and each turn's file changes |
| `codewhale exec "…"` | One task without the interface, for scripts and CI ([headless runs](docs/INSTALL.md#headless-scripts-ci)) |
| `codewhale web` | A local web client on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request; the [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) runs it on your own model key |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals and each turn's file changes |
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
The terminal app, `exec`, `web`, and the Runtime API share one engine
The terminal app, `exec`, `web` and the Runtime API share one engine
([architecture](docs/ARCHITECTURE.md)). A community-maintained
[VS Code extension](https://marketplace.visualstudio.com/items?itemName=HengQuWorld.brotherwhale-vscode)
connects to it from the sidebar ([source](https://github.com/HengQuWorld/CodeWhale-VSCode)).
A native desktop app built on the Runtime API is in development; it has no
public download yet.
## Cost and accounts
<p align="center">
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">
<img src="web/public/codewhale-x-orcarouter.webp" alt="Codewhale × OrcaRouter: +30% DeepSeek V4.1 Flash usage" width="560">
</a>
</p>
Codewhale is free and MIT licensed. With your own API key you pay the provider
directly; local models need no key. The footer and `/cost` show what a session
has cost when the price is known, and an unknown price is shown as unknown,
never as free.
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">Codewhale × OrcaRouter</a>
gives 30% extra DeepSeek V4.1 Flash credit on each OrcaRouter top-up until
November 21, 2026. This link is part of a referral campaign; Codewhale may
earn a referral reward on top-ups made through it.
You don't need an account. Keys you add with `/provider` stay on your machine.
## How it stays safe
## Safety and privacy
Two switches say how much Codewhale may do on its own. `Tab`, when the input
is empty, changes the mode: **Plan** reads and searches but changes nothing,
**Work** edits files and runs commands, and **Operate** has the same access as
Work and keeps going until the goal is verified or it needs you. `Shift+Tab`
changes the permission. **Ask**, the default, applies file edits in your
repository without asking and asks before shell commands that are not
read-only.
**Auto-Review** lets rules and a reviewer model approve routine calls and
refuses high-risk ones; switch to Ask to approve those yourself. **Full
Access** never asks and turns off Codewhale's sandbox, although repository
rules and managed policy still apply. The
[v0.10.2 guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/docs/MODES.md)
has the details.
Coming in 0.10.3: `Shift+Tab` becomes one dial through Ask, Accept Edits,
Plan, Auto-Review and Full Access, `Tab` no longer changes the mode, and
Operate is retired. Ask will ask before every file edit too, and the new
default, Accept Edits, will apply edits inside the git repository and still ask
before commands. The [current guide](docs/MODES.md) describes it.
In Ask and Auto-Review, commands run in an OS sandbox where the system has
one: Seatbelt on macOS, and bubblewrap on Linux when it is installed.
Windows has no OS sandbox yet, and Full Access turns the sandbox off. Inside
the sandbox, shell commands cannot read credential stores such as `~/.ssh`,
`~/.aws` and `.env` files; from 0.10.2 the same rule covers terminal sessions
and more file tools. `/preview-request` shows a redacted preview of the next
request without sending it.
one: Seatbelt on macOS, and bubblewrap on Linux when it is installed. Windows
has no OS sandbox yet, and Full Access turns the sandbox off. Inside the
sandbox, shell commands and terminal sessions cannot read credential stores
such as `~/.ssh`, `~/.aws` and `.env` files.
`/preview-request` shows a redacted preview of the next request without
sending it.
You need no account, and keys you add with `/provider` stay on your machine.
With your own API key you pay the provider directly, and local models need no
key. The footer and `/cost` show what a session has cost when the price is
known, and an unknown price is shown as unknown, never as free.
Telemetry is on by default. It sends aggregate counts (version and platform,
session length and outcome, which built-in providers you used, feature and
error counts) with a random install ID that rotates every 90 days, and never
conversations, code, prompts, model names, file or repository names, or
credentials. `codewhale config set telemetry false` turns
it off. At startup Codewhale also
checks GitHub for a new release; set `CODEWHALE_NO_UPDATE_CHECK=1` to skip
that. More in [authorization order](docs/AUTHORIZATION_ORDER.md),
[sandboxing](docs/SANDBOX.md), and [telemetry](docs/TELEMETRY.md).
credentials. This command turns it off:
```bash
codewhale config set telemetry false
```
At startup Codewhale also checks GitHub for a new release; set
`CODEWHALE_NO_UPDATE_CHECK=1` to skip that. More in
[authorization order](docs/AUTHORIZATION_ORDER.md),
[sandboxing](docs/SANDBOX.md) and [telemetry](docs/TELEMETRY.md).
## Where it is going
Codewhale is becoming one environment across your terminal, a desktop app,
the browser and cloud computers, all run by one engine. Each part says where
it stands today.
| Part | State | Today |
| --- | --- | --- |
| Terminal app | Released | Install it on macOS, Linux or Windows and run `codewhale` in any repository. |
| Local browser client | Released | Run `codewhale web` to read the conversation, send a task and answer approvals in your browser; it comes with the terminal and needs no account. |
| Many agents on one task | Released | Sub-agents, Fleets and workflows let several agents share one task, each with its own role. |
| Desktop app | In development, no download | We are building a desktop app on the same engine, and it has no public download yet. |
| Cloud computers | Invite-only | Cloud computers are invite-only, and we are building them so a session can keep working on a machine in the cloud. |
| One session across them | Being built | We are building one session that goes with you from the terminal to the desktop, the browser and the cloud, and stays yours the whole way. |
The [roadmap](docs/ROADMAP.md) says what each release covers and where you
can help.
## Coming from another agent
Codewhale reads `AGENTS.md` on its own. Instruction files written for other
tools, such as `CLAUDE.md`, `.cursorrules` or
`.github/copilot-instructions.md`, load only when you opt in with
`project_instruction_imports = ["claude"]` (or `"cursor"`, `"cline"`,
`"windsurf"`, `"copilot"`, `"all"`) in your config; see
[config.example.toml](config.example.toml). Codewhale warns when it finds one
it did not load.
`/import-claude` prepares an import of your Claude Code settings and MCP
servers for you to review. `/plugin install` accepts Claude Code plugins that
contain skills, commands, agent profiles and MCP servers, and refuses plugins
that need hooks or LSP servers
([details](docs/CLAUDE_PLUGIN_COMPAT.md)).
## Documentation
These guides follow the main branch and can describe changes that are not
in the latest release yet.
| Start here | Reference |
| --- | --- |
| [Installation](docs/INSTALL.md) | [Configuration](docs/CONFIGURATION.md) |
| [Providers and local models](docs/PROVIDERS.md) | [Architecture](docs/ARCHITECTURE.md) |
| [Modes and permissions](docs/MODES.md) | [Runtime API](docs/RUNTIME_API.md) |
| [Keybindings](docs/KEYBINDINGS.md) | [Plugin authoring](docs/PLUGIN_AUTHORING.md) |
| [GitHub PR review](docs/GITHUB_ACTION.md) | [All documentation](docs/README.md) |
| [User guide](docs/GUIDE.md) | [Configuration](docs/CONFIGURATION.md) |
| [Installation](docs/INSTALL.md) | [Architecture](docs/ARCHITECTURE.md) |
| [Providers and local models](docs/PROVIDERS.md) | [Runtime API](docs/RUNTIME_API.md) |
| [Modes and permissions](docs/MODES.md) | [Plugin authoring](docs/PLUGIN_AUTHORING.md) |
| [Keybindings](docs/KEYBINDINGS.md) | [Receipts](docs/RECEIPTS.md) |
| [GitHub pull request review](docs/GITHUB_ACTION.md) | [All documentation](docs/README.md) |
## Community
## Contributing
Codewhale is what it is because of its community, and all feedback is
welcome: a bug, an idea, or something that looked or felt wrong.
[Open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) for any
of these; an issue is also the best place to start a new feature. Pull requests
are welcome for fixes, translations, providers and planned work. The
[contributing guide](CONTRIBUTING.md) shows where each kind of help goes, and
the [roadmap](docs/ROADMAP.md) shows where the project is headed. Contributors
keep credit for the work that lands, and the
[repository layout](CONTRIBUTING.md#project-structure) shows where things live.
Codewhale is built in the open by the people who use it, in many languages.
<a href="docs/CONTRIBUTORS.md#everyone"><img src="docs/assets/contributors.png" width="760" alt="The Codewhale contributor wall: one avatar for each person whose code, co-authored commits, carried-forward pull requests or credited reports are in Codewhale, with the total count at the top."></a>
The [contributor wall](docs/CONTRIBUTORS.md) counts everyone whose code,
co-authored commits, carried-forward pull requests or credited reports are in
Codewhale, including people GitHub's own contributor list misses.
`scripts/contributor-wall.py` regenerates the picture and the list from git
history and GitHub.
All feedback is welcome: a bug, an idea, or something that looked or felt
wrong. [Open an issue](https://github.com/codewhale-hq/Codewhale/issues/new/choose)
for any of these; an issue is also the best place to start a new feature.
Pull requests are welcome for fixes, translations, providers and planned work.
Questions go to [Discussions](https://github.com/codewhale-hq/Codewhale/discussions).
The [contributing guide](CONTRIBUTING.md) shows where each kind of help goes,
how your work lands with your name on it, and
[where things live](CONTRIBUTING.md#project-structure).
Chat on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer,
Hunter, on WeChat (`hunterbown`) and ask to join the Whale Brothers group.
@@ -276,10 +280,8 @@ Everyone is welcome.
Codewhale started as `deepseek-tui` and still reads that project's
configuration and sessions. It is independently maintained and not affiliated
with DeepSeek or any other model provider. Thanks to
[everyone who has contributed](docs/CONTRIBUTORS.md).
[![Codewhale contributors](docs/assets/contributors.png)](docs/CONTRIBUTORS.md#everyone)
with DeepSeek or any other model provider.
[MIT](LICENSE). Code adapted from other open-source projects is listed in the
[third-party notices](docs/THIRD_PARTY_NOTICES.md).
third-party notices for [vendored sources](THIRD_PARTY_NOTICES.md) and
[adapted designs](docs/THIRD_PARTY_NOTICES.md).
+38
View File
@@ -164,6 +164,44 @@ The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
from the bundler's metafile; Codewhale writes it beside the bundle it
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
## DeepSeek Harness Creator files (`dsh-agent-preset` skills, `dsh-plugin-manager`, `dsh-tool-cordis`, `dsh-sandbox`) — MIT
Codewhale ships, unmodified, the four authoring skills of
`@deepseek-ai/dsh-agent-preset` 0.2.2-rc.1
(`crates/skills/assets/dsh-creator/`), and keeps byte-for-byte copies of
`@deepseek-ai/dsh-plugin-manager` 0.2.2-rc.1 `lib/types/tools.js` and
`@deepseek-ai/dsh-tool-cordis` 0.2.2-rc.1 `lib/index.js`
(`crates/engine/extension-host/src/dsh/upstream/creator/`).
`@deepseek-ai/dsh-sandbox` 0.2.2-rc.1 is named for the approval excerpt that
slice will add. Each package is published under the licence text below, and
each pin's `UPSTREAM.json` records the SHA-256 of every file.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Model Context Protocol SDK and bundled dependencies
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "codewhale-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for Codewhale"
[lints]
workspace = true
[dependencies]
codewhale-config = { path = "../config", version = "0.10.3" }
serde.workspace = true
File diff suppressed because it is too large. Load diff
-1
View File
@@ -32,7 +32,6 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-core = { path = "../core", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,441 @@
use super::*;
use agent_client_protocol::v1::{ConnectionTo, Responder};
use agent_client_protocol::{Agent, Client, Lines, UntypedMessage};
use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, MAX_FRAME_BYTES};
use futures_util::{Sink, Stream};
use std::io;
use std::sync::{MutexGuard, PoisonError};
use tokio::sync::{Notify, mpsc};
const PROMPT_BUSY: &str = "a session/prompt turn is already in progress";
const OUTGOING_QUEUE_FRAMES: usize = 256;
const OVERSIZE_RESPONSE: &str =
"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay";
type Cx = ConnectionTo<Client>;
pub(super) struct Shared {
server: tokio::sync::Mutex<AcpServer>,
prompt: std::sync::Mutex<Option<mpsc::UnboundedSender<Inbound>>>,
idle: Notify,
ending: Notify,
failure: std::sync::Mutex<Option<anyhow::Error>>,
}
struct PromptGuard(Arc<Shared>);
impl Drop for PromptGuard {
fn drop(&mut self) {
self.0.release();
}
}
impl PromptGuard {
fn finish(self, responder: Responder<Value>, result: std::result::Result<Value, SdkError>) {
let mut slot = self.0.slot();
let _ = responder.respond_with_result(result);
*slot = None;
drop(slot);
self.0.idle.notify_waiters();
}
}
impl Shared {
pub(super) fn new(server: AcpServer) -> Self {
Self {
server: tokio::sync::Mutex::new(server),
prompt: std::sync::Mutex::new(None),
idle: Notify::new(),
ending: Notify::new(),
failure: std::sync::Mutex::new(None),
}
}
pub(super) fn fail(&self, error: anyhow::Error) {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert(error);
self.tell_prompt(Inbound::Eof);
self.ending.notify_one();
}
pub(super) fn take_failure(&self) -> Option<anyhow::Error> {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
#[cfg(test)]
pub(super) fn into_server(self) -> AcpServer {
self.server.into_inner()
}
fn slot(&self) -> MutexGuard<'_, Option<mpsc::UnboundedSender<Inbound>>> {
self.prompt.lock().unwrap_or_else(PoisonError::into_inner)
}
fn prompt_active(&self) -> bool {
self.slot().is_some()
}
fn tell_prompt(&self, message: Inbound) {
if let Some(inbox) = self.slot().as_ref() {
let _ = inbox.send(message);
}
}
fn release(&self) {
*self.slot() = None;
self.idle.notify_waiters();
}
async fn wait_idle(&self) {
loop {
let notified = self.idle.notified();
if !self.prompt_active() {
return;
}
notified.await;
}
}
fn cancel(&self, params: &Value) {
if let Some(session) = params.get("sessionId").and_then(Value::as_str) {
self.tell_prompt(Inbound::Cancel {
session: session.to_string(),
});
}
}
async fn request(
self: &Arc<Self>,
method: String,
params: Value,
responder: Responder<Value>,
cx: Cx,
) {
match method.as_str() {
"session/cancel" => {
self.cancel(&params);
let _ = responder.respond(Value::Null);
}
"session/prompt" => self.start_prompt(params, responder, cx).await,
_ if self.prompt_active() => self.during_prompt(&cx, method, params, responder),
"shutdown" => {
let _ = responder.respond(Value::Null);
self.ending.notify_one();
}
"session/load" => self.load(params, responder, cx).await,
_ => {
let result = self
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
}
}
}
fn during_prompt(
self: &Arc<Self>,
cx: &Cx,
method: String,
params: Value,
responder: Responder<Value>,
) {
let served = matches!(
method.as_str(),
"session/list"
| "session/set_config_option"
| "session/set_mode"
| "session/set_model"
| "session/listProviders"
| "session/currentModel"
| "session/selectModel"
);
if !served {
let error = if matches!(
method.as_str(),
"initialize" | "session/new" | "session/load" | "shutdown"
) {
SdkError::new(-32603, PROMPT_BUSY)
} else {
SdkError::method_not_found().data(Value::String(method))
};
let _ = responder.respond_with_error(error);
return;
}
let shared = Arc::clone(self);
let _ = cx.spawn(async move {
let result = shared
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
Ok(())
});
}
async fn load(&self, params: Value, responder: Responder<Value>, cx: Cx) {
let mut server = self.server.lock().await;
let result = async {
let loaded = server.load_session(params).await.map_err(SdkError::from)?;
if let Some(session) = loaded.get("sessionId").and_then(Value::as_str) {
let updates = server
.history_updates(session)
.await
.map_err(|error| SdkError::new(-32603, error.to_string()))?;
for update in updates {
cx.send_notification(acp::SessionNotification::new(
session.to_string(),
update,
))?;
}
}
Ok::<_, SdkError>(loaded)
}
.await;
drop(server);
let _ = responder.respond_with_result(result);
}
async fn start_prompt(self: &Arc<Self>, params: Value, responder: Responder<Value>, cx: Cx) {
if self.prompt_active() {
let _ = responder.respond_with_error(SdkError::new(-32603, PROMPT_BUSY));
return;
}
let admitted = {
let server = self.server.lock().await;
server.validate_prompt(&params).map(|(session, prompt)| {
let thread = server.sessions[&session].thread_id.clone();
(session, thread, prompt, server.prompt_run())
})
};
let (session, thread, prompt, run) = match admitted {
Ok(admitted) => admitted,
Err(error) => {
let _ = responder.respond_with_error(error.into());
return;
}
};
let (answers, mut inbox) = mpsc::unbounded_channel();
*self.slot() = Some(answers.clone());
let guard = PromptGuard(Arc::clone(self));
let task_cx = cx.clone();
let _ = cx.spawn(async move {
let result = {
let mut emit = |outgoing: Outgoing| -> Result<()> {
match outgoing {
Outgoing::Update(notification) => task_cx
.send_notification(notification)
.map_err(|error| anyhow!("ACP update was not sent: {}", error.message)),
Outgoing::Permission { key, request } => {
let answers = answers.clone();
task_cx
.send_request(request)
.on_receiving_result(async move |reply| {
let gone = matches!(
&reply,
Err(error)
if agent_client_protocol::is_incoming_transport_closed(error)
|| agent_client_protocol::is_connection_terminated(error)
);
if !gone {
let _ = answers.send(Inbound::Permission {
key,
allow: permission_allowed(&reply),
});
}
Ok(())
})
.map_err(|error| {
anyhow!("ACP permission request was not sent: {}", error.message)
})
}
}
};
run.drive_prompt(&session, &thread, prompt, &mut emit, &mut inbox)
.await
};
let result = match result {
Ok(stop) => to_wire(&acp::PromptResponse::new(stop)).map_err(SdkError::from),
Err(error) => Err(SdkError::new(
-32603,
codewhale_engine::client::redact_model_bound_text(&error.to_string(), &[]),
)),
};
guard.finish(responder, result);
Ok(())
});
}
}
fn permission_allowed(
reply: &std::result::Result<acp::RequestPermissionResponse, SdkError>,
) -> bool {
matches!(
reply,
Ok(response) if matches!(
&response.outcome,
acp::RequestPermissionOutcome::Selected(selected)
if &*selected.option_id.0 == "allow-once"
)
)
}
fn closes_input(line: &str) -> bool {
line.len() <= 256
&& line.contains("daemon/input_closed")
&& serde_json::from_str::<Value>(line)
.is_ok_and(|message| crate::is_control_input_closed(&message))
}
fn incoming_lines(
input: Box<dyn AsyncBufRead + Send + Unpin>,
shared: Arc<Shared>,
) -> impl Stream<Item = io::Result<String>> + Send + 'static {
futures_util::stream::unfold(
(BoundedLines::new(input), shared),
|(mut lines, shared)| async move {
loop {
match lines.next_line().await {
Ok(Some(line)) if line.trim().is_empty() => continue,
Ok(Some(line)) if closes_input(&line) => return None,
Ok(Some(line)) => return Some((Ok(line), (lines, shared))),
Ok(None) => return None,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
return Some((Ok(error.to_string()), (lines, shared)));
}
Err(error) => {
shared.fail(anyhow!("ACP input failed: {error}"));
return None;
}
}
}
},
)
}
pub(super) fn bound_outgoing(line: String) -> io::Result<String> {
if line.len() <= MAX_FRAME_BYTES {
return Ok(line);
}
let refused = || io::Error::new(io::ErrorKind::InvalidData, FrameRefusal::TooLarge);
let Ok(Value::Object(frame)) = serde_json::from_str::<Value>(&line) else {
return Err(refused());
};
if frame.contains_key("method") || !frame.contains_key("id") {
return Err(refused());
}
let failure = |id: Value| {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":OVERSIZE_RESPONSE}})
.to_string()
};
let replaced = failure(frame["id"].clone());
Ok(if replaced.len() <= MAX_FRAME_BYTES {
replaced
} else {
failure(Value::Null)
})
}
pub(super) fn outgoing_lines(
frames: mpsc::Sender<String>,
shared: Arc<Shared>,
) -> impl Sink<String, Error = io::Error> + Send + 'static {
futures_util::sink::unfold(
(frames, shared),
|(frames, shared), line: String| async move {
let line = match bound_outgoing(line) {
Ok(line) => line,
Err(error) => {
shared.fail(anyhow!("ACP output refused: {error}"));
return Ok((frames, shared));
}
};
frames
.send(line)
.await
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ACP output closed"))?;
Ok::<_, io::Error>((frames, shared))
},
)
}
pub(super) async fn serve_connection<'a>(
server: AcpServer,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> Result<Arc<Shared>> {
let shared = Arc::new(Shared::new(server));
let (frames, mut queued) = mpsc::channel::<String>(OUTGOING_QUEUE_FRAMES);
let transport = Lines::new(
outgoing_lines(frames, Arc::clone(&shared)),
incoming_lines(input, Arc::clone(&shared)),
);
let requests = Arc::clone(&shared);
let notifications = Arc::clone(&shared);
let finishing = Arc::clone(&shared);
let connection = Agent
.v1()
.on_receive_request(
async move |request: UntypedMessage, responder, cx| {
let (method, params) = request.into_parts();
requests.request(method, params, responder, cx).await;
Ok(())
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_notification(
async move |notification: UntypedMessage, _cx| {
let (method, params) = notification.into_parts();
if method == "session/cancel" {
notifications.cancel(&params);
}
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.connect_with(transport, async move |cx| {
tokio::select! {
() = cx.incoming_closed() => {}
() = finishing.ending.notified() => {}
}
finishing.tell_prompt(Inbound::Eof);
finishing.wait_idle().await;
Ok(())
});
let connection = async {
match connection.await {
Err(error) if agent_client_protocol::is_incoming_transport_closed(&error) => Ok(()),
other => other.map_err(|error| anyhow!("ACP connection failed: {}", error.message)),
}
};
let failing = Arc::clone(&shared);
let pump = async {
let mut broken = false;
while let Some(frame) = queued.recv().await {
if broken {
continue;
}
if let Err(error) =
crate::stdio::write_control_line(&mut output, frame.as_bytes()).await
{
broken = true;
failing.fail(error);
}
}
};
let (ended, ()) = tokio::join!(connection, pump);
drop(failing);
if let Some(error) = shared.take_failure() {
return Err(error);
}
ended?;
Ok(shared)
}
+184 -196
View File
@@ -5,12 +5,17 @@ use codewhale_engine::runtime_threads::{
manager::ExternalApprovalDecision, manager::StartTurnRequest,
};
use std::time::Duration;
use tokio::sync::mpsc::UnboundedReceiver;
struct Permission {
thread_id: String,
turn_id: String,
approval_id: String,
execution_id: String,
}
pub(super) struct PromptRun {
pub(super) runtime: Arc<RuntimeThreadManager>,
pub(super) sessions_dir: PathBuf,
}
struct Projection<'a> {
session: &'a str,
thread: &'a str,
@@ -42,21 +47,32 @@ impl Drop for TurnClaim {
}
}
}
impl AcpServer {
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&mut self,
params: Value,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<&'static str> {
let (session_id, prompt) = self
.validate_prompt(&params)
.map_err(|error| anyhow!(error.message))?;
let binding = self
.sessions
.get(&session_id)
.ok_or_else(|| anyhow!("unknown sessionId"))?;
let thread_id = binding.thread_id.clone();
fn admitted_call<'a>(
calls: &'a HashMap<String, PendingToolCall>,
execution: &str,
) -> Option<(&'a str, &'a PendingToolCall)> {
let mut id = execution;
loop {
if let Some((key, call)) = calls.get_key_value(id) {
return Some((key.as_str(), call));
}
id = id.rsplit_once('.')?.0;
}
}
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
impl PromptRun {
pub(super) async fn drive_prompt(
&self,
session_id: &str,
thread_id: &str,
prompt: String,
emit: Emit<'_>,
inbox: &mut UnboundedReceiver<Inbound>,
) -> Result<acp::StopReason> {
let session_id = session_id.to_string();
let thread_id = thread_id.to_string();
// Subscribe before snapshot/admission; replay deduplicates by seq.
let mut receiver = self.runtime.subscribe_events();
let cursor = self.runtime.get_thread_detail(&thread_id).await?.latest_seq;
@@ -86,7 +102,7 @@ impl AcpServer {
permissions: HashMap::new(),
};
let terminal = match self
.project_turn(&mut projection, &mut receiver, reader, writer)
.project_turn(&mut projection, &mut receiver, inbox, emit)
.await
{
Ok(terminal) => terminal,
@@ -144,9 +160,6 @@ impl AcpServer {
}
};
claim.armed = false;
if let Some(binding) = self.sessions.get_mut(&session_id) {
binding.cursor = projection.cursor;
}
self.save_checkpoint(&thread_id, &session_id).await?;
if terminal
.model_request_diagnostics
@@ -154,11 +167,13 @@ impl AcpServer {
.and_then(|facts| facts.stop_reason)
== Some(RuntimeTurnStopReason::StepBudgetExhausted)
{
return Ok("max_turn_requests");
return Ok(acp::StopReason::MaxTurnRequests);
}
match terminal.status {
RuntimeTurnStatus::Completed => Ok("end_turn"),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => Ok("cancelled"),
RuntimeTurnStatus::Completed => Ok(acp::StopReason::EndTurn),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => {
Ok(acp::StopReason::Cancelled)
}
_ => Err(anyhow!(
"{}",
terminal.error.unwrap_or_else(
@@ -209,12 +224,12 @@ impl AcpServer {
})?;
Ok(())
}
async fn project_turn<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
async fn project_turn(
&self,
projection: &mut Projection<'_>,
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
inbox: &mut UnboundedReceiver<Inbound>,
emit: Emit<'_>,
) -> Result<TurnRecord> {
let session = projection.session;
let thread = projection.thread;
@@ -232,45 +247,26 @@ impl AcpServer {
}
tokio::select! {
() = async { match cancel_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(anyhow!("Core cancellation remains nonterminal; ACP cannot report a fabricated cancelled receipt")),
line = reader.next_line(), if !eof => {
let line = match line {
Ok(line) => line,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
continue;
message = inbox.recv(), if !eof => {
match message {
Some(Inbound::Cancel { session: cancelled_session }) => {
if cancelled_session == session {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
}
Err(error) => return Err(error.into()),
};
let Some(line) = line else {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(30)); continue;
};
if line.trim().is_empty() { continue; }
let message: Value = match serde_json::from_str(&line) {
Ok(message) => message,
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
};
if crate::is_control_input_closed(&message) {
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
}
let response_id = message.get("id").cloned().map(|id| self.response_id_policy.response_id(id));
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { write_jsonrpc_error(writer, response_id, -32600, "jsonrpc version must be 2.0").await?; continue; }
if is_jsonrpc_response(&message) {
if !cancelled && let Some(id) = message.get("id").and_then(Value::as_str) && let Some(permission) = projection.permissions.remove(id) { self.answer_permission(&permission, &message).await?; }
continue;
}
if message.get("method").and_then(Value::as_str) == Some("session/cancel") {
if message.pointer("/params/sessionId").and_then(Value::as_str) == Some(session) {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
Some(Inbound::Permission { key, allow }) => {
if !cancelled && let Some(permission) = projection.permissions.remove(&key) { self.answer_permission(&permission, allow).await?; }
}
Some(Inbound::Eof) | None => {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
if let Some(id) = response_id { write_jsonrpc_result(writer, id, json!(null)).await?; }
} else if let Some(id) = response_id { write_jsonrpc_error(writer, Some(id), -32603, "a session/prompt turn is already in progress").await?; }
}
}
record = async { replay_batch.pop_front().expect("nonempty bounded replay batch") }, if !replay_batch.is_empty() => {
projection.emit = !cancelled && !eof;
if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); }
if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); }
}
batch = async { replay.as_mut().expect("active replay").batches.recv().await }, if replay.is_some() && replay_batch.is_empty() => {
match batch {
@@ -280,7 +276,7 @@ impl AcpServer {
}
event = receiver.recv(), if replay.is_none() && replay_batch.is_empty() => {
match event {
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); } },
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); } },
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let captured = self.runtime.replay_events(thread, Some(projection.cursor), None).await?;
if captured.base_seq > projection.cursor { return Err(anyhow!("ACP replay no longer covers the cursor; projection is incomplete")); }
@@ -292,7 +288,7 @@ impl AcpServer {
}
}
}
async fn answer_permission(&self, permission: &Permission, response: &Value) -> Result<()> {
async fn answer_permission(&self, permission: &Permission, allow: bool) -> Result<()> {
let detail = self
.runtime
.get_thread_detail(&permission.thread_id)
@@ -304,15 +300,6 @@ impl AcpServer {
}) {
return Ok(());
}
let allow = response.get("error").is_none()
&& response
.pointer("/result/outcome/outcome")
.and_then(Value::as_str)
== Some("selected")
&& response
.pointer("/result/outcome/optionId")
.and_then(Value::as_str)
== Some("allow-once");
self.runtime
.deliver_approval_from(
&permission.approval_id,
@@ -327,11 +314,11 @@ impl AcpServer {
.await?;
Ok(())
}
async fn project_event<W: AsyncWrite + Unpin>(
async fn project_event(
&self,
projection: &mut Projection<'_>,
record: &RuntimeEventRecord,
writer: &mut W,
out: Emit<'_>,
) -> Result<Option<TurnRecord>> {
let session = projection.session;
let thread = projection.thread;
@@ -353,7 +340,7 @@ impl AcpServer {
if emit && p.get("kind").and_then(Value::as_str) == Some("agent_message") =>
{
if let Some(text) = p.get("delta").and_then(Value::as_str) {
write_session_update(writer, session, text.to_string()).await?;
out(update(session, agent_text(text.to_string())))?;
}
}
"item.started" => {
@@ -373,7 +360,10 @@ impl AcpServer {
input: tool.get("input").cloned().unwrap_or(Value::Null),
};
if emit {
write_tool_call_start(writer, session, &call).await?;
out(update(
session,
acp::SessionUpdate::ToolCall(tool_call_start(&call)),
))?;
}
calls.insert(id, call);
}
@@ -386,7 +376,15 @@ impl AcpServer {
let call = calls
.get(id)
.ok_or_else(|| anyhow!("Dispatch projection lacks its admitted start"))?;
write_tool_call_update(writer, session, call, "in_progress", None).await?;
out(update(
session,
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
call,
acp::ToolCallStatus::InProgress,
None,
&[],
)),
))?;
}
"item.completed" | "item.failed" | "item.interrupted" if emit => {
if let Some(item) = p.get("item")
@@ -403,19 +401,19 @@ impl AcpServer {
.unwrap_or_default();
let status = if item.get("status").and_then(Value::as_str) == Some("completed")
{
"completed"
acp::ToolCallStatus::Completed
} else {
"failed"
acp::ToolCallStatus::Failed
};
write_tool_call_update_with_blocks(
writer,
out(update(
session,
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)
.await?;
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)),
))?;
}
}
"approval.required" if emit => {
@@ -433,14 +431,67 @@ impl AcpServer {
&& pending.turn_id == turn
&& pending.tool_call_id.as_deref() == Some(execution)
}) {
let call = calls
.get(execution)
let (shown, call) = admitted_call(calls, execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = format!(
"codewhale-permission-{}",
NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
let id = uuid::Uuid::new_v4().to_string();
let nested = shown != execution;
let nested_call;
let shown_call = if nested {
nested_call = PendingToolCall {
execution_id: execution.to_string(),
name: p
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or(&call.name)
.to_string(),
input: p.get("input").cloned().unwrap_or(Value::Null),
};
&nested_call
} else {
call
};
let title = tool_call_title(shown_call);
let description = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty());
let summary = p
.get("summary")
.and_then(Value::as_str)
.filter(|text| nested && !text.trim().is_empty());
let text = match (summary, description) {
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
(Some(text), None) | (None, Some(text)) => text.to_string(),
(None, None) => title.clone(),
};
let mut fields = acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(shown_call))
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]);
if !nested {
fields = fields.status(acp::ToolCallStatus::Pending);
}
let request = acp::RequestPermissionRequest::new(
session.to_string(),
acp::ToolCallUpdate::new(shown.to_string(), fields),
vec![
acp::PermissionOption::new(
"allow-once",
"Allow once",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
"reject-once",
"Reject",
acp::PermissionOptionKind::RejectOnce,
),
],
);
write_json_line(writer, json!({"jsonrpc":"2.0","id":id,"method":"session/request_permission","params":{"sessionId":session,"toolCall":{"toolCallId":execution,"title":tool_call_title(call),"kind":tool_call_kind(call),"status":"pending","rawInput":call.input,"content":[{"type":"content","content":{"type":"text","text":p.get("description")}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}})).await?;
out(Outgoing::Permission {
key: id.clone(),
request,
})?;
permissions.insert(
id,
Permission {
@@ -489,6 +540,22 @@ impl AcpServer {
mod tests {
use super::super::tests::{Rig, fixture_config};
use super::*;
fn frame(outgoing: Outgoing) -> Value {
match outgoing {
Outgoing::Update(notification) => {
json!({"jsonrpc":"2.0","method":"session/update","params":serde_json::to_value(notification).unwrap()})
}
Outgoing::Permission { key, request } => {
json!({"jsonrpc":"2.0","id":key,"method":"session/request_permission","params":serde_json::to_value(request).unwrap()})
}
}
}
fn recording(out: &mut Vec<Value>) -> impl FnMut(Outgoing) -> Result<()> + Send + '_ {
move |outgoing| {
out.push(frame(outgoing));
Ok(())
}
}
fn event(seq: u64, thread: &str, turn: &str, name: &str, payload: Value) -> RuntimeEventRecord {
serde_json::from_value(json!({"seq":seq,"timestamp":chrono::Utc::now(),"thread_id":thread,"turn_id":turn,"event":name,"payload":payload})).unwrap()
}
@@ -606,14 +673,15 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
// Project the actual Runtime records, not synthetic ACP frames.
for record in rig.server.runtime.events_since(&thread, None)? {
rig.server
.project_event(&mut projection, &record, &mut output)
.prompt_run()
.project_event(&mut projection, &record, &mut recording(&mut output))
.await?;
}
let wire = super::super::tests::parse_lines(output);
let wire = output;
let requests: Vec<_> = wire
.iter()
.filter(|frame| frame["method"] == "session/request_permission")
@@ -635,12 +703,8 @@ mod tests {
)
.ok_or_else(|| anyhow!("ACP permission was not retained"))?;
rig.server
.answer_permission(
&permission,
&json!({
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}
}),
)
.prompt_run()
.answer_permission(&permission, true)
.await?;
assert_eq!(
tokio::time::timeout(Duration::from_secs(10), engine.recv_approval_event())
@@ -758,7 +822,7 @@ mod tests {
})
.await??;
let mut terminal = None;
let mut settled_output = Vec::new();
let mut settled_output: Vec<Value> = Vec::new();
for record in rig
.server
.runtime
@@ -766,7 +830,12 @@ mod tests {
{
if let Some(completed) = rig
.server
.project_event(&mut projection, &record, &mut settled_output)
.prompt_run()
.project_event(
&mut projection,
&record,
&mut recording(&mut settled_output),
)
.await?
{
terminal = Some(completed);
@@ -781,7 +850,7 @@ mod tests {
assert!(projection.permissions.is_empty());
assert!(projection.calls.is_empty());
assert!(
!super::super::tests::parse_lines(settled_output)
!settled_output
.iter()
.any(|frame| frame["method"] == "session/request_permission")
);
@@ -804,7 +873,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
let start = event(
1,
&thread,
@@ -813,11 +882,13 @@ mod tests {
json!({"tool":{"id":"core-call","name":"read","input":{"path":"x"}}}),
);
rig.server
.project_event(&mut p, &start, &mut output)
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.await?;
let before = output.len();
rig.server
.project_event(&mut p, &start, &mut output)
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.await?;
assert_eq!(output.len(), before);
let dispatch = event(
@@ -829,7 +900,8 @@ mod tests {
);
assert!(
rig.server
.project_event(&mut p, &dispatch, &mut output)
.prompt_run()
.project_event(&mut p, &dispatch, &mut recording(&mut output))
.await
.is_err()
);
@@ -850,7 +922,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
for record in [
event(
1,
@@ -882,7 +954,8 @@ mod tests {
),
] {
rig.server
.project_event(&mut p, &record, &mut output)
.prompt_run()
.project_event(&mut p, &record, &mut recording(&mut output))
.await?;
}
assert!(output.is_empty());
@@ -904,7 +977,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
let failure = event(
1,
&thread,
@@ -914,7 +987,8 @@ mod tests {
);
let error = rig
.server
.project_event(&mut p, &failure, &mut output)
.prompt_run()
.project_event(&mut p, &failure, &mut recording(&mut output))
.await
.unwrap_err();
assert!(error.to_string().contains("no terminal receipt"));
@@ -922,90 +996,4 @@ mod tests {
rig.close().await;
Ok(())
}
struct FailedAnswerWriter(Vec<u8>);
impl AsyncWrite for FailedAnswerWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
bytes: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
if String::from_utf8_lossy(bytes).contains("agent_message_chunk") {
return std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"fixture client disappeared after tool effect",
)));
}
self.0.extend_from_slice(bytes);
std::task::Poll::Ready(Ok(bytes.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
#[tokio::test(flavor = "current_thread")]
async fn failed_transport_after_completed_write_retains_actual_core_terminal_and_checkpoint()
-> Result<()> {
use codewhale_engine::llm_client::mock::canned;
let mut config = fixture_config();
config.yolo = Some(true);
let mut rig = Rig::new(
config,
vec![
canned::tool_call_turn(
"write",
"write",
r#"{"path":"writer-failure.txt","content":"completed"}"#,
),
canned::simple_text_turn("final answer"),
],
)?;
let id = rig.new_session().await;
let (client, input) = tokio::io::duplex(1024);
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
let mut writer = FailedAnswerWriter(Vec::new());
let error = tokio::time::timeout(
Duration::from_secs(20),
rig.server.drive_prompt(
json!({"sessionId":id,"prompt":"write then answer"}),
&mut reader,
&mut writer,
),
)
.await?
.unwrap_err();
drop(client);
assert!(error.to_string().contains("partial receipts retained"));
assert_eq!(
std::fs::read_to_string(rig.workspace.join("writer-failure.txt"))?,
"completed"
);
let store = codewhale_engine::session_manager::SessionManager::new(
rig.server.sessions_dir.clone(),
)?;
let saved = store.load_session(&id)?;
assert!(
saved
.messages
.iter()
.flat_map(|m| &m.content)
.any(|b| matches!(b, codewhale_models::ContentBlock::ToolResult { .. }))
);
let thread = &rig.server.sessions[&id].thread_id;
let detail = rig.server.runtime.get_thread_detail(thread).await?;
assert!(!matches!(
detail.turns.last().unwrap().status,
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
));
rig.close().await;
Ok(())
}
}
+67 -2
View File
@@ -41,6 +41,34 @@ mod platform {
tokio::io::WriteHalf<Connection>,
>;
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
let mut tail = Vec::new();
let mut probe = checked.as_path();
loop {
match probe.canonicalize() {
Ok(real) => {
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tail.push(probe.file_name()?.to_owned());
probe = probe.parent()?;
}
Err(_) => return None,
}
}
}
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
}
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
left == right
|| (cfg!(any(target_os = "macos", windows))
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
}
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
if left == right {
return true;
@@ -227,6 +255,34 @@ mod platform {
);
Ok(())
}
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
&& matches!(
selected.symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
});
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
return Ok(false);
}
anyhow::ensure!(
self.publication.parent.retire_private_receipt(
&self.publication.receipt_name,
&self.publication.file
)?,
PublicationChangedBeforeAttach
);
Ok(true)
}
async fn discard_if_store_gone(
self: &Arc<Self>,
selected_store: &std::path::Path,
) -> Result<bool> {
let held = self.clone();
let selected_store = selected_store.to_path_buf();
owner_work(move || held.discard_if_store_gone_blocking(selected_store)).await
}
/// Caller retains the real Runtime lease; this checks evidence only.
pub(crate) fn revalidate_receipt(&self) -> Result<bool> {
if !self.publication.receipt_is_current()? {
@@ -345,8 +401,9 @@ mod platform {
pub async fn probe_owner_for_host_startup(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
selected_store: &std::path::Path,
) -> Result<HostOwnerProbe> {
probe_frontend(
match probe_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Control,
@@ -355,7 +412,15 @@ mod platform {
None,
None,
)
.await
.await?
{
HostOwnerProbe::Unavailable(publication)
if publication.discard_if_store_gone(selected_store).await? =>
{
Ok(HostOwnerProbe::Absent)
}
probe => Ok(probe),
}
}
pub async fn connect(
+4
View File
@@ -1349,6 +1349,7 @@ mod platform {
let config = root.join("config.toml");
std::fs::write(&config, "")?;
let parent = PrivateDirectory::admit(path.parent().unwrap())?;
std::fs::create_dir_all(root.join("runtime"))?;
let stale = std::os::unix::net::UnixListener::bind(&path)?;
let identity = parent.socket_identity("daemon.sock")?.unwrap();
parent.protect_socket("daemon.sock", identity)?;
@@ -1383,6 +1384,7 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(config.clone()),
Some(path.clone()),
&owner.data_dir,
)
.await?
else {
@@ -1392,6 +1394,7 @@ mod platform {
let observed_hook = observed.clone();
let selected = path.clone();
let selected_config = config.clone();
let selected_store = owner.data_dir.clone();
let stale_capture = captured.clone();
BEFORE_OWNER_REBIND.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move |endpoint| {
@@ -1412,6 +1415,7 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(selected_config),
Some(endpoint),
&selected_store,
)
.await
.unwrap(),
+1
View File
@@ -668,6 +668,7 @@ pub async fn run_exec_agent(
execution_config.subagent_heartbeat_timeout_secs_for_provider(&effective_identity),
),
prefer_bwrap: execution_config.prefers_bwrap(),
tool_launcher: execution_config.tool_launcher.clone(),
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
read_only_roots: execution_config.bwrap_ro_roots.clone(),
device_roots: execution_config.bwrap_dev_roots.clone(),
+2 -33
View File
@@ -14,7 +14,6 @@ use crate::stdio::{BoundedLines, run_stdio_loop};
use anyhow::Context as _;
use anyhow::{Result, anyhow};
use axum::http::{Method, StatusCode};
use codewhale_agent::ModelRegistry;
use codewhale_app_server_protocol::rpc::{
RequestId, RpcCall, RpcErrorDetails, RpcErrorResponse, RpcResponse,
};
@@ -23,7 +22,6 @@ use codewhale_app_server_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES;
use codewhale_app_server_protocol::runtime::RuntimeImageInput;
use codewhale_config::ConfigStore;
use codewhale_core::Runtime;
use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
use codewhale_protocol::EventFrame;
use codewhale_state::StateStore;
use serde::Deserialize;
@@ -364,7 +362,6 @@ pub struct AppState {
/// Bookkeeping config and read-only historical archive access.
/// Actual turns and transcript writes belong to the captured owner.
runtime: Arc<RwLock<Runtime>>,
registry: ModelRegistry,
auth_token: Option<String>,
#[cfg(test)]
runtime_bridge: Arc<Mutex<Option<SharedRuntimeBridge>>>,
@@ -797,46 +794,19 @@ async fn build_state_off_runtime(
fn build_state_with_transport(
config_path: Option<PathBuf>,
auth_token: Option<String>,
transport: AppTransport,
_transport: AppTransport,
) -> Result<AppState> {
let has_explicit_config_path = config_path.is_some();
let store = ConfigStore::load(config_path)?;
let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
let config = store.config.clone();
let registry = ModelRegistry::default();
let state_db_path = config_path
.as_ref()
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
let state_store = StateStore::open(state_db_path)?;
let mut hooks = HookDispatcher::default();
// Stdio carries JSON-RPC on stdout: printing raw hook events there
// corrupts the protocol stream (#5165). HTTP mode keeps the stdout
// sink for local development visibility.
if transport == AppTransport::Http {
hooks.add_sink(Arc::new(StdoutHookSink));
}
// The hook log sits beside the state database: next to an explicit
// config, otherwise in the directory the state store resolved under the
// Codewhale home. Never relative to the process cwd (#6513).
let hook_log_path = state_store
.db_path()
.parent()
.unwrap_or_else(|| Path::new("."))
.join("events.jsonl");
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
if let Some(socket_path) = config
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_ref())
.filter(|path| !path.as_os_str().is_empty())
{
hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
}
let runtime = Runtime::new(config.clone(), state_store, hooks);
let runtime = Runtime::new(config.clone(), state_store);
Ok(AppState {
captured_owner: None,
@@ -846,7 +816,6 @@ fn build_state_with_transport(
config_path,
config: Arc::new(RwLock::new(config)),
runtime: Arc::new(RwLock::new(runtime)),
registry,
auth_token,
#[cfg(test)]
runtime_bridge: Arc::new(Mutex::new(None)),
+37 -1
View File
@@ -76,6 +76,18 @@ impl McpServerSettings {
}
}
fn refuse_shell_tools(&mut self) -> Vec<String> {
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
.into_iter()
.partition(|name| {
build_exposed_tools(std::slice::from_ref(name))
.iter()
.any(|tool| tool.internal == "exec_shell")
});
self.expose_tools = kept;
refused
}
fn from_toml(contents: &str) -> Result<Self> {
let config: McpServerConfigFile = toml::from_str(contents)?;
Ok(Self {
@@ -98,12 +110,21 @@ pub async fn run_mcp_server(
workspace: PathBuf,
skills_dir: PathBuf,
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
tool_launcher_configured: bool,
) -> Result<()> {
// Settings load is a synchronous config read; keep it off the async
// worker per the blocking-call convention.
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
.await
.context("MCP server settings task failed")??;
if tool_launcher_configured {
for name in settings.refuse_shell_tools() {
eprintln!(
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
tool_launcher, and this server does not run commands through it."
);
}
}
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
// stdout carries the protocol; the notice goes to stderr so an operator
// whose config lists write tools sees why they are missing.
@@ -906,6 +927,21 @@ mod tests {
);
}
#[test]
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
let mut settings = McpServerSettings::from_toml(
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
)
.expect("parse config");
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
let server = test_server(PathBuf::from("."), settings);
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
assert_eq!(names, vec!["file_read"]);
for internal in ["exec_shell", "Bash", "bash"] {
assert!(server.registry.get(internal).is_none(), "{internal}");
}
}
#[test]
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
let settings = McpServerSettings::from_toml(
+12 -5
View File
@@ -111,7 +111,14 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
providers.len() <= 256,
"Runtime provider catalog exceeds its limit"
);
let aliases = state.registry.list();
let aliases = codewhale_config::catalog::reviewed::bundled_reviewed()
.selections
.iter()
.filter_map(|row| {
codewhale_config::ProviderKind::parse_config_identity(&row.provider)
.map(|provider| (provider, row))
})
.collect::<Vec<_>>();
let mut models = Vec::new();
let mut routes = std::collections::HashSet::new();
for provider in providers {
@@ -149,13 +156,13 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
.context("Runtime model identity is invalid")?;
let compatibility_aliases = aliases
.iter()
.find(|entry| {
.find(|(provider, row)| {
parsed != codewhale_config::ProviderKind::Custom
&& provider_id.is_none_or(|identity| identity == kind)
&& entry.provider == parsed
&& entry.id == id
&& *provider == parsed
&& row.id == id
})
.map(|entry| entry.aliases.clone())
.map(|(_, row)| row.aliases.clone())
.unwrap_or_default();
let capabilities: codewhale_config::route::RouteCapabilities =
match model.pointer("/facts/capabilities") {
+80 -4
View File
@@ -129,6 +129,7 @@ mod members;
mod memory_lens;
mod mobile;
mod plans;
mod plugin_index;
mod plugins;
pub(crate) mod review;
mod secrets;
@@ -386,7 +387,7 @@ impl RuntimeWorkspaceScopes {
let owner = self.clone();
codewhale_runtime::runtime_control::owner_work(move || {
let (canonical, directory) =
codewhale_util::workspace_dir::open_workspace_directory(&lexical)?;
codewhale_util::workspace_dir::open_pinned_workspace_directory(&lexical)?;
let directory = Arc::new(directory);
let mut scopes = owner.scopes.lock();
if let Some(scope) = scopes.get(&lexical) {
@@ -1323,6 +1324,7 @@ struct ThreadSummary {
preview: String,
model: String,
mode: String,
agent_preset: String,
workspace: PathBuf,
branch: Option<String>,
head: Option<String>,
@@ -1578,10 +1580,41 @@ struct RuntimeInfoResponse {
job_operation_store_id: Option<String>,
account: RuntimeAccountInfo,
experimental: RuntimeExperimentalCapabilities,
sandbox: RuntimeSandboxInfo,
// Backward-compatible alias kept for existing clients.
version: &'static str,
}
#[derive(Debug, Serialize)]
struct RuntimeSandboxInfo {
kind: String,
tool_launcher: bool,
}
async fn runtime_sandbox_info(state: &RuntimeApiState) -> RuntimeSandboxInfo {
let (prefer_bwrap, tool_launcher, denied_read_paths) = {
let cfg = state.config.read();
(
cfg.prefers_bwrap(),
cfg.tool_launcher.clone(),
codewhale_sandbox::read_guard::ReadDenylist::from_config(&cfg).subtree_paths(),
)
};
let launcher_configured = tool_launcher.is_some();
let kind = tokio::task::spawn_blocking(move || {
let mut manager = codewhale_sandbox::SandboxManager::with_bwrap_preference(prefer_bwrap);
manager.set_tool_launcher(tool_launcher);
manager.set_denied_read_subpaths(denied_read_paths);
manager.configured_sandbox().unwrap_or_default().to_string()
})
.await
.unwrap_or_else(|_| codewhale_sandbox::SandboxType::None.to_string());
RuntimeSandboxInfo {
kind,
tool_launcher: launcher_configured,
}
}
fn default_runtime_capabilities() -> RuntimeCapabilities {
RuntimeCapabilities {
client_token_intents: true,
@@ -2035,7 +2068,7 @@ async fn admit_runtime_host(
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let probe = match crate::daemon_client::probe_owner_for_host_startup(
config_path.clone(), selected_socket.clone(),
config_path.clone(), selected_socket.clone(), &selected_store,
).await {
Err(error) if error.is::<PublicationChangedBeforeAttach>() => {
tokio::time::sleep(Duration::from_millis(25)).await;
@@ -3283,6 +3316,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}", get(get_task))
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/modes", get(list_modes))
.route("/v1/commands", get(list_commands))
.route("/v1/threads/{id}/commands", post(run_thread_command))
.route(
@@ -3339,6 +3373,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills/{name}/audit", get(audit_skill_api))
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
.route("/v1/apps/plugins", get(plugins::list_plugins))
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
.route("/v1/apps/avatars", get(avatars::list_avatars))
.route(
"/v1/apps/avatars/{handle}/atlases/{page}",
@@ -3904,7 +3939,7 @@ async fn create_thread(
)
.await
.map_err(|e| {
if let Some(refusal) = external_agent_refusal(&e) {
if let Some(refusal) = external_agent_refusal(&e).or_else(|| mode_refusal(&e)) {
refusal
} else if e
.is::<codewhale_engine::runtime_threads::manager::RuntimeThreadCreateConflict>()
@@ -4153,6 +4188,10 @@ async fn list_threads_summary(
preview,
model: thread.model,
mode: thread.mode,
agent_preset: codewhale_engine::conversation_modes::effective_id(
thread.agent_preset.as_deref(),
)
.to_string(),
branch: workspace_git.and_then(|git| git.branch.clone()),
head: workspace_git.and_then(|git| git.head.clone()),
dirty: workspace_git.is_some_and(|git| git.dirty),
@@ -5838,6 +5877,10 @@ struct CommandCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
workspace_sourced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
registration_id: Option<String>,
}
@@ -5896,6 +5939,8 @@ fn command_catalog(
shadowed_by,
shadowed_aliases,
origin: None,
plugin_id: None,
workspace_sourced: false,
registration_id: None,
});
}
@@ -5919,6 +5964,8 @@ fn command_catalog(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: None,
plugin_id: command.plugin_id.clone(),
workspace_sourced: command.workspace_sourced,
registration_id: None,
});
}
@@ -6001,6 +6048,8 @@ async fn list_commands(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: Some(reference.origin.clone()),
plugin_id: Some(reference.plugin_id.clone()),
workspace_sourced: false,
registration_id: query
.thread_id
.as_deref()
@@ -6217,6 +6266,17 @@ async fn list_hooks(
}))
}
async fn list_modes(
State(state): State<RuntimeApiState>,
) -> Json<codewhale_engine::conversation_modes::ConversationModeTable> {
Json(
codewhale_engine::conversation_modes::current_table(
state.runtime_threads.extension_host_options(),
)
.await,
)
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
@@ -7083,6 +7143,7 @@ async fn runtime_info(
capabilities.fleet_run_create = !session_only;
capabilities.fleet_run_start = !session_only;
capabilities.fleet_local_target = !session_only;
let sandbox = runtime_sandbox_info(&state).await;
Json(RuntimeInfoResponse {
service: "codewhale-runtime-api",
runtime_api_version: RUNTIME_API_VERSION,
@@ -7097,6 +7158,7 @@ async fn runtime_info(
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
account,
experimental: RuntimeExperimentalCapabilities::default(),
sandbox,
version,
})
}
@@ -14796,6 +14858,20 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
}
}
fn mode_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::conversation_modes::ModeRefusal;
let error = err.downcast_ref::<ModeRefusal>()?;
Some(ApiError {
status: match error.code {
ModeRefusal::MODE_UNAVAILABLE => StatusCode::FAILED_DEPENDENCY,
ModeRefusal::MODE_LOCKED => StatusCode::CONFLICT,
_ => StatusCode::BAD_REQUEST,
},
message: error.message.clone(),
code: Some(error.code),
})
}
fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::fleet::agents::ExecutorUnavailable;
let error = err.downcast_ref::<ExecutorUnavailable>()?;
@@ -14811,7 +14887,7 @@ fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
}
fn map_thread_err(err: anyhow::Error) -> ApiError {
if let Some(refusal) = external_agent_refusal(&err) {
if let Some(refusal) = external_agent_refusal(&err).or_else(|| mode_refusal(&err)) {
return refusal;
}
if let Some(error) = err.downcast_ref::<codewhale_engine::runtime_threads::manager::RuntimeTurnOperationLookupError>() {
+20 -2
View File
@@ -515,9 +515,11 @@ fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> {
}
let index = record.as_bytes()[0] as char;
let worktree = record.as_bytes()[1] as char;
let path = record[3..].to_string();
let Some(path) = record.get(3..).map(str::to_string) else {
continue;
};
let mut old_path = None;
if matches!(index, 'R' | 'C') {
if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
old_path = records.next().map(str::to_string);
}
entries.push(GitFileEntry {
@@ -2909,6 +2911,22 @@ mod tests {
);
}
#[test]
fn worktree_rename_consumes_its_original_path_record() {
let rows = parse_porcelain(" R new.md\0ab说明.md\0 M a.txt\0");
let rows: Vec<_> = rows
.iter()
.map(|row| (row.path.as_str(), row.status, row.old_path.as_deref()))
.collect();
assert_eq!(
rows,
[
("new.md", "renamed", Some("ab说明.md")),
("a.txt", "modified", None),
]
);
}
pub(super) fn repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path();
@@ -0,0 +1,489 @@
use std::collections::{BTreeSet, HashMap};
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::{Query, State};
use serde::Serialize;
use serde_json::Value;
use super::plugins::{self, PluginSummaryEntry};
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
const MAX_ADDED_NAMES: usize = 512;
#[derive(Debug, Serialize)]
pub(super) struct PluginIndexResponse {
workspace: String,
items: Vec<IndexItem>,
unavailable: Vec<IndexNote>,
problems: Vec<IndexNote>,
}
#[derive(Debug, Serialize)]
struct IndexNote {
kind: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
struct IndexItem {
id: String,
kind: &'static str,
name: String,
description: Option<String>,
source: &'static str,
state: &'static str,
labels: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adds: Option<PluginAdds>,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'static str>,
owner: &'static str,
row: Value,
}
#[derive(Debug, Default, Serialize)]
struct PluginAdds {
skills: Vec<String>,
connectors: Vec<String>,
hooks: Vec<String>,
commands: Vec<String>,
profiles: Vec<String>,
}
impl PluginAdds {
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
[
("skill", !self.skills.is_empty()),
("connector", !self.connectors.is_empty()),
("hook", !self.hooks.is_empty()),
("command", !self.commands.is_empty()),
(
"tool",
entry.inventory.native > 0 || entry.inventory.lsp > 0,
),
("profile", !self.profiles.is_empty()),
]
.into_iter()
.filter_map(|(label, present)| present.then_some(label))
.collect()
}
}
fn row_value<T: Serialize>(row: &T) -> Value {
serde_json::to_value(row).unwrap_or(Value::Null)
}
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
unavailable.push(IndexNote {
kind,
message: error.message.clone(),
});
}
fn plugin_source(origin: &str) -> &'static str {
match origin {
"codewhale-builtin" => "built_in",
"workspace-codewhale" => "project",
_ => "user",
}
}
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
if matches!(
entry.trust_status,
"content-changed" | "capabilities-changed"
) || entry.state == "enabled-untrusted"
{
return "needs_review";
}
match entry.state {
"active" => "enabled",
"disabled" => "disabled",
_ => "unavailable",
}
}
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
if let Some(wanted) = extension
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
{
return;
}
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
names.insert(if lowercase {
stem.to_lowercase()
} else {
stem.to_string()
});
}
}
async fn component_names(
paths: &[PathBuf],
extension: Option<&str>,
lowercase: bool,
omitted: &mut bool,
) -> Vec<String> {
let mut names = BTreeSet::new();
for path in paths {
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
continue;
};
if meta.is_file() {
add_stem(&mut names, path, extension, lowercase);
} else if meta.is_dir()
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
{
let mut seen = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
seen += 1;
if seen > MAX_ADDED_NAMES {
*omitted = true;
break;
}
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
add_stem(&mut names, &entry.path(), extension, lowercase);
}
}
}
if names.len() >= MAX_ADDED_NAMES {
*omitted = true;
break;
}
}
if names.iter().any(|name| name.chars().any(char::is_control)) {
*omitted = true;
names.retain(|name| !name.chars().any(char::is_control));
}
names.into_iter().take(MAX_ADDED_NAMES).collect()
}
async fn plugin_adds(
plugin: &codewhale_plugins::types::LoadedPlugin,
omitted: &mut bool,
) -> PluginAdds {
let mut skills: Vec<String> = plugin
.skill_snapshots
.iter()
.map(|skill| skill.name.clone())
.collect();
skills.sort();
let mut connectors: Vec<String> = plugin
.manifest
.mcp_servers
.as_ref()
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
connectors.sort();
PluginAdds {
skills,
connectors,
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
}
}
async fn plugin_adds_by_id(
state: &RuntimeApiState,
omitted: &mut BTreeSet<String>,
) -> HashMap<String, PluginAdds> {
let registry = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let mut adds = HashMap::new();
for plugin in registry.list() {
let mut cut = false;
adds.insert(
plugin.id.as_str().to_string(),
plugin_adds(plugin, &mut cut).await,
);
if cut {
omitted.insert(plugin.name().to_string());
}
}
adds
}
fn skill_source(
path: Option<&PathBuf>,
is_bundled: bool,
skills_dir: &FsPath,
workspace: &FsPath,
) -> &'static str {
if is_bundled {
return "built_in";
}
match path {
Some(path) if path.starts_with(skills_dir) => "user",
Some(path) if path.starts_with(workspace) => "project",
Some(_) => "other_app",
None => "plugin",
}
}
pub(super) async fn get_plugin_index(
State(state): State<RuntimeApiState>,
) -> Result<Json<PluginIndexResponse>, ApiError> {
let mut items = Vec::new();
let mut unavailable = Vec::new();
let mut problems = Vec::new();
let commands = super::list_commands(
State(state.clone()),
Query(CommandsQuery { thread_id: None }),
)
.await;
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
if let Ok(Json(response)) = &commands {
for command in &response.commands {
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
plugin_commands
.entry(plugin_id.clone())
.or_default()
.insert(command.name.clone());
}
}
}
match plugins::list_plugins(State(state.clone())).await {
Ok(Json(response)) => {
let mut omitted = BTreeSet::new();
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
for name in &omitted {
problems.push(IndexNote {
kind: "plugin",
message: format!(
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
),
});
}
for diagnostic in &response.registry_diagnostics {
problems.push(IndexNote {
kind: "plugin",
message: diagnostic.message.clone(),
});
}
for entry in &response.plugins {
let mut adds = adds_by_id.remove(&entry.id);
if entry.state == "active"
&& commands.is_ok()
&& let Some(adds) = adds.as_mut()
{
adds.commands = plugin_commands
.remove(&entry.id)
.map(|names| names.into_iter().collect())
.unwrap_or_default();
}
items.push(IndexItem {
id: format!("plugin:{}", entry.id),
kind: "plugin",
name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.name.clone()),
description: entry.description.clone(),
source: plugin_source(entry.origin),
state: plugin_state(entry),
labels: adds
.as_ref()
.map(|adds| adds.labels(entry))
.unwrap_or_default(),
plugin: None,
adds,
tier: None,
owner: "/v1/apps/plugins",
row: row_value(entry),
});
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match plugins::list_plugin_catalog(State(state.clone())).await {
Ok(Json(catalog)) => {
for (tier, rows) in [
("builtin", &catalog.builtin),
("experimental", &catalog.experimental),
("more", &catalog.more),
] {
for row in rows {
items.push(IndexItem {
id: format!("plugin:catalog:{}", row.id),
kind: "plugin",
name: row.title.clone(),
description: row.description.clone(),
source: "built_in",
state: if !row.available {
"unavailable"
} else if row.enabled {
"enabled"
} else {
"disabled"
},
labels: Vec::new(),
plugin: None,
adds: None,
tier: Some(tier),
owner: "/v1/apps/plugins/catalog",
row: row_value(row),
});
}
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match super::list_skills(State(state.clone())).await {
Ok(Json(response)) => {
for warning in &response.warnings {
problems.push(IndexNote {
kind: "skill",
message: warning.clone(),
});
}
for skill in &response.skills {
if skill.plugin_id.is_some() {
continue;
}
items.push(IndexItem {
id: format!("skill:{}", skill.name),
kind: "skill",
name: skill.name.clone(),
description: Some(skill.description.clone()),
source: skill_source(
skill.path.as_ref(),
skill.is_bundled,
&response.directory,
&state.workspace,
),
state: if skill.enabled { "enabled" } else { "disabled" },
labels: vec!["skill"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/skills",
row: row_value(skill),
});
}
}
Err(error) => note_failure(&mut unavailable, "skill", &error),
}
match super::list_mcp_servers(State(state.clone())).await {
Ok(Json(response)) => {
for server in &response.servers {
let source = match server.origin {
"plugin" => continue,
"project" => "project",
"runtime" => "runtime",
_ => "user",
};
items.push(IndexItem {
id: format!("connector:{}", server.name),
kind: "connector",
name: server.name.clone(),
description: None,
source,
state: if !server.enabled {
"disabled"
} else if server.auth_required {
"needs_sign_in"
} else {
"enabled"
},
labels: vec!["connector"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/apps/mcp/servers",
row: row_value(server),
});
}
}
Err(error) => note_failure(&mut unavailable, "connector", &error),
}
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
Ok(Json(response)) => {
for problem in &response.problems {
problems.push(IndexNote {
kind: "hook",
message: problem.clone(),
});
}
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
for hook in &response.hooks {
let ordinal = ordinals.entry(hook.event).or_default();
*ordinal += 1;
let source = match hook.source {
"plugin" => continue,
"project" => "project",
_ => "user",
};
let name = hook
.name
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{ordinal}", hook.event),
kind: "hook",
name,
description: None,
source,
state: if response.enabled {
"enabled"
} else {
"disabled"
},
labels: vec!["hook"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/hooks",
row: row_value(hook),
});
}
}
Err(error) => note_failure(&mut unavailable, "hook", &error),
}
match commands {
Ok(Json(response)) => {
for command in &response.commands {
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
("builtin", _) => continue,
("user", Some(_)) => continue,
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
_ if command.workspace_sourced => ("project", None),
_ => ("user", None),
};
items.push(IndexItem {
id: format!("command:{}", command.name),
kind: "command",
name: command.name.clone(),
description: command.summary.clone(),
source,
state: "enabled",
labels: vec!["command"],
plugin,
adds: None,
tier: None,
owner: "/v1/commands",
row: row_value(command),
});
}
}
Err(error) => note_failure(&mut unavailable, "command", &error),
}
Ok(Json(PluginIndexResponse {
workspace: state.workspace.display().to_string(),
items,
unavailable,
problems,
}))
}
+11 -2
View File
@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use codewhale_plugins::host_services::{PersonVia, PluginApprover, ReviewApproval};
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
};
@@ -1432,8 +1433,16 @@ async fn run_registry_mutation(
let mut registry = (*registry).clone();
let result = match mutation {
RegistryMutation::Trust { .. } => registry.trust(selector),
RegistryMutation::Enable => registry.enable(selector),
RegistryMutation::Trust { .. } => registry.trust_reviewed(
selector,
ReviewApproval {
approved_by: PluginApprover::person(PersonVia::App),
host_services: None,
},
),
RegistryMutation::Enable => {
registry.enable_by(selector, PluginApprover::person(PersonVia::App))
}
RegistryMutation::Restage => registry.restage(selector),
RegistryMutation::Disable => registry.disable(selector),
RegistryMutation::Revoke => registry.revoke_trust(selector),
@@ -15,9 +15,11 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod catalog_parity;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
mod conversation_modes;
#[cfg(unix)]
mod external_agent_options;
#[cfg(unix)]
@@ -27,6 +29,7 @@ mod headless_catalog;
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_index;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
@@ -423,6 +426,7 @@ fn saved_session_with_blocks(blocks: Vec<codewhale_models::ContentBlock>) -> Sav
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -563,6 +567,7 @@ fn messages_from_thread_detail_batches_tool_results() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
let turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -795,6 +800,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
},
turns: Vec::new(),
items: Vec::new(),
@@ -11083,6 +11089,8 @@ async fn runtime_info_reports_bind_state() -> Result<()> {
assert!(info["account"].get("refresh_token").is_none());
assert!(info["account"].get("email").is_none());
assert!(info["experimental"].is_object());
assert!(info["sandbox"]["kind"].is_string());
assert_eq!(info["sandbox"]["tool_launcher"], false);
handle.abort();
Ok(())
@@ -0,0 +1,600 @@
use super::*;
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
now_unix,
};
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
use codewhale_engine::model_inventory::ModelInventory;
use codewhale_runtime::config::ProviderIdentity;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
const SINGULAR: &str = "model list / model resolve";
const PLURAL: &str = "models";
const RUNTIME: &str = "runtime api";
const ROUTING: &str = "routing inventory";
const COST: &str = "cost";
const UNKNOWN: &str = "unknown";
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
struct Route {
label: &'static str,
api_path: &'static str,
identity: ProviderIdentity,
base_url: String,
models: &'static [&'static str],
}
fn cold_process() {
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
}
fn known<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
}
fn rate(value: &Value) -> String {
let number = match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.parse::<f64>().ok(),
_ => None,
};
known(number)
}
fn roster(mut models: Vec<String>) -> String {
models.sort();
models.dedup();
models.join(",")
}
fn read(
readings: &mut Readings,
route: &Route,
model: &str,
field: &'static str,
surface: &'static str,
value: String,
) {
readings
.entry((route.label.to_string(), model.to_string(), field))
.or_default()
.insert(surface, value);
}
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
let fingerprint = base_url_fingerprint(base_url);
let fetched_at = now_unix();
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
identity.provider,
identity.key.as_str(),
base_url,
);
assert_eq!(
codewhale_engine::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.key.to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: ROSTER
.iter()
.map(|id| CatalogOffering {
provider: identity.key.to_string(),
wire_model_id: (*id).to_string(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
})
.collect(),
},
),
Some(CatalogStatus::Fresh)
);
}
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
cold_process();
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
let listed =
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
.into_iter()
.find(|(listed, _)| listed == route.identity.key.as_str())
.map(|(_, models)| roster(models));
read(
readings,
route,
"*",
"roster",
SINGULAR,
listed.unwrap_or_else(|| "route not listed".into()),
);
for model in route.models {
let limits = codewhale_engine::route_preferences::resolve_model(
config_path,
route.identity.key.as_str(),
&route.base_url,
Some(model),
)
.map(|candidate| candidate.limits());
let (context, output) = match limits {
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
};
read(readings, route, model, "limit.context", SINGULAR, context);
read(readings, route, model, "limit.output", SINGULAR, output);
}
Ok(())
}
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
cold_process();
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&route.identity)
.map_err(anyhow::Error::msg)?;
read(
readings,
route,
"*",
"roster",
PLURAL,
roster(codewhale_engine::provider_lake::listed_models_for_route(
&route_config,
&route.identity,
)),
);
Ok(())
}
async fn read_runtime(
readings: &mut Readings,
client: &reqwest::Client,
addr: &SocketAddr,
route: &Route,
) -> Result<()> {
let body: Value = client
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
.send()
.await?
.error_for_status()?
.json()
.await?;
let models = body["models"].as_array().context("models array")?;
read(
readings,
route,
"*",
"roster",
RUNTIME,
roster(
models
.iter()
.filter_map(|model| model["id"].as_str().map(str::to_string))
.collect(),
),
);
for model in route.models {
let facts = models
.iter()
.find(|entry| entry["id"] == *model)
.map_or(&Value::Null, |entry| &entry["facts"]);
read(
readings,
route,
model,
"limit.context",
RUNTIME,
known(facts["effective_limits"]["context_tokens"].as_u64()),
);
read(
readings,
route,
model,
"limit.output",
RUNTIME,
known(facts["effective_limits"]["output_tokens"].as_u64()),
);
read(
readings,
route,
model,
"window",
RUNTIME,
known(facts["effective_context"]["tokens"].as_u64()),
);
read(
readings,
route,
model,
"window.source",
RUNTIME,
known(facts["effective_context"]["source"].as_str()),
);
read(
readings,
route,
model,
"price.input",
RUNTIME,
rate(&facts["pricing"]["input_per_million"]),
);
read(
readings,
route,
model,
"price.output",
RUNTIME,
rate(&facts["pricing"]["output_per_million"]),
);
read(
readings,
route,
model,
"price.basis",
RUNTIME,
format!(
"provenance={} status={} source={}",
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
),
);
}
Ok(())
}
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
let candidates: Vec<_> = inventory
.candidates
.iter()
.filter(|candidate| candidate.identity == route.identity)
.collect();
read(
readings,
route,
"*",
"roster",
ROUTING,
roster(
candidates
.iter()
.map(|candidate| candidate.model.clone())
.collect(),
),
);
for model in route.models {
let candidate = candidates
.iter()
.find(|candidate| candidate.model == *model);
read(
readings,
route,
model,
"window",
ROUTING,
known(candidate.map(|candidate| candidate.context_window)),
);
read(
readings,
route,
model,
"window.source",
ROUTING,
known(candidate.map(|candidate| {
if candidate.context_window_unverified {
"unverified"
} else {
"verified"
}
})),
);
read(
readings,
route,
model,
"limit.output",
ROUTING,
known(candidate.and_then(|candidate| candidate.max_output)),
);
}
Ok(())
}
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
let usage = Usage {
input_tokens: 100_000,
output_tokens: 10_000,
..Default::default()
};
for model in route.models {
let envelope = EffectiveRouteEnvelope::from_admitted(
Some(config),
&route.identity,
*model,
Some(&route.base_url),
chrono::Utc::now(),
);
let quote = envelope.provider_live_pricing.as_ref();
let audit = envelope.audit(&usage);
read(
readings,
route,
model,
"price.input",
COST,
quote
.and_then(|quote| quote.input_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.output",
COST,
quote
.and_then(|quote| quote.output_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.basis",
COST,
format!(
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
quote.map(|quote| quote.provenance.clone()),
envelope.billing_mode,
known(audit.exact_usd.as_deref()),
audit.unpriced_reason,
),
);
}
}
#[tokio::test]
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
let _env = lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let root = tempfile::tempdir()?;
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
let catalog_path = root.path().join("captured-models-dev.json");
fs::write(&catalog_path, captured)?;
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
cold_process();
let config_path = root.path().join("config.toml");
fs::write(
&config_path,
r#"provider = "anthropic"
[providers.anthropic]
api_key = "parity-fixture-key"
model = "claude-opus-5"
[providers.zai]
api_key = "parity-fixture-key"
model = "GLM-5.3"
[providers.lab]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/v1"
api_key = "parity-fixture-key"
model = "claude-haiku-4-5"
"#,
)?;
let config = Config::load(Some(config_path.clone()), None)?;
let route = |label: &'static str,
api_path: &'static str,
key: &str,
models: &'static [&'static str]|
-> Result<Route> {
let identity = config
.resolve_provider_pin_identity(key)
.map_err(anyhow::Error::msg)?;
Ok(Route {
label,
api_path,
base_url: config.base_url_for_route(&identity),
identity,
models,
})
};
let routes = [
route(
"anthropic (official, id-only roster)",
"anthropic/models",
"anthropic",
ROSTER,
)?,
route(
"lab (custom endpoint, same spelling)",
"custom/models?model_provider_id=lab",
"lab",
&["claude-haiku-4-5"],
)?,
route(
"zai (plan endpoint)",
"zai/models",
"zai",
&["GLM-5.3", "GLM-5.2"],
)?,
];
assert!(
codewhale_engine::models_dev_live::refresh(false)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
> 0
);
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
let mut readings = Readings::new();
for route in &routes {
read_singular(&mut readings, &config_path, route)?;
read_plural(&mut readings, &config, route)?;
}
cold_process();
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
.await?
.expect("loopback server required");
let client = codewhale_release::tls::reqwest_client();
for route in &routes {
read_runtime(&mut readings, &client, &addr, route).await?;
read_routing(&mut readings, &config, route)?;
read_cost(&mut readings, &config, route);
}
handle.abort();
cold_process();
println!(
"PARITY captured_catalog sha256={} bytes={}",
Sha256::digest(captured.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
captured.len()
);
let mut differing = Vec::new();
let mut known_differences: Vec<String> = Vec::new();
for ((route, model, field), surfaces) in &readings {
let mut values: Vec<String> = surfaces
.values()
.map(|value| {
if *field == "roster" {
roster(
value
.to_ascii_lowercase()
.split(',')
.map(str::to_string)
.collect(),
)
} else {
value.clone()
}
})
.collect();
values.sort();
values.dedup();
let compared = !matches!(*field, "price.basis" | "window.source");
let borrowed_window = *field == "window"
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
&& readings
.get(&(route.clone(), model.clone(), "window.source"))
.and_then(|sources| sources.get(ROUTING))
.map(String::as_str)
== Some("unverified");
let verdict = if !compared {
"info"
} else if borrowed_window {
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
"KNOWN-DIFFERENCE"
} else if values.len() == 1 {
"agree"
} else {
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
"DIFFER"
};
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
}
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
readings
.get(&(route.label.to_string(), model.to_string(), field))
.and_then(|surfaces| surfaces.get(surface))
.cloned()
.unwrap_or_default()
};
assert_eq!(
value(&routes[0], "*", "roster", SINGULAR),
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
"the singular command lists the route's own roster, not every bundled row"
);
assert_eq!(
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
"1000000"
);
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
"200000"
);
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
"5"
);
for field in ["limit.context", "limit.output", "price.input"] {
assert_eq!(
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
UNKNOWN,
"a custom endpoint borrows no fact from a same-named model: {field}"
);
}
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
"unverified",
"routing never presents a same-named model's window as this endpoint's"
);
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
UNKNOWN
);
for model in ["GLM-5.3", "GLM-5.2"] {
let basis = value(&routes[2], model, "price.basis", COST);
assert!(
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
"a plan route is never billed per token: {model}: {basis}"
);
}
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[2], model, "price.input", surface),
rate,
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
);
}
}
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
assert!(
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
"a custom endpoint is never given a charge: {basis}"
);
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
UNKNOWN,
"a metered first-party model with no price row reads unknown: {surface}"
);
}
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
assert!(
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
"an unpriced model produces no charge and says why: {basis}"
);
assert_eq!(
known_differences,
vec![
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
],
"the only named difference is the custom endpoint's borrowed routing window"
);
assert!(
differing.is_empty(),
"surfaces disagree on the same captured catalog:\n{}",
differing.join("\n")
);
Ok(())
}
@@ -28,6 +28,8 @@ fn user(name: &str) -> UserCommand {
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: None,
workspace_sourced: false,
builtin_bindings: vec![name.to_string()],
}
}
@@ -0,0 +1,82 @@
use super::*;
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
#[tokio::test]
async fn modes_route_lists_the_table_and_threads_carry_and_refuse_modes() -> Result<()> {
let _unavailable = CreatorAvailabilityGuard::unforced();
let Some((addr, _threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}");
let table: Value = client
.get(format!("{base}/v1/modes"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(table["default"], "standard");
assert_eq!(table["modes"][0]["id"], "standard");
assert_eq!(table["modes"][0]["source"], "builtin");
assert_eq!(table["modes"][0]["available"], true);
assert!(table["modes"][0].get("reason").is_none());
assert_eq!(table["modes"][1]["id"], "creator");
assert_eq!(table["modes"][1]["available"], false);
assert_eq!(
table["modes"][1]["reason"],
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON
);
let unavailable = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(unavailable.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
let body: Value = unavailable.json().await?;
assert_eq!(body["error"]["code"], "mode_unavailable");
let unknown = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "minimal"}))
.send()
.await?;
assert_eq!(unknown.status(), reqwest::StatusCode::BAD_REQUEST);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_mode");
let created: Value = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "standard"}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = created["id"].as_str().expect("thread id").to_string();
let summary: Value = client
.get(format!("{base}/v1/threads/summary"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let row = summary
.as_array()
.and_then(|rows| rows.iter().find(|row| row["id"] == id.as_str()))
.expect("summary row");
assert_eq!(row["agent_preset"], "standard");
let patched = client
.patch(format!("{base}/v1/threads/{id}"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(patched.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
handle.abort();
let _ = handle.await;
Ok(())
}
@@ -0,0 +1,219 @@
use super::*;
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
impl CommandCatalog for Catalog {
fn answers_to(&self, _name: &str) -> bool {
false
}
fn builtins(&self) -> Vec<BuiltinCommand> {
Vec::new()
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
let plugin_id = self.0.lock().expect("plugin id").clone();
let command = |name: &str, plugin: Option<String>| UserCommand {
name: name.to_string(),
aliases: Vec::new(),
description: Some("A command.".to_string()),
usage: None,
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: plugin,
workspace_sourced: false,
builtin_bindings: Vec::new(),
};
vec![command("mine", None), command("ship", plugin_id)]
}
}
#[tokio::test]
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("index-route");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let plugin_id = Arc::new(std::sync::Mutex::new(None));
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
plugin_id.clone(),
)));
let sources = root.join("sources");
fs::create_dir_all(&sources)?;
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
fs::create_dir_all(bundle.join("commands"))?;
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
fs::create_dir_all(bundle.join("agents"))?;
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
plugin_ui::install_active(
&client,
&format!("http://{addr}/v1/apps/plugins"),
bundle.display().to_string(),
)
.await?;
let listed: Value = client
.get(format!("http://{addr}/v1/apps/plugins"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = listed["plugins"]
.as_array()
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
.and_then(|plugin| plugin["id"].as_str())
.expect("installed plugin id")
.to_string();
*plugin_id.lock().expect("plugin id") = Some(id.clone());
let commands: Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let ship = commands["commands"]
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
.expect("ship row");
assert_eq!(ship["plugin_id"], id.as_str());
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = index["items"].as_array().expect("items");
let find = |id: &str| items.iter().find(|item| item["id"] == id);
let plugin = items
.iter()
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
.unwrap_or_else(|| panic!("plugin row in {index}"));
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["state"], "enabled");
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
assert_eq!(
plugin["labels"],
serde_json::json!(["skill", "command", "profile"])
);
assert_eq!(plugin["row"]["name"], "indexed");
assert!(find("skill:greet").is_none());
assert!(find("command:ship").is_none());
let mine = find("command:mine").expect("a hand-written command is a row");
assert_eq!(mine["source"], "user");
assert_eq!(mine["labels"], serde_json::json!(["command"]));
assert!(
items
.iter()
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
"built-in switches are rows"
);
assert_eq!(
index["unavailable"].as_array().map(Vec::len),
Some(0),
"{index}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn the_index_keeps_same_name_hooks_distinct() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("hook-index-route");
let workspace = root.join("workspace");
let mut config = Config::default();
config.hooks = Some(codewhale_config::hooks::HooksConfig {
hooks: vec![
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo first",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo second",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo third",
),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo fourth",
)
.with_name("session_start 3"),
],
enabled: true,
..Default::default()
});
let (addr, _runtime_threads, handle) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
plugin_discovery: Some(isolated_plugin_discovery(&root, &workspace)),
..TestServerOverrides::default()
},
)
.await?
.expect("hook index test server");
let client = codewhale_release::tls::reqwest_client();
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let hooks: Vec<_> = index["items"]
.as_array()
.expect("items")
.iter()
.filter(|item| item["kind"] == "hook")
.collect();
assert_eq!(hooks.len(), 4, "{index}");
for (ordinal, hook) in hooks.iter().enumerate() {
assert_eq!(
hook["id"],
format!("hook:user:session_start:{}", ordinal + 1)
);
assert_eq!(hook["state"], "enabled");
}
assert_eq!(hooks[0]["name"], "gate");
assert_eq!(hooks[1]["name"], "gate");
assert_eq!(hooks[2]["name"], "session_start 3");
assert_eq!(hooks[3]["name"], "session_start 3");
assert_eq!(hooks[3]["row"]["command"], "echo fourth");
assert_eq!(hooks[0]["row"]["command"], "echo first");
assert_eq!(hooks[1]["row"]["command"], "echo second");
handle.abort();
Ok(())
}
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
Ok(bundle)
}
async fn install_active(
pub(super) async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
@@ -135,6 +135,11 @@ async fn ui(
Ok((status, response.json().await?))
}
async fn mutate(client: &reqwest::Client, url: String) -> Result<(StatusCode, String)> {
let response = client.post(url).send().await?;
Ok((response.status(), response.text().await?))
}
fn refusal(status: u16, body: &serde_json::Value) -> (u16, String) {
(
status,
@@ -370,14 +375,14 @@ async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() ->
.is_client_error()
);
let disabled = client.post(format!("{base}/{name}/disable")).send().await?;
assert_eq!(disabled.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/disable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
for url in [&asset_url, &route] {
let response = client.get(url).header("If-Match", &revision).send().await?;
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED, "{url}");
}
let enabled = client.post(format!("{base}/{name}/enable")).send().await?;
assert_eq!(enabled.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/enable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let stale = client
.get(&asset_url)
.header("If-Match", &revision)
@@ -459,13 +464,22 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
fs::create_dir_all(&root)?;
let bundle = write_native_ui_bundle(tmp.path(), "nativeui")?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
let Some((addr, handle)) = spawn_plugin_api_server(root.clone(), workspace.clone()).await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
let installed = install_active(&client, &base, format!("path:{}", bundle.display())).await?;
let name = installed["name"].as_str().unwrap().to_string();
let recorded =
isolated_plugin_discovery(&root, &workspace).inspect_registry_for_workspace(&workspace);
let by_app = codewhale_plugins::host_services::PluginApprover::person(
codewhale_plugins::host_services::PersonVia::App,
);
let plugin = recorded.get(&name).unwrap();
assert_eq!(plugin.approved_by.as_ref(), Some(&by_app));
assert_eq!(plugin.enabled_by.as_ref(), Some(&by_app));
let (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{minted}");
@@ -509,8 +523,8 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
let revoked = client.post(format!("{base}/{name}/revoke")).send().await?;
assert_eq!(revoked.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/revoke")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let after = client
.get(&asset)
.header("If-Match", &revision)
@@ -1202,15 +1202,15 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("a live Runtime owner must prevent workspace replacement");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert!(!retired_workspace.exists());
}
manager.shutdown_and_wait().await?;
#[cfg(windows)]
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("Engine shutdown must retain the Runtime owner's workspace lock");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
.expect_err("Engine shutdown must retain the Runtime owner's pinned workspace");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert!(!retired_workspace.exists());
assert!(Arc::ptr_eq(
&scopes.admit(selected_workspace.clone()).await?,
@@ -559,6 +559,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
operation_key: None,
model: Some(command.model.clone()),
model_provider: Some(command.model_provider.clone()),
@@ -721,6 +722,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
dynamic_tools: Vec::new(),
environments: Vec::new(),
})
-75
View File
@@ -367,81 +367,6 @@ fn build_state_keeps_resolved_explicit_config_path() {
);
}
#[tokio::test]
async fn stdio_transport_never_registers_the_stdout_hook_sink() {
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
let http_state =
build_state_with_transport(Some(config_path.clone()), None, AppTransport::Http)
.expect("http state");
let stdio_state = build_state_with_transport(Some(config_path), None, AppTransport::Stdio)
.expect("stdio state");
let http_sinks = http_state.runtime.read().await.hooks.sink_count();
let stdio_sinks = stdio_state.runtime.read().await.hooks.sink_count();
assert_eq!(
http_sinks,
stdio_sinks + 1,
"HTTP mode keeps StdoutHookSink + JsonlHookSink; stdio must drop the stdout sink (#5165)"
);
}
// Points CODEWHALE_HOME at a temp dir for one test and restores it after.
// The variable is process-global, so these tests hold a lock.
static CODEWHALE_HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct CodewhaleHomeGuard {
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CodewhaleHomeGuard {
fn set(value: &Path) -> Self {
let lock = CODEWHALE_HOME_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe { std::env::set_var("CODEWHALE_HOME", value) };
Self { prior, _lock: lock }
}
}
impl Drop for CodewhaleHomeGuard {
fn drop(&mut self) {
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe {
match &self.prior {
Some(value) => std::env::set_var("CODEWHALE_HOME", value),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
}
#[tokio::test]
async fn hook_log_without_config_path_goes_beside_state_db_not_cwd() {
let home = tempfile::tempdir().expect("tempdir");
let _home = CodewhaleHomeGuard::set(home.path());
let state = build_state_with_transport(None, None, AppTransport::Stdio).expect("state");
state
.runtime
.read()
.await
.hooks
.emit(codewhale_hooks::HookEvent::ResponseStart {
response_id: "resp-6513".to_string(),
})
.await;
let log = fs::read_to_string(home.path().join("events.jsonl"))
.expect("hook log beside state.db in CODEWHALE_HOME");
assert!(log.contains("resp-6513"), "{log}");
}
async fn response_body_json(response: Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
+6 -44
View File
@@ -18,50 +18,12 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
const KNOWN: &[Allowed] = &[
Allowed {
side: Side::Agent,
frame: "initialize response",
problem: "field `.agentCapabilities.modelSelection` is not in the official type",
reason: "Codewhale extension flag; official AgentCapabilities has no modelSelection",
},
Allowed {
side: Side::Agent,
frame: "session/new response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official NewSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official LoadSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load reuses the session/new body; official LoadSessionResponse has no sessionId",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].createdAt` is not in the official type",
reason: "official SessionInfo carries updatedAt only",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].messageCount` is not in the official type",
reason: "official SessionInfo has no message count",
},
Allowed {
side: Side::Agent,
frame: "response",
problem: "answers no outstanding request (id null)",
reason: "an unknown notification is answered with an id-null -32601 error; JSON-RPC forbids replying to a notification",
},
];
const KNOWN: &[Allowed] = &[Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load keeps the session/new body with its root sessionId through the 0.10.3 tag; official LoadSessionResponse has no sessionId",
}];
fn wire<T: Serialize>(value: T) -> Value {
serde_json::to_value(value).expect("official type serializes")
+546 -2
View File
@@ -3,12 +3,13 @@ mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::json;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
@@ -296,3 +297,546 @@ async fn journey() -> Result<()> {
manager.shutdown_and_wait().await?;
Ok(())
}
const MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:[{name:'echo',description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}]};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-echo:' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
#[test]
fn plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(120), extension_journey())
.await
.map_err(|_| anyhow!("extension journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension journey thread panicked"))?
}
async fn extension_journey() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("plugin_and_mcp_tools_run_as_acp_tool_calls") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{"fixture":{"command":node,"args":[server]}}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 4,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("plugin-call", "ctx_probe", "{}"),
canned::tool_call_turn("mcp-call", "mcp_fixture_echo", r#"{"text":"from-acp"}"#),
canned::simple_text_turn("both extension tools ran"),
]));
manager.set_test_model_client(provider.clone());
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let meta = &initialized["result"]["agentCapabilities"]["_meta"]["codewhale"];
assert_eq!(meta["plugins"], true);
assert_eq!(meta["configuredMcpServers"], true);
assert_eq!(meta["clientMcpServers"], false);
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"use both extension tools"}]}}))
.await?;
let mut asked = Vec::new();
let mut frames = Vec::new();
let done = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
asked.push(
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string(),
);
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(
asked.iter().any(|title| title.starts_with("ctx_probe")),
"the plugin tool asks through the permission dial: {asked:?} {frames:#?}"
);
assert!(
asked
.iter()
.any(|title| title.starts_with("mcp_fixture_echo")),
"the MCP tool asks through the permission dial: {asked:?}"
);
let announced: Vec<_> = updates(&frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/title")?.as_str())
.map(str::to_string)
.collect();
assert!(announced.iter().any(|title| title == "ctx_probe"), "{announced:?}");
assert!(
announced.iter().any(|title| title == "mcp_fixture_echo"),
"{announced:?}"
);
let completed = updates(&frames, "tool_call_update")
.iter()
.filter(|frame| {
frame.pointer("/params/update/status") == Some(&json!("completed"))
})
.count();
assert_eq!(completed, 2, "both extension calls complete: {frames:#?}");
let rendered = serde_json::to_string(&frames)?;
assert!(rendered.contains("mcp-echo:from-acp"), "{rendered}");
let probed = updates(&frames, "tool_call_update")
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/0/content/text"))
.filter_map(Value::as_str)
.filter_map(|text| serde_json::from_str::<Value>(&text[text.find('{')?..]).ok())
.find_map(|result| Some(PathBuf::from(result.get("workspace")?.as_str()?)))
.ok_or_else(|| anyhow!("the plugin result did not reach the editor: {rendered}"))?;
assert_eq!(probed.canonicalize()?, workspace.canonicalize()?);
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"both extension tools ran"
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
let first = serde_json::to_string(
provider
.captured_requests()
.first()
.ok_or_else(|| anyhow!("no model request"))?,
)?;
assert!(
first.contains("ctx_probe") && first.contains("mcp_fixture_echo"),
"the first model request carries both extension tools"
);
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
struct Outcome {
response: Value,
frames: Vec<Value>,
asked: Vec<Value>,
}
impl Outcome {
fn rendered(&self) -> String {
serde_json::to_string(&self.frames).unwrap_or_default()
}
fn completed(&self) -> usize {
updates(&self.frames, "tool_call_update")
.iter()
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
.count()
}
fn titles(&self) -> Vec<String> {
self.asked
.iter()
.map(|frame| {
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
fn announced_ids(&self) -> Vec<String> {
updates(&self.frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
.map(str::to_string)
.collect()
}
}
async fn run_prompt(
client: &mut Client,
session: &str,
allow: &dyn Fn(usize) -> bool,
) -> Result<Outcome> {
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
)
.await?;
let mut frames = Vec::new();
let mut asked = Vec::new();
let response = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
let option = if allow(asked.len()) {
"allow-once"
} else {
"reject-once"
};
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
.await?;
asked.push(frame);
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
Ok(Outcome {
response,
frames,
asked,
})
}
async fn new_session(
client: &mut Client,
manager: &RuntimeThreadManager,
workspace: &Path,
turns: Vec<CannedTurn>,
plan: bool,
) -> Result<String> {
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
.to_string();
if plan {
let (set, _) = client
.request(
"session/set_mode",
json!({"sessionId":session,"modeId":"plan"}),
)
.await?;
assert!(set.get("error").is_none(), "{set}");
}
Ok(session)
}
#[test]
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(180), gates())
.await
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension gates thread panicked"))?
}
async fn gates() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, GATES_MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
"off":{"command":node,"args":[server],"disabled":true}
}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 16,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let manager_for_script = Arc::clone(&manager);
let script =
async {
let manager = manager_for_script;
let result = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n1",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested allowed"),
],
false,
)
.await?;
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
assert!(allowed.titles()[0].starts_with("cc_call"));
let announced = allowed.announced_ids();
for request in &allowed.asked {
let id = request["params"]["toolCall"]["toolCallId"]
.as_str()
.unwrap_or_default();
assert!(
announced.iter().any(|known| known == id),
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
);
}
let nested = &allowed.asked[1]["params"]["toolCall"];
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
assert_eq!(nested["kind"], "edit", "{nested}");
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
assert!(nested.get("status").is_none(), "{nested}");
assert_eq!(
std::fs::read_to_string(workspace.join("nested.txt"))?,
"via-plugin"
);
std::fs::remove_file(workspace.join("nested.txt"))?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n2",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested denied"),
],
false,
)
.await?;
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
assert!(denied.response.get("error").is_none(), "{}", denied.response);
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
assert!(
!workspace.join("nested.txt").exists(),
"a rejected nested write must not run"
);
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("r1", "ctx_probe", "{}"),
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
canned::simple_text_turn("rejections done"),
],
false,
)
.await?;
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
let rendered = rejected.rendered();
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
assert!(!rendered.contains("note.txt"), "{rendered}");
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("p1", "ctx_probe", "{}"),
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
canned::simple_text_turn("plan done"),
],
true,
)
.await?;
let plan = run_prompt(&mut client, &session, &|_| true).await?;
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
assert!(!plan.rendered().contains("mcp-ran:"));
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
canned::tool_call_turn(
"f4",
"start_mcp_server",
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
),
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
canned::simple_text_turn("fabricated done"),
],
false,
)
.await?;
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
assert!(!fabricated.rendered().contains("mcp-ran:"));
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
+97 -2
View File
@@ -139,6 +139,7 @@ impl BootstrapPublicationFixture {
if reused {
process_start.push_str("-different-generation");
}
std::fs::create_dir_all(harness.root.join("runtime")).unwrap();
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
@@ -168,6 +169,7 @@ impl BootstrapPublicationFixture {
match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
self.owner.config_path.clone(),
Some(self.owner.socket_path.clone()),
&self.owner.data_dir,
)
.await
.unwrap()
@@ -250,12 +252,105 @@ async fn owner_bootstrap_unavailable_is_host_only_and_read_only() {
fixture.parent.socket_identity("daemon.sock").unwrap(),
identity
);
assert!(
!fixture.owner.data_dir.exists(),
assert_eq!(
std::fs::read_dir(&fixture.owner.data_dir).unwrap().count(),
0,
"standalone control must not open or mint a store"
);
}
#[tokio::test]
async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds() {
let fixture = BootstrapPublicationFixture::new("boot-gone", true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.harness.root.join("another-store"),
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Unavailable(_)
));
fixture.assert_publication(&fixture.bytes);
let probe = codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.owner.data_dir,
)
.await
.unwrap();
assert!(matches!(
probe,
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
assert!(!fixture.owner.data_dir.exists());
fixture.bind_replacement(None).await.unwrap();
}
async fn assert_gone_store_spelled_as_is_discarded(
label: &str,
spell: impl FnOnce(&Path) -> PathBuf,
) {
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
let spelled = spell(&fixture.harness.root);
assert_ne!(spelled, fixture.owner.data_dir);
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&spelled,
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
root.join("alias").join("runtime")
})
.await;
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
assert!(upper.exists(), "this test needs a case-insensitive volume");
upper.join("RUNTIME")
})
.await;
}
#[tokio::test]
async fn live_owner_with_a_missing_store_is_never_discarded() {
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
fixture.capture().await;
fixture.assert_publication(&fixture.bytes);
}
#[tokio::test]
async fn owner_bootstrap_live_process_and_reused_pid_refuse_retirement() {
for reused in [false, true] {
+1
View File
@@ -38,6 +38,7 @@ codewhale-workflow = { path = "../workflow", version = "0.10.3" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
+1 -1
View File
@@ -379,7 +379,7 @@ fn url_carries_credential(text: &str) -> bool {
// `***`; count masks rather than compare bytes, because it re-encodes the
// query and would otherwise flag benign URLs.
let masks = |value: &str| value.matches("***").count();
if masks(&codewhale_secrets::sanitize::redact_url_for_display(text)) > masks(text) {
if masks(&codewhale_sanitize::sanitize::redact_url_for_display(text)) > masks(text) {
return true;
}
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
+32 -4
View File
@@ -117,7 +117,7 @@ struct Cli {
#[arg(
long,
value_name = "BOOL",
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
help = "Control usage reporting (default on; Codewhale + PostHog; \
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
)]
telemetry: Option<bool>,
@@ -234,10 +234,10 @@ enum Commands {
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect or restage reviewed plugin bundles",
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
alias = "plugin",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
@@ -1957,7 +1957,7 @@ enum ConfigCommand {
Unset {
key: String,
},
/// Review aggregate usage counting by Codewhale and PostHog (default on).
/// Review usage reporting by Codewhale and PostHog (default on).
Telemetry {
/// Optional compatibility form: enable future sessions under this policy version.
#[arg(long, value_name = "VERSION")]
@@ -6792,6 +6792,10 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
set_tui_env(
codewhale_config::TELEMETRY_SOURCE_ENV,
resolved_runtime.telemetry_source.as_str(),
);
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
}
@@ -7226,6 +7230,30 @@ mod tests {
);
}
#[test]
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
let _lock = env_lock();
let _guards = [
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
];
let cli = parse_ok(&["codewhale"]);
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
runtime.telemetry = false;
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
apply_tui_env(&cli, &runtime, &[]);
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
assert_eq!(
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
Ok("config")
);
}
#[test]
fn clap_command_definition_is_consistent() {
Cli::command().debug_assert();
+129 -2
View File
@@ -425,6 +425,74 @@ pub struct RuntimeRequestStats {
pub provider_reported_output_tokens: u64,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct SessionUsageStats {
pub sessions: u64,
pub usage_receipts: u64,
pub usage_receipts_incomplete: u64,
pub provider_reported_input_tokens: u64,
pub provider_reported_output_tokens: u64,
pub sessions_with_total_tokens_only: u64,
pub total_tokens_only: u64,
}
fn record_session_usage(v: &Value, rollup: &mut Rollup) {
let Some(sources) = v
.pointer("/metadata/cost/accounted_usage_sources")
.and_then(Value::as_object)
else {
let total = v
.pointer("/metadata/total_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
if total > 0 {
let stats = &mut rollup.session_usage;
stats.sessions_with_total_tokens_only =
stats.sessions_with_total_tokens_only.saturating_add(1);
stats.total_tokens_only = stats.total_tokens_only.saturating_add(total);
}
return;
};
let stats = &mut rollup.session_usage;
let mut counted = false;
for contribution in sources.values() {
let record = contribution.get("record");
let reported = match record
.and_then(|record| record.pointer("/settlement/coverage"))
.and_then(Value::as_str)
{
Some(coverage) => matches!(coverage, "reported" | "incomplete"),
None => record
.and_then(|record| record.pointer("/usage/usage"))
.is_some_and(|usage| {
usage.get("input_tokens").and_then(Value::as_u64) != Some(0)
|| usage.get("output_tokens").and_then(Value::as_u64) != Some(0)
}),
};
if !reported {
continue;
}
let tokens = |name: &str| {
record
.and_then(|record| record.pointer(&format!("/usage/usage/{name}")))
.and_then(Value::as_u64)
};
let (Some(input), Some(output)) = (tokens("input_tokens"), tokens("output_tokens")) else {
stats.usage_receipts_incomplete = stats.usage_receipts_incomplete.saturating_add(1);
continue;
};
stats.usage_receipts = stats.usage_receipts.saturating_add(1);
stats.provider_reported_input_tokens =
stats.provider_reported_input_tokens.saturating_add(input);
stats.provider_reported_output_tokens =
stats.provider_reported_output_tokens.saturating_add(output);
counted = true;
}
if counted {
stats.sessions = stats.sessions.saturating_add(1);
}
}
/// Top-level rollup.
#[derive(Debug, Default, serde::Serialize)]
pub struct Rollup {
@@ -445,6 +513,7 @@ pub struct Rollup {
pub capacity: CapacityStats,
pub credentials: CredentialStats,
pub runtime_requests: RuntimeRequestStats,
pub session_usage: SessionUsageStats,
/// Total lines read across all sources.
pub total_lines: u64,
/// Lines successfully parsed.
@@ -936,6 +1005,8 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
rollup.touch_ts(&ts);
}
record_session_usage(&v, rollup);
// Walk messages looking for tool_use calls with associated results.
let messages = match v.get("messages").and_then(|m| m.as_array()) {
Some(m) => m,
@@ -1640,15 +1711,44 @@ fn print_human(rollup: &Rollup, since: Option<DateTime<Utc>>) {
);
}
if runtime.provider_usage_receipts == 0 {
println!("Provider usage receipts: (none recorded; this is not zero usage)");
println!(
"Provider usage receipts (Runtime events): (none recorded; this is not zero usage)"
);
} else {
println!(
"Provider usage receipts: {} records, {} input tokens, {} output tokens",
"Provider usage receipts (Runtime events): {} records, {} input tokens, {} output tokens",
fmt_num(runtime.provider_usage_receipts),
fmt_num(runtime.provider_reported_input_tokens),
fmt_num(runtime.provider_reported_output_tokens),
);
}
let session_usage = &rollup.session_usage;
if session_usage.usage_receipts == 0 {
println!(
"Provider usage receipts (saved terminal sessions): (none recorded; this is not zero usage)"
);
} else {
println!(
"Provider usage receipts (saved terminal sessions): {} records in {} sessions, {} input tokens, {} output tokens",
fmt_num(session_usage.usage_receipts),
fmt_num(session_usage.sessions),
fmt_num(session_usage.provider_reported_input_tokens),
fmt_num(session_usage.provider_reported_output_tokens),
);
}
if session_usage.sessions_with_total_tokens_only > 0 {
println!(
"Saved sessions with a combined token total only: {} sessions, {} tokens (no per-call usage receipts saved)",
fmt_num(session_usage.sessions_with_total_tokens_only),
fmt_num(session_usage.total_tokens_only),
);
}
if session_usage.usage_receipts_incomplete > 0 {
println!(
"Saved-session usage coverage: {} usage receipts incomplete, not counted as zero",
fmt_num(session_usage.usage_receipts_incomplete),
);
}
if runtime.diagnostics_unavailable_turn_receipts > 0
|| runtime.diagnostics_incomplete_turn_receipts > 0
|| runtime.terminal_receipts_without_identity > 0
@@ -2241,6 +2341,33 @@ mod tests {
assert_eq!(runtime.model_requests_started, 2, "status is not a request");
}
#[test]
fn saved_terminal_session_usage_is_counted_apart_from_runtime_events() {
let session = serde_json::json!({
"metadata": { "cost": { "accounted_usage_sources": {
"a": { "record": { "settlement": { "coverage": "reported" }, "usage": { "usage": { "input_tokens": 40, "output_tokens": 6 } } } },
"b": { "record": { "settlement": { "coverage": "missing" }, "usage": { "usage": { "input_tokens": 0, "output_tokens": 0 } } } },
"c": { "record": { "settlement": { "coverage": "incomplete" }, "usage": { "usage": { "input_tokens": 3 } } } }
} } }
});
let mut rollup = Rollup::default();
record_session_usage(&session, &mut rollup);
let usage = &rollup.session_usage;
assert_eq!(usage.usage_receipts, 1);
assert_eq!(usage.usage_receipts_incomplete, 1);
assert_eq!(usage.sessions, 1);
assert_eq!(usage.provider_reported_input_tokens, 40);
assert_eq!(usage.provider_reported_output_tokens, 6);
record_session_usage(&serde_json::json!({ "metadata": {} }), &mut rollup);
assert_eq!(rollup.session_usage.sessions, 1);
record_session_usage(
&serde_json::json!({ "metadata": { "total_tokens": 900 } }),
&mut rollup,
);
assert_eq!(rollup.session_usage.sessions_with_total_tokens_only, 1);
assert_eq!(rollup.session_usage.total_tokens_only, 900);
}
#[test]
fn runtime_usage_receipts_deduplicate_by_runtime_event_identity() {
let usage = runtime_event(
@@ -39,6 +39,8 @@ pub struct Receipt {
/// own record; empty when no turn recorded one.
pub postures: Vec<&'static str>,
pub totals: ReceiptTotals,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ReceiptUsage>,
pub actions: Vec<ReceiptAction>,
/// Actions left off the list because it hit [`MAX_RECEIPT_ACTIONS`].
pub omitted_actions: usize,
@@ -47,6 +49,50 @@ pub struct Receipt {
pub claim_ceiling: [&'static str; 3],
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<ReceiptTokens>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<ReceiptCost>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ReceiptTokens {
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
pub total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calls: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptCost {
pub state: ReceiptCostState,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
pub currency: &'static str,
pub priced_turns: u32,
pub unpriced_turns: u32,
pub report: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptCostState {
Priced,
Partial,
Unknown,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
@@ -263,6 +263,7 @@ fn empty_receipt() -> Receipt {
turn: None,
postures: vec![],
totals: ReceiptTotals::default(),
usage: None,
actions: vec![],
omitted_actions: 0,
not_recorded: vec![],
+17 -12
View File
@@ -217,7 +217,21 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
};
let projection = diagnostics.cost_projection();
let details = arg.is_some_and(|arg| arg.trim().eq_ignore_ascii_case("details"));
let plan_only = projection.total == 0.0
if cost_is_subscription_only(&projection) && !details {
return CommandResult::message(format!(
"Session Cost:\n ─────────────────────────────\n {SUBSCRIPTION_ONLY_COST_NOTE}\n\n Run /cost details for the route receipts."
));
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
}
pub const SUBSCRIPTION_ONLY_COST_NOTE: &str = "These routes use subscription billing. Codewhale does not know the amount charged by your provider.";
pub fn cost_is_subscription_only(projection: &DebugCostProjection) -> bool {
projection.total == 0.0
&& projection.priced_turns == 0
&& projection.unpriced_turns == 0
&& !projection.legacy_coverage_unknown
@@ -227,19 +241,10 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
receipt
.split_whitespace()
.any(|field| field == "billing_mode=subscription")
});
if plan_only && !details {
return CommandResult::message(
"Session Cost:\n ─────────────────────────────\n These routes use subscription billing. Codewhale does not know the amount charged by your provider.\n\n Run /cost details for the route receipts.".to_string(),
);
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
})
}
fn cost_report(
pub fn cost_report(
cost: &DebugCostProjection,
presentation: &dyn CommandPresentationContext,
) -> Result<String, String> {
@@ -414,6 +414,53 @@ pub fn action_line(action: &ReceiptAction) -> String {
one_line(&line)
}
fn tokens_line(tokens: &ReceiptTokens) -> String {
let mut line = match (tokens.input, tokens.output) {
(Some(input), Some(output)) => {
format!("Tokens: {input} in · {output} out · {} total", tokens.total)
}
_ => format!("Tokens: {} total", tokens.total),
};
if let Some(calls) = tokens.calls {
line.push_str(&format!(
" · {}",
plural(calls, "provider call", "provider calls")
));
}
let detail: Vec<String> = [
("cache read", tokens.cache_read),
("cache write", tokens.cache_write),
("reasoning", tokens.reasoning),
]
.into_iter()
.filter_map(|(label, value)| value.map(|value| format!("{value} {label}")))
.collect();
if !detail.is_empty() {
line.push_str(&format!(" ({})", detail.join(", ")));
}
line
}
fn usage_block(usage: &ReceiptUsage) -> String {
let mut out = String::new();
if let Some(tokens) = &usage.tokens {
out.push_str(&one_line(&tokens_line(tokens)));
out.push('\n');
}
if let Some(cost) = &usage.cost {
out.push_str("Cost, as /cost reports it:\n");
for line in cost
.report
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
out.push_str(&format!(" {}\n", one_line(line)));
}
}
out
}
/// The readable receipt: header, totals, one line per action, then what the
/// record does not hold. Valid Markdown and plain enough for a terminal.
#[must_use]
@@ -457,6 +504,10 @@ pub fn render_markdown(receipt: &Receipt) -> String {
out.push_str("\n\n");
out.push_str(&one_line(&totals_line(receipt)));
out.push_str("\n\n");
if let Some(usage) = &receipt.usage {
out.push_str(&usage_block(usage));
out.push('\n');
}
let width = receipt.actions.len().to_string().len();
for action in &receipt.actions {
out.push_str(&format!(
+1
View File
@@ -25,6 +25,7 @@ semver.workspace = true
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
fd-lock = "4.0.4"
libc = "0.2"
+2 -2
View File
@@ -300,7 +300,7 @@ pub(crate) fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
pub(crate) fn backup_value_carries_secret(value: &toml_edit::Value) -> bool {
value
.as_str()
.is_some_and(codewhale_secrets::sanitize::contains_secret)
.is_some_and(codewhale_sanitize::sanitize::contains_secret)
}
pub(crate) fn scrub_backup_decor(decor: &mut toml_edit::Decor) {
@@ -331,7 +331,7 @@ pub(crate) fn scrub_backup_comments(text: &str) -> String {
comment
.split_once('=')
.is_some_and(|(key, _)| is_sensitive_config_key(key))
|| codewhale_secrets::sanitize::contains_secret(comment)
|| codewhale_sanitize::sanitize::contains_secret(comment)
})
})
.collect()
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn toml_error_location(source: Option<&str>, err: &toml::de::Error) -> Strin
.and_then(|rest| rest.strip_prefix("\nin `"))
.and_then(|rest| rest.strip_suffix("`\n"))
.filter(|keys| !keys.is_empty())
.map(|keys| format!("in `{}`", codewhale_secrets::redact::redact_secrets(keys)));
.map(|keys| format!("in `{}`", codewhale_sanitize::redact::redact_secrets(keys)));
match (position, key_path) {
(Some(position), Some(keys)) => format!("{position}, {keys}"),
(Some(position), None) => position,
+1 -1
View File
@@ -128,7 +128,7 @@ pub(crate) fn redact_secret(secret: &str) -> String {
pub fn is_sensitive_config_key(key: &str) -> bool {
key.rsplit('.')
.next()
.is_some_and(codewhale_secrets::sanitize::is_sensitive_key_name)
.is_some_and(codewhale_sanitize::sanitize::is_sensitive_key_name)
}
/// Resolve dotted paths without treating a dotted key as a top-level literal.
+87 -325
View File
@@ -140,48 +140,17 @@ impl EnvRuntimeOverrides {
Self {
provider,
provider_source,
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
novita_model: env_non_blank(&["NOVITA_MODEL"]),
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
@@ -216,9 +185,7 @@ impl EnvRuntimeOverrides {
None
}
}),
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
@@ -227,290 +194,85 @@ impl EnvRuntimeOverrides {
}
})
.filter(|headers| !headers.is_empty()),
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_model: std::env::var("MODELSCOPE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_model: std::env::var("ZENMUX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_base_url: std::env::var("CSDN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_model: std::env::var("CSDN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_model: std::env::var("CONCENTRATE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
together_model: env_non_blank(&["TOGETHER_MODEL"]),
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
xai_model: env_non_blank(&["XAI_MODEL"]),
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
csdn_model: env_non_blank(&["CSDN_MODEL"]),
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
// an origin this route would refuse to send a bearer to is
// dropped here rather than resolved into a route.
codewhale_base_url: provider::codewhale_api_base_from_env(),
codewhale_model: std::env::var("CODEWHALE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
}
}
-7
View File
@@ -5,13 +5,6 @@ use std::path::PathBuf;
/// On-disk schema for the `[hook_sinks]` table.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookSinksToml {
/// Unix domain socket path used by the app-server event sink.
///
/// Known limitation: only `codewhale app-server` reads this table; the
/// TUI and the Runtime API do not dispatch hook events to it.
///
/// When unset, no Unix socket sink is registered. There is deliberately no
/// shared `/tmp` default because socket ownership should be explicit.
#[serde(default)]
pub unix_socket_path: Option<PathBuf>,
}
+2 -2
View File
@@ -314,10 +314,10 @@ fn rollback(snapshots: &[Snapshot]) {
}
// FEAT-025 D4: the pure secret-redaction primitives moved to
// `codewhale-secrets::redact` so portable command helpers can share one
// `codewhale-sanitize::redact` so portable command helpers can share one
// implementation without depending on this crate. Re-exported here to keep
// the existing `codewhale_config::persistence::*` public API stable.
pub use codewhale_secrets::redact::{
pub use codewhale_sanitize::redact::{
REDACTED, RedactionPolicy, redact_json_model_bound_secrets, redact_json_secrets,
redact_model_bound_secrets, redact_secrets, redact_secrets_with,
};
+2 -2
View File
@@ -59,7 +59,7 @@ pub fn check_config_toml_choice(key: &str, value: &str) -> Result<()> {
} else {
""
};
let value = codewhale_secrets::redact::redact_secrets(value);
let value = codewhale_sanitize::redact::redact_secrets(value);
bail!(
"invalid value '{value}' for '{key}': config.toml accepts {}.{settings_note} \
No value was changed.\nfix: codewhale config set {key} {}",
@@ -205,7 +205,7 @@ pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
// echoed; an ordinary typo stays readable.
Some(name) => format!(
"unknown provider '{}'",
codewhale_secrets::redact::redact_secrets(&name)
codewhale_sanitize::redact::redact_secrets(&name)
),
None => "unknown provider".to_string(),
},
+1 -1
View File
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"telemetry",
SettingKind::Bool(TELEMETRY),
"false",
"true",
ui(
TAB_TRUST,
"network",
+6 -7
View File
@@ -27,10 +27,11 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
crate::env_overrides::env_non_blank(&[
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
])
.as_deref(),
),
}
}
@@ -50,9 +51,7 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
+28
View File
@@ -33,6 +33,8 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
/// Whether an environment-level kill switch forces telemetry off here.
///
/// A floor is *not* the same as "telemetry resolved to false": off is the
@@ -92,6 +94,32 @@ impl TelemetrySource {
Self::Default => "default",
}
}
#[must_use]
pub fn from_label(label: &str) -> Option<Self> {
match label {
"cli" => Some(Self::Cli),
"env" => Some(Self::Env),
"config" => Some(Self::Config),
"default" => Some(Self::Default),
_ => None,
}
}
}
#[must_use]
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
}
#[must_use]
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
let (on, source) = resolved_telemetry_consent(file_telemetry);
match (source, dispatched_telemetry_source()) {
(TelemetrySource::Env, Some(declared)) => (on, declared),
_ => (on, source),
}
}
/// Read the telemetry environment override, reporting an unreadable value
+120
View File
@@ -8763,6 +8763,7 @@ struct TelemetryEnvGuard {
codewhale: Option<OsString>,
deepseek: Option<OsString>,
floor: Option<OsString>,
source: Option<OsString>,
codewhale_endpoint: Option<OsString>,
deepseek_endpoint: Option<OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
@@ -8775,6 +8776,7 @@ impl TelemetryEnvGuard {
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
floor: env::var_os(TELEMETRY_FLOOR_ENV),
source: env::var_os(TELEMETRY_SOURCE_ENV),
// The endpoint variables are cleared too. Resolution now has a
// shipped default, so an ambient endpoint in the developer's or
// CI's environment is the difference between pinning the default
@@ -8788,6 +8790,7 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
}
@@ -8808,6 +8811,13 @@ impl TelemetryEnvGuard {
}
}
fn set_source(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::set_var(TELEMETRY_SOURCE_ENV, value);
}
}
fn set_floor(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
@@ -8821,6 +8831,7 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
}
}
}
@@ -8841,6 +8852,10 @@ impl Drop for TelemetryEnvGuard {
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
None => env::remove_var(TELEMETRY_FLOOR_ENV),
}
match self.source.take() {
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
None => env::remove_var(TELEMETRY_SOURCE_ENV),
}
match self.codewhale_endpoint.take() {
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
@@ -8995,6 +9010,57 @@ fn telemetry_consent_names_its_source() {
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
let guard = TelemetryEnvGuard::take();
guard.set("false");
guard.set_floor("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Config);
guard.set("false");
guard.set_floor("1");
guard.set_source("env");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Env);
guard.set("true");
guard.set_floor("0");
guard.set_source("default");
let (on, source) = dispatched_telemetry_consent(None);
assert!(on);
assert_eq!(source, TelemetrySource::Default);
guard.set("false");
guard.set_floor("1");
guard.set_source("cli");
let (on, source) = dispatched_telemetry_consent(None);
assert!(!on);
assert_eq!(source, TelemetrySource::Cli);
guard.clear();
guard.set("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(
source,
TelemetrySource::Env,
"a source with no dispatcher floor statement is not believed"
);
guard.clear();
guard.set("0");
guard.set_floor("1");
guard.set_source("nonsense");
let (_, source) = dispatched_telemetry_consent(None);
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn resolved_runtime_options_mints_a_route_candidate() {
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
@@ -10701,6 +10767,60 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
));
}
#[test]
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let names = [
"CODEWHALE_FORCE_HTTP1",
"DEEPSEEK_FORCE_HTTP1",
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
];
let saved: Vec<(&str, Option<OsString>)> = names
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// SAFETY: env mutation is serialized by `env_lock` and restored below.
unsafe {
env::set_var("CODEWHALE_MODEL", "");
env::set_var("DEEPSEEK_MODEL", "legacy-model");
env::set_var("CODEWHALE_BASE_URL", " ");
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
env::set_var("MOONSHOT_BASE_URL", "");
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
env::set_var("CODEWHALE_FORCE_HTTP1", "");
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
}
let env_overrides = EnvRuntimeOverrides::load();
let force_http1 = crate::stream_timeouts::force_http1_from_env();
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
unsafe {
for (name, value) in saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
assert_eq!(
env_overrides.active_route_base_url.as_deref(),
Some("https://legacy.example/v1")
);
assert_eq!(
env_overrides.moonshot_base_url.as_deref(),
Some("https://kimi.example/v1")
);
assert!(force_http1);
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
}
#[cfg(unix)]
#[test]
fn failed_legacy_state_copy_leaves_no_partial_primary() {
-2
View File
@@ -14,7 +14,6 @@ workspace = true
serde.workspace = true
thiserror.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
regex = "1.11"
@@ -24,4 +23,3 @@ serde_json = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
tempfile.workspace = true
uuid.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "time"] }
-9
View File
@@ -1,9 +0,0 @@
//! `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
//!
//! Re-exports the protocol ids so every crate that depends on `core` (the
//! TUI, CLI, app-server) speaks the same typed ids without depending on
//! `protocol` directly. The persisted JSON shape stays a plain string
//! (`"thread-…"` / `"session-…"`) so existing `state.json` / `threads/`
//! files need no migration.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
-78
View File
@@ -1,78 +0,0 @@
//! Session tree journal placeholder (issue #5262).
//!
//! The journal is append-only with an in-memory tree projection:
//! every non-header entry carries `id` + `parentId`, the active position is
//! a `leafId`, appending creates a child of the leaf, and branching only
//! moves the leaf — it never rewrites history. This file lands the entry
//! shape that #5262's tree operations hang off of; compaction and
//! branch-summary entry kinds are included as first-class kinds but their
//! *strategies* are deferred.
//!
//! Re-exports the protocol journal as the canonical shape so `protocol` and
//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that
//! owns the `current_leaf_id` column in `state.threads`.
pub use codewhale_protocol::journal::{Journal, JournalEntry};
use serde::{Deserialize, Serialize};
/// Persisted thread metadata extension for the tree. This is the
/// `current_leaf_id` column added to `state.threads`; `None` before the
/// first turn, `Some(id)` after. The existing `threads` JSON shape is
/// otherwise unchanged (back-compat: old rows read as `None` and the next
/// append mints the header leaf).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadLeafState {
pub thread_id: String,
pub leaf_id: Option<String>,
}
/// First-class journal entry kinds (data shape lands now; strategies later).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalKind {
Header,
User,
Assistant,
ToolResult,
Compaction,
BranchSummary,
}
impl JournalKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResult => "tool_result",
Self::Compaction => "compaction",
Self::BranchSummary => "branch_summary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leaf_state_roundtrip() {
let s = ThreadLeafState {
thread_id: "thread-1".into(),
leaf_id: Some("entry-abc".into()),
};
let j = serde_json::to_string(&s).unwrap();
let back: ThreadLeafState = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn journal_append_is_child_of_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("hi"));
assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str()));
}
}
+5 -18
View File
@@ -1,12 +1,9 @@
pub mod context_reference;
pub mod fragments;
pub mod ids;
pub mod journal;
pub mod prefix_cache;
pub mod request;
pub mod role;
pub mod secret_eq;
pub mod session;
pub mod tool_parser;
pub use context_reference::{
@@ -15,10 +12,9 @@ pub use context_reference::{
};
use codewhale_config::{ConfigToml, ProviderKind};
use codewhale_hooks::HookDispatcher;
use codewhale_state::StateStore;
/// Compatibility configuration and hooks.
/// Compatibility configuration.
///
/// Conversation execution and durable history belong to the held canonical
/// RuntimeThreadManager/Engine. Legacy State data is retained for read-only
@@ -26,16 +22,11 @@ use codewhale_state::StateStore;
pub struct Runtime {
pub config: ConfigToml,
state: StateStore,
pub hooks: HookDispatcher,
}
impl Runtime {
pub fn new(config: ConfigToml, state: StateStore, hooks: HookDispatcher) -> Self {
Self {
config,
state,
hooks,
}
pub fn new(config: ConfigToml, state: StateStore) -> Self {
Self { config, state }
}
/// Existing compatibility data and receipts; no transcript controller.
@@ -103,7 +94,7 @@ mod tests {
fn runtime_bookkeeping_keeps_the_legacy_store_read_only() {
let store = temp_core_state("read-only-runtime");
let before = store.list_threads(Default::default()).unwrap();
let mut runtime = Runtime::new(ConfigToml::default(), store, HookDispatcher::default());
let mut runtime = Runtime::new(ConfigToml::default(), store);
runtime.update_config(ConfigToml::default());
assert_eq!(
runtime
@@ -113,11 +104,7 @@ mod tests {
.len(),
before.len()
);
let reopened = Runtime::new(
runtime.config.clone(),
runtime.state_store().clone(),
HookDispatcher::default(),
);
let reopened = Runtime::new(runtime.config.clone(), runtime.state_store().clone());
assert!(
reopened
.state_store()
-135
View File
@@ -1,135 +0,0 @@
//! `Thread` / `Session` split (issue #5261).
//!
//! `codewhale`'s `Session` was really a thread. The new split is:
//! - `Thread` — durable, persisted, owns the append-only `Journal` and the
//! `leafId` cursor. One row in `state.threads`, one directory on disk.
//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the
//! in-memory `TurnContext` plus the live approval/sandbox posture for this
//! `SessionId`. Many sessions can attach to one thread over time, but only
//! one `Session` drives a turn for a given `ThreadId` at a time.
//!
//! The canonical RuntimeThreadManager creates sessions without a TUI and drives
//! the same Engine turn loop used by mounted clients. These types preserve the
//! shared model, append log, prefix stability and message revision state; the
//! compatibility Core Runtime owns only configuration, hooks and job bookkeeping.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, ThreadId};
use crate::journal::Journal;
/// Durable thread (the former `Session`). One per conversation, persisted in
/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
/// journal cursor — plus the typed `ThreadId`. All other fields keep their
/// persisted JSON shape unchanged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub thread_id: ThreadId,
/// Active branch tip. `None` before the first journal header.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
/// Journal (append-only). In-memory projection of the persisted
/// `threads/turns/items/events` layout is derived root→leaf.
#[serde(default)]
pub journal: Journal,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
pub workspace: PathBuf,
#[serde(default)]
pub ephemeral: bool,
}
impl Thread {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
thread_id,
leaf_id: None,
journal: Journal::new(),
model: model.into(),
reasoning_effort: None,
workspace,
ephemeral: false,
}
}
#[must_use]
pub fn leaf_id(&self) -> Option<&str> {
self.leaf_id.as_deref()
}
pub fn set_leaf(&mut self, leaf: Option<String>) {
self.leaf_id = leaf;
}
}
/// Ephemeral session within a thread (one engine lifetime / one turn's
/// live posture). The TUI's `EngineHandle` and the headless `exec` both
/// hold a `Session` that points at the same `ThreadId` but with different
/// `SessionId`s.
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: SessionId,
pub thread_id: ThreadId,
/// Model for this session's next turn (may differ from thread default).
pub model: String,
pub workspace: PathBuf,
/// Monotonic `messages_revision` for prefix-cache memoization (carried
/// from the former `Session::messages_revision`).
pub messages_revision: u64,
}
impl Session {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
session_id: SessionId::new(),
thread_id,
model: model.into(),
workspace,
messages_revision: 0,
}
}
pub fn bump_revision(&mut self) {
self.messages_revision = self.messages_revision.wrapping_add(1);
}
}
/// Split helper: derive a `Session` from an existing `Thread` without
/// cloning the journal. Headless and TUI call the same constructor so
/// the request shape stays identical.
#[must_use]
pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_and_session_ids_are_distinct_scopes() {
let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
assert_eq!(s1.thread_id, s2.thread_id);
assert_ne!(s1.session_id, s2.session_id);
}
#[test]
fn leaf_is_moved_not_rewritten() {
let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
let a = t.journal.append("header", serde_json::json!({}));
let b = t.journal.append("user", serde_json::json!("b"));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
assert!(t.journal.branch_to(&a));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf
}
}
+5 -3
View File
@@ -1183,7 +1183,7 @@ impl Git {
overrides: Vec<(String, &'static str)>,
) -> anyhow::Result<Command> {
let mut command = Self::review_base(workspace)?;
let mut count = 2;
let mut count = 3;
for (key, value) in overrides {
// A subsection may contain '='; `-c key=value` would then
// override a different key. Separate env fields preserve it.
@@ -1195,7 +1195,7 @@ impl Git {
Ok(command)
}
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
use anyhow::Context;
@@ -1257,7 +1257,7 @@ impl Git {
// Both phases must observe the same effective repository config.
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
.env("GIT_CONFIG_VALUE_0", "false")
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
@@ -1265,6 +1265,8 @@ impl Git {
"GIT_CONFIG_VALUE_1",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_CONFIG_KEY_2", "core.quotePath")
.env("GIT_CONFIG_VALUE_2", "false")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1");
Ok(command)
-9
View File
@@ -79,7 +79,6 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-memory = { path = "../memory", version = "0.10.3" }
anyhow.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-command-contract = { path = "../command-contract", version = "0.10.3" }
@@ -203,14 +202,9 @@ codewhale-util = { path = "../util", version = "0.10.3", features = ["test-suppo
codewhale-models = { path = "../models", version = "0.10.3", features = [
"test-support",
] }
cucumber = "0.23.0"
wiremock = "0.6"
tiny_http = "0.12"
pretty_assertions = "1.4"
rio-vt = "0.5.1"
rusqlite.workspace = true
schemars = { version = "1.2.1", features = ["derive", "preserve_order"] }
tokio-tungstenite = "0.30"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -219,9 +213,6 @@ libc = "0.2"
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
arboard = { version = "3.4", features = ["wayland-data-control"] }
[target.'cfg(not(target_env = "ohos"))'.dependencies]
portable-pty = "0.9"
+78 -31
View File
@@ -727,7 +727,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -745,7 +745,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1167,7 +1167,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1176,7 +1176,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1205,8 +1205,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -1951,9 +1951,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -2650,14 +2650,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -2714,10 +2714,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -2761,9 +2761,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -7370,9 +7370,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -8421,8 +8421,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -12795,7 +12795,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -14483,6 +14484,51 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -14490,7 +14536,8 @@ var init_adapters = __esm({
"use strict";
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -14719,16 +14766,16 @@ function entryListProblem(rows, at2 = "") {
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
}
for (const [index, row] of rows.entries()) {
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
if (typeof row !== "object" || row === null || Array.isArray(row)) {
return `${label} is not a plugin row (expected a map with a "name")`;
return `${label2} is not a plugin row (expected a map with a "name")`;
}
const { name, group, config } = row;
if (typeof name !== "string" || name === "") {
return `${label} names no plugin (a "name" string is required)`;
return `${label2} names no plugin (a "name" string is required)`;
}
if (group === true) {
const nested = entryListProblem(config, label);
const nested = entryListProblem(config, label2);
if (nested !== void 0) return nested;
}
}
@@ -14751,8 +14798,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
continue;
}
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label, name: row.name });
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label: label2, name: row.name });
}
return found;
}
@@ -921,7 +921,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -939,7 +939,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1361,7 +1361,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1370,7 +1370,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1399,8 +1399,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -2145,9 +2145,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -4543,14 +4543,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -4607,10 +4607,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -4655,9 +4655,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -9683,9 +9683,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -10734,8 +10734,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -15147,7 +15147,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -16662,6 +16663,52 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host2) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host2.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host2.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host2.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host2.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -16670,7 +16717,8 @@ var init_adapters = __esm({
init_define_BUILTIN_MODULE_DIGESTS();
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -17449,10 +17497,10 @@ function describeError2(error) {
if (error instanceof Error) return `${error.name}: ${error.message}`;
return String(error);
}
function withDeadline(promise, ms, label) {
function withDeadline(promise, ms, label2) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms} ms`)), ms);
timer = setTimeout(() => reject(new Error(`${label2} exceeded ${ms} ms`)), ms);
timer.unref();
});
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
+78 -31
View File
@@ -755,7 +755,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -773,7 +773,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1195,7 +1195,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1204,7 +1204,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1233,8 +1233,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -1979,9 +1979,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -2621,14 +2621,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -2685,10 +2685,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -2732,9 +2732,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -7348,9 +7348,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -8399,8 +8399,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -12773,7 +12773,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -14159,6 +14160,51 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -14166,7 +14212,8 @@ var init_adapters = __esm({
"use strict";
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -14395,16 +14442,16 @@ function entryListProblem(rows, at2 = "") {
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
}
for (const [index, row] of rows.entries()) {
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
if (typeof row !== "object" || row === null || Array.isArray(row)) {
return `${label} is not a plugin row (expected a map with a "name")`;
return `${label2} is not a plugin row (expected a map with a "name")`;
}
const { name, group, config } = row;
if (typeof name !== "string" || name === "") {
return `${label} names no plugin (a "name" string is required)`;
return `${label2} names no plugin (a "name" string is required)`;
}
if (group === true) {
const nested = entryListProblem(config, label);
const nested = entryListProblem(config, label2);
if (nested !== void 0) return nested;
}
}
@@ -14427,8 +14474,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
continue;
}
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label, name: row.name });
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label: label2, name: row.name });
}
return found;
}
+1 -1
View File
@@ -18,7 +18,7 @@ var ErrorCode = {
Denied: -32003,
Cancelled: -32800
};
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote"];
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote", "plugins"];
var METHODS = [
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams", tiers: ["plugin", "builtin"] },
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams", tiers: ["plugin", "builtin"] },
@@ -0,0 +1,432 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const root = join(here, '..')
const SOURCES = {
compatibility: join(root, 'src/dsh/compatibility.json'),
hostRoot: join(root, 'src/root.ts'),
shellHooks: join(root, 'src/dsh/shell-hooks.ts'),
seatTable: join(root, '../../app-server/src/runtime_api/plugins.rs'),
catalog: join(root, 'src/dsh/inspect-catalog.json'),
}
const read = (path) => readFileSync(path, 'utf8').replace(/\r\n/g, '\n')
const SERVICES = {
tools: {
summary: 'The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.',
availability: 'always',
methods: [
{ name: 'register', signature: 'register(definition: ToolDefinition): () => void', summary: 'Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer.' },
{ name: 'get', signature: 'get(name: string, agent: Agent): { name: string } | undefined', summary: 'Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call\'s own agent.' },
],
},
commands: {
summary: 'Slash commands a person types. A handler runs only when the person types the command.',
availability: 'always',
methods: [
{ name: 'register', signature: 'register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void', summary: 'Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer.' },
],
},
storage: {
summary: 'Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.',
availability: 'always',
methods: [
{ name: 'get', signature: 'get(key: string): Promise<Json | undefined>', summary: 'Read one stored value.' },
{ name: 'set', signature: 'set(key: string, value: Json): Promise<void>', summary: 'Store one JSON value, within the bounded key, value and owner limits.' },
{ name: 'delete', signature: 'delete(key: string): Promise<void>', summary: 'Remove one stored value.' },
],
},
skills: {
summary: 'Skill roots: reviewed folders of skills added to the Engine\'s skill catalog.',
availability: 'always',
methods: [
{ name: 'registerRoot', signature: 'registerRoot(definition: { path: string }): () => void', summary: 'Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused.' },
],
},
sessions: {
summary: 'The session of the calling tool invocation.',
availability: 'tool-invocation',
methods: [
{ name: 'flush', signature: 'flush(session: Session): Promise<boolean>', summary: 'Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available.' },
],
},
userQuestions: {
summary: 'Questions put to the person through the Engine\'s own question card.',
availability: 'tool-invocation',
methods: [
{ name: 'ask', signature: 'ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>', summary: 'Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported.' },
],
},
subagents: {
summary: 'One independent child agent a root tool call may start.',
availability: 'tool-invocation',
methods: [
{ name: 'getProvider', signature: 'getProvider(id: \'spawn\'): Provider', summary: 'Describe the one provider: a fresh child with no inherited conversation.' },
{ name: 'startActivation', signature: 'startActivation(options: { provider: \'spawn\', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: \'caller\' | \'parent\' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>', summary: 'Start the child through the Engine\'s approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child.' },
{ name: 'result', signature: 'activation.result: Promise<{ stopReason: string, output: { type: \'text\', text: string }[], diagnostic?: string }>', summary: 'A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks.' },
{ name: 'dispose', signature: 'activation.dispose(): Promise<void>', summary: 'Stop that exact child and its own descendants.' },
],
},
agents: {
summary: 'Reserved. The host offers no methods on agents.',
availability: 'none',
methods: [],
},
systemPrompt: {
summary: 'The child agent\'s persona slot, reachable only inside an agent/created listener.',
availability: 'child-agent-hook',
methods: [
{ name: 'section', signature: 'agent.ctx.systemPrompt.section({ name: \'deployment:persona-prefix\', order: agent.ctx.systemPrompt.getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\'), text: string }): () => void', summary: 'Set the child\'s persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder(\'DEPLOYMENT_PERSONA_PREFIX\') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine\'s own system prompt cannot be replaced.' },
],
},
logger: {
summary: 'Cordis logging. Every line becomes a host log notification attributed to the plugin.',
availability: 'always',
cordis: true,
methods: [
{ name: 'logger', signature: 'ctx.logger(name?: string): Logger', summary: 'A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info.' },
],
},
events: {
summary: 'Cordis event dispatch. Query the host Event provider for the events this host fires.',
availability: 'always',
cordis: true,
methods: [
{ name: 'on', signature: 'ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider).' },
{ name: 'once', signature: 'ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean', summary: 'Listen to an event once.' },
{ name: 'emit', signature: 'ctx.emit(name: string, ...args: any[]): void', summary: 'Dispatch an event to its listeners.' },
],
},
reflect: {
summary: 'Cordis service reflection. A plugin may provide its own services but not the Engine\'s.',
availability: 'always',
cordis: true,
methods: [
{ name: 'provide', signature: 'ctx.reflect.provide(name: string, value?: any): () => void', summary: 'Provide a service under a name. Names in refusedServices are refused: the Engine owns them.' },
{ name: 'get', signature: 'ctx.reflect.get(name: string, strict?: boolean): any', summary: 'Read a provided service.' },
],
},
registry: {
summary: 'Cordis plugin registry.',
availability: 'always',
cordis: true,
methods: [
{ name: 'get', signature: 'ctx.registry.get(plugin: Plugin): Runtime | undefined', summary: 'Look up the runtime record of a plugin.' },
{ name: 'resolve', signature: 'ctx.registry.resolve(plugin: Plugin): Function | undefined', summary: 'Resolve a plugin shape to its callback.' },
],
},
prompt: {
summary: 'Attributed sections of the Engine\'s runtime prompt. A plugin cannot replace the system prompt.',
availability: 'always',
methods: [
{ name: 'registerSection', signature: 'registerSection(definition: { id: string, text: string, interpolate?: \'model-cwd\' }): () => void', summary: 'Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded.' },
],
},
avatars: {
summary: 'Avatar packs for the terminal.',
availability: 'always',
methods: [
{ name: 'registerPack', signature: 'registerPack(definition: { path: string }): () => void', summary: 'Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin.' },
],
},
mcp: {
summary: 'Proposals of MCP servers. The Engine owns connection, credentials and tool calls.',
availability: 'native-review',
methods: [
{ name: 'registerServer', signature: 'registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void', summary: 'Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition.' },
],
},
shellHooks: {
summary: 'Claude Code and Codex style command hooks, run by the Engine.',
availability: 'native-review',
methods: [
{ name: 'register', signature: 'register(definition: { dialect: \'claude-code\' | \'codex\', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void', summary: 'Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported.' },
],
},
remote: {
summary: 'Remote pairing, served by the Engine.',
availability: 'review-grant',
methods: [
{ name: 'status', signature: 'status(): Promise<Status>', summary: 'Whether remote access is on.' },
{ name: 'pair', signature: 'pair(options?: { label?: string }): Promise<Pairing>', summary: 'Start a pairing.' },
{ name: 'clients', signature: 'clients(): Promise<Client[]>', summary: 'List paired clients.' },
{ name: 'revoke', signature: 'revoke(id: string): Promise<void>', summary: 'Revoke one grant by the id pair returned.' },
],
},
}
const AVAILABILITY = {
always: 'Available to every plugin that injects it.',
'tool-invocation': 'Answers only inside a live tool call of the plugin.',
'child-agent-hook': 'Answers only inside an agent/created listener, on the child agent.',
'native-review': 'Available to a reviewed native plugin.',
'review-grant': 'Served by the Engine only when the plugin\'s review granted it.',
none: 'No methods.',
}
const EVENTS_SUPPORTED = [
{
name: 'tools/pre-execute',
status: 'supported',
mode: 'ordered listeners, native hooks first, then plugin listeners in registration order',
signature: 'ctx.on(\'tools/pre-execute\', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)',
summary: 'Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: \'deny\', reason }, { kind: \'ask\', reason? }, { kind: \'revise\', input }, { kind: \'annotate\', text } or { kind: \'abstain\' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.',
options: 'prepend and global are refused.',
},
{
name: 'agent/created',
status: 'supported',
mode: 'listeners of the owning plugin only',
signature: 'ctx.on(\'agent/created\', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)',
summary: 'Fires for a child agent that this plugin\'s own subagents.startActivation creates, before the child starts. The listener may set the child\'s persona and deny tools to it; the window closes when the listeners return.',
options: 'global is refused.',
},
]
const REFUSED_EVENT_REASON = 'Not projected by this host: no core projection exists, so a listener would never fire.'
const ROW_PACKAGES = {
'@deepseek-ai/dsh-mcp-client': {
summary: 'An MCP server row. The Engine proposes the server and owns the connection.',
schema: {
type: 'object',
additionalProperties: false,
required: ['transport', 'serverName'],
properties: {
transport: { type: 'string', enum: ['stdio', 'streamable-http', 'sse'] },
serverName: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$' },
command: { type: 'string', description: 'stdio only: the program to run.' },
args: { type: 'array', items: { type: 'string' }, description: 'stdio only.' },
env: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
cwd: { type: 'string', description: 'stdio only: a bundle-relative directory, no .. components.' },
url: { type: 'string', description: 'streamable-http and sse: the endpoint, with no credentials, query or fragment.' },
headers: { type: 'object', maxProperties: 0, description: 'Must be empty or absent: credentials stay with the Engine.' },
toolCallTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000 },
failOnStartupError: { type: 'boolean', const: false, description: 'Only false: the Engine connects lazily and reports failures through /mcp.' },
},
},
limits: ['stdio rows name a command; streamable-http and sse rows name a url.', 'Reconnect and instruction bounds belong to the Engine and are not configurable here.'],
example: { transport: 'stdio', serverName: 'docs', command: 'node', args: ['server.js'], cwd: '.' },
},
'@deepseek-ai/dsh-skill-filesystem': {
summary: 'A skill folder row, limited to reviewed roots inside the bundle.',
schema: {
type: 'object',
additionalProperties: false,
required: ['includeDefaultRoots', 'watch', 'customSkillDirs'],
properties: {
providerName: { type: 'string', maxLength: 64 },
includeDefaultRoots: { type: 'boolean', const: false, description: 'Must be false: ambient roots are not read.' },
watch: { type: 'boolean', const: false, description: 'Must be false: there is no file watcher.' },
customSkillDirs: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Bundle-relative skill roots, no duplicates.' },
},
},
limits: ['Both includeDefaultRoots and watch must be written out as false.'],
example: { includeDefaultRoots: false, watch: false, customSkillDirs: ['skills'] },
},
'@deepseek-ai/dsh-hooks-claude-code': {
summary: 'Claude Code style command hooks read from a reviewed file in the bundle.',
schema: {
type: 'object',
additionalProperties: true,
required: ['configPath'],
properties: {
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
},
},
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
example: { configPath: 'hooks/hooks.json' },
},
'@deepseek-ai/dsh-hooks-codex': {
summary: 'Codex style command hooks read from a reviewed file in the bundle.',
schema: {
type: 'object',
additionalProperties: true,
required: ['configPath'],
properties: {
configPath: { type: 'string', description: 'A regular file inside the bundle, at most 1 MiB, unchanged since review.' },
pluginRoot: { type: 'string', description: 'Absent, ".", or this reviewed bundle root.' },
defaultTimeoutMs: { type: 'integer', minimum: 1000, maximum: 86400000, multipleOf: 1000, description: 'Whole seconds, used when a hook sets none; default 600000.' },
},
},
limits: ['projectDir is refused: each process uses the current workspace.', 'Only command hooks run; non-command and asynchronous hooks fail the row.', 'At most 128 commands per row.'],
example: { configPath: 'hooks/hooks.json' },
},
'@deepseek-ai/dsh-persona': {
summary: 'Text added before and after the Engine\'s own prompt for this plugin\'s entry.',
schema: {
type: 'object',
additionalProperties: false,
required: ['prefix'],
properties: {
prefix: { type: 'string', description: 'At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded.' },
suffix: { type: 'string', description: 'Same limits as prefix.' },
complete: { type: 'boolean', const: false, description: 'Replacing the whole prompt belongs to the Engine.' },
includeRuntimeContext: { type: 'boolean', const: true, description: 'Suppressing the runtime context belongs to the Engine.' },
},
},
limits: ['Needs an exact Engine-selected entry scope.'],
example: { prefix: 'Answer in short sentences.' },
},
}
const THEME = {
tokens: [],
colorSchemes: ['light', 'dark'],
scheme: 'The frame sets data-theme and color-scheme on its document to the app\'s current scheme and follows changes.',
api: [
{ name: 'ctx.theme.getTheme', signature: 'getTheme(): { active: { colorScheme: \'light\' | \'dark\' } }', summary: 'The scheme in use now.' },
{ name: 'ctx.theme.overrideTokens', signature: 'overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void', summary: 'Set custom properties on the frame\'s document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer.' },
],
notes: [
'The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.',
'The frame\'s background is transparent and its margin is zero, so the plugin sits on the app\'s own surface.',
],
}
const SEAT_NOTES = {
'customize.plugins.detail': { drawn: true, where: 'The plugin\'s own page under Customize, Plugins.' },
'conversation.composer.dock': { drawn: false, where: 'Under the composer. The desktop app does not place it yet.' },
'conversation.chat.node': { drawn: false, where: 'Inside the conversation. The desktop app does not place it yet.' },
'workpane.panel': { drawn: false, where: 'The work pane. The desktop app does not place it yet.' },
'sidebar.section': { drawn: false, where: 'The sidebar. The desktop app does not place it yet.' },
'command.row': { drawn: false, where: 'The command list. The desktop app does not place it yet.' },
}
function between(text, open, close, what) {
const start = text.indexOf(open)
if (start < 0) throw new Error(`${what}: cannot find ${open}`)
const end = text.indexOf(close, start + open.length)
if (end < 0) throw new Error(`${what}: cannot find the end of ${open}`)
return text.slice(start + open.length, end)
}
export function parseSeatTable(source) {
const text = source.replace(/\r\n/g, '\n')
const constants = Object.fromEntries([...text.matchAll(/const ([A-Z_]+): &str = "([^"]+)";/g)].map((m) => [m[1], m[2]]))
const resolve = (literal, name) => literal !== undefined ? literal : constants[name]
const seatBlock = between(text, 'const PLUGIN_UI_SEATS: [&str; ', '];', 'seat list')
const declaredSeats = Number(seatBlock.slice(0, seatBlock.indexOf(']')))
const seatBody = seatBlock.slice(seatBlock.indexOf('= [') + 3)
const seats = [...seatBody.matchAll(/"([^"]+)"|([A-Z_]+)/g)].map((m) => resolve(m[1], m[2]))
if (seats.length !== declaredSeats || seats.some((seat) => seat === undefined)) throw new Error('the seat list does not match its declared length')
const tableBlock = between(text, 'const DSH_SEAT_TABLE: [(&str, DshSeat); ', '];\n', 'slot table')
const declaredSlots = Number(tableBlock.slice(0, tableBlock.indexOf(']')))
const tableBody = tableBlock.slice(tableBlock.indexOf('= [') + 3)
const slots = [...tableBody.matchAll(/\(\s*"([^"]+)",\s*DshSeat::(Seat|Unsupported)\(\s*(?:"([^"]*)"|([A-Z_]+))\s*\)\s*,?\s*\)/g)].map((m) => {
const value = resolve(m[3], m[4])
return m[2] === 'Seat' ? { slot: m[1], seat: value, supported: true } : { slot: m[1], seat: null, supported: false, reason: value }
})
if (slots.length !== declaredSlots) throw new Error(`the slot table declares ${declaredSlots} rows and ${slots.length} were read`)
for (const slot of slots) if (slot.supported && !seats.includes(slot.seat)) throw new Error(`slot ${slot.slot} maps to ${slot.seat}, which is not a seat`)
return { seats, slots }
}
export function parseRefusedEvents(source) {
const text = source.replace(/\r\n/g, '\n')
const body = between(text, 'const unsupportedDshLifecycle = new Set([', '])', 'refused events')
return [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
}
export function parseHookEvents(source) {
const text = source.replace(/\r\n/g, '\n')
const body = between(text, 'const EVENTS:Record<string,string>={', '}', 'hook events')
return [...body.matchAll(/([A-Za-z]+):'([a-z_]+)'/g)].map((m) => ({ event: m[1], point: m[2] }))
}
export function buildCatalog(paths = SOURCES) {
const compatibility = JSON.parse(read(paths.compatibility))
const provided = Object.entries(compatibility.provided_services).flatMap(([group, names]) => names.map((name) => ({ group, name })))
const known = new Set(Object.keys(SERVICES))
const missing = provided.filter(({ name }) => !known.has(name)).map(({ name }) => name)
if (missing.length) throw new Error(`compatibility.json provides services with no entry in the method table: ${missing.join(', ')}`)
const extra = [...known].filter((name) => !provided.some((entry) => entry.name === name))
if (extra.length) throw new Error(`the method table names services the host does not provide: ${extra.join(', ')}`)
const services = provided.map(({ group, name }) => {
const entry = SERVICES[name]
return {
key: name,
group,
summary: entry.summary,
availability: entry.availability,
availabilityText: AVAILABILITY[entry.availability],
methods: entry.methods,
}
})
const refusedEvents = parseRefusedEvents(read(paths.hostRoot))
const hookEvents = parseHookEvents(read(paths.shellHooks))
const events = [
...EVENTS_SUPPORTED,
...refusedEvents.map((name) => ({ name, status: 'refused', summary: REFUSED_EVENT_REASON })),
]
const rowPackages = Object.keys(compatibility.row_bridges)
const unknownRows = rowPackages.filter((name) => !ROW_PACKAGES[name])
const staleRows = Object.keys(ROW_PACKAGES).filter((name) => !rowPackages.includes(name))
if (unknownRows.length || staleRows.length) throw new Error(`row packages differ from compatibility.json: missing ${unknownRows.join(', ')}; stale ${staleRows.join(', ')}`)
const rows = rowPackages.map((name) => ({
id: `row:${name}`,
name,
bridge: compatibility.row_bridges[name],
summary: ROW_PACKAGES[name].summary,
limits: ROW_PACKAGES[name].limits,
example: ROW_PACKAGES[name].example,
...(name.includes('hooks-') ? { hookEvents } : {}),
schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title: name, ...ROW_PACKAGES[name].schema },
}))
const table = parseSeatTable(read(paths.seatTable))
const unknownSeatNotes = table.seats.filter((seat) => !SEAT_NOTES[seat])
const staleSeatNotes = Object.keys(SEAT_NOTES).filter((seat) => !table.seats.includes(seat))
if (unknownSeatNotes.length || staleSeatNotes.length) throw new Error(`seat notes differ from the Engine seat list: missing ${unknownSeatNotes.join(', ')}; stale ${staleSeatNotes.join(', ')}`)
const seats = table.seats.map((seat) => ({ seat, drawn: SEAT_NOTES[seat].drawn, where: SEAT_NOTES[seat].where }))
return {
version: 1,
targetDshVersion: compatibility.target_dsh_version,
host: {
services,
refusedServices: compatibility.refused_services,
events,
cordisInternalEvents: 'Cordis fires its own internal/* lifecycle events. They are not part of this host\'s supported contract and are not listed.',
config: rows,
},
client: {
seats,
slots: table.slots,
theme: THEME,
},
}
}
export function renderCatalog(catalog) {
return `${JSON.stringify(catalog, null, 2)}\n`
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const text = renderCatalog(buildCatalog())
if (process.argv.includes('--check')) {
const current = existsOrEmpty(SOURCES.catalog)
if (current !== text) {
process.stderr.write('inspect-catalog.json is out of date: run node scripts/gen-inspect-catalog.mjs\n')
process.exit(1)
}
} else {
writeFileSync(SOURCES.catalog, text)
}
}
function existsOrEmpty(path) {
try {
return read(path)
} catch {
return ''
}
}
@@ -5,6 +5,7 @@ import type { RpcPeer } from '../rpc.ts'
import type { HostTier } from '../tier.ts'
import { sessionsAdapter, systemPromptAdapter } from './session-prompt.ts'
import { DSH_AGENT_ADAPTERS } from './agents.ts'
import { remoteAdapter } from './remote.ts'
export interface AdapterHost {
readonly tier: HostTier
@@ -20,4 +21,4 @@ export interface DshAdapter {
define(host: AdapterHost): new (ctx: any) => Service
}
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS]
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter]
@@ -23,7 +23,8 @@
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
"refused_services": [
@@ -0,0 +1,918 @@
{
"version": 1,
"targetDshVersion": "0.2.2-rc.1",
"host": {
"services": [
{
"key": "tools",
"group": "dsh",
"summary": "The tool registry: a plugin adds model-callable tools. The Engine owns calling, approval and results.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "register",
"signature": "register(definition: ToolDefinition): () => void",
"summary": "Register one tool built with defineTool from @deepseek-ai/dsh-tools. The definition must stay inside the supported JSON Schema subset (type, oneOf, properties, required, additionalProperties, items, enum, const). A second argument is refused. Returns an idempotent disposer."
},
{
"name": "get",
"signature": "get(name: string, agent: Agent): { name: string } | undefined",
"summary": "Whether the calling agent has a tool by this name. Answers only inside a live tool call, for that call's own agent."
}
]
},
{
"key": "commands",
"group": "dsh",
"summary": "Slash commands a person types. A handler runs only when the person types the command.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "register",
"signature": "register(definition: { name: string, description: string, argumentHint?: string, handler(call: { args: string, signal: AbortSignal }): CommandResult }): () => void",
"summary": "Register one slash command. Names start with a lower case letter, then lower case letters, digits, _ and -, at most 64 characters; at most 64 commands per plugin. A name that clashes with a built-in command gets the alias plugin-<plugin-slug>-<command>, and is refused if that alias is invalid or also taken. A name another plugin already registered is refused, so pick a plugin-specific name. Returns an idempotent disposer."
}
]
},
{
"key": "storage",
"group": "dsh",
"summary": "Owner-local JSON persistence under the data folder the Engine assigned. It holds no session history and no secrets.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "get",
"signature": "get(key: string): Promise<Json | undefined>",
"summary": "Read one stored value."
},
{
"name": "set",
"signature": "set(key: string, value: Json): Promise<void>",
"summary": "Store one JSON value, within the bounded key, value and owner limits."
},
{
"name": "delete",
"signature": "delete(key: string): Promise<void>",
"summary": "Remove one stored value."
}
]
},
{
"key": "skills",
"group": "dsh",
"summary": "Skill roots: reviewed folders of skills added to the Engine's skill catalog.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerRoot",
"signature": "registerRoot(definition: { path: string }): () => void",
"summary": "Add a bundle-relative folder of SKILL.md packages. At most 8 roots per plugin. Absolute paths, links, empty components, . and .. are refused."
}
]
},
{
"key": "sessions",
"group": "dsh",
"summary": "The session of the calling tool invocation.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "flush",
"signature": "flush(session: Session): Promise<boolean>",
"summary": "Ask the Engine to persist the committed prefix of the exact session of this tool call. Nothing else on sessions is available."
}
]
},
{
"key": "userQuestions",
"group": "dsh",
"summary": "Questions put to the person through the Engine's own question card.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "ask",
"signature": "ask(request: { agent: Agent, questions: Question[], signal?: AbortSignal }): Promise<Answers>",
"summary": "Ask the person typed questions during a live root tool call. Timed or continued questions (wait) are not supported."
}
]
},
{
"key": "subagents",
"group": "dsh",
"summary": "One independent child agent a root tool call may start.",
"availability": "tool-invocation",
"availabilityText": "Answers only inside a live tool call of the plugin.",
"methods": [
{
"name": "getProvider",
"signature": "getProvider(id: 'spawn'): Provider",
"summary": "Describe the one provider: a fresh child with no inherited conversation."
},
{
"name": "startActivation",
"signature": "startActivation(options: { provider: 'spawn', childId: string, request: { parent: Agent, prompt: TextBlock[], agentOptions?: { model?: string } }, signal?: AbortSignal, delivery?: 'caller' | 'parent' }): Promise<{ childId: string, messageId: string, result: Promise<ChildResult>, dispose(): Promise<void> }>",
"summary": "Start the child through the Engine's approval gate. Resolves to the activation handle { childId, messageId, result, dispose }. Prompts are text only; only the model option is accepted. Needs the live calling agent as parent and is not available inside a child."
},
{
"name": "result",
"signature": "activation.result: Promise<{ stopReason: string, output: { type: 'text', text: string }[], diagnostic?: string }>",
"summary": "A getter, not a method: read the property, do not call it. Resolves when the child finishes, with its stop reason and its text output blocks; diagnostic notes dropped non-text blocks."
},
{
"name": "dispose",
"signature": "activation.dispose(): Promise<void>",
"summary": "Stop that exact child and its own descendants."
}
]
},
{
"key": "agents",
"group": "dsh",
"summary": "Reserved. The host offers no methods on agents.",
"availability": "none",
"availabilityText": "No methods.",
"methods": []
},
{
"key": "systemPrompt",
"group": "dsh",
"summary": "The child agent's persona slot, reachable only inside an agent/created listener.",
"availability": "child-agent-hook",
"availabilityText": "Answers only inside an agent/created listener, on the child agent.",
"methods": [
{
"name": "section",
"signature": "agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: string }): () => void",
"summary": "Set the child's persona text, at most 32 KiB, once. name must be the string deployment:persona-prefix (the PERSONA_PREFIX_SECTION value) and order must be what getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') returns, which is 0. Anything else is refused. Called anywhere else it fails. The Engine's own system prompt cannot be replaced."
}
]
},
{
"key": "logger",
"group": "cordis",
"summary": "Cordis logging. Every line becomes a host log notification attributed to the plugin.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "logger",
"signature": "ctx.logger(name?: string): Logger",
"summary": "A named logger with debug, info, warn and error. The host reports error and warn as such and everything else as info."
}
]
},
{
"key": "events",
"group": "cordis",
"summary": "Cordis event dispatch. Query the host Event provider for the events this host fires.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "on",
"signature": "ctx.on(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
"summary": "Listen to an event. The host refuses five lifecycle events and limits the options of two (see the Event provider)."
},
{
"name": "once",
"signature": "ctx.once(name: string, listener: Function, options?: boolean | { prepend?: boolean, global?: boolean }): () => boolean",
"summary": "Listen to an event once."
},
{
"name": "emit",
"signature": "ctx.emit(name: string, ...args: any[]): void",
"summary": "Dispatch an event to its listeners."
}
]
},
{
"key": "reflect",
"group": "cordis",
"summary": "Cordis service reflection. A plugin may provide its own services but not the Engine's.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "provide",
"signature": "ctx.reflect.provide(name: string, value?: any): () => void",
"summary": "Provide a service under a name. Names in refusedServices are refused: the Engine owns them."
},
{
"name": "get",
"signature": "ctx.reflect.get(name: string, strict?: boolean): any",
"summary": "Read a provided service."
}
]
},
{
"key": "registry",
"group": "cordis",
"summary": "Cordis plugin registry.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "get",
"signature": "ctx.registry.get(plugin: Plugin): Runtime | undefined",
"summary": "Look up the runtime record of a plugin."
},
{
"name": "resolve",
"signature": "ctx.registry.resolve(plugin: Plugin): Function | undefined",
"summary": "Resolve a plugin shape to its callback."
}
]
},
{
"key": "prompt",
"group": "codewhale",
"summary": "Attributed sections of the Engine's runtime prompt. A plugin cannot replace the system prompt.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerSection",
"signature": "registerSection(definition: { id: string, text: string, interpolate?: 'model-cwd' }): () => void",
"summary": "Contribute a plain-text section of at most 4 KiB; at most 128 sections per plugin. The id is lower case letters, digits, _ and -, starts with a letter, at most 64 characters. With interpolate set, only {{model}} and {{cwd}} are expanded."
}
]
},
{
"key": "avatars",
"group": "codewhale",
"summary": "Avatar packs for the terminal.",
"availability": "always",
"availabilityText": "Available to every plugin that injects it.",
"methods": [
{
"name": "registerPack",
"signature": "registerPack(definition: { path: string }): () => void",
"summary": "Register a reviewed bundle-relative avatar pack: a path ending in .json, at most 4 packs per plugin."
}
]
},
{
"key": "mcp",
"group": "codewhale",
"summary": "Proposals of MCP servers. The Engine owns connection, credentials and tool calls.",
"availability": "native-review",
"availabilityText": "Available to a reviewed native plugin.",
"methods": [
{
"name": "registerServer",
"signature": "registerServer(definition: { serverName: string, server: Record<string, unknown> }): () => void",
"summary": "Propose one server. serverName is a letter or digit followed by up to 31 letters, digits, _ or -. headers and env must stay empty and the url may carry no credentials, query or fragment. At most 64 servers per plugin; at most 64 KiB per definition."
}
]
},
{
"key": "shellHooks",
"group": "codewhale",
"summary": "Claude Code and Codex style command hooks, run by the Engine.",
"availability": "native-review",
"availabilityText": "Available to a reviewed native plugin.",
"methods": [
{
"name": "register",
"signature": "register(definition: { dialect: 'claude-code' | 'codex', point: string, matcher?: string, hook: { event: string, command: string, timeout_secs: number, background: false, continue_on_error: false, name: string } }): () => void",
"summary": "Register one command hook for a reviewed native plugin. Forced continuation, observer steering, non-command and asynchronous hooks are not supported."
}
]
},
{
"key": "remote",
"group": "codewhale",
"summary": "Remote pairing, served by the Engine.",
"availability": "review-grant",
"availabilityText": "Served by the Engine only when the plugin's review granted it.",
"methods": [
{
"name": "status",
"signature": "status(): Promise<Status>",
"summary": "Whether remote access is on."
},
{
"name": "pair",
"signature": "pair(options?: { label?: string }): Promise<Pairing>",
"summary": "Start a pairing."
},
{
"name": "clients",
"signature": "clients(): Promise<Client[]>",
"summary": "List paired clients."
},
{
"name": "revoke",
"signature": "revoke(id: string): Promise<void>",
"summary": "Revoke one grant by the id pair returned."
}
]
}
],
"refusedServices": [
"shellHooks",
"loader",
"approval",
"agents",
"subagents",
"sessions",
"llm",
"sandboxPolicy",
"credentials",
"fs",
"subprocess",
"systemPrompt",
"runtimeLoop",
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"logger",
"userQuestions"
],
"events": [
{
"name": "tools/pre-execute",
"status": "supported",
"mode": "ordered listeners, native hooks first, then plugin listeners in registration order",
"signature": "ctx.on('tools/pre-execute', async (exec: { name: string, callId: string, arguments: object, signal: AbortSignal, workspace: string, mode: string, model: string }, next: () => Promise<Abstention>) => Verdict | undefined)",
"summary": "Programmable tool admission for a reviewed native plugin. The frozen call view has no session, agent, tool or approval handle. Return { kind: 'deny', reason }, { kind: 'ask', reason? }, { kind: 'revise', input }, { kind: 'annotate', text } or { kind: 'abstain' }; undefined abstains. A malformed answer, error or timeout fails the call closed. The listener batch has a five second deadline.",
"options": "prepend and global are refused."
},
{
"name": "agent/created",
"status": "supported",
"mode": "listeners of the owning plugin only",
"signature": "ctx.on('agent/created', async ({ agent }: { agent: { id: string, ctx: { systemPrompt: { getSectionOrder(name), section(section) }, tools: { restrict(filter: { deny: string[] }) } } } }) => void)",
"summary": "Fires for a child agent that this plugin's own subagents.startActivation creates, before the child starts. The listener may set the child's persona and deny tools to it; the window closes when the listeners return.",
"options": "global is refused."
},
{
"name": "agent/pre-step",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "agent/turn-stopping",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "tools/post-execute",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "subagent/start",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
},
{
"name": "subagent/end",
"status": "refused",
"summary": "Not projected by this host: no core projection exists, so a listener would never fire."
}
],
"cordisInternalEvents": "Cordis fires its own internal/* lifecycle events. They are not part of this host's supported contract and are not listed.",
"config": [
{
"id": "row:@deepseek-ai/dsh-mcp-client",
"name": "@deepseek-ai/dsh-mcp-client",
"bridge": "mcp",
"summary": "An MCP server row. The Engine proposes the server and owns the connection.",
"limits": [
"stdio rows name a command; streamable-http and sse rows name a url.",
"Reconnect and instruction bounds belong to the Engine and are not configurable here."
],
"example": {
"transport": "stdio",
"serverName": "docs",
"command": "node",
"args": [
"server.js"
],
"cwd": "."
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-mcp-client",
"type": "object",
"additionalProperties": false,
"required": [
"transport",
"serverName"
],
"properties": {
"transport": {
"type": "string",
"enum": [
"stdio",
"streamable-http",
"sse"
]
},
"serverName": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
},
"command": {
"type": "string",
"description": "stdio only: the program to run."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "stdio only."
},
"env": {
"type": "object",
"maxProperties": 0,
"description": "Must be empty or absent: credentials stay with the Engine."
},
"cwd": {
"type": "string",
"description": "stdio only: a bundle-relative directory, no .. components."
},
"url": {
"type": "string",
"description": "streamable-http and sse: the endpoint, with no credentials, query or fragment."
},
"headers": {
"type": "object",
"maxProperties": 0,
"description": "Must be empty or absent: credentials stay with the Engine."
},
"toolCallTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000
},
"failOnStartupError": {
"type": "boolean",
"const": false,
"description": "Only false: the Engine connects lazily and reports failures through /mcp."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-skill-filesystem",
"name": "@deepseek-ai/dsh-skill-filesystem",
"bridge": "skills",
"summary": "A skill folder row, limited to reviewed roots inside the bundle.",
"limits": [
"Both includeDefaultRoots and watch must be written out as false."
],
"example": {
"includeDefaultRoots": false,
"watch": false,
"customSkillDirs": [
"skills"
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-skill-filesystem",
"type": "object",
"additionalProperties": false,
"required": [
"includeDefaultRoots",
"watch",
"customSkillDirs"
],
"properties": {
"providerName": {
"type": "string",
"maxLength": 64
},
"includeDefaultRoots": {
"type": "boolean",
"const": false,
"description": "Must be false: ambient roots are not read."
},
"watch": {
"type": "boolean",
"const": false,
"description": "Must be false: there is no file watcher."
},
"customSkillDirs": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"items": {
"type": "string"
},
"description": "Bundle-relative skill roots, no duplicates."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-hooks-claude-code",
"name": "@deepseek-ai/dsh-hooks-claude-code",
"bridge": "hooks-claude-code",
"summary": "Claude Code style command hooks read from a reviewed file in the bundle.",
"limits": [
"projectDir is refused: each process uses the current workspace.",
"Only command hooks run; non-command and asynchronous hooks fail the row.",
"At most 128 commands per row."
],
"example": {
"configPath": "hooks/hooks.json"
},
"hookEvents": [
{
"event": "SessionStart",
"point": "session_start"
},
{
"event": "UserPromptSubmit",
"point": "message_submit"
},
{
"event": "PreToolUse",
"point": "tool_call_before"
},
{
"event": "PostToolUse",
"point": "tool_call_after"
},
{
"event": "Stop",
"point": "turn_end"
},
{
"event": "SubagentStart",
"point": "subagent_spawn"
},
{
"event": "SubagentStop",
"point": "subagent_complete"
}
],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-hooks-claude-code",
"type": "object",
"additionalProperties": true,
"required": [
"configPath"
],
"properties": {
"configPath": {
"type": "string",
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
},
"pluginRoot": {
"type": "string",
"description": "Absent, \".\", or this reviewed bundle root."
},
"defaultTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000,
"description": "Whole seconds, used when a hook sets none; default 600000."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-hooks-codex",
"name": "@deepseek-ai/dsh-hooks-codex",
"bridge": "hooks-codex",
"summary": "Codex style command hooks read from a reviewed file in the bundle.",
"limits": [
"projectDir is refused: each process uses the current workspace.",
"Only command hooks run; non-command and asynchronous hooks fail the row.",
"At most 128 commands per row."
],
"example": {
"configPath": "hooks/hooks.json"
},
"hookEvents": [
{
"event": "SessionStart",
"point": "session_start"
},
{
"event": "UserPromptSubmit",
"point": "message_submit"
},
{
"event": "PreToolUse",
"point": "tool_call_before"
},
{
"event": "PostToolUse",
"point": "tool_call_after"
},
{
"event": "Stop",
"point": "turn_end"
},
{
"event": "SubagentStart",
"point": "subagent_spawn"
},
{
"event": "SubagentStop",
"point": "subagent_complete"
}
],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-hooks-codex",
"type": "object",
"additionalProperties": true,
"required": [
"configPath"
],
"properties": {
"configPath": {
"type": "string",
"description": "A regular file inside the bundle, at most 1 MiB, unchanged since review."
},
"pluginRoot": {
"type": "string",
"description": "Absent, \".\", or this reviewed bundle root."
},
"defaultTimeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 86400000,
"multipleOf": 1000,
"description": "Whole seconds, used when a hook sets none; default 600000."
}
}
}
},
{
"id": "row:@deepseek-ai/dsh-persona",
"name": "@deepseek-ai/dsh-persona",
"bridge": "persona",
"summary": "Text added before and after the Engine's own prompt for this plugin's entry.",
"limits": [
"Needs an exact Engine-selected entry scope."
],
"example": {
"prefix": "Answer in short sentences."
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "@deepseek-ai/dsh-persona",
"type": "object",
"additionalProperties": false,
"required": [
"prefix"
],
"properties": {
"prefix": {
"type": "string",
"description": "At most 4096 UTF-8 bytes, no control characters. Only {{model}} and {{cwd}} are expanded."
},
"suffix": {
"type": "string",
"description": "Same limits as prefix."
},
"complete": {
"type": "boolean",
"const": false,
"description": "Replacing the whole prompt belongs to the Engine."
},
"includeRuntimeContext": {
"type": "boolean",
"const": true,
"description": "Suppressing the runtime context belongs to the Engine."
}
}
}
}
]
},
"client": {
"seats": [
{
"seat": "customize.plugins.detail",
"drawn": true,
"where": "The plugin's own page under Customize, Plugins."
},
{
"seat": "conversation.composer.dock",
"drawn": false,
"where": "Under the composer. The desktop app does not place it yet."
},
{
"seat": "conversation.chat.node",
"drawn": false,
"where": "Inside the conversation. The desktop app does not place it yet."
},
{
"seat": "workpane.panel",
"drawn": false,
"where": "The work pane. The desktop app does not place it yet."
},
{
"seat": "sidebar.section",
"drawn": false,
"where": "The sidebar. The desktop app does not place it yet."
},
{
"seat": "command.row",
"drawn": false,
"where": "The command list. The desktop app does not place it yet."
}
],
"slots": [
{
"slot": "plugins.bundle.config",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.row.config",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.tab",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "plugins.detail.section",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.section",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.plugin.item",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.general.item",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "settings.plugins.tab",
"seat": "customize.plugins.detail",
"supported": true
},
{
"slot": "conversation.chat.node",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.turnTail",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.assistant-actions",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.chat.text-interaction",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.user.text.node",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "tool.call.toolview",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.view",
"seat": "conversation.chat.node",
"supported": true
},
{
"slot": "conversation.input.dock",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.input.overlay",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.input.right",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "conversation.composer.dock",
"seat": "conversation.composer.dock",
"supported": true
},
{
"slot": "sidebar.right.pane.tab",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "main.conversation",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "main.page-menu",
"seat": "workpane.panel",
"supported": true
},
{
"slot": "sidebar.panellist",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.workspaces",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.footer.action",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.session.row.leading",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "sidebar.workspaces.session.row.action",
"seat": "sidebar.section",
"supported": true
},
{
"slot": "conversation.session.header.actions",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
},
{
"slot": "conversation.session.header.utilities",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
},
{
"slot": "contextMenu.icon",
"seat": null,
"supported": false,
"reason": "no ADE seat yet"
}
],
"theme": {
"tokens": [],
"colorSchemes": [
"light",
"dark"
],
"scheme": "The frame sets data-theme and color-scheme on its document to the app's current scheme and follows changes.",
"api": [
{
"name": "ctx.theme.getTheme",
"signature": "getTheme(): { active: { colorScheme: 'light' | 'dark' } }",
"summary": "The scheme in use now."
},
{
"name": "ctx.theme.overrideTokens",
"signature": "overrideTokens(source: string, tokens: Record<string, { light: string, dark: string }>): () => void",
"summary": "Set custom properties on the frame's document for each scheme. source is at most 256 characters; each name starts with -- and is at most 128 characters; each value is at most 512 characters. Returns a disposer."
}
],
"notes": [
"The frame defines no named design tokens of its own. There are no --dsw-* tokens to read: a plugin that wants tokens defines them with overrideTokens and uses them in its own styles.",
"The frame's background is transparent and its margin is zero, so the plugin sits on the app's own surface."
]
}
}
}
@@ -0,0 +1,245 @@
import { isJson } from '../json.ts'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './dsh-tools-compat.js'
import catalogData from './inspect-catalog.json' with { type: 'json' }
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
export type InspectPlatform = 'host' | 'client'
export interface InspectMethod {
name: string
description: string
inputSchema: JsonValue
outputSchema: JsonValue
}
export interface InspectProviderView {
platform: InspectPlatform
id: string
description: string
methods: InspectMethod[]
}
export interface InstalledRow {
id: string
name: string
state: string
}
export interface ClientState {
connected: boolean
drawnSeats?: readonly string[]
}
export interface InspectSources {
tools?: (agent: unknown, signal: AbortSignal) => Promise<readonly JsonValue[]> | readonly JsonValue[]
installed?: (signal: AbortSignal) => Promise<readonly InstalledRow[]> | readonly InstalledRow[]
client?: () => ClientState
}
export interface CordisInspect {
list(): InspectProviderView[]
query(platform: string, provider: string, method: string, input: JsonValue | undefined, agent?: unknown, signal?: AbortSignal): Promise<JsonValue>
}
interface Provider {
platform: InspectPlatform
id: string
description: string
methods: InspectMethod[]
run(method: string, input: Record<string, JsonValue>, agent: unknown, signal: AbortSignal): Promise<any> | any
}
const catalog = catalogData as any
const EMPTY_INPUT: JsonValue = { type: 'object', properties: {}, additionalProperties: false }
const ANY_OUTPUT: JsonValue = { description: 'JSON data owned by this inspect provider.' }
const NO_CLIENT = 'No client is connected. Plugin screens are drawn by the desktop app; in a terminal nothing is drawn, so every slot reports drawn: false.'
const exactInput = (field: string, description: string): JsonValue => ({
type: 'object',
properties: { [field]: { type: 'string', description } },
additionalProperties: false,
})
const method = (name: string, description: string, inputSchema: JsonValue = EMPTY_INPUT, outputSchema: JsonValue = ANY_OUTPUT): InspectMethod =>
({ name, description, inputSchema, outputSchema })
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
function serviceView(service: any): JsonValue {
return {
key: service.key,
group: service.group,
summary: service.summary,
availability: service.availability,
availabilityText: service.availabilityText,
methods: service.methods,
}
}
function serviceProvider(): Provider {
return {
platform: 'host', id: 'Service',
description: 'The services this host gives plugins, with their methods, and the services a plugin may not provide. A name that is not listed does not exist here.',
methods: [method('listService', 'Without input: the directory of services with one-line summaries and method signatures. With service: that service\'s exact contract, with availability. These are services a plugin injects, not DSH\'s full catalogue.',
exactInput('service', 'Exact service key. Omit it for the directory.'))],
run(_name, input) {
const key = input.service
if (key === undefined) {
return {
services: catalog.host.services.map((s: any) => ({ key: s.key, group: s.group, summary: s.summary, availability: s.availability, signatures: s.methods.map((m: any) => m.signature) })),
refusedServices: catalog.host.refusedServices,
note: 'refusedServices are names a plugin may not provide: the Engine owns them. Service methods named by the host Event and Config providers are separate.',
targetDshVersion: catalog.targetDshVersion,
}
}
const found = catalog.host.services.find((s: any) => s.key === key)
if (!found) throw new Error(`Host service "${String(key)}" is not provided by this host. Call without service for the directory.`)
return { service: serviceView(found), targetDshVersion: catalog.targetDshVersion }
},
}
}
function eventProvider(): Provider {
return {
platform: 'host', id: 'Event',
description: 'The events a plugin may listen to in this host, and the ones the host refuses.',
methods: [method('listEvents', 'Without input: the directory of events with status supported or refused. With event: its exact contract.',
exactInput('event', 'Exact event name. Omit it for the directory.'))],
run(_name, input) {
const key = input.event
if (key === undefined) {
return {
events: catalog.host.events.map((e: any) => ({ name: e.name, status: e.status, summary: e.summary })),
note: catalog.host.cordisInternalEvents,
}
}
const found = catalog.host.events.find((e: any) => e.name === key)
if (!found) throw new Error(`Host event "${String(key)}" is not known to this host. Call without event for the directory.`)
return { event: found }
},
}
}
const CONFIG_INPUT: JsonValue = {
type: 'object',
properties: {
entry: { type: 'string', description: 'Exact entry id from the directory; returns that entry\'s Config schema.' },
name: { type: 'string', description: 'Exact package or plugin name; limits the directory to it.' },
offset: { type: 'integer', description: 'Zero-based directory offset; defaults to 0.' },
limit: { type: 'integer', description: 'Directory page size, from 1 to 100; defaults to 25.' },
},
additionalProperties: false,
}
function configProvider(sources: InspectSources): Provider {
return {
platform: 'host', id: 'Config',
description: 'Config a composition row may carry: the packages a row may name, each with the config it accepts, and the plugins installed on this host with their state.',
methods: [method('listConfigs', 'Without entry: a page of the directory (row packages first, then installed plugins) with total and nextOffset. With entry: one row package\'s exact JSON Schema, limits and a valid example. Installed plugins are listed with their state; their own config is not described here.', CONFIG_INPUT)],
async run(_name, input, _agent, signal) {
const rows: any[] = catalog.host.config.map((r: any) => ({ id: r.id, name: r.name, kind: 'row-package', status: 'schema', bridge: r.bridge, summary: r.summary }))
const installed = sources.installed ? await sources.installed(signal) : []
for (const row of installed) rows.push({ id: `plugin:${row.id}`, name: row.name, kind: 'installed-plugin', status: row.state })
if (input.entry !== undefined) {
const pkg = catalog.host.config.find((r: any) => r.id === input.entry)
if (pkg) return { entry: pkg.id, name: pkg.name, kind: 'row-package', bridge: pkg.bridge, summary: pkg.summary, limits: pkg.limits, example: pkg.example, ...pkg.hookEvents ? { hookEvents: pkg.hookEvents } : {}, schema: pkg.schema }
const live = rows.find((r) => r.id === input.entry)
if (live) return { entry: live.id, name: live.name, kind: live.kind, status: live.status, schema: null, note: 'An installed plugin\'s own Config is not described by this host.' }
throw new Error(`Host Config entry "${String(input.entry)}" is not listed. Call without entry for the directory.`)
}
const filtered = input.name === undefined ? rows : rows.filter((r) => r.name === input.name)
const offset = typeof input.offset === 'number' ? input.offset : 0
const limit = typeof input.limit === 'number' ? input.limit : 25
if (offset < 0 || limit < 1 || limit > 100) throw new Error('Host Config directory needs offset >= 0 and 1 <= limit <= 100')
const page = filtered.slice(offset, offset + limit)
const next = offset + page.length
return { entries: page, total: filtered.length, ...next < filtered.length ? { nextOffset: next } : {} }
},
}
}
function toolProvider(sources: InspectSources): Provider | undefined {
const source = sources.tools
if (!source) return undefined
return {
platform: 'host', id: 'Tool',
description: 'Tools visible to the requesting agent.',
methods: [method('listTools', 'Return every tool schema currently callable by this agent.')],
async run(_name, _input, agent, signal) {
return { tools: [...await source(agent, signal)] }
},
}
}
const SUBTREE_INPUT = exactInput('root', 'Exact slot name to select. Omit it for every slot.')
function slotsProvider(sources: InspectSources): Provider {
return {
platform: 'client', id: 'Slots',
description: 'The DSH slot names this host accepts, the seat each maps to, and whether the connected client draws that seat.',
methods: [method('listSubTree', 'Return every slot with its seat and drawn state; with root, also that slot selected. Slots are static names here, not a live tree, and their props are not described by this host: a plugin registers on a slot, the Engine serves it on the mapped seat, and the client draws only the seats it places.', SUBTREE_INPUT)],
run(_name, input) {
const state = sources.client?.() ?? { connected: false }
const drawn = new Set(state.connected ? state.drawnSeats ?? catalog.client.seats.filter((s: any) => s.drawn).map((s: any) => s.seat) : [])
const slots = catalog.client.slots.map((s: any) => ({
type: 'slot', name: s.slot, seat: s.seat, supported: s.supported,
drawn: s.supported && drawn.has(s.seat),
...s.reason ? { reason: s.reason } : {},
}))
const seats = catalog.client.seats.map((s: any) => ({ seat: s.seat, drawn: drawn.has(s.seat), where: s.where }))
const out: Record<string, JsonValue> = {
client: state.connected ? { connected: true } : { connected: false, message: NO_CLIENT },
seats, trees: slots, referencedTypes: [],
}
if (input.root !== undefined) {
const selected = slots.find((s: any) => s.name === input.root)
out.requestedRoot = { name: input.root, available: selected !== undefined }
if (selected) out.selected = selected
}
return out
},
}
}
function themeProvider(): Provider {
return {
platform: 'client', id: 'Theme',
description: 'What the plugin frame provides for theming.',
methods: [method('listTokens', 'Return the named tokens the frame provides (none), the colour schemes, and the theme calls a plugin has.')],
run() {
return { tokens: catalog.client.theme.tokens, colorSchemes: catalog.client.theme.colorSchemes, scheme: catalog.client.theme.scheme, api: catalog.client.theme.api, notes: catalog.client.theme.notes, referencedTypes: [] }
},
}
}
export function createCordisInspect(sources: InspectSources = {}): CordisInspect {
const providers: Provider[] = [serviceProvider(), eventProvider(), configProvider(sources), toolProvider(sources), slotsProvider(sources), themeProvider()]
.filter((p): p is Provider => p !== undefined)
for (const provider of providers) {
if (provider.id.trim() === '' || provider.description.trim() === '') throw new Error('Cordis inspect provider needs an id and a description')
for (const m of provider.methods) {
assertSupportedJsonSchema(m.inputSchema)
assertSupportedJsonSchema(m.outputSchema)
}
}
const label = (platform: InspectPlatform) => platform === 'host' ? 'Host' : 'Client'
return {
list() {
return providers.map((p) => ({ platform: p.platform, id: p.id, description: p.description, methods: copy(p.methods) }))
},
async query(platform, providerId, methodName, input, agent, signal = new AbortController().signal) {
if (platform !== 'host' && platform !== 'client') throw new Error(`Cordis inspect platform must be "host" or "client", not "${String(platform)}"`)
const provider = providers.find((p) => p.platform === platform && p.id === providerId)
if (!provider) throw new Error(`${label(platform)} Cordis inspect provider "${providerId}" is not registered`)
const found = provider.methods.find((m) => m.name === methodName)
if (!found) throw new Error(`Cordis inspect provider "${provider.id}" has no method "${methodName}"`)
const violations = validateJsonSchemaValue(found.inputSchema, input ?? {}, 'input') as string[]
if (violations.length > 0) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} rejected input: ${violations.join('; ')}`)
signal.throwIfAborted()
const data = await provider.run(methodName, (input ?? {}) as Record<string, JsonValue>, agent, signal)
signal.throwIfAborted()
if (!isJson(data)) throw new Error(`${label(platform)} Cordis inspect ${provider.id}.${found.name} returned a non-JSON value`)
return copy(data)
},
}
}
@@ -0,0 +1,33 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { DshAdapter } from './adapters.ts'
function label(value: unknown): string | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'remote.pair label must be a string')
return value
}
function grant(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, 'remote.revoke needs the grant id remote.pair returned')
return value
}
export const remoteAdapter: DshAdapter = {
service: 'remote',
define(host) {
class RemoteService extends Service {
constructor(ctx: any) { super(ctx, 'remote') }
status() { return host.broker(this.ctx, 'remote', 'status', {}) }
pair(options?: { label?: unknown }) {
const value = label(options?.label)
return host.broker(this.ctx, 'remote', 'pair', value === undefined ? {} : { label: value })
}
clients() { return host.broker(this.ctx, 'remote', 'clients', {}) }
revoke(id: unknown) { return host.broker(this.ctx, 'remote', 'revoke', { grant: grant(id) }) }
}
Object.freeze(RemoteService.prototype)
return RemoteService
},
}
@@ -0,0 +1,64 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { DshAdapter } from './adapters.ts'
const SESSION = /^[A-Za-z0-9._-]{1,64}$/
function invalid(message: string): never {
throw new RpcError(ErrorCode.InvalidParams, message)
}
function session(value: unknown): string {
if (typeof value !== 'string' || !SESSION.test(value)) invalid('a terminal name is 1 to 64 letters, digits, `_`, `-` or `.`')
return value as string
}
function text(value: unknown, what: string): string {
if (typeof value !== 'string' || value.length === 0) invalid(`${what} is a non-empty string`)
return value as string
}
function bounded(value: unknown, what: string, min: number, max: number): number | undefined {
if (value === undefined) return undefined
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) invalid(`${what} is an integer between ${min} and ${max}`)
return value as number
}
function compact(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))
}
export const terminalAdapter: DshAdapter = {
service: 'terminal',
define(host) {
class TerminalService extends Service {
constructor(ctx: any) { super(ctx, 'terminal') }
list() { return host.broker(this.ctx, 'terminal', 'list', {}) }
run(command: unknown, options?: { session?: unknown, timeoutSecs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'run', compact({
command: text(command, 'a command'),
session: options?.session === undefined ? undefined : session(options.session),
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
}))
}
send(name: unknown, input: unknown, options?: { waitMs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'send', compact({
session: session(name),
text: text(input, 'terminal input'),
wait_ms: bounded(options?.waitMs, 'waitMs', 0, 60_000),
}))
}
wait(name: unknown, options?: { timeoutSecs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'wait', compact({
session: session(name),
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
}))
}
cancel(name: unknown) { return host.broker(this.ctx, 'terminal', 'cancel', { session: session(name) }) }
reset(name: unknown) { return host.broker(this.ctx, 'terminal', 'reset', { session: session(name) }) }
}
Object.freeze(TerminalService.prototype)
return TerminalService
},
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,17 @@
# DeepSeek Harness creator files, pinned
Byte-for-byte copies of the files DeepSeek Harness 0.2.2-rc.1 uses for its
Creator mode, kept so Codewhale hosts the same tool and skill text rather than
a rewrite. `UPSTREAM.json` records the package, version, upstream path and
SHA-256 of every file; `test/creator-pin.test.mjs` fails when a file drifts.
- `plugin-manager/tools.js`: `@deepseek-ai/dsh-plugin-manager` `lib/types/tools.js`
- `tool-cordis/index.js`: `@deepseek-ai/dsh-tool-cordis` `lib/index.js`
- `sandbox/escalation.js`: not yet pinned (see `UPSTREAM.json`, `pending`)
The four authoring skills of `@deepseek-ai/dsh-agent-preset` are pinned beside
the skills crate at `crates/skills/assets/dsh-creator/`.
The files are MIT licensed by DeepSeek; `LICENSE` is the licence text shipped
in each of the four packages. Nothing here is edited: change the pin by
replacing the file and its recorded hash together, at a new DSH version.
@@ -0,0 +1,39 @@
{
"repository": "https://github.com/deepseek-ai/deepseek-harness",
"version": "0.2.2-rc.1",
"license": "MIT",
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"source": "npm packages as shipped in DeepSeek Harness desktop 0.2.2-rc.1",
"packages": [
"@deepseek-ai/dsh-agent-preset",
"@deepseek-ai/dsh-plugin-manager",
"@deepseek-ai/dsh-tool-cordis",
"@deepseek-ai/dsh-sandbox"
],
"files": [
{
"path": "plugin-manager/tools.js",
"package": "@deepseek-ai/dsh-plugin-manager",
"upstream_path": "lib/types/tools.js",
"sha256": "18d9f06f20a821bd205b6d88f87f440659730ef9431fb92351a50d86779da3ca",
"bytes": 12583,
"verbatim": true
},
{
"path": "tool-cordis/index.js",
"package": "@deepseek-ai/dsh-tool-cordis",
"upstream_path": "lib/index.js",
"sha256": "ce0cd4d8983532c920cab7713dc51874f6d710c2c46c4a095167bfa980becdb0",
"bytes": 3687,
"verbatim": true
}
],
"pending": [
{
"path": "sandbox/escalation.js",
"package": "@deepseek-ai/dsh-sandbox",
"reason": "excerpt of approveEscalation and its mode table; needs the published tarball, not yet fetched"
}
],
"skills_pin": "crates/skills/assets/dsh-creator/UPSTREAM.json"
}
@@ -0,0 +1,154 @@
/** Agent-facing current-profile management using the same service as Web controls. */
import { assertNever } from '@deepseek-ai/dsh-util-values';
import { approveEscalation } from '@deepseek-ai/dsh-sandbox';
import { defineTool } from '@deepseek-ai/dsh-tools';
import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot';
/** What the model does next for each refusal the manager reports. */
const ERROR_STEPS = {
'management-required': 'This entry belongs to plugin management itself and cannot be changed.',
'unaddressable': 'This entry has no unique profile patch target; tell the user to edit the profile patch file directly.',
'unknown-plugin': 'Call list_plugins and retry with an exact entryId.',
'invalid-spec': 'Retry with an npm package name, a git URL, a tarball URL, or an absolute local directory.',
'already-installed': 'The package is already installed; to install another version, call remove_bundle first, then install_bundle again.',
'ambiguous-install': 'The dependency change named no single package; call list_bundles to see what was installed.',
'not-bundle': 'The package declares no dsh.bundle and cannot be managed as a plugin; install a DSH plugin bundle instead.',
'not-removable': 'Call list_bundles; only bundles marked removable can be removed.',
'stop-profile': 'This profile runs without HMR; tell the user to stop DSH and run `dsh plugin --profile <profile> remove <bundle>`.',
'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.',
'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.',
'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.',
'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.',
'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.',
};
/** What the model does next for each package-manager failure. */
const FAILURE_STEPS = {
'pnpm-missing': 'pnpm is not available to DSH; tell the user to install pnpm.',
'timeout': 'Retry, or retry with another registry.',
'not-found': 'Check the package name and version with the user, then retry.',
'no-matching-version': 'Retry with a published version.',
'network': 'Retry later or pass another registry.',
'disk-full': 'Tell the user to free disk space, then retry.',
'permission': 'Tell the user DSH cannot write the profile directory; retry after they fix its permissions.',
'build-blocked': 'Ask the user whether to allow the install scripts of the packages in pendingBuilds; if they approve, retry with approvedBuilds.',
};
/** Why a change waits for a restart, as the model tells the user. */
const RESTART_STEPS = {
'no-hmr': 'this profile applies changes only at startup',
'package-moved': 'an installed package now resolves to another directory, or the change replaces a package the DSH installation provides; remove a package before installing it from another location',
'host-module': 'the change reaches modules DSH itself loaded',
'unmanaged-plugin': 'the change reaches a plugin running outside the plugin list',
'module-comparison': 'DSH could not compare loaded plugins with their files',
};
/**
* The model-facing result of a change: the manager's result plus the next step its outcome calls for.
* @param result The manager's result.
* @returns The result with `nextStep` when it is not plainly applied.
*/
function withNextStep(result) {
switch (result.application) {
case 'applied':
return result;
case 'cancelled':
return { ...result, nextStep: 'The installation was cancelled and its files restored; nothing changed.' };
case 'overridden':
return { ...result, nextStep: 'Saved, but a higher-priority layer such as the agent preset overrides it; tell the user it is not in effect.' };
case 'restart-required': {
const reason = result.restartReason === undefined ? '' : `: ${RESTART_STEPS[result.restartReason]}`;
return { ...result, nextStep: `Tell the user to restart DSH to apply this change${reason}.` };
}
case 'failed': {
const code = result.error?.code ?? 'operation-error';
// A refusal code is more specific than the package manager's failure kind.
const kind = code === 'operation-error' ? result.packageResult?.kind : undefined;
return { ...result, nextStep: (kind === undefined ? undefined : FAILURE_STEPS[kind]) ?? ERROR_STEPS[code] };
}
/* v8 ignore next -- ChangeResult.application is a closed union */
default: return assertNever(result.application);
}
}
/** Required services for the management tool. */
export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
/** Register one management tool for discovery and the four persistent actions.
* @param ctx Agent-scoped tool registration context.
*/
export function apply(ctx) {
ctx.tools.register(defineTool({
name: 'plugin_manager',
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.',
parameters: {
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' },
target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle.' },
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.' },
runtimeVersion: { type: 'string', description: 'For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.' },
acceptRisk: { type: 'boolean', description: 'For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.' },
approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
registry: { type: 'string', description: 'For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.' },
offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
await approveEscalation({
requestedMode: 'danger-full-access', effectiveMode: policy.mode, subject: 'plugin management operation',
justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`,
}, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId,
toolName: 'plugin_manager', signal: exec.signal });
exec.signal.throwIfAborted();
const manager = ctx.pluginManager;
switch (args.action) {
case 'list_version_exemptions':
return JSON.stringify({ runtimeVersion: getDshRuntimeVersion(), ...manager.listVersionExemptions() });
case 'set_version_exemption':
if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) {
throw new Error('target package-name@version, runtimeVersion, and enabled are required');
}
return JSON.stringify(withNextStep(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk)));
case 'list_plugins':
case 'list_bundles': {
const offset = args.offset ?? 0;
const limit = args.limit ?? 25;
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100');
}
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles();
const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => 'rows' in row
// GUI listing and update eligibility do not change the tool's management operations.
? (({ updatable: _updatable, listedByDefault: _listedByDefault, ...bundle }) => ({
...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared),
}))(row)
: row);
return JSON.stringify({ entries, total: rows.length,
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null });
}
case 'set_plugin':
case 'set_bundle': {
if (args.target === undefined || args.enabled === undefined)
throw new Error('target and enabled are required');
return JSON.stringify(withNextStep(await (args.action === 'set_plugin'
? manager.setPluginEnabled(args.target, args.enabled)
: manager.setBundleEnabled(args.target, args.enabled))));
}
case 'install_bundle':
if (args.target === undefined)
throw new Error('target package spec is required');
return JSON.stringify(withNextStep(await manager.installBundle(args.target, {
...args.enabled === undefined ? {} : { enabled: args.enabled },
...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds },
...args.registry === undefined ? {} : { registry: args.registry },
})));
case 'remove_bundle':
if (args.target === undefined)
throw new Error('target bundle name is required');
return JSON.stringify(withNextStep(await manager.removeBundle(args.target)));
/* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */
default: return assertNever(args.action);
}
},
presentCall: args => ({ card: 'generic', title: 'Manage profile plugins', kind: args.action.startsWith('list_') ? 'read' : 'other', rawInput: args }),
}));
}
//# sourceMappingURL=tools.js.map
@@ -0,0 +1,100 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
//#region lib/types/present.js
/**
* Render provider-directory inspection.
* @returns replay-safe generic call presentation.
*/
function presentInspectListCall() {
return {
card: "generic",
kind: "read",
title: "List Cordis Inspect Providers"
};
}
/**
* Render one provider query.
* @param args - target platform, provider, and method.
* @returns replay-safe generic call presentation.
*/
function presentInspectQueryCall(args) {
return {
card: "generic",
kind: "read",
title: `Query Cordis ${args.platform} ${args.provider}.${args.method}`
};
}
//#endregion
//#region lib/types/index.js
const name = "tool-cordis";
const inject = ["tools", "cordisInspect"];
function requireAgent(exec) {
if (exec.agent === void 0) throw new Error("Cordis inspection requires an Agent-backed session");
return exec.agent;
}
/** Register read-only runtime inspection tools over the Host providers that
* `@deepseek-ai/dsh-tool-cordis/host` registers once per process.
* @param ctx Agent-scoped registration context.
*/
function apply(ctx) {
ctx.tools.register(defineTool({
name: "cordis_inspect_list",
description: "List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.",
parameters: {},
output: {
schema: { type: "json" },
render: (_args, value) => [{
type: "text",
text: JSON.stringify(value, null, 2)
}]
},
execute(_args, _exec) {
return Promise.resolve({ providers: ctx.cordisInspect.list() });
},
presentCall: presentInspectListCall
}));
ctx.tools.register(defineTool({
name: "cordis_inspect_query",
description: "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response within the configured timeout; otherwise it reports a Client failure or asks you to reconnect and retry. This Tool cannot invoke business Service methods or modify the runtime.",
parameters: {
platform: {
type: "string",
required: true,
enum: ["host", "client"],
description: "Runtime platform that owns the Provider."
},
provider: {
type: "string",
required: true,
description: "Exact Provider ID returned by cordis_inspect_list."
},
method: {
type: "string",
required: true,
description: "Exact method name declared by the Provider manifest."
},
input: {
type: "json",
description: "Optional query input; it must satisfy the method input schema."
}
},
output: {
schema: { type: "json" },
render: (_args, value) => [{
type: "text",
text: JSON.stringify(value, null, 2)
}]
},
async execute(args, exec) {
const data = await ctx.cordisInspect.query(args.platform, args.provider, args.method, args.input, requireAgent(exec), exec.signal);
return {
platform: args.platform,
provider: args.provider,
method: args.method,
data
};
},
presentCall: presentInspectQueryCall
}));
}
//#endregion
export { apply, inject, name };
Loaded 100 of 584 files, more files were not shown because too many files have changed in this diff. Show more