Compare commits

...
21 Commits
Author SHA1 Message Date
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
58 changed files with 4515 additions and 531 deletions

No files matched your search

+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
+6 -1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **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.
- **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 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.
@@ -38,9 +39,12 @@ 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)
- 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)
@@ -53,6 +57,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.
Generated
+12 -12
View File
@@ -1999,7 +1999,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 +2526,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2667,7 +2667,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 +2770,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -4993,7 +4993,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 +6339,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 +6403,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 +7213,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -7245,7 +7245,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 +8364,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]]
+41 -1
View File
@@ -13,6 +13,7 @@ use codewhale_engine::runtime_threads::{
};
use codewhale_execpolicy::ApprovalMode;
use codewhale_runtime::config::Config;
use codewhale_runtime::features::Feature;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
@@ -1122,13 +1123,14 @@ fn validate_mcp_servers(params: &Value) -> std::result::Result<(), AcpError> {
None => Ok(()),
Some(Value::Array(servers)) if servers.is_empty() => Ok(()),
Some(Value::Array(_)) => Err(AcpError::invalid_params(
"Client-supplied MCP servers are unsupported by Codewhale's ACP adapter. Use an empty mcpServers list, or configure MCP through codewhale app-server --http and the Runtime API. ACP v1 stdio MCP support is incomplete.",
"Client-supplied MCP servers are unsupported by Codewhale's ACP adapter. Use an empty mcpServers list; sessions use the MCP servers and plugins configured in Codewhale itself.",
)),
Some(_) => Err(AcpError::invalid_params("mcpServers must be an array")),
}
}
fn initialize_result(client_protocol_version: Option<u64>, config: &Config) -> Value {
let features = config.features();
json!({
"protocolVersion": client_protocol_version
.map(|version| version.min(ACP_PROTOCOL_VERSION))
@@ -1145,6 +1147,14 @@ fn initialize_result(client_protocol_version: Option<u64>, config: &Config) -> V
"http": false,
"sse": false
},
"_meta": {
"codewhale": {
"plugins": features.enabled(Feature::ExtensionHost)
&& codewhale_plugins::activation::extension_host_policy_enabled(),
"configuredMcpServers": features.enabled(Feature::Mcp),
"clientMcpServers": false
}
},
// ACP `SessionCapabilities` fields are objects, never booleans:
// `{}` means "supported", absent/null means "not supported"
// (#5969 — a boolean here made JetBrains' strictly-typed client
@@ -1849,6 +1859,36 @@ mod tests {
assert_eq!(result["agentCapabilities"]["modelSelection"], true);
}
#[test]
fn initialize_advertises_configured_plugins_and_mcp_but_not_client_servers() {
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let mut config = Config::default();
config.set_feature("extension_host", true).unwrap();
config.set_feature("mcp", true).unwrap();
let result = initialize_result(Some(1), &config);
let meta = &result["agentCapabilities"]["_meta"]["codewhale"];
assert_eq!(meta["plugins"], true);
assert_eq!(meta["configuredMcpServers"], true);
assert_eq!(meta["clientMcpServers"], false);
assert_eq!(
result["agentCapabilities"]["mcpCapabilities"]["http"],
false
);
config.set_feature("extension_host", false).unwrap();
config.set_feature("mcp", false).unwrap();
let off = initialize_result(Some(1), &config);
assert_eq!(
off["agentCapabilities"]["_meta"]["codewhale"]["plugins"],
false
);
assert_eq!(
off["agentCapabilities"]["_meta"]["codewhale"]["configuredMcpServers"],
false
);
}
#[test]
fn extract_prompt_text_accepts_text_and_resource_blocks() {
let prompt = json!([
+41 -2
View File
@@ -227,6 +227,36 @@ mod platform {
);
Ok(())
}
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
let gone =
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
.is_ok_and(|selected| {
selected == 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 +375,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 +386,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 -1
View File
@@ -2035,7 +2035,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;
@@ -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)
@@ -509,8 +514,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)
+202 -1
View File
@@ -8,7 +8,8 @@ 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::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
@@ -296,3 +297,203 @@ 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(())
}
+50 -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,58 @@ 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();
}
#[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] {
+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;
}
@@ -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,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
},
}
@@ -44,7 +44,7 @@ before(async () => {
const adapterFor = (service) => ({ service, define: () => class extends Service { constructor(ctx) { super(ctx, service) } } })
test('the shipped adapter table holds the reviewed adapters, and the root still builds', () => {
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents'])
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents', 'remote'])
assert.ok(new HostRoot(rpc, 'plugin'))
assert.ok(new HostRoot(rpc, 'builtin'))
})
@@ -0,0 +1,68 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let remoteAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-remote-'))
const bundle = async (entry, name) => {
const outfile = join(dir, name)
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
return import(pathToFileURL(outfile).href)
}
;({ remoteAdapter } = await bundle('../src/dsh/remote.ts', 'remote.mjs'))
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
;({ Context } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
async function harness() {
const calls = []
const host = {
tier: 'plugin',
rpc: { request: async () => { throw new Error('remote never calls the core directly') }, notify() {} },
ownerOf: () => undefined,
liveInvocation: () => undefined,
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
log() {},
}
const ctx = new Context()
ctx.plugin(remoteAdapter.define(host))
await settle()
return { ctx, calls }
}
test('remote sends every method through the remote broker with a typed payload and nothing else', async () => {
const { ctx, calls } = await harness()
assert.deepEqual(await ctx.remote.status(), { ok: 'status' })
await ctx.remote.pair()
await ctx.remote.pair({ label: 'phone' })
await ctx.remote.clients()
await ctx.remote.revoke('cwrg_1')
assert.deepEqual(calls, [
['remote', 'status', {}],
['remote', 'pair', {}],
['remote', 'pair', { label: 'phone' }],
['remote', 'clients', {}],
['remote', 'revoke', { grant: 'cwrg_1' }],
])
})
test('remote refuses malformed arguments locally, before any broker call', async () => {
const { ctx, calls } = await harness()
assert.throws(() => ctx.remote.pair({ label: 3 }), (error) => error.code === ErrorCode.InvalidParams)
for (const id of [undefined, '', 7, 'x'.repeat(129)]) {
assert.throws(() => ctx.remote.revoke(id), (error) => error.code === ErrorCode.InvalidParams)
}
assert.equal(typeof ctx.remote.listen, 'undefined')
assert.deepEqual(calls, [])
})
@@ -0,0 +1,85 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let terminalAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-terminal-'))
const bundle = async (entry, name) => {
const outfile = join(dir, name)
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
return import(pathToFileURL(outfile).href)
}
;({ terminalAdapter } = await bundle('../src/dsh/terminal.ts', 'terminal.mjs'))
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
;({ Context } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
async function harness() {
const calls = []
const host = {
tier: 'plugin',
rpc: { request: async () => { throw new Error('terminal never calls the core directly') }, notify() {} },
ownerOf: () => undefined,
liveInvocation: () => undefined,
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
log() {},
}
const ctx = new Context()
ctx.plugin(terminalAdapter.define(host))
await settle()
return { ctx, calls }
}
test('terminal sends every method through the terminal broker with a typed payload and nothing else', async () => {
const { ctx, calls } = await harness()
assert.deepEqual(await ctx.terminal.list(), { ok: 'list' })
await ctx.terminal.run('make')
await ctx.terminal.run('make', { session: 'build', timeoutSecs: 30, cwd: '/', sandbox_permissions: 'danger' })
await ctx.terminal.send('build', 'y\n', { waitMs: 0 })
await ctx.terminal.wait('build')
await ctx.terminal.wait('build', { timeoutSecs: 600 })
await ctx.terminal.cancel('build')
await ctx.terminal.reset('build')
assert.deepEqual(calls, [
['terminal', 'list', {}],
['terminal', 'run', { command: 'make' }],
['terminal', 'run', { command: 'make', session: 'build', timeout_secs: 30 }],
['terminal', 'send', { session: 'build', text: 'y\n', wait_ms: 0 }],
['terminal', 'wait', { session: 'build' }],
['terminal', 'wait', { session: 'build', timeout_secs: 600 }],
['terminal', 'cancel', { session: 'build' }],
['terminal', 'reset', { session: 'build' }],
])
})
test('terminal refuses malformed arguments locally, before any broker call', async () => {
const { ctx, calls } = await harness()
const refused = (act) => assert.throws(act, (error) => error.code === ErrorCode.InvalidParams)
for (const command of [undefined, '', 7, ['ls']]) refused(() => ctx.terminal.run(command))
for (const name of [undefined, '', 7, '../term-1', 'dsh:b:x', 'a b', 'n'.repeat(65)]) {
refused(() => ctx.terminal.run('true', { session: name === undefined ? null : name }))
refused(() => ctx.terminal.send(name, 'x'))
refused(() => ctx.terminal.wait(name))
refused(() => ctx.terminal.cancel(name))
refused(() => ctx.terminal.reset(name))
}
refused(() => ctx.terminal.send('build', ''))
for (const secs of [0, 601, 1.5, '30']) {
refused(() => ctx.terminal.run('true', { timeoutSecs: secs }))
refused(() => ctx.terminal.wait('build', { timeoutSecs: secs }))
}
refused(() => ctx.terminal.send('build', 'x', { waitMs: 60_001 }))
for (const absent of ['spawn', 'kill', 'resize', 'read', 'exec']) assert.equal(typeof ctx.terminal[absent], 'undefined')
assert.deepEqual(calls, [])
})
+3 -1
View File
@@ -6242,7 +6242,9 @@ impl Engine {
};
}
if self.is_acp_turn() {
return self.acp_tool_build(input_policy, &route, allowed_tools);
return self
.acp_turn_tool_build(input_policy, &route, allowed_tools, mcp_access)
.await;
}
if self.rlm_host.is_some() {
return self.rlm_tool_build(input_policy, &route);
+84 -5
View File
@@ -40,11 +40,61 @@ impl Engine {
self.host_profile.is_acp() || self.turn_narrowing.is_acp()
}
pub(super) async fn acp_turn_tool_build(
&mut self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
mcp_access: McpAccess,
) -> TurnToolBuild {
if let Some(attachment) = self
.extension_host
.as_ref()
.filter(|_| self.config.features.enabled(Feature::ExtensionHost))
{
if attachment.selection_settled() {
attachment.sync_in_background();
} else {
attachment.sync_within(EXTENSION_FIRST_SYNC_WAIT).await;
}
}
let mcp = if self.config.features.enabled(Feature::Mcp) {
if mcp_access.may_connect() {
self.wait_for_mcp_boot().await;
let tools = self.mcp_tools().await;
let server_count = match self.mcp_pool.as_ref() {
Some(pool) => pool.lock().await.connected_servers().len(),
None => 0,
};
McpToolState::Live {
tools,
server_count,
}
} else {
self.passive_mcp_snapshot().await
}
} else {
McpToolState::Disabled
};
self.acp_tool_build_with_mcp(authority, route, allowed, mcp)
}
#[cfg(test)]
pub(super) fn acp_tool_build(
&self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
) -> TurnToolBuild {
self.acp_tool_build_with_mcp(authority, route, allowed, McpToolState::Disabled)
}
pub(super) fn acp_tool_build_with_mcp(
&self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
mcp: McpToolState,
) -> TurnToolBuild {
let mut context = self.build_tool_context_for_turn(authority, route);
let requested = self
@@ -85,6 +135,13 @@ impl Engine {
builder = builder.with_foreground_shell_tools();
}
let mut registry = builder.build(context);
let extension_host = self
.extension_host
.as_ref()
.filter(|_| self.config.features.enabled(Feature::ExtensionHost));
if let Some(attachment) = extension_host {
attachment.manager().note_native_names(registry.names());
}
if let Some(overrides) = self
.api_config
.tools
@@ -95,15 +152,28 @@ impl Engine {
remove_acp_overridden_builtin(&mut registry, name);
}
}
let plugin_tool_names: HashSet<String> = extension_host
.map(|attachment| attachment.install_tools(&mut registry))
.unwrap_or_default()
.into_iter()
.collect();
let mut mcp_tools = if self.child_host.is_none() {
mcp.tools().to_vec()
} else {
Vec::new()
};
mcp_tools.sort_by(|a, b| a.name.cmp(&b.name));
let mcp_tool_names: Vec<String> = mcp_tools.iter().map(|tool| tool.name.clone()).collect();
let allowed = Some(
registry
.names()
.into_iter()
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
.map(str::to_string)
.chain(mcp_tool_names.iter().cloned())
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
.collect(),
);
let catalog = self.child_host.as_ref().map_or_else(
let mut catalog = self.child_host.as_ref().map_or_else(
|| registry.to_api_tools_with_cache(true),
|child| {
child
@@ -111,6 +181,15 @@ impl Engine {
.tools_for_model(&registry, &child.authority.agent_type)
},
);
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
}
catalog.extend(mcp_tools.into_iter().map(|mut tool| {
tool.defer_loading = Some(false);
tool
}));
let always_load = catalog.iter().map(|tool| tool.name.clone()).collect();
TurnToolBuild {
surface: ToolSurfacePolicy::new(
@@ -125,11 +204,11 @@ impl Engine {
self.config.max_tool_calls,
tool_catalog::ToolMode::Direct,
),
mcp_tool_names: Vec::new(),
mcp: McpToolState::Disabled,
mcp_tool_names,
mcp,
subagent_runtime_model: None,
mailbox: None,
plugin_tool_names: HashSet::new(),
plugin_tool_names,
}
}
}
@@ -179,6 +179,49 @@ async fn child_ceiling_filters_acp_foreground_catalog_without_optional_allow_lis
assert!(!dir.path().join("blocked.txt").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn child_engine_gets_no_mcp_tools_from_the_acp_tool_build() {
let dir = tempdir().unwrap();
let _home = crate::test_support::SealedHome::at(dir.path());
let (child, _handle, _authority, route) = fixture(dir.path(), None);
let (mut root, _root_handle) = Engine::new(
EngineConfig {
workspace: dir.path().into(),
snapshots_enabled: false,
memory_enabled: false,
..Default::default()
},
&route.api_config,
);
root.host_profile = EngineHostProfile::Acp;
let authority =
TurnAuthority::from_effective_fields(AppMode::Agent, true, false, true, ApprovalMode::Auto);
let live = || McpToolState::Live {
tools: vec![api_tool("mcp_fixture_echo")],
server_count: 1,
};
let offered = root.acp_tool_build_with_mcp(&authority, &route, None, live());
assert_eq!(offered.mcp_tool_names, ["mcp_fixture_echo"]);
assert!(
offered
.surface
.catalog
.iter()
.any(|tool| tool.name == "mcp_fixture_echo")
);
assert!(offered.surface.passes_allow_list("mcp_fixture_echo"));
let build = child.acp_tool_build_with_mcp(&authority, &route, None, live());
assert!(build.mcp_tool_names.is_empty());
assert!(
!build
.surface
.catalog
.iter()
.any(|tool| tool.name == "mcp_fixture_echo")
);
assert!(!build.surface.passes_allow_list("mcp_fixture_echo"));
}
#[tokio::test(flavor = "current_thread")]
async fn child_registry_rejects_context_override_identity_before_tool_effect() {
let dir = tempdir().unwrap();
@@ -742,7 +742,10 @@ impl Engine {
if let Some(context) = context
&& (context.acp_host.is_some() || context.child_host.is_some())
{
if context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME {
let acp_mcp_call = context.acp_host.is_some() && McpPool::is_mcp_tool(&tool_name);
if !acp_mcp_call
&& (context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME)
{
let spec = registry
.and_then(|registry| registry.get(&tool_name))
.ok_or_else(|| {
+5 -1
View File
@@ -2044,7 +2044,11 @@ impl Engine {
)));
}
if blocked_error.is_none() && self.is_acp_turn() && !registry_has_spec {
if blocked_error.is_none()
&& self.is_acp_turn()
&& !registry_has_spec
&& !McpPool::is_mcp_tool(&tool_name)
{
blocked_error = Some(ToolError::not_available(format!(
"{tool_name} is outside the ACP foreground tool profile"
)));
@@ -1818,10 +1818,12 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
let echo: &[(DshService, super::dsh_broker::Arm)] =
&[(DshService::Workspace, echo_arm as super::dsh_broker::Arm)];
for service in DshService::ALL
.into_iter()
.filter(|s| *s != DshService::Network)
{
for service in DshService::ALL.into_iter().filter(|s| {
!matches!(
s,
DshService::Network | DshService::Remote | DshService::Terminal
)
}) {
let ticket = &tickets_a[service.name()];
let result = f
.broker(
@@ -2095,7 +2097,10 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
service.name()
);
}
if service != DshService::Network {
if !matches!(
service,
DshService::Network | DshService::Remote | DshService::Terminal
) {
let result = f
.broker(
TABLE,
@@ -2568,3 +2573,457 @@ async fn dsh_network_arm_refuses_foreign_tickets_unknown_methods_and_revoked_own
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
#[tokio::test]
async fn dsh_remote_broker_pairs_through_the_host_and_refuses_foreign_stale_and_unknown_calls() {
use super::dsh_broker::TABLE;
use super::protocol::DshService;
use super::{RemoteDenial, remote_grants};
use std::collections::BTreeMap;
let _grants = super::dsh_broker::TestGrants::only(&[DshService::Workspace, DshService::Remote]);
let tickets = |guard: &super::core_call::InvocationGuard| -> BTreeMap<String, String> {
guard
.broker_tickets()
.unwrap()
.into_iter()
.map(|wire| (wire.service, wire.ticket))
.collect()
};
let mut f = fixture(true);
f.in_session("remote-session-a");
let guard_a = f.begin(7, &f.owner);
let a = tickets(&guard_a);
let remote = a["remote"].clone();
let paired = f
.broker(
TABLE,
&f.owner,
&remote,
"remote",
"pair",
json!({"label": "phone"}),
)
.await
.unwrap();
let grant = paired["grant"].as_str().unwrap().to_string();
assert_eq!(paired["state"], "awaiting_host");
assert!(!paired.to_string().contains("cwrp_"), "{paired}");
let presented = remote_grants()
.awaiting_host()
.into_iter()
.find(|row| row.grant == grant)
.expect("the host sees the pairing");
assert_eq!(
(presented.plugin_id.as_str(), presented.session_id.as_str()),
("a", "remote-session-a")
);
let client = remote_grants().redeem(&presented.secret, "pixel").unwrap();
assert_eq!(
remote_grants().redeem(&presented.secret, "pixel"),
Err(RemoteDenial::Replayed)
);
assert_eq!(
remote_grants().authorize(&client.token, "remote-session-a"),
Ok(grant.clone())
);
assert_eq!(
remote_grants().authorize(&client.token, "remote-session-b"),
Err(RemoteDenial::ForeignSession)
);
let unknown = f
.broker(TABLE, &f.owner, &remote, "remote", "tunnel", json!({}))
.await;
assert_eq!(
dsh_refusal(&unknown),
(error_code::INVALID_PARAMS, "unknown_method".into())
);
let wrong_service = f
.broker(
TABLE,
&f.owner,
&a["workspace"],
"remote",
"status",
json!({}),
)
.await;
assert_eq!(
dsh_refusal(&wrong_service),
(error_code::REFUSED, "ticket_refused".into())
);
let foreign_plugin = f
.broker(
TABLE,
&f.other,
&remote,
"remote",
"revoke",
json!({"grant": grant}),
)
.await;
assert_eq!(
dsh_refusal(&foreign_plugin),
(error_code::REFUSED, "ticket_refused".into())
);
f.in_session("remote-session-b");
let guard_b = f.begin(7, &f.owner);
let remote_b = tickets(&guard_b)["remote"].clone();
let foreign_session = f
.broker(
TABLE,
&f.owner,
&remote_b,
"remote",
"revoke",
json!({"grant": grant}),
)
.await;
assert_eq!(
dsh_refusal(&foreign_session),
(error_code::INVALID_PARAMS, "invalid_input".into())
);
let listed_b = f
.broker(TABLE, &f.owner, &remote_b, "remote", "clients", json!({}))
.await
.unwrap();
assert_eq!(listed_b, json!({"clients": []}));
assert_eq!(
remote_grants().authorize(&client.token, "remote-session-a"),
Ok(grant.clone())
);
drop(guard_a);
let stale = f
.broker(
TABLE,
&f.owner,
&remote,
"remote",
"revoke",
json!({"grant": grant}),
)
.await;
assert_eq!(
dsh_refusal(&stale),
(error_code::REFUSED, "ticket_refused".into())
);
assert!(
remote_grants()
.authorize(&client.token, "remote-session-a")
.is_ok(),
"a paired client outlives the invocation that asked for it"
);
f.in_session("remote-session-a");
let guard_a2 = f.begin(7, &f.owner);
let remote_a2 = tickets(&guard_a2)["remote"].clone();
let listed = f
.broker(TABLE, &f.owner, &remote_a2, "remote", "clients", json!({}))
.await
.unwrap();
assert_eq!(listed["clients"][0]["grant"], grant.as_str());
assert_eq!(listed["clients"][0]["state"], "paired");
assert!(!listed.to_string().contains("cwrc_"), "{listed}");
let revoked = f
.broker(
TABLE,
&f.owner,
&remote_a2,
"remote",
"revoke",
json!({"grant": grant}),
)
.await
.unwrap();
assert_eq!(revoked, json!({"grant": grant, "revoked": true}));
assert_eq!(
remote_grants().authorize(&client.token, "remote-session-a"),
Err(RemoteDenial::Unknown)
);
drop(guard_a2);
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn terminal_asked(f: &Fixture) -> usize {
f.server.asked.load(Ordering::SeqCst)
}
#[cfg(all(unix, not(target_env = "ohos")))]
#[tokio::test]
async fn dsh_terminal_arm_runs_through_the_gate_in_the_plugins_own_thread_owned_terminals() {
use super::dsh_broker::{TABLE, TestGrants};
use super::dsh_terminal::MAX_TERMINALS;
use super::protocol::DshService;
use crate::tools::terminal_session;
use codewhale_agent_contract::worker_profile::ShellPolicy;
use codewhale_sandbox::SandboxPolicy;
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Terminal]);
let mut f = fixture(true);
let workspace = f.context.workspace.clone();
let specs = crate::tools::registry::ToolRegistryBuilder::new()
.with_file_tools()
.with_terminal_tools()
.build(ToolContext::new(&workspace))
.all();
f.gate = f.gate.clone().for_extension(f.caller(), specs);
f.context.elevated_sandbox_policy = Some(SandboxPolicy::DangerFullAccess);
f.context.state_namespace = "thread-a".into();
f.in_session("terminal-session-a");
let guard_a = f.begin(7, &f.owner);
let terminal = broker_ticket(&guard_a, "terminal").unwrap();
let other_service = broker_ticket(&guard_a, "workspace").unwrap();
let run = json!({"command": "printf 'dsh-%s' terminal", "session": "build"});
let unknown = f
.broker(TABLE, &f.owner, &terminal, "terminal", "spawn", json!({}))
.await;
assert_eq!(
dsh_refusal(&unknown),
(error_code::INVALID_PARAMS, "unknown_method".into())
);
let escaped = f
.broker(
TABLE,
&f.owner,
&terminal,
"terminal",
"run",
json!({"command": "true", "session": "../term-1"}),
)
.await;
assert_eq!(
dsh_refusal(&escaped),
(error_code::INVALID_PARAMS, "invalid_input".into())
);
for (owner, ticket) in [
(&f.other, terminal.as_str()),
(&f.owner, other_service.as_str()),
(&f.owner, guard_a.ticket()),
] {
let result = f
.broker(TABLE, owner, ticket, "terminal", "run", run.clone())
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::REFUSED, "ticket_refused".into())
);
}
let (cx, _violations, host_cancel) = HostRequestContext::for_test(51);
host_cancel.cancel();
let cancelled = f
.broker_in(
cx,
TABLE,
&f.owner,
&terminal,
"terminal",
"run",
run.clone(),
)
.await;
assert_eq!(
dsh_refusal(&cancelled),
(error_code::CANCELLED, "cancelled".into())
);
assert_eq!(terminal_asked(&f), 0, "nothing refused reached the gate");
assert!(
terminal_session::owned_sessions(&workspace, "thread-a")
.unwrap()
.is_empty(),
"nothing refused started a shell"
);
let empty = f
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
.await
.unwrap();
assert_eq!(empty["terminals"], json!([]));
assert_eq!(empty["instance"], terminal_session::instance_id());
assert_eq!(terminal_asked(&f), 0, "a listing asks no card");
let ran = f
.broker(TABLE, &f.owner, &terminal, "terminal", "run", run.clone())
.await
.unwrap();
assert_eq!(ran["ok"], true, "{ran}");
assert_eq!(ran["session"], "build");
assert!(
ran["output"].as_str().unwrap().contains("dsh-terminal"),
"{ran}"
);
assert_eq!(terminal_asked(&f), 1, "the command went through the gate");
let terminal_id = ran["terminal_id"].as_str().unwrap().to_string();
let owned = terminal_session::owned_sessions(&workspace, "thread-a").unwrap();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].name, "dsh:a:build");
assert_eq!(owned[0].id, terminal_id);
let shell = terminal_session::lookup(&workspace, Some("thread-a"), &terminal_id)
.expect("the thread's own terminal routes reach the shell the broker started");
terminal_session::resize_session(&shell.lock().unwrap(), 30, 100).unwrap();
assert!(terminal_session::lookup(&workspace, Some("thread-b"), &terminal_id).is_none());
let receipts = guard_a.receipts().expect("the terminal call is receipted");
assert_eq!(receipts["total"], 1, "{receipts}");
assert!(receipts.to_string().contains("terminal/run"), "{receipts}");
terminal_session::get_or_create(
"thread-a",
"term-1",
&workspace,
SandboxPolicy::DangerFullAccess,
&f.context.shell_manager,
)
.unwrap();
for method in ["send", "wait", "cancel", "reset"] {
let mut input = json!({"session": "term-1"});
if method == "send" {
input["text"] = json!("exit\n");
}
let result = f
.broker(TABLE, &f.owner, &terminal, "terminal", method, input)
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::INVALID_PARAMS, "invalid_input".into()),
"{method}: the model's own shell is not the plugin's"
);
}
let listed = f
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
.await
.unwrap();
assert_eq!(listed["terminals"].as_array().unwrap().len(), 1, "{listed}");
assert_eq!(listed["terminals"][0]["session"], "build");
assert_eq!(listed["terminals"][0]["terminal_id"], terminal_id.as_str());
assert_eq!(listed["terminals"][0]["running"], true);
assert_eq!(terminal_asked(&f), 1);
let allowed = f.context.clone();
for (denied_tools, policy, list_code) in [
(vec!["terminal/run".to_string()], ShellPolicy::Full, None),
(
vec!["terminal/wait".to_string()],
ShellPolicy::Full,
Some("refused"),
),
(Vec::new(), ShellPolicy::ReadOnly, None),
(Vec::new(), ShellPolicy::None, Some("refused")),
] {
f.context = allowed.clone().with_shell_policy(policy);
f.context.disallowed_tools = denied_tools.clone();
let narrowed = f.begin(7, &f.owner);
let ticket = broker_ticket(&narrowed, "terminal").unwrap();
if denied_tools != ["terminal/wait"] {
let result = f
.broker(TABLE, &f.owner, &ticket, "terminal", "run", run.clone())
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::REFUSED, "refused".into()),
"{denied_tools:?} {policy:?}"
);
}
let listing = f
.broker(TABLE, &f.owner, &ticket, "terminal", "list", json!({}))
.await;
match list_code {
Some(code) => assert_eq!(dsh_refusal(&listing), (error_code::REFUSED, code.into())),
None => assert!(listing.is_ok(), "{listing:?}"),
}
drop(narrowed);
}
f.context = allowed;
assert_eq!(
terminal_asked(&f),
1,
"a caller that may not use the tool never reaches the gate"
);
for n in 1..MAX_TERMINALS {
terminal_session::get_or_create(
"thread-a",
&format!("dsh:a:n{n}"),
&workspace,
SandboxPolicy::DangerFullAccess,
&f.context.shell_manager,
)
.unwrap();
}
let over = f
.broker(
TABLE,
&f.owner,
&terminal,
"terminal",
"run",
json!({"command": "true", "session": "one-too-many"}),
)
.await;
assert_eq!(dsh_refusal(&over), (error_code::REFUSED, "refused".into()));
assert_eq!(
terminal_asked(&f),
1,
"the limit is checked before the card"
);
f.context.state_namespace = "thread-b".into();
f.in_session("terminal-session-b");
let guard_b = f.begin(7, &f.owner);
let terminal_b = broker_ticket(&guard_b, "terminal").unwrap();
let elsewhere = f
.broker(TABLE, &f.owner, &terminal_b, "terminal", "list", json!({}))
.await
.unwrap();
assert_eq!(
elsewhere["terminals"],
json!([]),
"another session sees none"
);
let foreign = f
.broker(
TABLE,
&f.owner,
&terminal_b,
"terminal",
"wait",
json!({"session": "build"}),
)
.await;
assert_eq!(
dsh_refusal(&foreign),
(error_code::INVALID_PARAMS, "invalid_input".into())
);
drop(guard_a);
let stale = f
.broker(TABLE, &f.owner, &terminal, "terminal", "list", json!({}))
.await;
assert_eq!(
dsh_refusal(&stale),
(error_code::REFUSED, "ticket_refused".into())
);
assert_eq!(
terminal_session::kill_owner_sessions(&workspace, "thread-a").unwrap(),
MAX_TERMINALS + 1,
"deleting the thread ends the plugin's shells with the thread's own"
);
assert!(terminal_session::lookup(&workspace, Some("thread-a"), &terminal_id).is_none());
f.manager.shared.core_calls.revoke_owner("a");
let revoked = f
.broker(TABLE, &f.owner, &terminal_b, "terminal", "list", json!({}))
.await;
assert_eq!(
dsh_refusal(&revoked),
(error_code::REFUSED, "ticket_refused".into())
);
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
+566 -3
View File
@@ -1,6 +1,569 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Remote)
pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
pub const CLIENT_TTL: Duration = Duration::from_secs(30 * 60);
pub const MAX_PENDING_PAIRINGS: usize = 4;
const MAX_LABEL_CHARS: usize = 64;
const GRANT_PREFIX: &str = "cwrg_";
const PAIRING_PREFIX: &str = "cwrp_";
const CLIENT_PREFIX: &str = "cwrc_";
#[derive(Deserialize)]
#[serde(
tag = "method",
content = "input",
rename_all = "snake_case",
deny_unknown_fields
)]
enum RemoteMethod {
Status {},
Pair {
#[serde(default)]
label: Option<String>,
},
Clients {},
Revoke {
grant: String,
},
}
impl BrokerMethods for RemoteMethod {
const NAMES: &'static [&'static str] = &["status", "pair", "clients", "revoke"];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePairing {
pub grant: String,
pub plugin_id: String,
pub session_id: String,
pub label: String,
pub secret: String,
pub expires_in_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteClient {
pub grant: String,
pub session_id: String,
pub token: String,
pub expires_in_seconds: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteDenial {
Unknown,
Expired,
Replayed,
ForeignSession,
}
struct Client {
token_digest: [u8; 32],
label: String,
expires_at: Instant,
}
struct Grant {
plugin_id: String,
session_id: String,
label: String,
pairing_digest: [u8; 32],
pairing: Option<String>,
pairing_expires_at: Instant,
client: Option<Client>,
}
impl Grant {
fn state(&self, now: Instant) -> &'static str {
match &self.client {
Some(client) if now < client.expires_at => "paired",
Some(_) => "expired",
None if now < self.pairing_expires_at => "awaiting_host",
None => "expired",
}
}
fn retire_at(&self, pairing_ttl: Duration) -> Instant {
self.client
.as_ref()
.map_or(self.pairing_expires_at, |client| client.expires_at)
+ pairing_ttl
}
}
#[derive(Default)]
struct Grants {
by_id: HashMap<String, Grant>,
by_pairing: HashMap<[u8; 32], String>,
by_client: HashMap<[u8; 32], String>,
}
impl Grants {
fn remove(&mut self, id: &str) -> Option<Grant> {
let grant = self.by_id.remove(id)?;
self.by_pairing.remove(&grant.pairing_digest);
if let Some(client) = &grant.client {
self.by_client.remove(&client.token_digest);
}
Some(grant)
}
fn prune(&mut self, now: Instant, pairing_ttl: Duration) {
let retired: Vec<String> = self
.by_id
.iter()
.filter(|(_, grant)| now >= grant.retire_at(pairing_ttl))
.map(|(id, _)| id.clone())
.collect();
for id in retired {
self.remove(&id);
}
}
}
pub struct RemoteGrants {
pairing_ttl: Duration,
client_ttl: Duration,
grants: Mutex<Grants>,
}
pub fn remote_grants() -> &'static RemoteGrants {
static GRANTS: OnceLock<RemoteGrants> = OnceLock::new();
GRANTS.get_or_init(|| RemoteGrants::new(PAIRING_TTL, CLIENT_TTL))
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(std::future::ready(remote_grants().serve(&call)))
}
impl RemoteGrants {
#[must_use]
pub fn new(pairing_ttl: Duration, client_ttl: Duration) -> Self {
Self {
pairing_ttl,
client_ttl,
grants: Mutex::new(Grants::default()),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Grants> {
let mut grants = self
.grants
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
grants.prune(Instant::now(), self.pairing_ttl);
grants
}
fn serve(&self, call: &BrokerCall) -> Result<Value, BrokerRefusal> {
let plugin = call.owner.plugin_id.as_str();
let session = call.session_id.as_str();
match decode::<RemoteMethod>(call)? {
RemoteMethod::Status {} => Ok(self.status(plugin, session)),
RemoteMethod::Pair { label } => self.pair(plugin, session, label.as_deref()),
RemoteMethod::Clients {} => Ok(self.clients(plugin, session)),
RemoteMethod::Revoke { grant } => self.revoke(plugin, session, &grant),
}
}
fn status(&self, plugin: &str, session: &str) -> Value {
let grants = self.lock();
let now = Instant::now();
let mut awaiting = 0;
let mut paired = 0;
for grant in grants.by_id.values() {
if grant.plugin_id != plugin || grant.session_id != session {
continue;
}
match grant.state(now) {
"awaiting_host" => awaiting += 1,
"paired" => paired += 1,
_ => {}
}
}
json!({
"transport": "runtime_http_sse",
"pairing": "host_presented",
"public_listener": false,
"tunnel": false,
"awaiting_host": awaiting,
"paired": paired,
"max_pending": MAX_PENDING_PAIRINGS,
})
}
fn pair(
&self,
plugin: &str,
session: &str,
label: Option<&str>,
) -> Result<Value, BrokerRefusal> {
let label = clean_label(label).map_err(|message| BrokerRefusal::InvalidInput {
service: DshService::Remote,
message,
})?;
let mut grants = self.lock();
let now = Instant::now();
let pending = grants
.by_id
.values()
.filter(|grant| {
grant.plugin_id == plugin
&& grant.session_id == session
&& grant.state(now) == "awaiting_host"
})
.count();
if pending >= MAX_PENDING_PAIRINGS {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Remote,
message: format!(
"this plugin already has {MAX_PENDING_PAIRINGS} pairings awaiting the host in this session; revoke one first"
),
});
}
let id = random_capability(GRANT_PREFIX);
let secret = random_capability(PAIRING_PREFIX);
let pairing_digest = digest(&secret);
grants.by_pairing.insert(pairing_digest, id.clone());
grants.by_id.insert(
id.clone(),
Grant {
plugin_id: plugin.to_string(),
session_id: session.to_string(),
label: label.clone(),
pairing_digest,
pairing: Some(secret),
pairing_expires_at: now + self.pairing_ttl,
client: None,
},
);
Ok(json!({
"grant": id,
"label": label,
"state": "awaiting_host",
"expires_in_seconds": self.pairing_ttl.as_secs(),
}))
}
fn clients(&self, plugin: &str, session: &str) -> Value {
let grants = self.lock();
let now = Instant::now();
let mut rows: Vec<Value> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.plugin_id == plugin && grant.session_id == session)
.map(|(id, grant)| {
let expires_at = grant
.client
.as_ref()
.map_or(grant.pairing_expires_at, |client| client.expires_at);
json!({
"grant": id,
"label": grant.label,
"client": grant.client.as_ref().map(|client| client.label.clone()),
"state": grant.state(now),
"expires_in_seconds": expires_at.saturating_duration_since(now).as_secs(),
})
})
.collect();
rows.sort_by(|a, b| a["grant"].as_str().cmp(&b["grant"].as_str()));
json!({ "clients": rows })
}
fn revoke(&self, plugin: &str, session: &str, grant: &str) -> Result<Value, BrokerRefusal> {
let mut grants = self.lock();
let owned = grants
.by_id
.get(grant)
.is_some_and(|row| row.plugin_id == plugin && row.session_id == session);
if !owned {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Remote,
message: "no such remote grant for this plugin and session".into(),
});
}
grants.remove(grant);
Ok(json!({ "grant": grant, "revoked": true }))
}
#[must_use]
pub fn awaiting_host(&self) -> Vec<RemotePairing> {
let grants = self.lock();
let now = Instant::now();
let mut rows: Vec<RemotePairing> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.state(now) == "awaiting_host")
.filter_map(|(id, grant)| {
Some(RemotePairing {
grant: id.clone(),
plugin_id: grant.plugin_id.clone(),
session_id: grant.session_id.clone(),
label: grant.label.clone(),
secret: grant.pairing.clone()?,
expires_in_seconds: grant
.pairing_expires_at
.saturating_duration_since(now)
.as_secs(),
})
})
.collect();
rows.sort_by(|a, b| a.grant.cmp(&b.grant));
rows
}
pub fn redeem(&self, secret: &str, client_label: &str) -> Result<RemoteClient, RemoteDenial> {
let mut grants = self.lock();
let now = Instant::now();
let id = grants
.by_pairing
.get(&digest(secret))
.cloned()
.ok_or(RemoteDenial::Unknown)?;
let client_ttl = self.client_ttl;
let grant = grants.by_id.get_mut(&id).ok_or(RemoteDenial::Unknown)?;
if grant.client.is_some() {
return Err(RemoteDenial::Replayed);
}
if now >= grant.pairing_expires_at {
return Err(RemoteDenial::Expired);
}
let token = random_capability(CLIENT_PREFIX);
let token_digest = digest(&token);
grant.pairing = None;
grant.client = Some(Client {
token_digest,
label: clean_label(Some(client_label)).unwrap_or_default(),
expires_at: now + client_ttl,
});
let session_id = grant.session_id.clone();
grants.by_client.insert(token_digest, id.clone());
Ok(RemoteClient {
grant: id,
session_id,
token,
expires_in_seconds: client_ttl.as_secs(),
})
}
pub fn authorize(&self, token: &str, session_id: &str) -> Result<String, RemoteDenial> {
let grants = self.lock();
let id = grants
.by_client
.get(&digest(token))
.ok_or(RemoteDenial::Unknown)?;
let grant = grants.by_id.get(id).ok_or(RemoteDenial::Unknown)?;
let client = grant.client.as_ref().ok_or(RemoteDenial::Unknown)?;
if Instant::now() >= client.expires_at {
return Err(RemoteDenial::Expired);
}
if grant.session_id != session_id {
return Err(RemoteDenial::ForeignSession);
}
Ok(id.clone())
}
pub fn revoke_session(&self, session_id: &str) -> usize {
let mut grants = self.lock();
let ids: Vec<String> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.session_id == session_id)
.map(|(id, _)| id.clone())
.collect();
for id in &ids {
grants.remove(id);
}
ids.len()
}
}
fn clean_label(label: Option<&str>) -> Result<String, String> {
let label = label.unwrap_or("").trim();
if label.chars().any(char::is_control) {
return Err("a remote label may not contain control characters".into());
}
if label.chars().count() > MAX_LABEL_CHARS {
return Err(format!(
"a remote label is at most {MAX_LABEL_CHARS} characters"
));
}
Ok(label.to_string())
}
fn digest(secret: &str) -> [u8; 32] {
Sha256::digest(secret.as_bytes()).into()
}
fn random_capability(prefix: &str) -> String {
format!(
"{prefix}{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::OwnerRef;
fn call(plugin: &str, session: &str, method: &str, input: Value) -> BrokerCall {
let owner = OwnerRef {
plugin_id: plugin.into(),
generation: 1,
owner_token: "owner".into(),
};
BrokerCall {
context: super::super::dsh_broker::BrokerContext::detached_for_test(
&owner,
DshService::Remote,
),
owner,
session_id: session.into(),
service: DshService::Remote,
method: method.into(),
input,
}
}
#[tokio::test]
async fn remote_grants_pair_present_redeem_once_and_revoke_bound_to_plugin_and_session() {
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
let paired = grants
.serve(&call("a", "s1", "pair", json!({"label": "phone"})))
.unwrap();
let id = paired["grant"].as_str().unwrap().to_string();
assert_eq!(paired["state"], "awaiting_host");
assert!(!paired.to_string().contains(PAIRING_PREFIX));
let shown = grants.awaiting_host();
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].plugin_id.as_str(), shown[0].session_id.as_str()),
("a", "s1")
);
let client = grants.redeem(&shown[0].secret, "pixel").unwrap();
assert_eq!(client.session_id, "s1");
assert_eq!(
grants.redeem(&shown[0].secret, "pixel"),
Err(RemoteDenial::Replayed)
);
assert!(grants.awaiting_host().is_empty());
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
assert_eq!(
grants.authorize(&client.token, "s2"),
Err(RemoteDenial::ForeignSession)
);
let listed = grants
.serve(&call("a", "s1", "clients", json!({})))
.unwrap();
assert_eq!(listed["clients"][0]["state"], "paired");
assert_eq!(listed["clients"][0]["client"], "pixel");
assert!(!listed.to_string().contains(CLIENT_PREFIX));
for (plugin, session) in [("b", "s1"), ("a", "s2")] {
assert_eq!(
grants
.serve(&call(plugin, session, "clients", json!({})))
.unwrap(),
json!({"clients": []})
);
assert!(matches!(
grants.serve(&call(plugin, session, "revoke", json!({"grant": id}))),
Err(BrokerRefusal::InvalidInput { .. })
));
}
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
assert_eq!(
grants
.serve(&call("a", "s1", "revoke", json!({"grant": id})))
.unwrap(),
json!({"grant": id, "revoked": true})
);
assert_eq!(
grants.authorize(&client.token, "s1"),
Err(RemoteDenial::Unknown)
);
}
#[tokio::test]
async fn remote_grants_refuse_stale_pairings_and_clients_and_cap_pending_pairings() {
let stale = RemoteGrants::new(Duration::ZERO, Duration::from_secs(60));
stale.serve(&call("a", "s", "pair", json!({}))).unwrap();
let secret = {
let grants = stale.grants.lock().unwrap();
grants.by_id.values().next().unwrap().pairing.clone()
};
assert!(stale.awaiting_host().is_empty());
assert_eq!(
stale.redeem(&secret.unwrap_or_default(), "x"),
Err(RemoteDenial::Unknown)
);
let expiring = RemoteGrants::new(Duration::from_secs(60), Duration::ZERO);
expiring.serve(&call("a", "s", "pair", json!({}))).unwrap();
let secret = expiring.awaiting_host()[0].secret.clone();
let client = expiring.redeem(&secret, "x").unwrap();
assert_eq!(
expiring.authorize(&client.token, "s"),
Err(RemoteDenial::Expired)
);
let capped = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
for _ in 0..MAX_PENDING_PAIRINGS {
capped.serve(&call("a", "s", "pair", json!({}))).unwrap();
}
assert!(matches!(
capped.serve(&call("a", "s", "pair", json!({}))),
Err(BrokerRefusal::InvalidInput { .. })
));
assert!(capped.serve(&call("b", "s", "pair", json!({}))).is_ok());
assert!(matches!(
capped.serve(&call("a", "s", "pair", json!({"label": "a\u{7}"}))),
Err(BrokerRefusal::InvalidInput { .. })
));
assert_eq!(capped.revoke_session("s"), MAX_PENDING_PAIRINGS + 1);
assert!(capped.awaiting_host().is_empty());
}
#[tokio::test]
async fn remote_methods_decode_strictly_and_refuse_unknown_methods() {
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
for method in ["listen", "tunnel", "expose", "anything"] {
assert!(matches!(
grants.serve(&call("a", "s", method, json!({}))),
Err(BrokerRefusal::UnknownMethod {
service: DshService::Remote,
..
})
));
}
for (method, input) in [
("pair", json!({"label": 3})),
("pair", json!({"label": "x", "public": true})),
("revoke", json!({})),
("status", Value::Null),
] {
assert!(
matches!(
grants.serve(&call("a", "s", method, input.clone())),
Err(BrokerRefusal::InvalidInput { .. })
),
"{method} {input}"
);
}
let status = grants.serve(&call("a", "s", "status", json!({}))).unwrap();
assert_eq!(status["public_listener"], false);
assert_eq!(status["transport"], "runtime_http_sse");
}
}
@@ -1,6 +1,524 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
use std::path::Path;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Terminal)
use codewhale_agent_contract::worker_profile::ShellPolicy;
use codewhale_workflow_js::ToolCallResponse;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
use crate::tools::execution_envelope::enforce_tool_denial;
const SUPPORTED: bool = cfg!(all(unix, not(target_env = "ohos")));
const DEFAULT_SESSION: &str = "term-1";
const MAX_SESSION_NAME: usize = 64;
const MAX_COMMAND_BYTES: usize = 16 * 1024;
const MAX_INPUT_BYTES: usize = 64 * 1024;
const MAX_TIMEOUT_SECS: u64 = 600;
const MAX_SETTLE_MS: u64 = 60_000;
pub(super) const MAX_TERMINALS: usize = 8;
const LOG: &str = "codewhale::dsh_terminal";
static REGISTRY_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum TerminalMethod {
#[serde(rename = "list")]
List(NoInput),
#[serde(rename = "run")]
Run(RunInput),
#[serde(rename = "send")]
Send(SendInput),
#[serde(rename = "wait")]
Wait(WaitInput),
#[serde(rename = "cancel")]
Cancel(SessionInput),
#[serde(rename = "reset")]
Reset(SessionInput),
}
impl BrokerMethods for TerminalMethod {
const NAMES: &'static [&'static str] = &["list", "run", "send", "wait", "cancel", "reset"];
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NoInput {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunInput {
command: String,
#[serde(default)]
session: Option<String>,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SendInput {
session: String,
text: String,
#[serde(default)]
wait_ms: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitInput {
session: String,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SessionInput {
session: String,
}
#[derive(Serialize)]
struct Terminal {
terminal_id: String,
session: String,
state: String,
running: bool,
exit_code: Option<u32>,
updated_at: String,
}
struct Live {
instance: String,
terminals: Vec<Terminal>,
}
impl Live {
fn holds(&self, session: &str) -> bool {
self.terminals
.iter()
.any(|terminal| terminal.session == session)
}
fn existing(&self, session: &str) -> Result<(), BrokerRefusal> {
if self.holds(session) {
Ok(())
} else {
Err(invalid(
"this plugin has no live terminal with that name in this session; `list` reports the live ones",
))
}
}
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let method = decode::<TerminalMethod>(&call)?;
if call.context.cancel().is_cancelled() {
return Err(refused("the terminal call was withdrawn"));
}
match method {
TerminalMethod::List(NoInput {}) => {
let live = live(&call, "terminal/wait").await?;
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
Ok(json!({
"instance": live.instance,
"limit": MAX_TERMINALS,
"terminals": live.terminals,
}))
}
TerminalMethod::Run(RunInput {
command,
session,
timeout_secs,
}) => {
let session = session_name(session.as_deref().unwrap_or(DEFAULT_SESSION))?;
if command.trim().is_empty()
|| command.len() > MAX_COMMAND_BYTES
|| command.contains('\0')
{
return Err(invalid(format!(
"a command is 1 to {MAX_COMMAND_BYTES} bytes with no NUL"
)));
}
let timeout_secs = timeout(timeout_secs)?;
let live = live(&call, "terminal/run").await?;
if !live.holds(session) && live.terminals.len() >= MAX_TERMINALS {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "terminal limit reached");
return Err(refused(
"this plugin already holds the most terminals it may open in this session",
));
}
let mut input =
json!({ "command": command, "session": owned_name(&call, session) });
if let Some(timeout_secs) = timeout_secs {
input["timeout_secs"] = json!(timeout_secs);
}
nested(&call, "terminal/run", session, input).await
}
TerminalMethod::Send(SendInput {
session,
text,
wait_ms,
}) => {
let session = session_name(&session)?;
if text.is_empty() || text.len() > MAX_INPUT_BYTES {
return Err(invalid(format!(
"terminal input is 1 to {MAX_INPUT_BYTES} bytes"
)));
}
if wait_ms.is_some_and(|wait_ms| wait_ms > MAX_SETTLE_MS) {
return Err(invalid(format!("wait_ms is at most {MAX_SETTLE_MS}")));
}
live(&call, "terminal/send").await?.existing(session)?;
let mut input = json!({ "session": owned_name(&call, session), "text": text });
if let Some(wait_ms) = wait_ms {
input["wait_ms"] = json!(wait_ms);
}
nested(&call, "terminal/send", session, input).await
}
TerminalMethod::Wait(WaitInput {
session,
timeout_secs,
}) => {
let session = session_name(&session)?;
let timeout_secs = timeout(timeout_secs)?;
live(&call, "terminal/wait").await?.existing(session)?;
let mut input = json!({ "session": owned_name(&call, session) });
if let Some(timeout_secs) = timeout_secs {
input["timeout_secs"] = json!(timeout_secs);
}
nested(&call, "terminal/wait", session, input).await
}
TerminalMethod::Cancel(SessionInput { session }) => {
let session = session_name(&session)?;
live(&call, "terminal/cancel").await?.existing(session)?;
let input = json!({ "session": owned_name(&call, session) });
nested(&call, "terminal/cancel", session, input).await
}
TerminalMethod::Reset(SessionInput { session }) => {
let session = session_name(&session)?;
live(&call, "terminal/reset").await?.existing(session)?;
let input = json!({ "session": owned_name(&call, session) });
nested(&call, "terminal/reset", session, input).await
}
}
})
}
fn invalid(message: impl Into<String>) -> BrokerRefusal {
BrokerRefusal::InvalidInput {
service: DshService::Terminal,
message: message.into(),
}
}
fn refused(message: &str) -> BrokerRefusal {
BrokerRefusal::Refused {
service: DshService::Terminal,
message: message.to_string(),
}
}
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
let ok = !name.is_empty()
&& name.len() <= MAX_SESSION_NAME
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
if ok {
Ok(name)
} else {
Err(invalid(format!(
"a terminal name is 1 to {MAX_SESSION_NAME} letters, digits, `_`, `-` or `.`"
)))
}
}
fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
match secs {
Some(secs) if !(1..=MAX_TIMEOUT_SECS).contains(&secs) => Err(invalid(format!(
"timeout_secs is between 1 and {MAX_TIMEOUT_SECS}"
))),
other => Ok(other),
}
}
fn prefix(plugin: &str) -> String {
format!("dsh:{plugin}:")
}
fn owned_name(call: &BrokerCall, session: &str) -> String {
format!("{}{session}", prefix(&call.owner.plugin_id))
}
async fn live(call: &BrokerCall, tool_name: &'static str) -> Result<Live, BrokerRefusal> {
if !SUPPORTED {
return Err(BrokerRefusal::NotAvailable(DshService::Terminal));
}
let mut tool = call.context.tool().clone();
let _ = tool.refresh_live_posture();
let needs_full = matches!(
tool_name,
"terminal/run" | "terminal/send" | "terminal/reset"
);
if !tool.shell_policy.allows_shell()
|| (needs_full && tool.shell_policy != ShellPolicy::Full)
|| enforce_tool_denial(&tool, tool_name, &json!({})).is_err()
{
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "terminal refused: the caller may not use this shell tool");
return Err(refused("terminals are not available to this caller"));
}
let workspace = tool.workspace.clone();
let owner = tool.state_namespace.clone();
let mine = prefix(&call.owner.plugin_id);
let read = async move {
let permit = REGISTRY_GATE.acquire().await.map_err(|_| ())?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
owned(&workspace, &owner, &mine)
})
.await
.map_err(|_| ())?
.map_err(|error| {
tracing::warn!(target: LOG, %error, "terminal registry read failed");
})
};
tokio::select! {
biased;
() = call.context.cancel().cancelled() => Err(refused("the terminal call was withdrawn")),
live = read => live.map_err(|()| refused("the terminal registry could not be read")),
}
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn owned(workspace: &Path, owner: &str, mine: &str) -> Result<Live, String> {
use crate::tools::terminal_session;
let terminals = terminal_session::owned_sessions(workspace, owner)?
.into_iter()
.filter_map(|info| {
let session = info.name.strip_prefix(mine)?;
session_name(session).ok()?;
Some(Terminal {
session: session.to_string(),
state: match info.exit_code {
Some(_) => "exited".to_string(),
None => serde_json::to_value(info.state)
.ok()
.and_then(|state| state.as_str().map(str::to_owned))
.unwrap_or_default(),
},
running: info.exit_code.is_none(),
exit_code: info.exit_code,
terminal_id: info.id,
updated_at: info.updated_at,
})
})
.collect();
Ok(Live {
instance: terminal_session::instance_id().to_string(),
terminals,
})
}
#[cfg(not(all(unix, not(target_env = "ohos"))))]
fn owned(_workspace: &Path, _owner: &str, _mine: &str) -> Result<Live, String> {
Err("terminal sessions are not supported on this platform".to_string())
}
async fn nested(
call: &BrokerCall,
tool: &'static str,
session: &str,
input: Value,
) -> Result<Value, BrokerRefusal> {
let plugin = &call.owner.plugin_id;
match call.context.core_call(tool, input).await {
Ok(response) => {
tracing::info!(target: LOG, %plugin, tool, session, ok = response.ok, "terminal call ran through the gate");
Ok(outcome(session, response))
}
Err(refusal) => {
let code = refusal.code();
let detail = match &refusal {
BrokerRefusal::Denied { message, .. } | BrokerRefusal::Refused { message, .. } => {
message.as_str()
}
_ => "",
};
tracing::info!(target: LOG, %plugin, tool, session, code, detail, "terminal call not run");
Err(match refusal {
BrokerRefusal::Denied { .. } => BrokerRefusal::Denied {
service: DshService::Terminal,
message: "the terminal call was denied".to_string(),
},
_ => refused("the terminal call was refused"),
})
}
}
}
fn text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn outcome(session: &str, response: ToolCallResponse) -> Value {
if !response.ok {
return json!({ "ok": false, "session": session, "output": text(&response.result) });
}
let metadata = &response.result["metadata"];
json!({
"ok": true,
"session": session,
"output": text(&response.result["content"]),
"truncated": !response.result["truncated"].is_null(),
"status": metadata["status"],
"exit_code": metadata["exit_code"],
"cwd": metadata["cwd"],
"terminal_id": metadata["terminal_session_id"],
"state": metadata["terminal_state"],
"previous_terminal_id": metadata["previous_session_id"],
})
}
#[cfg(test)]
mod tests {
use super::super::dsh_broker::BrokerContext;
use super::*;
use codewhale_config::OwnerRef;
fn call(owner: &OwnerRef, method: &str, input: Value) -> BrokerCall {
BrokerCall {
owner: owner.clone(),
session_id: "session".into(),
service: DshService::Terminal,
method: method.into(),
input,
context: BrokerContext::detached_for_test(owner, DshService::Terminal),
}
}
fn owner() -> OwnerRef {
OwnerRef {
plugin_id: "a".into(),
generation: 1,
owner_token: "t".into(),
}
}
#[tokio::test]
async fn terminal_methods_decode_strictly_and_bound_every_input_before_anything_runs() {
let owner = owner();
for method in ["spawn", "open", "kill", "resize", "read", "exec", "core"] {
let refusal = serve(call(&owner, method, json!({}))).await.unwrap_err();
assert_eq!(refusal.code(), "unknown_method", "{method}");
}
let long_name = "n".repeat(MAX_SESSION_NAME + 1);
let long_command = "c".repeat(MAX_COMMAND_BYTES + 1);
let long_text = "t".repeat(MAX_INPUT_BYTES + 1);
for (method, input) in [
("list", json!({ "workspace": "/" })),
("run", json!({})),
("run", json!({ "command": "" })),
("run", json!({ "command": " \n" })),
("run", json!({ "command": "a\u{0}b" })),
("run", json!({ "command": long_command })),
("run", json!({ "command": "true", "session": "" })),
("run", json!({ "command": "true", "session": "../term-1" })),
("run", json!({ "command": "true", "session": "dsh:b:x" })),
("run", json!({ "command": "true", "session": long_name })),
("run", json!({ "command": "true", "timeout_secs": 0 })),
("run", json!({ "command": "true", "timeout_secs": 601 })),
(
"run",
json!({ "command": "true", "sandbox_permissions": "danger" }),
),
("run", json!({ "command": "true", "name": "exec_shell" })),
("send", json!({ "session": "x" })),
("send", json!({ "session": "x", "text": "" })),
("send", json!({ "session": "x", "text": long_text })),
(
"send",
json!({ "session": "x", "text": "y", "wait_ms": 60_001 }),
),
("wait", json!({ "session": "x", "timeout_secs": 601 })),
("wait", json!({})),
("cancel", json!({ "session": "a b" })),
("reset", json!({ "session": "x", "force": true })),
] {
let refusal = serve(call(&owner, method, input.clone()))
.await
.unwrap_err();
assert_eq!(refusal.code(), "invalid_input", "{method} {input}");
}
}
#[tokio::test]
async fn a_terminal_call_whose_cancel_has_fired_has_no_effect() {
let owner = owner();
let fired = call(&owner, "run", json!({ "command": "true" }));
fired.context.cancel().cancel();
let refusal = serve(fired).await.unwrap_err();
assert_eq!(refusal.code(), "refused");
}
#[test]
fn terminal_names_are_scoped_to_the_plugin_and_a_result_is_a_typed_projection() {
assert_eq!(prefix("a"), "dsh:a:");
assert!(
"dsh:a:b:x"
.strip_prefix(&prefix("a"))
.is_some_and(|rest| session_name(rest).is_err())
);
let ran = outcome(
"build",
ToolCallResponse {
ok: true,
result: json!({
"content": 42,
"truncated": null,
"metadata": {
"status": "completed",
"exit_code": 0,
"cwd": "/w",
"terminal_session_id": "id-1",
"terminal_state": "idle",
"state_path": "/private/state.json",
},
}),
},
);
assert_eq!(
ran,
json!({
"ok": true,
"session": "build",
"output": "42",
"truncated": false,
"status": "completed",
"exit_code": 0,
"cwd": "/w",
"terminal_id": "id-1",
"state": "idle",
"previous_terminal_id": null,
})
);
let failed = outcome(
"build",
ToolCallResponse {
ok: false,
result: json!("boom"),
},
);
assert_eq!(
failed,
json!({ "ok": false, "session": "build", "output": "boom" })
);
}
}
+1
View File
@@ -127,6 +127,7 @@ mod dsh_catalog;
mod dsh_llm;
mod dsh_network;
mod dsh_remote;
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
mod dsh_settings;
mod dsh_terminal;
mod dsh_workspace;
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -14568,10 +14568,7 @@ source_usage_records: Default::default(),
}
manager
};
let mut features = cfg.features();
if self.host_profile.is_acp() {
features.disable(codewhale_runtime::features::Feature::Mcp);
}
let features = cfg.features();
let engine_cfg = EngineConfig {
user_input_responder: true,
session_checkpoint_backend: Some(
@@ -1155,6 +1155,51 @@ async fn interrupting_a_pending_approval_answers_the_agents_request_as_cancelled
Ok(())
}
#[tokio::test]
async fn an_agent_that_ignores_cancel_is_stopped_after_the_grace_period() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(
workspace.join("fixture-plan.json"),
json!({ "ignoreCancel": true }).to_string(),
)?;
let manager = test_manager(root.path().join("runtime"))?;
let thread = manager
.create_thread(fixture_thread_request(&workspace, "ask"))
.await?;
let turn = manager.start_turn(&thread.id, prompt("never stop")).await?;
let deadline = Instant::now() + Duration::from_secs(30);
while fixture_lines(&workspace, "held-prompts.jsonl").is_empty() {
anyhow::ensure!(Instant::now() < deadline, "the agent never got the prompt");
sleep(Duration::from_millis(50)).await;
}
let interrupted = Instant::now();
manager.interrupt_turn(&thread.id, &turn.id).await?;
let settled = wait_for_turn(&manager, &turn.id).await?;
let waited = interrupted.elapsed();
assert_eq!(settled.status, RuntimeTurnStatus::Interrupted);
assert!(
settled
.error
.as_deref()
.is_some_and(|reason| reason.contains("ended its process")),
"the turn did not settle with the killed reason: {:?}",
settled.error
);
assert!(
waited >= Duration::from_secs(9) && waited < Duration::from_secs(15),
"the forced stop came after {waited:?}, outside the grace period plus five seconds"
);
manager.shutdown_and_wait().await?;
Ok(())
}
#[tokio::test]
async fn additional_directories_reach_only_an_agent_that_advertises_them() -> Result<()> {
if !node_available().await {
+2 -1
View File
@@ -708,7 +708,8 @@ pub fn enforce_tool_authority(
| "context_archive_get"
| "context_archive_search"
| "present"
) {
) && tool.extension_caller().is_none()
{
return Err(ToolError::not_available(format!(
"{name} is outside the ACP foreground tool profile"
)));
+36 -1
View File
@@ -1,7 +1,7 @@
#[path = "support/acp_conformance.rs"]
mod conformance;
use codewhale_engine::fleet::acp::{AcpClient, PermissionPolicy};
use codewhale_engine::fleet::acp::{AcpClient, ControlWrite, PermissionPolicy};
use codewhale_engine::fleet::host::{
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
LocalProcessFleetHostAdapter,
@@ -11,6 +11,18 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
const KNOWN: &[Allowed] = &[
Allowed {
side: Side::Client,
frame: "session/set_model request",
problem: "`session/set_model` is not a client request in official v1",
reason: "compatibility, kept: sent only to an agent that offers the pre-v1 `models` object and no `model` config option",
},
Allowed {
side: Side::Agent,
frame: "session/new response",
problem: "field `.models` is not in the official type",
reason: "the fixture's legacy leg offers the pre-v1 `models` object on purpose, to make the client send session/set_model",
},
Allowed {
side: Side::Agent,
frame: "initialize response",
@@ -149,6 +161,29 @@ fn the_acp_client_and_its_fixture_agents_speak_official_v1_shapes() {
},
));
let legacy_workspace = tempfile::tempdir().unwrap();
std::fs::write(
legacy_workspace.path().join("fixture-plan.json"),
r#"{"legacyModels":true}"#,
)
.unwrap();
frames.extend(captured(
"acp-agent.mjs",
legacy_workspace.path(),
&capture.path().join("legacy.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let created = client
.new_session(&identity, legacy_workspace.path(), &[], STEP)
.unwrap();
let model = created.offer.control("model").unwrap();
assert_eq!(model.write, Some(ControlWrite::SetModel));
client
.apply_control(&created.session_id, model, "legacy-b", STEP)
.unwrap();
},
));
let mut observed = Conformance::checking(&[Side::Client, Side::Agent]);
for (side, frame) in &frames {
observed.observe(*side, frame);
+13 -1
View File
@@ -96,7 +96,12 @@ function handle(message) {
record('session-params.jsonl', { method: message.method, params: message.params });
sessionId = `fixture-session-${process.pid}`;
cwd = message.params?.cwd ?? cwd;
result(message.id, { sessionId });
result(message.id, {
sessionId,
...(plan().legacyModels
? { models: { currentModelId: 'legacy-a', availableModels: [{ modelId: 'legacy-a', name: 'Legacy A' }, { modelId: 'legacy-b', name: 'Legacy B' }] } }
: {}),
});
break;
case 'session/load':
record('session-params.jsonl', { method: message.method, params: message.params });
@@ -106,11 +111,18 @@ function handle(message) {
notify('session/update', { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replayed answer' } } });
result(message.id, {});
break;
case 'session/set_model':
result(message.id, {});
break;
case 'session/set_config_option':
configModel = message.params?.value ?? null;
result(message.id, { configOptions: [] });
break;
case 'session/prompt':
if (plan().ignoreCancel) {
record('held-prompts.jsonl', { id: message.id });
break;
}
if (HANG) {
pendingPrompt = message;
break;
+2 -1
View File
@@ -23,7 +23,8 @@
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
"refused_services": [
+64 -8
View File
@@ -1208,7 +1208,7 @@ fn persist_plugin_state_with_directory_sync(
#[cfg(windows)]
fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt as _;
use windows::Win32::Foundation::WIN32_ERROR;
use windows::Win32::Foundation::{ERROR_ACCESS_DENIED, WIN32_ERROR};
use windows::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_TEMPORARY, MOVEFILE_REPLACE_EXISTING,
MOVEFILE_WRITE_THROUGH, MoveFileExW, SetFileAttributesW,
@@ -1237,12 +1237,17 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul
})?;
// The state lock serializes writers, but not everything that opens the
// stable file: another registry re-applies the state directory's
// stable file. Another registry re-applies the state directory's
// inheritable ACL before it takes that lock, and Defender or the indexer
// can scan the file. Each holds the destination only briefly, and
// MoveFileExW then reports a refusal that clears on its own. Re-attempt
// only the rename, on the schedule the other atomic writers share; the
// hardened temporary and the stable file are both left untouched.
// can scan the file; each holds it only briefly. A plugin UI grant holds
// it for up to an hour. MoveFileExW refuses to replace a destination that
// any handle has open, so on access denied publish with std::fs::rename,
// which replaces a destination whose holders share delete access (POSIX
// rename: NTFS, Windows 10 1709 or later), and flush through a handle
// opened beforehand because that rename does not write through. When that
// is refused too, re-attempt on the schedule the other atomic writers
// share; a failed attempt leaves the hardened temporary and the stable
// file untouched.
let mut attempt = 0;
// SAFETY: both paths are NUL-terminated and live.
while let Err(error) = unsafe {
@@ -1254,7 +1259,19 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul
} {
// MoveFileExW reports an HRESULT; the shared classifier reads the
// Win32 code it wraps.
let backoff = WIN32_ERROR::from_error(&error).and_then(|code| {
let code = WIN32_ERROR::from_error(&error);
if code == Some(ERROR_ACCESS_DENIED)
&& let Ok(Some(source)) = open_existing_regular_file(&temporary_path, true)
&& fs::rename(&temporary_path, path).is_ok()
{
temporary.disable_cleanup(true);
return source.sync_all().map_err(|error| {
format!(
"plugin state was published but its durability could not be confirmed: {error}"
)
});
}
let backoff = code.and_then(|code| {
codewhale_util::utils::windows_publish_retry_delay(
&std::io::Error::from_raw_os_error(code.0 as i32),
attempt,
@@ -3060,7 +3077,10 @@ pub fn capture_plugin_state_authority(
#[cfg(test)]
mod state_publication_tests {
use super::{PluginStateFile, harden_plugin_state_file, save_state_with_hardener};
use super::{
PluginStateFile, harden_plugin_state_file, open_existing_regular_file,
save_state_with_hardener,
};
fn prepare_private_directory(_path: &std::path::Path) {
#[cfg(unix)]
@@ -3093,6 +3113,42 @@ mod state_publication_tests {
assert_eq!(entries, [std::ffi::OsString::from("state-鲸.json")]);
}
#[test]
fn state_publication_replaces_a_stable_file_that_a_reader_still_holds_open() {
use std::io::Read as _;
let directory = tempfile::tempdir().unwrap();
prepare_private_directory(directory.path());
let state_path = directory.path().join("state.json");
std::fs::write(&state_path, b"old-authoritative-state").unwrap();
let mut held = open_existing_regular_file(&state_path, false)
.unwrap()
.unwrap();
save_state_with_hardener(
&state_path,
&PluginStateFile::default(),
harden_plugin_state_file,
)
.unwrap();
let mut published = open_existing_regular_file(&state_path, false)
.unwrap()
.unwrap();
assert!(!codewhale_util::files::same_file(&held, &published).unwrap());
let mut superseded = String::new();
held.read_to_string(&mut superseded).unwrap();
assert_eq!(superseded, "old-authoritative-state");
let mut current = String::new();
published.read_to_string(&mut current).unwrap();
assert!(current.contains("\"schema_version\": 1"));
let entries = std::fs::read_dir(directory.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(entries, [std::ffi::OsString::from("state.json")]);
}
#[test]
fn failed_temp_hardening_never_publishes_new_plugin_state() {
let directory = tempfile::tempdir().unwrap();
+6 -1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **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.
- **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 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.
@@ -38,9 +39,12 @@ 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)
- 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)
@@ -53,6 +57,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.
+1 -1
View File
@@ -541,7 +541,7 @@ Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}
Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Plugins, DSH and MCP tools are not yet attached to sessions driven over ACP (`codewhale serve --acp`); the ACP server's capabilities say so. Attaching them is in progress for 0.10.3. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
## Asking the core to run a tool
+6 -3
View File
@@ -873,10 +873,13 @@ MCP management for those integrations. The restricted tool catalog below is a
Codewhale host profile, not an ACP protocol restriction.
The trusted local ACP profile narrows Core to file/search/git/patch and admitted
foreground shell tools. Shell requires both editor terminal support and operator
foreground shell tools, plus reviewed plugin tools (`extension_host` feature) and
the tools of MCP servers configured in Codewhale (`mcp` feature), under the same
permission setting. Shell requires both editor terminal support and operator
`allow_shell`; a requested external sandbox that is unavailable removes shell.
MCP, dynamic tools, task/PTY/background shell, interpreter, subagent and RLM
lifecycles are unavailable on this transport. Built-in overrides remove the
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
interpreter, subagent and RLM lifecycles are unavailable on this transport.
Built-in overrides remove the
whole compatibility alias family. Final dispatch rechecks the profile, so a
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
remains read-only under Full Access. Full Access and the ordinary approval
+2 -2
View File
@@ -93,8 +93,8 @@ step "blocking-calls budget" "python3 scripts/check-blocking-calls-budget.py --u
python3 scripts/check-blocking-calls-budget.py
if command -v node >/dev/null 2>&1; then
step "website generated facts" \
"cd web && npm ci (if node_modules is missing) && npm run prebuild, then commit web/lib/facts.generated.ts and web/data/*.generated.json (check-facts.mjs names the stale file); for a stale docs/RUNTIME_ROUTES.md run node web/scripts/check-runtime-routes.mjs --write, then node web/scripts/derive-facts.mjs" \
bash -c 'rc=0; node web/scripts/check-facts.mjs || rc=1; node web/scripts/check-cloud-facts.mjs || rc=1; node web/scripts/check-runtime-routes.mjs || rc=1; exit "${rc}"'
"cd web && npm ci (if node_modules is missing) && npm run prebuild, then commit web/lib/facts.generated.ts and web/data/*.generated.json and web/data/agent-docs.release.json and agent-docs.release-index.json (check-facts.mjs and check-release-docs.mjs name the stale file); for a stale docs/RUNTIME_ROUTES.md run node web/scripts/check-runtime-routes.mjs --write, then node web/scripts/derive-facts.mjs" \
bash -c 'rc=0; node web/scripts/check-facts.mjs || rc=1; node web/scripts/check-cloud-facts.mjs || rc=1; node web/scripts/check-release-docs.mjs || rc=1; node web/scripts/check-runtime-routes.mjs || rc=1; exit "${rc}"'
else
echo "== website generated facts: SKIPPED, node is not on PATH; run cd web && npm run check:facts && node scripts/check-runtime-routes.mjs before pushing" >&2
fi
+2 -2
View File
@@ -1,10 +1,10 @@
import { AGENT_DOCS, buildTopicMarkdown } from "@/lib/agent-docs";
import { RELEASE_DOCS, buildTopicMarkdown } from "@/lib/release-docs";
export const dynamic = "force-static";
export const dynamicParams = false;
export function generateStaticParams() {
return AGENT_DOCS.map((document) => ({ topic: `${document.slug}.md` }));
return RELEASE_DOCS.topics.map((document) => ({ topic: `${document.slug}.md` }));
}
export async function GET(_request: Request, { params }: { params: Promise<{ topic: string }> }) {
+2 -2
View File
@@ -1,9 +1,9 @@
import { AGENT_DOCS, buildTopicMarkdown } from "@/lib/agent-docs";
import { RELEASE_DOCS, buildTopicMarkdown } from "@/lib/release-docs";
import { buildLlmsTxt } from "@/lib/llms-txt";
export const dynamic = "force-static";
export function GET() {
const body = [buildLlmsTxt(), ...AGENT_DOCS.map((document) => buildTopicMarkdown(document.slug))].join("\n\n");
const body = [buildLlmsTxt(), ...RELEASE_DOCS.topics.map((document) => buildTopicMarkdown(document.slug))].join("\n\n");
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" } });
}
+6 -6
View File
@@ -323,8 +323,8 @@
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "2c1092eecdbcde392e8778703e92b68ea738b7329886eac9f1eafb06d085138f",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
"sha256": "fee756fa0715db7c6c3e83c5fbd124b9b0faa0cb11dded91845d4c913839bc5b",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
}
]
},
@@ -334,8 +334,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "6ba5585f222d835e1552644e6e004cfd926500fc79c151c9eedd69c554331ad0",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
"sha256": "5599aca1526b6159a0a470f65a8c5e6bf75c9c3c2d6d6547006ce0f5217dd7b5",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
}
]
},
@@ -403,8 +403,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "6ba5585f222d835e1552644e6e004cfd926500fc79c151c9eedd69c554331ad0",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
"sha256": "5599aca1526b6159a0a470f65a8c5e6bf75c9c3c2d6d6547006ce0f5217dd7b5",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
},
{
"path": "docs/RUNTIME_ROUTES.md",
+35
View File
@@ -0,0 +1,35 @@
{
"tag": "v0.10.2",
"slugs": [
"install",
"guide",
"auth",
"account",
"providers",
"configuration",
"modes",
"review",
"work",
"pet",
"subagents",
"fleet",
"sandbox",
"trust",
"mcp",
"hooks",
"skills",
"claude-code",
"plugins",
"runtime-api",
"web",
"troubleshooting",
"contribution",
"runtime-reference",
"workflow-tutorial",
"plugin-authoring",
"vocabulary",
"constitution",
"tools"
],
"omitted": []
}
+523
View File
@@ -0,0 +1,523 @@
{
"format": "codewhale.release-docs.v1",
"tag": "v0.10.2",
"version": "0.10.2",
"topics": [
{
"id": "install",
"slug": "install",
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
}
],
"missing": []
},
{
"id": "guide",
"slug": "guide",
"sources": [
{
"path": "docs/GUIDE.md",
"sha256": "43b307ea1fc95898f1ec2484f0b9073b7774d14504d4b12c41e4bf0d5af3b336",
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, opeLine truncated
},
{
"path": "docs/INSTALL.md",
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
},
{
"path": "docs/KEYBINDINGS.md",
"sha256": "26d213c47e899e376f240d2f9f08efeac49911a7472ee7d7ed11fea249509ab3",
"content": "# Keybindings\n\n> 阅读简体中文版:[zh_hans/KEYBINDINGS.md](zh_hans/KEYBINDINGS.md)\n\nThis is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under \"Composer\" only takes effect when the composer is focused; one under \"Transcript\" only when the transcript has focus; and so on.\n\nGlobal key chords are not yet user-configurable — tracked for a future release (#436, #437). Hotbar slot actions are configurable with `[[hotbar]]` and `/hotbar`; the Hotbar activation chord remains `Alt-1` through `Alt-8`.\n\n## Global (any context)\n\n| Chord | Action |\n|----------------------|---------------------------------------------------------------|\n| `/help`, `F1` or `Ctrl-/` | Toggle the help overlay. `/help` is what the chrome advertises: it is the one route every terminal delivers, where `F1` is eaten by tmux and some emulators and `Ctrl-/` encoding varies |\n| `F2` | Toggle the typed Settings editor |\n| `F3` | Open the provider/model picker (same as `/provider`) |\n| `Ctrl-K` | Open the command palette (slash-command finder) |\n| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit |\n| `Ctrl-B` | Release the current shell wait so the turn can continue; foreground commands move into `/jobs`, and background tasks keep running. Use `/jobs` or `Bash` with `action: \"wait\"` to inspect output |\n| `Ctrl-D` | Quit (only when the composer is empty) |\n| `Tab` | When the composer is empty, cycle TUI mode: Plan → Work → Operate → Plan |\n| `Shift+Tab` | Cycle permission posture: Ask → Auto-Review → Full Access. Live regardless of composer contents or whether a turn is running (suppressed only while a modal other than Config is open) |\n| `Ctrl-T` | Cycle reasoning effort for the active model. Walks the same ladder as `/model` and `/effort` (catalog or documented route dialect). Always-thinking models omit `off`; Grok 4.6 includes `xhigh`. |\n| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |\n| `Ctrl-R` | Open the resume-session picker |\n| `Ctrl-L` | Compact the conversation context (status line shows progress; no-op while a compaction is already running) |\n| `Ctrl-O` | Open the reasoning detail for the selected or current turn, regardless of composer contents |\n| `Ctrl-Alt-O` | Open the whole-turn Turn Inspector, regardless of composer contents |\n| `Alt-V` / `Option-V` (macOS) | Open the details pager for the selected tool/sub-agent card; with nothing selected, the newest visible error, else the first visible card with details, else the most recent one. Click a card first to keep Alt-V on it while newer cards arrive. Terminals that emit the legacy Option-V glyph are also handled |\n| `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file tree in the workbar |\n| `Ctrl-]` or `Ctrl-Tab` | Cycle the pet view while the pet main view is showing; otherwise cycle the work dock: TODO → Agents → Jobs → Background. `Ctrl-]` is portable; `Ctrl-Tab` requires the kitty keyboard protocol. Terminal.app uses it to switch terminal tabs |\n| `Ctrl-Shift-Tab` | Cycle the pet view or work dock backwards where the terminal sends the kitty keyboard chord. Terminal.app uses it to switch terminal tabs |\n| `Alt-G` / `Alt-Shift-G` | Scroll transcript to top / bottom when the composer is empty |\n| `Alt-1`-`Alt-8` | Dispatch Hotbar slots 1-8 when no modal or inline picker is open |\n| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` | Select the workbar panel: Tasks / Agents / Context / Files |\n| `Ctrl-Alt-0` | Toggle the workbar off / back to the bottom placement |\n| `Alt-L` | Open the pager for the last message (composer empty) |\n| `Alt-P` / `Alt-A` / `Alt-Y` | Jump to Plan / Work, or request Full Access (`Alt-Y` is the legacy permission channel — Work + Full Access — not a separate mode; it honors a locked approval policy) |\n| `Ctrl-X` (Activity workbar) | Cancel all running background shell jobs |\n| `Esc` | Close topmost modal · cancel slash menu · dismiss toast. During a compact that is serving an in-flight turn, Esc stops the turn (the compact is collateral). |\n\n## Composer\n\nEditing the message you're about to send.\n\n| Chord | Action |\n|-----------------------------|---------------------------------------------------------|\n| `Enter` | Send when idle; queue while busLine truncated
}
],
"missing": []
},
{
"id": "auth",
"slug": "auth",
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
},
{
"path": "docs/PROVIDERS.md",
"sha256": "fd375b08942cfdc336986c1a7a0872df8a486c3feeea58b6d892f3d7a611c212",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: <https://cheaperinference.com/docs>.\n\nStandard Compute offers flat monthly plans with a compute budget. By default,\nrequests return HTTP 402 when it is used; eligible plans can enable Pay as you go\nto continue with additional prepaid charges. See its\n[budget policy](https://docs.standardcompute.com/budgets-and-limits).\nThe `standardcompute` model id Line truncated
},
{
"path": "docs/CODEWHALE_AGENT.md",
"sha256": "2b2955660667a8510ffb705f0e258e193d171bd80d013a0d6c4099025f8ebeba",
"content": "# Codewhale Agent — one identity, many surfaces\n\n\"Codewhale Agent\" is one product identity with many transport surfaces.\nThis page is the identity map: what each surface is, where each lives, and\nthe rules that keep them one product instead of several.\n\n## The one identity\n\n| Surface | Identity object | Where it lives | State |\n|---|---|---|---|\n| GitHub PR review | GitHub App bot `codewhale-agent[bot]` | GitHub org settings; repo variable `CODEWHALE_APP_ID` + secret `CODEWHALE_APP_PRIVATE_KEY` | `codewhale review --pr N --post` ships with the `codewhale-review.yml` workflow; it no-ops green until the App is configured (founder-gated), then posts as the App instead of the workflow token. Posting is always opt-in via `--post`. |\n| Commit co-author | `.github/AUTHOR_MAP` | repo | existing; harvested co-author credit lands in the contributor graph |\n| Chat channels | per-user bot registration bound to a Codewhale membership | control-plane `services/control-plane/src/integrations/chat/` BotGateway and adapters; contracts in `packages/contracts` | Source includes Telegram and Slack bot pairing and commands. Default read-only command allowlist; opt-in write verbs; approve/deny keyboards route as permission decisions. Availability depends on the deployed gateway and configured bot credentials. |\n\nSlack bot commands control Codewhale; they do not give an agent tools to search\nor read a Slack workspace. The Slack bot requires a user-managed Slack app,\ncredentials and webhook setup. Hosted Slack delivery and a real authenticated\nSlack task have not been qualified, so this source implementation is not a\nready-to-use Slack integration claim. External Slack tools require a separately\nconfigured and authenticated MCP server or API integration.\n\n## Rules that make it one identity\n\n- Every surface is I/O around the single `Engine::run_turn`\n (`crates/tui/src/core/engine/turn_loop.rs`). No surface gets its own\n engine or turn loop.\n- Every surface authenticates to the same Codewhale membership\n (`codewhale login` account session). Membership gates cloud agents;\n provider brands stay internal and invisible.\n- Approvals from any surface route back as permission decisions into the\n same engine. A bare message never executes a gated action.\n- Every command and approval is audited, on every channel.\n- All surfaces are FREE. Rate limits are anti-abuse only. There is no\n per-channel fee, message metering, team gating, or trial timer — and a\n billing hook on a channel feature is forbidden by the money model.\n\n## Naming\n\n- Product: Codewhale. Agent surface: \"Codewhale Agent\".\n- Bot handle wherever it can be chosen: `codewhale-agent`.\n- The agent speaks as \"Codewhale cloud agent\" — never as a provider brand.\n\n## Channel contract\n\nPer-channel command allowlists (default read-only: `status`, `jobs`,\n`receipts`, `help`; opt-in: `new task`, `approve`, `deny`), inline\napprove/deny keyboards routed as permission decisions, `assistant_changes`\nreply mode (message only when something changed), per-channel message\nformat adapters (including Telegram markdown and Slack blocks in source),\nquiet hours + digest, automation output routable\nto any paired channel, one pairing code bound inside the authenticated\napp, unbind revokes instantly.\n\nWeChat note: personal WeChat is supported only through Tencent's official\nClawBot (iLink) channel, never through unofficial account automation. The\nopen-source `integrations/weixin-bridge` uses it with a local\n`codewhale serve --http`, and it stops when Tencent's risk control objects.\nOfficial Account (公众号/服务号) APIs are not used.\n\n## Pointer\n\nChannel contracts live in `packages/contracts` (CWC repo); the gateway and\nadapters live in `services/control-plane/src` (CWC repo). The GitHub\nsurface lives in `.github/workflows/codewhale-review.yml` here.\n"
}
],
"missing": []
},
{
"id": "account",
"slug": "account",
"sources": [
{
"path": "docs/CONFIGURATION.md",
"sha256": "a42ae2b33032c404995a3fc2f34cf14c25698313e8fb0ea998dfd388b5d742bf",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `<codewhale_user_constitution>` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. Instruction files written for other tools (`CLAUDE.md`,\n `.claude/instructions.md`, `.cursorrules`, `.github/copilot-instructions.md`\n and similar) are read only when you opt in with the top-level\n `project_instruction_imports` key, for example `[\"claude\"]`, `[\"cursor\"]` or\n `[\"all\"]` (see `config.example.toml`); Codewhale warns when it finds one it\n did not load.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to Line truncated
},
{
"path": "docs/PROVIDERS.md",
"sha256": "fd375b08942cfdc336986c1a7a0872df8a486c3feeea58b6d892f3d7a611c212",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: <https://cheaperinference.com/docs>.\n\nStandard Compute offers flat monthly plans with a compute budget. By default,\nrequests return HTTP 402 when it is used; eligible plans can enable Pay as you go\nto continue with additional prepaid charges. See its\n[budget policy](https://docs.standardcompute.com/budgets-and-limits).\nThe `standardcompute` model id Line truncated
}
],
"missing": []
},
{
"id": "providers",
"slug": "providers",
"sources": [
{
"path": "docs/PROVIDERS.md",
"sha256": "fd375b08942cfdc336986c1a7a0872df8a486c3feeea58b6d892f3d7a611c212",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: <https://cheaperinference.com/docs>.\n\nStandard Compute offers flat monthly plans with a compute budget. By default,\nrequests return HTTP 402 when it is used; eligible plans can enable Pay as you go\nto continue with additional prepaid charges. See its\n[budget policy](https://docs.standardcompute.com/budgets-and-limits).\nThe `standardcompute` model id Line truncated
},
{
"path": "docs/MODEL_LAB.md",
"sha256": "e4f26dc6c65008e1ef3df19d6f113ed09399d788cadf7b9189214cb52744c4ef",
"content": "# Model Lab Roadmap\n\nModel Lab is the planned open-model workbench for Codewhale. The north star is\nsimple: Codewhale should make open-source and open-weight models practical in\nterminal coding workflows across every provider that offers them. Model Lab is how\nthose models become discoverable, evaluable, routable, servable, and exportable\nwithout weakening the current terminal-agent contract: local workspace control,\nexplicit provider auth, approval gates, and clear privacy boundaries.\n\nThis document is roadmap language. Some worksets below are roadmap-only.\n\n## Implemented Today\n\n- DeepSeek is the first-class default provider today, with `deepseek-v4-pro`,\n `deepseek-v4-flash`, streaming thinking blocks, Fin routing, `DEEPSEEK_*`\n environment variables, and `~/.deepseek` config compatibility.\n- OpenRouter, Novita, Fireworks, NVIDIA NIM, AtlasCloud, Wanjie Ark, Hugging\n Face Inference Providers, generic OpenAI-compatible endpoints, SGLang, vLLM,\n and Ollama are supported provider paths where their IDs appear in\n `/provider`, `codewhale --provider`, or `codewhale models`.\n- Hugging Face Inference Providers are available through the\n OpenAI-compatible router at `https://router.huggingface.co/v1`. Select the\n route with `huggingface`, `hugging-face`, `hugging_face`, or `hf`; configure\n `HUGGINGFACE_API_KEY` or `HF_TOKEN` for auth.\n- Model auto-routing chooses a concrete DeepSeek model and thinking level per\n turn. It is not a TUI mode.\n- Fin is the fast `deepseek-v4-flash` thinking-off path for routing,\n summaries, cheap checks, RLM child calls, wakeup verification, and\n binary-completion checks.\n- Self-hosted OpenAI-compatible endpoints can be used through SGLang, vLLM,\n Ollama, or the generic `openai` provider configuration.\n\n## Still Planned\n\n- A native Hugging Face Hub browser, model passport picker, or direct Hub search\n workflow. The OpenAI-compatible Hugging Face Inference Providers route is\n implemented separately as a chat provider.\n- Built-in Hugging Face model card, dataset, adapter, safetensors, Spaces, or\n Jobs workflows.\n- Native Unsloth, NeMo, or Arcee integrations.\n- A dedicated Model Lab UI tab.\n- Built-in eval leaderboards, hosted observability, or training-infrastructure\n orchestration.\n\nUntil those land, use the provider paths above, MCP servers, or external\nworkflows explicitly configured by the user.\n\n## Model Lab Principle\n\nModel Lab should help users answer practical questions:\n\n- Which model should handle this turn?\n- Which open or open-weight model can I run locally or through a trusted\n provider?\n- Which provider offers this model with the latency, price, context window,\n license, and privacy posture I need?\n- What did this model cost, how did it perform, and what data left my machine?\n- Can I reproduce, export, or self-host the route?\n\nIt should never hide provider boundaries, silently upload local artifacts, or\ndescribe a model as available before Codewhale can actually route to it.\n\n## Hugging Face Workset\n\nImplemented today:\n\n- Hugging Face Inference Providers as an explicit OpenAI-compatible router\n provider, selected with `huggingface`, `hugging-face`, `hugging_face`, or\n `hf`.\n- Model IDs are sent to the router exactly as selected, including\n org-prefixed Hugging Face model IDs.\n\nPlanned scope:\n\n- Hub API auth and model discovery.\n- Model cards, licenses, tags, safetensors metadata, adapters, and dataset\n links surfaced in a terminal-friendly way.\n- Native Hub browser and model-passport metadata on top of the already separate\n Hugging Face Inference Providers chat route.\n- Hugging Face Jobs as an optional remote execution path for user-approved\n experiments.\n\nNon-goal for now: claiming native Hub search, model passports, Spaces/Jobs, or\nModel Lab UI exists before those surfaces are implemented in code.\nThe inference-provider API key does not imply Hub browsing/export, upload, or\nJobs authorization.\n\n## Unsloth Workset\n\nPlanned scope:\n\n- Fine-tuning recipes and adapter workflows for users who already own the data\n and compute path.\n- Export guidance that keeps dataset, adapter, and checkpoint locations explicit.\n- Compatibility notes for models that can return to local serving or a hosted\n OpenAI-compatible endpoint.\n\n## NeMo Workset\n\nPlanned scope:\n\n- Training and alignment workflow notes for users operating NVIDIA-centric\n infrastructure.\n- Clear boundaries between NVIDIA NIM inference support that exists today and\n future NeMo training or customization workflows.\n\n## Arcee Workset\n\nPlanned scope:\n\n- Small-model routing and specialization experiments.\n- Exportable routes that make it clear when a task is handled by a smaller\n model, Fin, or full DeepSeek reasoning.\n\n## Serving Workset\n\nPlanned scope:\n\n- Better local and private serving ergonomics for SGLang, vLLM, Ollama, and\n OpenAI-compatible gateways.\n-Line truncated
}
],
"missing": []
},
{
"id": "configuration",
"slug": "configuration",
"sources": [
{
"path": "docs/CONFIGURATION.md",
"sha256": "a42ae2b33032c404995a3fc2f34cf14c25698313e8fb0ea998dfd388b5d742bf",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `<codewhale_user_constitution>` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. Instruction files written for other tools (`CLAUDE.md`,\n `.claude/instructions.md`, `.cursorrules`, `.github/copilot-instructions.md`\n and similar) are read only when you opt in with the top-level\n `project_instruction_imports` key, for example `[\"claude\"]`, `[\"cursor\"]` or\n `[\"all\"]` (see `config.example.toml`); Codewhale warns when it finds one it\n did not load.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to Line truncated
},
{
"path": "docs/LEGACY_PATHS.md",
"sha256": "89f7a556479d57911d9589af582c13a0c7494418d9d47f430d1bfa6c74ad3333",
"content": "# Legacy `.deepseek/` compatibility paths — audit & migration status (#3068)\n\n> 阅读简体中文版:[zh_hans/LEGACY_PATHS.md](zh_hans/LEGACY_PATHS.md)。\n\n## v0.9.10 cleanse ledger\n\nThis is the release-window decision record for residue that can otherwise look\ninterchangeable in a text search. A compatibility entry must name the contract\nthat still requires it; new production code must use a current path.\n\n| Class | Surface | Decision / contract |\n|---|---|---|\n| DELETE | Retired underwater build-version test override | Removed in `1cc4d1d1b`; the owner test was already deleted and the production-only build version remains authoritative. |\n| MIGRATE | Cross-task Agent Mail | `bd998495a` and managed-app `4ff7bc50d` replace guessed event aliases and same-session mailbox conflation with one durable typed runtime protocol. |\n| MIGRATE | TUI `status_message` writes | Active residue; touched flows must move to the typed toast/current-state owner. No new writes are allowed. |\n| COMPATIBILITY | `.deepseek/` state and stored provider aliases | Read fallback for upgraded installations and stored provider identity; writes remain Codewhale-only. Details are below. |\n| COMPATIBILITY | `agent_message` transcript/protocol decoding | Persisted transcripts and tool wire values require decoding; it is not the cross-task Agent Mail transport. |\n| COMPATIBILITY | Managed runtime envelope schema 1 | Previously persisted/enrolled runner replay; current local Codewhale Agent Mail producer is schema 2 and both normalize to the managed envelope. |\n| CURRENT | DeepSeek provider support | A real selectable provider, not product branding; provider-scoped names and configuration remain. |\n| CURRENT | Same-session subagent mailbox | Start/status/peek/message/followup/interrupt/wait/cancel stays scoped to a parent runtime session. |\n| CURRENT | Web locale dictionaries | The application and documentation routes have no page-local `isZh` branches; new copy continues through dictionaries. |\n| CURRENT | Safety, authorization, protocol, persistence, migration, and data-integrity tests | These protect external or durable contracts and are not copy/layout cleanup candidates. |\n\nSnapshot on 2026-08-19: `10,954` Rust `#[test]` attributes under\n`crates/tui`, `523` TUI `status_message` source lines, and zero page-local\n`isZh` files under `apps/web/app` in the paired managed-app candidate. Counts\nare inventory signals, not deletion targets.\n\nCodewhale was renamed from DeepSeek-TUI. To avoid breaking existing installs, the runtime reads\nstate from the new `~/.codewhale/` location but **falls back** to the legacy `~/.deepseek/` location,\nand always **writes** to `~/.codewhale/`. This doc audits each legacy reference and records a\nkeep / deprecate / remove decision so the migration is auditable.\n\n## The canonical resolver (use this for new code)\n\nState-dir resolution is consolidated in `crates/config/src/lib.rs`:\n\n| Symbol | Line | Purpose |\n|---|---|---|\n| `CODEWHALE_APP_DIR` / `LEGACY_APP_DIR` | 5369 | re-exported from `crates/paths` (defined at `crates/paths/src/lib.rs` 13 / 16) |\n| `codewhale_home()` | 5375 | `~/.codewhale` |\n| `legacy_deepseek_home()` | 5392 | `~/.deepseek` (legacy) |\n| `resolve_state_dir(subdir)` | 5434 | **read** path: `~/.codewhale/<subdir>`, falling back to `~/.deepseek/<subdir>` when only the legacy dir exists |\n| `ensure_state_dir(subdir)` | 5458 | **write** path: always creates under `~/.codewhale/<subdir>` |\n\nMigration contract: read-with-fallback, write-to-new. This preserves the v0.8.44 migration for\nusers who still have `~/.deepseek/` while steering all new writes to `~/.codewhale/`.\n\n## Per-path decisions\n\n**Decision for all legacy references below: keep-as-fallback.** Removing the `.deepseek` fallback\nwould strand users who upgraded in place and never re-ran onboarding. Revisit only after a release\nthat actively migrates `~/.deepseek/` → `~/.codewhale/` on first run and a deprecation window.\n\n| Reference | Routed through `resolve_state_dir`? | Decision |\n|---|---|---|\n| `config::resolve_state_dir` / `ensure_state_dir` | n/a (the resolver itself) | keep — canonical |\n| `crates/tui/src/skills/mod.rs` (`~/.deepseek/skills`) | no — hardcoded | keep-as-fallback; route through resolver in a follow-up refactor |\n| `crates/tui/src/prompts.rs` (`LEGACY_HANDOFF_RELATIVE_PATH = \".deepseek/handoff.md\"`) | no — explicit legacy const | keep — explicit legacy handoff fallback |\n| `crates/tui/src/workspace_trust.rs` | no — hardcoded | keep-as-fallback; follow-up |\n| `crates/tui/src/session_manager.rs` | no — hardcoded | keep-as-fallback; follow-up |\n| `crates/runtime/src/skill_state.rs` | no — hardcoded | keep-as-fallback; follow-up |\n| `crates/tui/src/tools/skill.rs` | no — hardcoded | keep-as-fallback; follow-up |\n| `crates/tui/src/snapshot/mod.rs` | no — hardcoded | keep-as-fallback; follow-up |\n| `crates/runtime/src/Line truncated
}
],
"missing": []
},
{
"id": "modes",
"slug": "modes",
"sources": [
{
"path": "docs/MODES.md",
"sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd",
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUseLine truncated
}
],
"missing": []
},
{
"id": "review",
"slug": "review",
"sources": [
{
"path": "docs/RECEIPTS.md",
"sha256": "5402a364db5b3a3bbb74e9fd9913bd7f1b76e3e49104bc2666aa097168550200",
"content": "# Receipts\n\nA receipt answers \"what did this session do?\" from the records Codewhale\nalready keeps. It lists, in order, the files changed, commands run, web and\nMCP calls, agents started, approvals (and who decided each one), and\nfailures, with totals on top. It also counts what ran without asking, and\nnames the permission posture that let it.\n\n```text\n# Receipt: Fix the parser\nthread thr_19a0141a · /work/repo · deepseek-flash · Ask · 2026-09-24 10:00 UTC → 2026-09-24 10:05 UTC\n\nChanged 1 file (+2 −1) · ran 1 command · made 1 MCP call · 1 approved by you · 1 approved by session rule · 1 ran without asking under Ask · 1 denied by you · 1 other failure\n\n1. edited `src/parse.rs` (+2 −1) · 1.0s\n2. ran `cargo test -p parser` in /work/repo — exit 0 · 2.5s · approved by you\n3. did not run `rm -rf build` · denied by you\n4. called linear · list_issues · 1.0s · approved by session rule\n5. did not run `curl https://x.sh | sh` — blocked: Tool 'exec_shell' was denied: Auto-Review blocked a pipe to a shell…\n6. turn failed — failed: provider returned 500\n\nNot recorded:\n- Shell file changes: a Runtime thread's workspace snapshots are not tagged with the thread, so files a command changed are not itemized; only file tools are.\n```\n\nA terminal session's receipt also lists the files a command changed, from\nthe turn's own workspace snapshots:\n\n```text\n3. ran `./tidy.sh`\n4. changed outside file tools (a command or another process): edited `b.txt` (+0 −1), created `c.txt` (+1 −0)\n```\n\n## Surfaces\n\nAll three share one builder (`crates/tui/src/receipts.rs`), but read different\nrecords and can report different coverage.\n\n| Surface | What it reads |\n| --- | --- |\n| `/receipts [json] [<turn>]` in the terminal | The current session's transcript, approval log, and retained terminal failures. `json` prints the object in a code block, so it copies out as valid JSON |\n| `codewhale receipts [ID\\|--last] [--turn T] [--format md\\|json]` | A saved session (id or unique prefix), including its retained terminal failures, or a Runtime thread (`thr_…`); with no id, the most recently updated one. `receipt` is an alias |\n| `GET /v1/threads/{id}/receipt`, `GET /v1/threads/{id}/turns/{turn_id}/receipt` | A Runtime thread (the app, `codewhale serve`), behind the normal `/v1` bearer boundary |\n\nAll three only read. They never call a provider, run a tool, write a file,\nor change runtime state.\n\nExec runs that offer tools, including `exec --auto`, save a session in every\noutput format, so `codewhale receipts --last` can read their receipts after\nthey finish. Resumed sessions and `--output-format stream-json` runs are also\nsaved. Other answer-only text and `--json` runs are saved only if conversation\ncompaction starts; use `--output-format stream-json` to always keep a session.\n\n`codewhale receipts` reads Runtime threads from the machine's default store\n(`tasks/runtime/`, or `$CODEWHALE_RUNTIME_DIR`). A thread kept in one\nterminal session's own store (`sessions/<id>/runtime/`) is not found by id;\nthe API reads whatever store its server owns.\n\n## Where the facts come from\n\n| Session kind | Record | Holds |\n| --- | --- | --- |\n| Terminal session | `sessions/<id>.json` | Every tool call and its result text, in order; each prompt's `<turn_meta>` names the posture the turn ran under; `turn_outcomes` records terminal turn status and errors |\n| Terminal session | `sessions/<id>/approval_receipts.jsonl` | Every approval ask and decision, with time and who decided |\n| Runtime thread | `tasks/runtime/turns`, `items` | Every tool call with its input, status, start and end time, and structured result (exit code, diff, agent status); each turn's `permission_posture` |\n| Runtime thread | `tasks/runtime/events/<thread>.jsonl` | `approval.required` / `approval.decided`, with the flags that say who decided |\n\nThe saved-session CLI and live `/receipts` read terminal turn failures,\nincluding model errors in exec runs, as `turn_failed` actions and count them\ntoward failures even when no tool failed. Truncating the conversation removes\noutcomes beyond the retained history, so later turns cannot inherit their failures.\nSaved sessions keep only the newest 64 recorded failed-turn outcomes. Older outcomes\nand legacy sessions without this record cannot provide a complete terminal\nfailure history from tool results alone.\n\n### Who decided an approval\n\n| Receipt says | Meaning | Recorded as |\n| --- | --- | --- |\n| approved by you / denied by you | A person answered: the terminal card, the app, the web mirror, or an API client acting for them | `decided_by: \"user\"`; Runtime event with no `auto` flag |\n| … by session rule | A remembered \"for this session\" rule answered | `decided_by: \"session_rule\"`; Runtime event with `auto` and a `grant_id` |\n| … by posture | The mode or permission posture answered without a prompt | `decided_by: Line truncated
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "a42ae2b33032c404995a3fc2f34cf14c25698313e8fb0ea998dfd388b5d742bf",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `<codewhale_user_constitution>` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. Instruction files written for other tools (`CLAUDE.md`,\n `.claude/instructions.md`, `.cursorrules`, `.github/copilot-instructions.md`\n and similar) are read only when you opt in with the top-level\n `project_instruction_imports` key, for example `[\"claude\"]`, `[\"cursor\"]` or\n `[\"all\"]` (see `config.example.toml`); Codewhale warns when it finds one it\n did not load.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to Line truncated
}
],
"missing": []
},
{
"id": "work",
"slug": "work",
"sources": [
{
"path": "docs/TOOL_SURFACE.md",
"sha256": "94238d1fc469c647f9e0f2727950bdd74cba0e1d64c93f828e26b101d2d3fbd5",
"content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with thirteen eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `task_shell_wait`\n13. `load_skill`\n14. `tool_search` (synthetic, always active)\n\nThe thirteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, and Plan registers only\nthe foreground shell surface (so no `task_shell_wait`), but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact Line truncated
},
{
"path": "docs/TOOL_LIFECYCLE.md",
"sha256": "bfb222f258d585410471033e7c9c0c2b18b2570d489bd9806b3af101ddc2bb62",
"content": "# Historical Tool-Surface Lifecycle Policy (v0.8.53)\n\n> 阅读简体中文版:[zh_hans/TOOL_LIFECYCLE.md](zh_hans/TOOL_LIFECYCLE.md)\n\n**Status:** Historical design record, not current runtime documentation. The\nv0.9.1 canonical action surface and replay-only alias contract are documented in\n[`RUNTIME_SIMPLIFICATION_DESIGN.md`](RUNTIME_SIMPLIFICATION_DESIGN.md) and\n[`TOOL_SURFACE.md`](TOOL_SURFACE.md). No catalog code landed in this old cycle — the code\nwork is **deferred**. This document is the umbrella policy for GitHub **#2681**,\nwith **#2682** and **#2683** as concrete instances of the planned diet. It\ndescribes *what will be done* and the invariants any future diet PR must hold.\n\n**Scope of related open work (do not contradict):**\n- PR **#2684** — subagent role vocabulary, lifecycle signals, eval ergonomics.\n Legacy subagent-name cleanup + guardrail tests in this policy rebase on #2684.\n- PR **#2685** — git-history active + RLM/field errors.\n\n**What actually happened, so you can read the rest as history:** the \"hidden\ncompatibility\" plan below was *not* what shipped in full. `exec_wait` and\n`exec_interact` were removed rather than kept dispatchable. The work-progress\nsurface is different: `todo_write` is the sole model-visible name, while\n`work_update`, `TodoWrite`, `todo`, `checklist_write`, and `checklist_update`\nstay registered as hidden-compatibility aliases of `TodoWriteTool` so old\ntranscripts replay; `checklist_add`, `checklist_list`, `todo_add`,\n`todo_update`, and `todo_list` are not registered and are not callable.\n`tts`/`speech` remain dispatchable. See [`TOOL_SURFACE.md`](TOOL_SURFACE.md)\nfor the shipped contract. Read §4 and §8 as a rejected catalog-diet proposal,\nnot as a guarantee that every listed alias is gone.\n\n**All file:line citations here were correct at v0.8.52/0.8.53 and are now\nexpired.** They have not been rewritten, because renumbering a historical\nrecord makes it look current. Symbols this document cites that no longer exist\nat all include `ARCEE_FIRST_TURN_NATIVE_TOOLS` and `apply_provider_tool_policy`\n(both removed by `1bfcced43c`, \"fix(engine): remove Arcee tool catalog\nexception\"), and the planned `HIDDEN_COMPATIBILITY_TOOLS` / `DEPRECATED_ALIASES`\nsets, which were never written. Resolve any symbol here against the tree before\nacting on it.\n\n---\n\n## 1. Purpose and the weaker-model problem\n\nCodewhale ships a large native tool surface. The first-turn *active* partition\nof that surface is what every model sees before it has run a single\n`tool_search_*` call. Today that active set contains several **near-duplicate\ntools** that map to the *same* implementation under different names:\n\n- `exec_wait` and `exec_shell_wait` are both `ShellWaitTool`\n (`crates/tui/src/tools/registry.rs:526,529`).\n- `exec_interact` and `exec_shell_interact` are both `ShellInteractTool`\n (`registry.rs:527,530`).\n- `tts` and `speech` are both `SpeechTool`\n (`registry.rs:787-792`, both deferred).\n- `todo_write` is the single model-visible `TodoWriteTool` surface;\n `todo_write`, `TodoWrite`, `todo`, `checklist_write`, and\n `checklist_update` are hidden compat aliases of it.\n\nFor a strong model, redundant names are harmless noise. For **weaker / smaller\nmodels** (the Arcee Trinity lane, `deepseek-v4-flash` child executors, and any\nnon-thinking executor), every additional near-duplicate in the visible set is a\nreal cost:\n\n- It widens the choice space with options that do *nothing distinct*, increasing\n wrong-tool selection and oscillation between synonyms.\n- It spends scarce first-turn catalog budget (Section 5) on zero-information\n entries.\n- It dilutes the \"one name = one thing\" contract that lets a small model reason\n about the surface at all.\n\nThe lifecycle policy exists to **shrink and discipline the model-visible\nsurface** without ever breaking the ability to replay an old transcript that\nreferenced a now-retired name.\n\n### Canonical work-tracking surface for v0.9.1\n\nThe model-visible progress surface is a single tool: `todo_write` (#4132).\nAgents and Fleet workers use it for concrete To-do / Work progress under the\nactive runtime thread or durable task.\n\n`task_*` and the Fleet/Workflow ledger remain the durable lifecycle owners.\nChecklist metadata is the model-visible projection of progress:\n`task_updates.checklist` carries the current items, completion percentage, and\nin-progress item.\n\n**The To-do is the only Work surface.** `update_plan` is conversational\nreasoning — strategy, context, and route notes for complex initiatives. It is\nnot a progress surface, must not duplicate To-do items, and plan-only state is\nnever rendered as a To-do snapshot.\n\n**No request re-states the list.** The model learns what is on the To-do the way\nit learns anything else: from its own `todo_write` call, whose input is the\nwhole list, and the short receipt it returned (counts and the Line truncated
}
],
"missing": []
},
{
"id": "pet",
"slug": "pet",
"sources": [
{
"path": "docs/PET_MODE.md",
"sha256": "33b6c3dae527ca950b9dab442741e198bf3f1bd78691da929aedff2628732cab",
"content": "# Pet mode\n\n> 阅读简体中文版:[zh_hans/PET_MODE.md](zh_hans/PET_MODE.md)\n\nPet mode turns the terminal you already use into a glanceable status view. A\nwhale fills the main view and shows what the session is doing: idle, working,\nwaiting for you, done, or failed. The message box stays at the bottom, so you\ncan keep typing. Replies and agents are one key away.\n\nEach terminal runs its own pet mode for its own session. With several terminals\nopen, you can look across them and see which one needs you, which is still\nworking, and which has finished.\n\n```text\nCodewhale ● working\n (the whale)\n ●\nF5 replies & agents · /pet off standard view\nCtrl+] view: whale\n╭──────────────────────────────────────────────────────────╮\n│❯ Type a follow-up… │\n╰──────────────────────────────────────────────────────────╯\n ● auto Shift+Tab to change work (Tab) 2 agents\n```\n\n## Turn it on\n\n```text\n/pet on # pet mode becomes the main view\n/pet off # back to the standard view\n/pet # toggle\n/pet status # on or off, whether the habitat is open, and companion identity\n```\n\n`/pet on` and `/pet off` are saved as the `pet_mode` setting (also in the\nsettings editor), so a new session starts the way you left it. Pet mode never\nstarts a turn, calls a model, or changes your draft, mode, or approvals; it only\npresents the session you are already running.\n\n## What the whale tells you\n\nThe first line of the view and the mark under the whale always carry the state\nas a mark and a word, so it reads without colour or motion:\n\n| State | Mark and word | Meaning |\n| --- | --- | --- |\n| Ready | `○ idle` | Nothing running. |\n| Working | `● working` | A turn is running, including tools and verification. |\n| Needs you | waiting or approval phase | The session is waiting for an approval or an answer. Motion is reduced while it waits. |\n| Done | `✓ Done` | The last turn completed. |\n| Failed | `✕ failed` | The last turn ended in failure. |\n\nThe status bar underneath keeps its usual live readouts, such as the agent\ncount (`2 agents`) and the work bar. Transient notices, such as a retry\n(`Retry attempt: transport 1/3; network error; waiting 0.94s`), show on the\nsecond line.\n\n## Views\n\n`Ctrl+]` switches the pet to its next view while the pet main view is showing.\nTerminals with the kitty keyboard protocol can also send `Ctrl+Tab` for the\nnext view and `Ctrl+Shift+Tab` for the previous view. Terminal.app uses those\nTab chords to switch terminal tabs; use `Ctrl+]` there. The\nfooter's second line names the current view, `Ctrl+] view: whale`, and\nclicking it also moves to the next view. `/pet avatar <key>` jumps straight to\none.\n\n| View | What it draws | When its requirement is missing |\n| --- | --- | --- |\n| `whale` | The companion's live dot whale. | Shows `cove` (or `braille`) until the companion is reachable (`codewhale pet serve`) and this terminal's session is its source (`/pet source`). |\n| `cove` | The local half-block whale, without the translucent cove scenery. | Needs colour: `braille` is shown under `NO_COLOR`, a 16-colour terminal, or an unknown background. `COLORTERM=truecolor` enables true colour; `CODEWHALE_APPEARANCE=dark` or `light` selects a known background. |\n| `braille` | The local whale in coloured Braille dots. | Shows only the state words under `CODEWHALE_ASCII_SAFE` or when the art area is smaller than 16x8 cells. |\n| `pixels` | The companion's whale as a kitty-graphics image. | Needs kitty graphics, synchronized output, a known cell size in pixels, and `CODEWHALE_PET_GRAPHICS` not set to `braille`; otherwise shows `particles`. Terminal.app has no kitty graphics. |\n| `particles` | The companion's Braille particle grid. | Shows the resting whale and `offline` while the companion is unreachable. |\n| `whale-girl` | The bundled sprite avatar. | Needs colour, as `cove` does; otherwise shows a one-ink ASCII form. |\n| Installed avatar packs | Each pack, in catalog order. | Shows `particles` while the pack's atlas loads. |\n\nThe order is `whale → cove → braille → pixels → particles → whale-girl →`\ninstalled packs, then back to `whale`. A view that cannot draw is never\nskipped: it shows its fallback, and the line under the title says what is\nmissing, for example `pixels needs kitty graphics · showing particles`.\n\n## Replies and agents on demand\n\nThe main view stays quiet. Press `F5`, or run `/pet inspect`, to open the\ninspector over it:\n\n- the whale and an **Agents** list on top, with each agent's state and usage;\n- a **Reply** pane below, which Line truncated
}
],
"missing": []
},
{
"id": "subagents",
"slug": "subagents",
"sources": [
{
"path": "docs/SUBAGENTS.md",
"sha256": "a80734a8ef34b138533db2fcc44698a7f8160eecd56b8573b09ce3d4bbf72d9e",
"content": "# Fleet and sub-agents\n\n> 阅读简体中文版:[zh_hans/SUBAGENTS.md](zh_hans/SUBAGENTS.md)\n\nFleet manages saved models and role assignments for these same sub-agents.\nUse `agent` for an individual assignment and `workflow` for phases with\ndependencies and completion checks. See [Workflow authoring](WORKFLOW_AUTHORING.md)\nfor plans that use the Fleet model shortlist.\n\nFleet roles are the user-facing vocabulary for delegated work: a parent\nlaunches a focused `general`, `explore`, `planner`, `reviewer`, `implement`,\n`test`, or `advisor` through `agent` and gets back an `agent_id`, declared\ndeliverables, and effective limits while the worker runs. The default receipt is\ncompact; request addressed detail when you need the transcript handle or ledger.\nThe internal runtime type is `FleetRole` (formerly\n`SubAgentType`); the older role spellings (`worker`, `scout`, `plan`,\n`review`, `builder`, `verifier`, `consultant`, `oracle`, …) remain accepted only as a persisted/deserialize\ncompatibility adapter during v0.9.x. New prompts and config should use fleet\nnames.\n\nChild assignments run through the same `Engine::run_turn` planner, executor,\nSession and approval inbox as ordinary turns. The private captured ChildGrant\nnarrows tool actions, shell, workspace, network and descendant depth at the final\nexecution boundary. The existing SubAgentManager still owns launch slots,\ncoordination, worker checkpoints and terminal delivery; it does not run another\nmodel/tool loop. Work uses the full explicit owner limit. At the limit the host\npreserves a partial-result receipt without an extra model reporting request.\n\nTransient provider failures use Core's dispatch and retry boundaries. An exhausted\nbudget or interrupted request preserves the recorded checkpoint and a continuation\nhandle. Each actually dispatched attempt carries its original session, route and\nsource identity: provider usage is billed once, then projected to the worker\nledger. A successful response without usage and an unknown request outcome retain\ndistinct coverage gaps; neither is priced as zero. For work that must survive\nprocess restarts, sleep or remote execution, use Fleet or a Workflow-backed run.\n\nSub-agents inherit the parent's permitted tool registry, including `agent`\ncoordination. Spawning obeys one absolute depth ceiling: the root is depth 0,\nits child is depth 1, and a child at `max_spawn_depth` cannot spawn again.\nThe default has no product depth cap. Explicit operator, role and saved-profile\nlimits still narrow descendant authority; model requests cannot choose caps. Recovery and transcript\nforking retain the source's position and bounds; they do not buy another\ngeneration. The removed `agent_open`/`agent_eval`/`agent_close` lifecycle tools\nare absent from every registry.\n\nHealthy children continue after an ordinary parent response. Their completion\nreturns through the existing Engine inbox and can wake the parent for another\nnormal turn. Explicit interruption or cancellation remains authoritative.\n`detached: true` additionally opts a subtree out of parent-turn cancellation;\nit does not remove child budgets or the headless host's deadline.\n\nThis doc covers roles and individual worker controls. Use `workflow` to coordinate\nmultiple assignments through the same worker runtime; see the per-role prompts in\n`crates/tui/src/tools/subagent/mod.rs` (`*_AGENT_INTRO`) and the in-line\ntool description.\n\n## Role taxonomy\n\nThe `type` field on `agent` selects a fleet posture for the child\n(`agent_type` is accepted as a compatibility alias). Each role is a distinct\nstance toward the work — not just a different label.\n\n## Maintainer posture\n\nSub-agents help Codewhale move faster, but the parent agent still owns the\nmaintainer decision. Use children to gather evidence, review patches, and run\nverification while keeping the community posture in\n[`AGENT_ETHOS.md`](AGENT_ETHOS.md): issues are open intake, PR gates are\nreview-load controls, and harvested work needs clear contributor credit.\n\nWhen a child reviews community work, the parent should still inspect the PR\ndiff, linked issues, tests, and CI before merging, harvesting, closing, or\ndeferring it. A sub-agent's result is a working set, not a substitute for\nstewardship.\n\n| Role | Stance | Writes? | Network? | Shell posture | Typical use |\n|---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------|\n| `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks |\n| `explore` | read-only; map the relevant code fast | no | yes | bounded inspection | \"find every call site of `Foo`; check the PR with gh\" |\n| `planner` | analyse and produce a strategy | noLine truncated
},
{
"path": "docs/FLEET.md",
"sha256": "134c6a358522c6367a8f004813daab56150a371e9433811236524fb2978ea0cf",
"content": "# Agent fleet\n\n> Baca terjemahan bahasa Indonesia: [id/FLEET.md](id/FLEET.md)\n\nAgent fleet is the local-first roster and member-selection layer for durable\nmulti-worker runs. It does not execute or authorize work. After fleet resolves\nwho should participate, the delegated coordinator launches a headless\n`codewhale exec` run and the Runtime tracks it durably. See\n[AGENT_RUNTIME.md](AGENT_RUNTIME.md) for how sub-agents, `exec`, and\nfleet-backed workers converge on one runtime. In product language, a user may\nstill \"open a sub-agent\"; in architecture language, durable nested work uses a\nfleet member identity with delegated runtime execution.\n\n## Naming and compatibility boundary\n\n**Fleet** is the public product noun. The durable ledger, saved rosters, config\ntables, and `--fleet` flag share that name:\n\n| Surface | Canonical |\n| --- | --- |\n| CLI | `codewhale fleet …` |\n| Slash command | `/fleet …` |\n\nThese shared names are load-bearing wherever changing them would break\nexisting workspaces, receipts, or scripts:\n\n- the durable ledger `.codewhale/fleet.jsonl` and the log directories\n `.codewhale/fleet/` and `.codewhale/fleet-host/`;\n- saved rosters `fleets/<name>.toml` and their `schema = \"fleet\"` header, under\n `$CODEWHALE_HOME/` or the workspace's `.codewhale/` (checked-in rosters at the\n workspace root's `fleets/` are still read);\n- the `[fleet]` config table (inline `[fleets.*]` tables were removed in 0.9.14; named fleets live in `fleets/<name>.toml` files);\n- the `codewhale workflow run --fleet <name>` flag;\n- wire, receipt, and control-plane operation ids such as `fleet.status`.\n\nThe rest of this document uses fleet as the public product noun and retains\nthese literal paths, keys, flags, and ids.\n\nUse a fleet roster rather than anonymous short-lived `agent` fanout whenever a\ndelegated run needs stable member identities across retries, sleep/restart,\nremote execution, receipts, or a ledgered audit trail. The initial CLI surface\nis:\n\nFor a guided start-to-monitor walkthrough that combines fleet task specs with\nWorkflow authoring, see [fleet + Workflow Tutorial](FLEET_WORKFLOW_TUTORIAL.md).\n\n```sh\ncodewhale fleet init\ncodewhale fleet run tasks.json --check # validate only; nothing is created or launched\ncodewhale fleet run tasks.json --max-workers 4\ncodewhale fleet status\ncodewhale fleet inspect <worker-id>\ncodewhale fleet logs <worker-id>\ncodewhale fleet artifacts <worker-id>\ncodewhale fleet interrupt <worker-id>\ncodewhale fleet restart <worker-id>\ncodewhale fleet resume <run-id>\ncodewhale fleet stop --all\n```\n\n`codewhale fleet resume <run-id>` is the restart-recovery verb: it replays the\nledger, reconciles any in-flight lease whose worker stopped heartbeating\n(retrying within the task's budget, else failing and escalating per the alert\npolicy), and prints the post-resume status. It launches no new work and is\nidempotent, so it is safe to run after a manager exit, laptop sleep, or runtime\nrestart.\n\nCoordinator state for fleet-backed runs is stored under the workspace in\n`.codewhale/fleet.jsonl`. Worker logs and adapter logs are stored under\n`.codewhale/fleet/` and `.codewhale/fleet-host/`.\n\n## Public contract: identity, membership, and selection\n\n**Fleet** = the user's model inventory: who is in the roster and which member is selected.\n\nA public fleet identity consists only of:\n\n- a stable member id and an optional user-facing name;\n- a semantic role, such as `explore`, `implement`, or `reviewer` (the legacy\n spellings `worker`, `scout`, `builder`, `verifier`, `consultant`, and `oracle`\n are still accepted on input and map to `general`, `explore`, `implement`,\n `test`, and `advisor`);\n- an exact provider/model identity, or an explicit inherited route;\n- visible roster state or origin.\n\nProject or workspace trust, filesystem and network reach, secret access,\napproval mode, sandboxing, tool authorization, and every other form of runtime\nauthority are separate delegated-coordination and Runtime policy inputs. They\nare never fleet identity fields, and they never select or reroute a fleet\nmember. The Runtime applies and clamps those policies only after member\nselection; if the selected member cannot run inside the effective envelope,\nlaunch fails closed instead of choosing somebody else.\n\nNatural-language member selection is deterministic. A caller may name:\n\n- an exact member id, optionally as `member:<id>` or `id:<id>`;\n- a unique user-facing member name, optionally as `name:<name>`;\n- a unique semantic role, for example `explore` or `role:explore`;\n- an exact pinned model id, for example `deepseek-v4-flash`, or its offline\n display name, for example `DeepSeek V4 Flash`; or\n- an exact `route:<provider>/<model>`.\n\nAn unqualified exact member id wins. Every other match succeeds only when it\nidentifies one distinct roster member. Multiple matches produce an ambiguity\nerroLine truncated
},
{
"path": "docs/MODES.md",
"sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd",
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUseLine truncated
}
],
"missing": []
},
{
"id": "fleet",
"slug": "fleet",
"sources": [
{
"path": "docs/FLEET.md",
"sha256": "134c6a358522c6367a8f004813daab56150a371e9433811236524fb2978ea0cf",
"content": "# Agent fleet\n\n> Baca terjemahan bahasa Indonesia: [id/FLEET.md](id/FLEET.md)\n\nAgent fleet is the local-first roster and member-selection layer for durable\nmulti-worker runs. It does not execute or authorize work. After fleet resolves\nwho should participate, the delegated coordinator launches a headless\n`codewhale exec` run and the Runtime tracks it durably. See\n[AGENT_RUNTIME.md](AGENT_RUNTIME.md) for how sub-agents, `exec`, and\nfleet-backed workers converge on one runtime. In product language, a user may\nstill \"open a sub-agent\"; in architecture language, durable nested work uses a\nfleet member identity with delegated runtime execution.\n\n## Naming and compatibility boundary\n\n**Fleet** is the public product noun. The durable ledger, saved rosters, config\ntables, and `--fleet` flag share that name:\n\n| Surface | Canonical |\n| --- | --- |\n| CLI | `codewhale fleet …` |\n| Slash command | `/fleet …` |\n\nThese shared names are load-bearing wherever changing them would break\nexisting workspaces, receipts, or scripts:\n\n- the durable ledger `.codewhale/fleet.jsonl` and the log directories\n `.codewhale/fleet/` and `.codewhale/fleet-host/`;\n- saved rosters `fleets/<name>.toml` and their `schema = \"fleet\"` header, under\n `$CODEWHALE_HOME/` or the workspace's `.codewhale/` (checked-in rosters at the\n workspace root's `fleets/` are still read);\n- the `[fleet]` config table (inline `[fleets.*]` tables were removed in 0.9.14; named fleets live in `fleets/<name>.toml` files);\n- the `codewhale workflow run --fleet <name>` flag;\n- wire, receipt, and control-plane operation ids such as `fleet.status`.\n\nThe rest of this document uses fleet as the public product noun and retains\nthese literal paths, keys, flags, and ids.\n\nUse a fleet roster rather than anonymous short-lived `agent` fanout whenever a\ndelegated run needs stable member identities across retries, sleep/restart,\nremote execution, receipts, or a ledgered audit trail. The initial CLI surface\nis:\n\nFor a guided start-to-monitor walkthrough that combines fleet task specs with\nWorkflow authoring, see [fleet + Workflow Tutorial](FLEET_WORKFLOW_TUTORIAL.md).\n\n```sh\ncodewhale fleet init\ncodewhale fleet run tasks.json --check # validate only; nothing is created or launched\ncodewhale fleet run tasks.json --max-workers 4\ncodewhale fleet status\ncodewhale fleet inspect <worker-id>\ncodewhale fleet logs <worker-id>\ncodewhale fleet artifacts <worker-id>\ncodewhale fleet interrupt <worker-id>\ncodewhale fleet restart <worker-id>\ncodewhale fleet resume <run-id>\ncodewhale fleet stop --all\n```\n\n`codewhale fleet resume <run-id>` is the restart-recovery verb: it replays the\nledger, reconciles any in-flight lease whose worker stopped heartbeating\n(retrying within the task's budget, else failing and escalating per the alert\npolicy), and prints the post-resume status. It launches no new work and is\nidempotent, so it is safe to run after a manager exit, laptop sleep, or runtime\nrestart.\n\nCoordinator state for fleet-backed runs is stored under the workspace in\n`.codewhale/fleet.jsonl`. Worker logs and adapter logs are stored under\n`.codewhale/fleet/` and `.codewhale/fleet-host/`.\n\n## Public contract: identity, membership, and selection\n\n**Fleet** = the user's model inventory: who is in the roster and which member is selected.\n\nA public fleet identity consists only of:\n\n- a stable member id and an optional user-facing name;\n- a semantic role, such as `explore`, `implement`, or `reviewer` (the legacy\n spellings `worker`, `scout`, `builder`, `verifier`, `consultant`, and `oracle`\n are still accepted on input and map to `general`, `explore`, `implement`,\n `test`, and `advisor`);\n- an exact provider/model identity, or an explicit inherited route;\n- visible roster state or origin.\n\nProject or workspace trust, filesystem and network reach, secret access,\napproval mode, sandboxing, tool authorization, and every other form of runtime\nauthority are separate delegated-coordination and Runtime policy inputs. They\nare never fleet identity fields, and they never select or reroute a fleet\nmember. The Runtime applies and clamps those policies only after member\nselection; if the selected member cannot run inside the effective envelope,\nlaunch fails closed instead of choosing somebody else.\n\nNatural-language member selection is deterministic. A caller may name:\n\n- an exact member id, optionally as `member:<id>` or `id:<id>`;\n- a unique user-facing member name, optionally as `name:<name>`;\n- a unique semantic role, for example `explore` or `role:explore`;\n- an exact pinned model id, for example `deepseek-v4-flash`, or its offline\n display name, for example `DeepSeek V4 Flash`; or\n- an exact `route:<provider>/<model>`.\n\nAn unqualified exact member id wins. Every other match succeeds only when it\nidentifies one distinct roster member. Multiple matches produce an ambiguity\nerroLine truncated
},
{
"path": "docs/FLEET_WORKFLOW_TUTORIAL.md",
"sha256": "6a6a3ac2554db4e590042e684eed7ff91ae984af8ecaa232c2005a7765ffcaf2",
"content": "# Fleet + Workflow Tutorial\n\n> 阅读简体中文版:[zh_hans/FLEET_WORKFLOW_TUTORIAL.md](zh_hans/FLEET_WORKFLOW_TUTORIAL.md)。\n\nFleet and Workflow are meant to work together, but they solve different parts\nof the problem:\n\n- **Fleet** configures and manages the same sub-agents: reusable roles, model\n routes, permissions, logs, artifacts, and status/restart/stop controls.\n- **Workflow** describes orchestration: phases, branches, reducers, loops, and\n agent leaves that can dispatch through the fleet/sub-agent runtime.\n\n**Default product path:** ask in natural language. Operate handles small or\ntightly coupled work directly under the active posture. Multi-step delegation\nuses a compact Workflow plan with named steps, dependencies, bounded scopes,\nand completion checks; results and evidence pass to the steps that need them.\nOne bounded, independent task can use a direct background agent. Reuse that\nagent with `followup` for continued work. Background work keeps the composer\navailable, and ordinary multi-agent work does not require workflow files. Details:\n[Automatic Workflows](AUTOMATIC_WORKFLOWS.md).\n\nThis tutorial covers the **manual** fleet task-spec / checked-in Workflow path\nfor operators who want durable host workers and reviewable specs. A\none-sentence request should still not silently generate `tasks.json`; worker\ncards and permission posture make dispatch visible without exposing authoring\nmechanics.\n\nThe examples use `codewhale fleet` and `/fleet`.\nOn-disk paths, config keys, and the Workflow `--fleet` flag use the Fleet name.\n\n## 1. Prepare The Workspace\n\nRun fleet from the workspace you want workers to inspect or modify:\n\n```sh\ncodewhale fleet init\n```\n\nThis creates the workspace ledger at `.codewhale/fleet.jsonl`. Worker logs and\nbounded artifacts live under `.codewhale/fleet/`; host adapter logs live under\n`.codewhale/fleet-host/`.\n\nIf you want named reusable workers, open the TUI and run:\n\n```text\n/fleet setup\n```\n\nPick a role, choose whether that profile inherits the operator route or pins a\nspecific provider/model, choose where the profile lives (**This project** →\n`.codewhale/agents/<role>.toml`, or **Personal** →\n`$CODEWHALE_HOME/agents/<role>.toml`, available across repositories while a\nsame-id project profile remains the higher-priority override), then review the\nexact file, permissions/tools/route posture, and save. The save control names\nits effect (\"Save to this project\" / \"Save as Personal profile\"), and\nreplacing an existing file always asks for a second confirmation. fleet task\nspecs can reference either resolved profile with `worker.agent_profile` or the\nshorter `worker.profile` alias.\n\nThis makes the fleet definition cross-repository, not the authority of one\nrunning session. For a multi-repository operation, launch Codewhale from a\nshared parent workspace. Profile availability does not grant filesystem access;\nthe session's workspace, explicit trusted paths, trust mode, and permission\nposture remain authoritative.\n\n## 2. Write A fleet Task Spec\n\n`codewhale fleet run` accepts JSON or TOML. The checked-in\n`docs/examples/fleet-dogfood.toml` file is the realistic manual smoke example;\nthe JSON below shows the same authoring shape with one read-only reviewer and\none bounded docs-note worker. The live Runtime policy controls secrets and\ntrust; fleet identity carries neither.\n\n```json\n{\n \"name\": \"docs readiness check\",\n \"labels\": {\n \"kind\": \"tutorial\"\n },\n \"tasks\": [\n {\n \"id\": \"map-docs\",\n \"name\": \"Map current docs\",\n \"objective\": \"Find the docs that describe fleet and Workflow.\",\n \"instructions\": \"Read docs/FLEET.md and docs/WORKFLOW_AUTHORING.md. Report the command surfaces, current limitations, and any confusing gaps.\",\n \"worker\": {\n \"role\": \"reviewer\",\n \"profile\": \"reviewer\",\n \"tools\": [\"rg\", \"sed\", \"git\"],\n \"model\": \"deepseek-v4-flash\"\n },\n \"workspace\": {\n \"required_files\": [\"docs/FLEET.md\", \"docs/WORKFLOW_AUTHORING.md\"],\n \"writable_paths\": [],\n \"environment\": {\n \"required\": [],\n \"allowlist\": []\n }\n },\n \"input_files\": [\"docs/FLEET.md\", \"docs/WORKFLOW_AUTHORING.md\"],\n \"expected_artifacts\": [\"log\", \"report\"],\n \"scorer\": {\n \"kind\": \"manual\"\n },\n \"retry_policy\": {\n \"max_attempts\": 1\n }\n },\n {\n \"id\": \"draft-gap-note\",\n \"name\": \"Draft gap note\",\n \"objective\": \"Draft a short local note for any missing tutorial steps.\",\n \"instructions\": \"Write a concise Markdown note with the missing fleet + Workflow tutorial steps. Do not edit public docs unless explicitly asked.\",\n \"worker\": {\n \"role\": \"builder\",\n \"tools\": [\"rg\", \"sed\Line truncated
},
{
"path": "docs/WORKFLOW_AUTHORING.md",
"sha256": "8a2433da7a3d025c01de1a98224719ef1351e72a039fcc7c55801243fcd2bd90",
"content": "# Workflow Authoring\n\n> 阅读简体中文版:[zh_hans/WORKFLOW_AUTHORING.md](zh_hans/WORKFLOW_AUTHORING.md)。\n\n> **Ordinary multi-agent work does not require this file.** In Operate, send\n> normal messages. Small work stays direct; multiple delegated steps use a\n> compact Workflow plan with dependencies, bounded scopes, and completion\n> evidence. Fleet manages the same sub-agents and roles. One bounded,\n> independent task can use a direct agent, with `followup` for continued work.\n> Act/Agent may also use optional soft-auto launch. See\n> [Automatic Workflows](AUTOMATIC_WORKFLOWS.md).\n\nWorkflow has one runtime boundary: authored source lowers to typed\nRust `WorkflowSpec`, Rust validates the IR, and the scheduler/headless worker\nruntime executes leaves. Authoring languages do not get hidden authority to own\nfiles, shell, network, providers, cancellation, or TUI state.\n\nCompatibility launch paths on the `workflow` tool:\n\n| Input | When to use |\n|-------|-------------|\n| `plan` | Structured goal / phases / children (preferred agent path) |\n| `script` | Short inline JS the model owns |\n| `source_path` | Checked-in `.workflow.js` / `.workflow.ts` in the workspace |\n\nUse `agent(action=\"roster\")` to inspect the saved Fleet models and roles before\nassigning children. Native plan children accept `model` for a saved shortlist\nselector, or `role`/`profile` for a saved assignment. Named Exact Fleets keep\ntheir member routes fixed and reject per-step model overrides. Plan children\nalso accept `cwd`, a repository-relative working directory — required in\nmulti-repository workspaces so the child (and worktree isolation) resolves the\nright repository, mirroring `task({cwd})`.\n\nFor a guided walkthrough from fleet task specs to Workflow authoring and\nmonitoring, see [fleet + Workflow Tutorial](FLEET_WORKFLOW_TUTORIAL.md).\n\n\n## Access model\n\nThe Workflow script is a **coordinator only**. It has no filesystem or shell of\nits own. Real work happens in sub-agents the script launches.\n\n| Layer | What it can access |\n|-------|--------------------|\n| Workflow script (JS VM) | Script variables, branching/loops, `task()` / `parallel()` / `pipeline()`, `phase` / `log`, `budget` / `args`. **No** direct FS, shell, network, env, imports, clock, or randomness. |\n| Workflow-spawned sub-agents | Normal tool surface (read/search/edit/write, shell, web, MCP) subject to role posture, allowlists, and parent policy. File edits for write-capable roles auto-accept under Workflow; shell / web / MCP still require parent auto-approve or fail closed. |\n| Parent session | Working directory, configured tools/MCP, permission mode, sandbox/network rules. |\n\n### Scale\n\n- Up to **16 concurrent** live agents in one run (additional spawns wait for a slot).\n- No default cumulative child limit; `[workflow] max_children = 0` leaves total work uncapped.\n- Owners can set a positive `max_children` limit and configure `max_concurrent`.\n- Automatic launch is model-judged on scope; the host enforces configured owner limits and plan validation.\n- Plan the population the work needs and let the host queue and clamp it.\n These ceilings are enforcement, not a reason to pre-shrink a valid plan.\n\nSee the Workflow JS sandbox tests for the fail-closed host surface inventory.\n\n## Language Choice\n\n| Surface | Strength | Tradeoff | Stance |\n|---|---|---|---|\n| YAML / JSON IR | Simple, reviewable, no runtime | Verbose for generated workflows | Keep as interchange/debug format |\n| JavaScript | Familiar object syntax and easy agent generation | Unsafe if executed as a general runtime | First-class authoring through declarative compile-only subset |\n| TypeScript | Best editor/types story for workflow SDK | Needs stripping/typechecking if full TS is supported | Same compile-only subset for now; richer SDK later |\n\nThe default high-capability path is TypeScript/JavaScript authoring, but only as\na compile step. The compiler accepts a JSON-compatible object inside\n`workflow({...})` from `.workflow.js` or `.workflow.ts`, lowers it to\n`WorkflowSpec`, and runs the Rust validation gate. (Starlark authoring was a\nbootstrap reference and has been removed; Workflow authoring is JS-only.)\n\n## Contract\n\nAccepted source shape:\n\n```js\nexport default workflow({\n \"id\": \"issue-audit-js\",\n \"goal\": \"Audit an issue fix with parallel agents\",\n \"nodes\": [\n {\n \"branch\": {\n \"id\": \"parallel-audit\",\n \"children\": [\n { \"agent\": { \"id\": \"code-audit\", \"prompt\": \"Review code\", \"agent_type\": \"review\" } },\n { \"agent\": { \"id\": \"test-audit\", \"prompt\": \"Review tests\", \"agent_type\": \"verifier\" } }\n ]\n }\n },\n { \"reduce\": { \"id\": \"summary\", \"inputs\": [\"code-audit\", \"test-audit\"], \"prompt\": \"Summarize\" } }\n ]\n});\n```\n\nSupported node wrappers: `agent`, `branch`, `sequence`, `reduLine truncated
}
],
"missing": []
},
{
"id": "sandbox",
"slug": "sandbox",
"sources": [
{
"path": "docs/SANDBOX.md",
"sha256": "1626a34e894f491c15959ebe82b87d2f669c792f9c9457fb912023dcee1a5a33",
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository contains a seccomp implementation module plus a future Windows\nhelper contract. They are not wired into child-command launch, so Codewhale\ndoes not advertise them as active sandboxes. Source-only sandbox code is not\nevidence that a command was restricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` shell mode turns it on\nonly in Full Access (see [Subagents](SUBAGENTS.md)); it then shares the host\nnetwork namespace, including loopback and abstract unix sockets, like a\nnetworLine truncated
},
{
"path": "docs/AUTHORIZATION_ORDER.md",
"sha256": "218dcab34337314dbf4083086a9451de4bece1f833d5bc33e5b5963d6cdf45bd",
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sLine truncated
}
],
"missing": []
},
{
"id": "trust",
"slug": "trust",
"sources": [
{
"path": "docs/SANDBOX.md",
"sha256": "1626a34e894f491c15959ebe82b87d2f669c792f9c9457fb912023dcee1a5a33",
"content": "# Sandbox threat model\n\n> 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。\n\nCodewhale can launch shell commands proposed by a model. Approval policy,\nworkspace-aware tools, and an operating-system command wrapper are separate\ncontrols: an approval is not a sandbox, and selecting `workspace-write` does\nnot prove that the current platform has an OS wrapper available.\n\nThis document describes only behavior wired into the command execution path.\nSee [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that\nrun before execution reaches this boundary.\n\n## Platform overview\n\n| Mechanism | Platform | Selection | What Codewhale reports |\n|---|---|---|---|\n| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |\n| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |\n| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |\n| No OS wrapper | Windows | Current implementation | `none` |\n| OpenSandbox-compatible service | Any supported host | `sandbox_backend = \"opensandbox\"` | External execution path |\n\nThe repository contains a seccomp implementation module plus a future Windows\nhelper contract. They are not wired into child-command launch, so Codewhale\ndoes not advertise them as active sandboxes. Source-only sandbox code is not\nevidence that a command was restricted.\n\n## macOS: Seatbelt\n\nCodewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the\nprobe succeeds and the selected `SandboxPolicy` requests a sandbox, the child\ncommand is wrapped with a generated Seatbelt profile.\n\nThe profile can provide:\n\n- broad filesystem reads;\n- writes limited by the selected policy, including the workspace and specific\n runtime/cache paths needed by supported tools;\n- network access only when the policy enables it. A `read-only` policy with\n network (the agent `read_only: true` shell mode) gets outbound IPv4 and the\n system DNS resolver only. IPv6 and the host's own addresses, loopback\n included, are denied, except the port of a local proxy named in\n `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay\n denied.\n\nIf the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS\nsandbox and launches the command without a Seatbelt wrapper. It does not set a\nSeatbelt marker on that fallback.\n\n## Linux: default bubblewrap\n\nLinux command sandboxing is on by default whenever bubblewrap works. Set the\ntop-level configuration key to opt out:\n\n```toml\nprefer_bwrap = false\n```\n\nCodewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular\nexecutable file AND a wrapped probe run proves it can create its namespaces\non this host — the exec bit alone lies where user namespaces are restricted\n(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),\nwhere every wrapped command would fail rather than run unsandboxed. The\nwrapper derives its mounts and network namespace from the resolved\n`SandboxPolicy`:\n\n```text\n/usr/bin/bwrap \\\n --unshare-all \\\n [--share-net] \\\n --ro-bind / / \\\n --dev /dev \\\n --proc /proc \\\n --tmpfs /tmp \\\n [--dev-bind <device-root> <device-root> ...] \\\n --bind <writable-root> <writable-root> ... \\\n --ro-bind <protected-descendant> <protected-descendant> ... \\\n [--ro-bind <extra-ro-root> <extra-ro-root> ...] \\\n --chdir <cwd> \\\n -- <program> <args>\n```\n\nThe sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null`\nworks), a private `/proc`, and a tmpfs `/tmp` (#5410). Two optional top-level\nconfig keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted\nread-only, applied last so they can narrow a policy-writable path) and\n`bwrap_dev_roots` (host character/block device nodes bind-mounted read-write;\ndirectories are never honored). Missing paths are skipped silently.\n\nThat gives the child a read-only root view. For `workspace-write`, every safe,\nexisting policy root is mounted read-write: the working directory, configured\nadditional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git\nworktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are\nremounted read-only after their writable parent. Missing paths, non-directory\npaths, and `/` are not promoted to writable mounts.\n\nFor `read-only`, there are no writable binds, so the working directory remains\ninside the read-only root view. `--unshare-all` isolates the network namespace\nby default. Codewhale adds `--share-net` only when the policy's\n`network_access` is true. The agent `read_only: true` shell mode turns it on\nonly in Full Access (see [Subagents](SUBAGENTS.md)); it then shares the host\nnetwork namespace, including loopback and abstract unix sockets, like a\nnetworLine truncated
},
{
"path": "docs/AUTHORIZATION_ORDER.md",
"sha256": "218dcab34337314dbf4083086a9451de4bece1f833d5bc33e5b5963d6cdf45bd",
"content": "# Authorization order\n\n> 阅读简体中文版:[zh_hans/AUTHORIZATION_ORDER.md](zh_hans/AUTHORIZATION_ORDER.md)。\n\nCodewhale combines tool availability, hooks, typed permission rules, approval\nposture, repository policy, and sandboxing. An approval from one layer is not a\nuniversal bypass: a later safety layer can still require review or block the\ncall, and an approval is not an operating-system sandbox grant.\n\nThis page records the order implemented by the interactive engine's\nmodel-requested tool path. Entrypoints that use only part of that path, such as\nthe core runtime's direct tool API, keep the relative order of the layers they\ndo use.\n\n## Model tool-call pipeline\n\nThe interactive engine evaluates a model-requested tool call in this order:\n\n| Order | Layer | Result |\n|---:|---|---|\n| 1 | Effective configuration and posture | User settings, command/runtime overrides, and the project overlay resolve before the turn. A project overlay may tighten `approval_policy`, `sandbox_mode`, or shell availability, but may not loosen them. |\n| 2 | Mode and tool admission | Plan-mode restrictions, input parse errors, per-command tool deny/allow lists, caller restrictions, and missing execution registrations fail before policy rules are considered. A tool present in both command lists is denied. |\n| 3 | Preparation, then `tool_call_before` hooks | Registry preparation is side-effect free. Foreground hooks then fold as `deny > ask > allow`, and a strict matching hook that produces no verdict fails closed. The last `updatedInput` wins; the engine prepares the rewritten input again before later gates inspect it. |\n| 4 | Registered-tool baseline | The prepared tool's `ApprovalRequirement` establishes its ordinary approval need. A hook `ask` is applied after that assignment so the baseline cannot erase it. Non-bypassable registered holds remain forced in postures that can prompt; Full Access auto-approves them instead of opening a contradictory modal. Plan mode also blocks write-capable tools here. |\n| 5 | Typed `permissions.toml` rule | A matching `deny` blocks. A matching `allow` may clear only ordinary registry approval; it cannot clear a hook `ask` or a non-bypassable registered hold. A matching `ask` forces review only in a posture that can prompt. Full Access/auto-approval is not downgraded into a prompt, while an explicit typed `deny` still blocks. |\n| 6 | Auto-review policy and built-in safety floor | Configured block rules run before the built-in floor, then configured allow rules and the deterministic fallback. This layer runs after typed permissions and can add a prompt or block, but cannot remove an earlier hold. The built-in floor refuses a shell command, shell input or verifier containing a hidden Unicode character, and holds publish-like actions, destructive background/headless actions and `curl`/`wget` credential uploads with a prompt no grant or allow rule can answer (a block in Auto-Review and Never). Full Access skips the built-in floor. |\n| 7 | Repository law | Protected path invariants can only add a prompt or block. A repo-law prompt becomes a hard block in Full Access, which has no contradictory approval modal. |\n| 8 | Human approval | A remaining prompt is sent to the approval channel. Denial stops the call. Approval authorizes this planned call; session and persistent choices affect later matching calls but do not erase a later gate from this call. |\n| 9 | Tool authority and execution sandbox | Worker authority envelopes, native tool path checks, and the selected OS or external sandbox still apply during execution. A sandbox denial remains a denial unless the user separately authorizes a supported elevation path. |\n\nThe ordering is intentionally monotonic after the typed permission layer:\nauto-review and repository law can tighten a result, not turn a previous block\nor prompt into an unreviewed execution. There are explicit posture choices\ninside those layers—for example, Full Access does not create an interactive\npublish prompt—but those choices do not let an earlier remembered grant erase\na hold that the layer actually produced.\n\n## Typed permission-rule selection\n\n`permissions.toml` is currently a sibling of the active user `config.toml`.\nThere is no project-local permission-rule source today. An optional `workspace`\nfield scopes one user rule to a repository; it does not create a project\noverlay. `/permissions` reports that source, matcher, scope, and whether the\nscope applies to the current workspace.\n\nThe execution-policy engine evaluates matching rules as follows:\n\n1. Normalize the tool, command, workspace, and any workspace-relative path.\n Unsafe or external paths do not become matchable file rules.\n2. Check denied command prefixes against the whole command and every chained\n segment. These hard prefix denies are merged across rulesets and always win.\n3. Compute a trusted-prefix candidate for an unchained sLine truncated
},
{
"path": "docs/TELEMETRY.md",
"sha256": "6e69aec8fe595b6d40bd63c71cfaea6cc980ca7b1adaa09b9e4920c50bfe3d53",
"content": "# Codewhale product telemetry\n\n> 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)\n\n**Usage analytics are on by default in the current source.** The first\ninteractive launch gives a localized, nonblocking disclosure naming **Codewhale\nand PostHog**, with a direct route to turn counting off. Notice version `5`\ndescribes this policy; displaying it is not recorded as human acceptance.\nMissing an older acceptance record does not turn a fresh installation off.\nExisting explicit opt-outs remain off across upgrades, and unreadable privacy\nstate fails closed. Environment and CLI kill switches still take precedence.\n\nRead the disclosure with `codewhale config telemetry`. Turn usage off in\n`/settings` or run `codewhale config set telemetry false`. Turning it back on\nin Settings or with `codewhale config set telemetry true` updates the existing\ndurable preference and privacy records for new sessions. The compatibility\ncommand `codewhale config telemetry --accept-notice 5` remains available, but\nis not required for a fresh installation. Collection never includes work content or credentials.\n\nThe PostHog sink is optional and inert until the ingest operator explicitly\nconfigures a project token and approved regional host, and records staging\nproof that the actual egress path does not forward the original client IP.\nSource readiness does not establish a deployment, an activated processor,\nan egress receipt, or a retention setting.\n\nCodewhale does not collect conversations, code, prompts, files, file/repo/branch\nnames, model content, or credentials. It sends no per-turn or per-tool timeline.\nIt does send the closed, aggregate schema below: version and platform classes,\nsession duration/outcome, feature/error counters, and a random install id that\nrotates every 90 days.\n\n**There is now a real endpoint.** An enabled session sends its batches to the\nfirst-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,\nwhich is the shipped default for `telemetry_endpoint`. What that service is,\nwhat it stores, and what it structurally cannot store is spelled out in \"What\nthe endpoint does\" below.\n\n**To send nothing anywhere, turn telemetry off** (see \"Turning it off\"). To stay\nenabled but contact nobody, set `telemetry_endpoint = \"\"`: batches are then\nappended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte\nfor byte what the server would have received, and no HTTP client is ever\nconstructed. That file is how you audit this document against reality.\n\nThis document is the schema. It is not a summary of the schema: a test in\n`crates/telemetry` parses the field names out of this file and asserts set\nequality against the structs the serializer actually uses, so a field that is\nhere and not in the code — or in the code and not here — fails the build.\n\nLocal saved sessions and `codewhale metrics` remain available with telemetry\ndisabled and require no account or hosted reporting service. Disabling telemetry\ndoes not disable local usage receipts.\n\n## Turning it off\n\nThere are two off switches and they do different things. Both stop collection\ncompletely; only one of them erases anything.\n\n```sh\ncodewhale config set telemetry false # opt out: stops collection and erases state\nCODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing\ncodewhale --telemetry false # the same kill switch, for one command\n```\n\n**`telemetry = false` in the config file is the opt-out.** It is a floor:\n`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a\nsetting you can undo by accident from a wrapper script is not a setting. It\ndeletes the random install id, truncates every buffered event and every dry-run\nrecord, and writes a tombstone. Appends, identity/state writes, and delivery all\nshare the wipe's ordering lock, so once opt-out returns no pre-opt-out write or\nPOST remains in flight. If any part of that wipe fails, the tombstone is still\nthere and the buffer is undrainable — a failed wipe fails closed. Every later\nrun re-asserts the same tombstone for as long as the setting stands, so it\nsurvives. Turning telemetry back on in `/settings` records that explicit preference\nin both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is\never sent.\n\n**The environment variable and the flag are kill switches, not opt-outs.**\nTelemetry is off for the run, nothing is written, nothing is sent — and\nnothing on disk is touched or deleted. That is deliberate: a harness or agent\nthat sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the\ninstall id and the dry-run records of the person who owns the machine. If you\nwant the erasing kind, use the config file.\n\n`CODEWHALE_TELEMETRY` (and its `DEEPSEEK_TELEMETRY` alias) accepts\n`0`, `1`, `true`, `false`, `yes`, `no`Line truncated
},
{
"path": "docs/public-surface-facts.json",
"sha256": "9651f09b589b1979cc217b67afe8fbcd42a37e5c4c4bd51f02d0c949a4b5d583",
"content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"agent\",\n \"workflow\",\n \"todo_write\",\n \"create_goal\",\n \"get_goal\",\n \"update_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docLine truncated
},
{
"path": "docs/PREVIEW_REQUEST.md",
"sha256": "3723e8f84ccf3199c4c0b7408f2b2e905a7efe29253fb7928e6acb0ebe9581e0",
"content": "# `/preview-request` — see the outbound request without sending it\n\n`/preview-request` renders a **request manifest**: a typed, redacted\ndescription of the request the next **primary agent turn** would send. It never\nsends that request, never appends to the conversation, and never writes engine,\nsession, or Work state. `/dryrun` and `/preview_request` are compatibility\naliases.\n\nIt is a human command. There is deliberately no model-visible tool for it.\n\n```\n/preview-request # session facts; route/body reported unavailable\n/preview-request json # same manifest as JSON\n/preview-request --prompt <text> # the next turn for that prompt\n/preview-request json --prompt <text> # both\n/preview-request base-prompt # exact base layer only; no runtime additions\n```\n\n### Argument grammar\n\n```\nargs := flag* [ \"--prompt\" <whitespace> prompt ]\nflag := json | --json | manifest | --manifest | prompt | base-prompt | --base-prompt\nprompt := every remaining byte, verbatim\n```\n\n**Flags come before `--prompt`, which is terminal.** Everything after it is\nprompt text — including a trailing `json`. `/preview-request --prompt fix it\njson` previews the prompt *\"fix it json\"* as a human table; the JSON spelling\nis `/preview-request json --prompt fix it`. There is exactly one reading of any\ninput, and an unknown argument before `--prompt` is rejected rather than\nguessed at.\n\n**The prompt keeps your bytes.** Repeated whitespace and newlines inside it\nsurvive; exactly one whitespace codepoint separates `--prompt` from its text\nand is consumed as syntax. Any additional leading whitespace, plus all\ntrailing whitespace and newlines, remains prompt data. The prompt is hashed\ninto the previewed body, so normalizing it would describe a request that\ndiffers from the real one in the one field you typed.\n\n`prompt` remains a compatibility alias for the ordinary protected manifest.\n`base-prompt` / `--base-prompt` are an explicit, human-only disclosure mode:\nthey print exactly the effective base-prompt bytes and nothing else. They\ncannot be combined with JSON or `--prompt`. Effective system text is never\nprinted because it can contain project instructions, skills, and memory.\n\n## `--prompt` is necessary for an exact manifest, and not always sufficient\n\nThe next user message is **part of the request**. Without it there is no\nnext-turn body to describe, and under auto model routing there is not even a\nroute: the route is decided by the text you have not typed yet.\n\nSo the manifest is sectioned, and each section is either exact or typed-absent:\n\n| Section | Exact when |\n| --- | --- |\n| `session` | always — posture, gates, base-prompt provenance, requested model/reasoning |\n| `route` | `--prompt` was supplied, an active goal has not exhausted its token budget, a fixed route is selected, no `message_submit` hooks are configured, and the shared planner resolved that route |\n| `tools` | the route is exact **and** the MCP tool state can be snapshotted without connecting |\n| `body` | the tool surface and authoritative Work snapshot are exact **and** no runtime transform would rewrite the request first |\n\n### Why a section stops being exact\n\n| Typed reason | What a real turn would do that an inspection may not |\n| --- | --- |\n| `auto-route-unresolved-until-next-prompt` | decide the route from text you have not typed |\n| `auto-route-classification-not-executed` | call the provider-backed Auto classifier; preview is strictly offline, so production must resolve it |\n| `no-hypothetical-prompt-supplied` | send a message the manifest does not have |\n| `message-submit-hooks-not-executed` | run mutable hooks that can rewrite or block the text — and therefore the route, tool policy, and body derived from it |\n| `prompt-resolution-failed` | fail the same way on skill authority or file mentions |\n| `route-plan-failed` | fail route resolution or preflight |\n| `mcp-state-not-snapshottable` | connect MCP servers and discover tools this catalog does not contain |\n| `runtime-transforms-before-send` | auto-compact, run context-overflow recovery, inject a background-shell completion, allow a running or terminal-undelivered child to complete, or flush pending LSP diagnostics before the first request |\n| `work-state-not-snapshottable` | read the current graph-backed Work projection; preview never substitutes an asynchronously published, potentially stale To-do view |\n| `goal-token-budget-exhausted` | stop the active goal before dispatch because durable token usage has reached the configured budget |\n| `goal-state-not-snapshottable` | decide whether the active goal's terminal budget gate permits another request |\n| `request-preparation-failed` | build no body at all |\n\n**`mcp-state-not-snapshottable` takes the body with it.** A catalog missing its\nMCP contribution is not \"the same request with no MCP tools\" — a Line truncated
}
],
"missing": []
},
{
"id": "mcp",
"slug": "mcp",
"sources": [
{
"path": "docs/MCP.md",
"sha256": "818faaf0a1db59bc05ce647c37187ae4dd1aab7685ebb63ff216c579e2a142d0",
"content": "# MCP (External Tool Servers)\n\nIn the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.\n\n\n> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)\n\ncodewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that the TUI starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.\n\nBrowsing note:\n- `Web` is the canonical, deferred built-in browsing tool; it provides\n `search`, `fetch`, and `wait` actions when network policy permits.\n- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only\n aliases. New prompts and integrations should use `Web`.\n\nServer mode note:\n- `codewhale serve --mcp` runs the MCP stdio server.\n- `codewhale serve --http` runs the runtime HTTP/SSE API (separate mode).\n- `codewhale mcp-server` is an equivalent stdio entrypoint on the same\n consolidated runtime.\n\nIn 0.10.1, the old child-server aggregation proxy is removed. `mcp-server`\nnow uses the same native tool server as `serve --mcp`; it does not launch the\nlegacy `mcp.server_definitions` list. Saved definitions are left intact. Configure\nexternal servers in `mcp.json` for the existing MCP client, or connect to those\nservers directly from your external client. Native server write tools remain\nwithheld unless the operator explicitly permits them in its server configuration.\n\n## Setup wizard vs manual MCP setup (#3407)\n\nThe `/setup` hub includes an optional **Tools and MCP**\nstep. That step is discovery/readiness only:\n\n| Wizard can do | Still requires manual / explicit action |\n| --- | --- |\n| Show configured servers as `healthy` / `needs_config` / `off` | Start or connect MCP servers |\n| Report config path presence (global + project) | Write or edit `mcp.json` contents |\n| Safe static health probe (missing command/url, broken absolute path, missing bearer env) | `codewhale mcp validate`, live connect, OAuth login |\n| Point at safe on-ramps (`/mcp`, `codewhale mcp init`, `codewhale doctor`) | Install community skills, trust skills, enable plugins |\n| Share Hotbar source counts from the same skill/MCP adapters (#3399) | Bind Hotbar slots (Hotbar step / `H`) |\n| Record optional/`needs_action` setup_state without blocking first-run | Anything that spawns processes or installs packages |\n\nEmpty inventory is **not** an error: first-run users see “nothing configured\nyet, that’s fine.” Failing or incomplete configured servers surface as\n`needs_config` with an actionable hint and never block setup completion.\nEnumeration never executes MCP/plugin commands beyond the static probe.\nSummaries redact commands, args, env, headers, and tokens.\n\n`codewhale doctor` reports MCP/skills/tools/plugins health with the same\noptional-surface intent (paths, counts, static checks) so wizard and doctor\nstay consistent.\n\n## Plugin-contributed MCP\n\nA reviewed local plugin bundle may contribute MCP servers without creating a\nsecond transport or approval system. The servers use the same MCP manager,\ntool approval, resource, prompt, timeout, and network-policy paths documented\nhere, and appear under namespaced `<plugin>-<server>` identities.\n\nThe bundle boundary is intentionally stricter than user-authored `mcp.json`:\nunknown fields and ambiguous transports fail closed; stdio environment values\nmust be exact environment-source references; remote literal headers and\nsecret-bearing URLs are rejected; declared network hosts must exactly match\nthe normalized endpoint host set; and redirects remain on the reviewed origin.\nReviewed plugin remotes also bypass ambient HTTP proxy configuration entirely;\nproxy credentials and proxy-observed traffic are not part of the v1 review.\nThe plugin review discloses local host-user authority, structural argv,\nenvironment provenance, endpoint, auth source names, scopes, and tool filters\nwithout reading or printing secret values.\n\nTrust stages reviewed content but does not enable it. Enablement attaches that\nstaged snapshot to the current workspace's MCP pool. Disable, revoke, and other\ncross-process generation changes remove catalog entries, cancel in-flight\noperations, and terminate plugin stdio children. Source or staged-tree drift is\nfully revalidated before each dispatch/catalogue boundary and fails the next\nboundary closed; v0.9.1 does not continuously hash mutable trees during an\nalready-running call and therefore does not promise drift-triggered mid-call\ncancellation. MCP subscriptions are not exposed through plugin bundles. See\n[Plugin bundles](PLUGIN_BUNDLES.md) for the complete lifecycle contract.\n\n## Bootstrap MCP Config\n\nCreate a staLine truncated
}
],
"missing": []
},
{
"id": "hooks",
"slug": "hooks",
"sources": [
{
"path": "docs/HOOKS.md",
"sha256": "42b35087066c06ea844032bc5214700cd99932b7619f2ef3e49bd6f60952bbcc",
"content": "# Hooks\n> 阅读简体中文版:[zh_hans/HOOKS.md](zh_hans/HOOKS.md)\n\nHooks run a shell command when the Codewhale **TUI** reaches a lifecycle\npoint. They are plain processes: they receive context through environment\nvariables, some receive a JSON payload on stdin, and three of them can steer\nwhat Codewhale does next.\n\nThis page is the authoritative reference for what is implemented today.\nConfiguration syntax that overlaps with the rest of `config.toml` lives in\n[CONFIGURATION.md](CONFIGURATION.md); this file is the event-by-event\ncontract.\n\n## Scope\n\nHooks fire in the interactive TUI and in the engine turn loop, which the\nRuntime API threads behind the desktop app and web drive as well.\n\n| Surface | Fires hooks |\n| --- | --- |\n| `codewhale` / `codew` interactive TUI | yes |\n| `codewhale exec` (headless one-shot) | opt-in: `--hooks` fires `tool_call_before` and `shell_env` |\n| Runtime API threads (desktop app, web) | yes: `tool_call_before`, `shell_env`, `tool_call_after`, `on_error`; `GET /v1/hooks` lists the set |\n| the `codewhale` CLI dispatcher and its subcommands | no |\n| app-server / ACP | no |\n| the `workflow` tool and sub-agent *internals* | no — but the TUI fires `subagent_spawn` / `subagent_complete` around them |\n| public API | there is none |\n\nThe `crates/hooks` event-sink crate in this repository is an unrelated\ninternal mechanism. It shares no configuration, no event names, and no\ncontract with the hooks described here.\n\n### `codewhale exec --hooks`\n\nHeadless runs fire no hooks by default — a CI job should not start paging an\non-call rotation merely because a config exists. `codewhale exec --hooks`\nopts the run in. The engine-side events are `tool_call_before` (exit code 2\nstill denies the call; `ask` resolves fail-closed because nothing can prompt\nheadlessly) and `shell_env`. UI-driven events such as `session_start`,\n`message_submit`, and `turn_end` do not fire — they live in the interactive\nshell, not the turn loop. Fleet worker subprocesses never fire operator\nhooks. Independently of this flag, `permissions.toml` typed rules already\napply to `exec` — the run drives the same turn loop, and a `deny` blocks in\nevery mode.\n\n## Quick start\n\n```toml\n# ~/.codewhale/config.toml\n[hooks]\nenabled = true\n\n[[hooks.hooks]]\nname = \"announce\"\nevent = \"session_start\"\ncommand = \"echo 'Codewhale session started'\"\n```\n\nRun `/hooks` in the TUI to list what is configured, whether the global switch\nis on, and any entry that was rejected at load. Run `/hooks events` for the\nevent names.\n\n## Configuration\n\n```toml\n[hooks]\nenabled = true # global switch; false suppresses every hook\ndefault_timeout_secs = 30 # see the timeout note below\nworking_dir = \"/path/to/dir\" # default: the session workspace\n\n[[hooks.hooks]]\nevent = \"tool_call_before\" # required; one of the 15 names below\ncommand = \"~/.codewhale/hooks/gate.sh\" # required; `sh -c` on Unix, `cmd /C` on Windows\nname = \"gate\" # optional label for /hooks and log lines\ntimeout_secs = 30 # optional, default 30\nbackground = false # optional; foreground inside the hook worker\ncontinue_on_error = true # optional, default true\ncondition = { type = \"tool_name\", name = \"bash\" } # optional\n```\n\n`timeout_secs` note, stated as implemented: when `[hooks].default_timeout_secs`\nis set it **overrides** every hook's own `timeout_secs`, it does not merely\nsupply a default for hooks that omit one. Leave it unset if you want per-hook\ntimeouts to apply. `/hooks list` shows the timeout the runtime will actually\napply, and names the override when one is in force.\n\n`default_timeout_secs = 0` is **rejected at load**. Because the value replaces\nevery hook's own `timeout_secs`, a zero there would expire every hook in the\nconfig immediately — including a `tool_call_before` gate, which then denies\nevery matching tool call. The override is ignored, per-hook `timeout_secs`\napplies, the hooks themselves still load, and the rejection is reported by\n`/hooks list` under *configuration problems*. Per-hook `timeout_secs = 0` is\nrejected too, but that only drops the one hook that wrote it.\n\nHooks run with the workspace (or `working_dir`) as the current directory.\n\n### Timeouts\n\nThe timeout applies to **foreground and background hooks alike**. When it\nexpires:\n\n- the hook's whole process group is killed — Unix process groups, Windows Job\n Objects — so a hook that spawns children does not outlive its budget;\n- the child is then reaped, so nothing is normally left detached or zombied;\n- a foreground hook's result is `success = false`, `exit_code = None`, empty\n `stdout`/`stderr`, and `error = \"Hook timed out after Ns\"`;\n- a background hook's timeout is logged at `warn` under the `hooks` target.\n Nothing is reported to the caller, because the caller stopped waitinLine truncated
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "a42ae2b33032c404995a3fc2f34cf14c25698313e8fb0ea998dfd388b5d742bf",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `<codewhale_user_constitution>` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. Instruction files written for other tools (`CLAUDE.md`,\n `.claude/instructions.md`, `.cursorrules`, `.github/copilot-instructions.md`\n and similar) are read only when you opt in with the top-level\n `project_instruction_imports` key, for example `[\"claude\"]`, `[\"cursor\"]` or\n `[\"all\"]` (see `config.example.toml`); Codewhale warns when it finds one it\n did not load.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to Line truncated
}
],
"missing": []
},
{
"id": "skills",
"slug": "skills",
"sources": [
{
"path": "docs/SKILLS.md",
"sha256": "2f9b64703036c8c6159c979c9c2f4fbebf2b96577f8081a0b09135c7d78e36e4",
"content": "# Skills Manager\n\nIn the terminal, `/skills` opens **Extensions → Skills**. Enter on a skill opens the dedicated manager, also available directly through `/skills manage`, for existing install, update, remove, and trust operations. `/skill <name>` still activates a skill, and the explicit inspection, remote, sync, and suggestion subcommands remain available.\n\n\n> 阅读简体中文版:[zh_hans/SKILLS.md](zh_hans/SKILLS.md)\n\nSkills are reusable `SKILL.md` instruction packs. Codewhale discovers them from\nseveral roots, but **only Codewhale-owned directories are writable**. The unified\n`/skills manage` manager is the interactive surface for audit and mutation; slash\naliases share the same write path.\n\nFor Claude Code plugin boundaries, see [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor `skills_dir` and `[skills]` config keys, see [CONFIGURATION.md](CONFIGURATION.md).\n\n## Architecture (four layers)\n\n| Layer | Role |\n| --- | --- |\n| **Root catalog** | Single source of precedence and ownership (`SkillRootCatalog`). |\n| **Audit** | Read-only, unmerged on-disk inventory (status, digest, actions). |\n| **Mutation controller** | Only writer for install / import / update / remove / trust. |\n| **Skills manager view** | TUI: emits events only; never writes files itself. |\n\nRuntime discovery (`SkillRegistry`) still merges skills for the model. Audit\nintentionally does **not** merge — it shows every on-disk copy so conflicts and\nshadowing stay visible.\n\n## Ownership and roots\n\n**Writable (Codewhale-owned)**\n\n| Scope | Path |\n| --- | --- |\n| Project | `<workspace>/.codewhale/skills/` |\n| Global | `~/.codewhale/skills/` |\n\n**Read-only compatible** (discover / import source only — never mutated in place)\n\nExamples: `<workspace>/.agents/skills`, `./skills`, `.claude/skills`,\n`.cursor/skills`, `.opencode/skills`, `~/.agents/skills`, `~/.claude/skills`,\nand similar harness layouts.\n\n**Audit-only (not runtime-active)**\n\n- Flat `<workspace>/skills` is an audit candidate until `[skills] flat_workspace_root = true`; an explicit `skills_dir` can also select it.\n- `.codex/skills` appears in **compatible** audit scans so operators can see it.\n It does **not** join the runtime discovery set.\n\nConfigured `skills_dir` that is not one of the owned Codewhale roots stays\nread-only. Discovery and the manager can list it; mutations still target owned\nproject/global roots only.\n\nWithin each scope, the owned `.codewhale/skills` root wins name collisions.\nProject order is `.codewhale`, `.agents`, `.claude`, `.opencode`, `.cursor`, then\nan opted-in flat `skills` root. Global order is `.codewhale`, `.agents`, `.claude`,\nthen legacy `.deepseek`. Project roots precede global roots. Shadowing warnings\nname both copies, so install and update select the owned copy in that scope.\n\nEvery root inside the workspace — owned, compatible, or a configured\n`skills_dir` that resolves there — loads only once the workspace is trusted\n(`/trust on --save`). Until then discovery names the skipped directories in its\nwarning, and the session's skills directory falls back to the global one.\n\n## Slash commands\n\n| Command | Behavior |\n| --- | --- |\n| `/skills` | Opens Extensions → Skills (owned-only scan, **no network**). |\n| `/skills manage` | Opens the dedicated Skills Manager for audit and mutation. |\n| `/skills <prefix>` | Text list filtered by name prefix. |\n| `/skills inspect` | Text discovery mode, searched directories, and source paths. |\n| `/skills --remote` | Explicit registry listing (network). |\n| `/skills suggest <task>` | Rank up to three remote skills for a task, with matching evidence and an explicit install command (network; no install). |\n| `/skills sync` | Explicit registry → local cache sync (network). |\n| `/skill <name>` | Activate a skill for the next turn. |\n| `/skill install [--project\\|--global] <spec>` | Install via mutation controller. |\n| `/skill update [--project\\|--global] <name>` | Update a managed skill from its registry provenance. |\n| `/skill uninstall [--project\\|--global] <name>` | Remove a managed skill. |\n| `/skill trust [--project\\|--global] <name>` | Write digest-bound advisory trust. |\n\nNotes:\n\n- There is **no** `/skills audit` subcommand. Use the manager (and `c` to toggle\n compatible roots) or `/skills inspect` for discovery details.\n- Bare `/skill install <spec>` (no scope flag) installs into the Codewhale\n **global** owned root.\n- `/skills suggest` only reads the curated registry through the existing\n network policy. It never downloads, trusts, enables, or activates a skill;\n each result gives a separate `/skill install <name>` command for the user to\n choose.\n- If the same name exists in both project and global owned roots, update /\n uninstall / trust require `--project` or `--global`.\n- If a name exists only under a compatible external root, writes are refused;\n import it throLine truncated
},
{
"path": "docs/CLAUDE_PLUGIN_COMPAT.md",
"sha256": "d1c66e79af9ca257d883c503f36a892a5d270326c7e1a79a5322e4b4a7057f24",
"content": "# Claude Plugin Compatibility\n\nCodewhale treats Claude Code skill folders as instruction bundles when they are\nplain `SKILL.md` directories. It does not run Claude Code plugin runtimes.\n\n## Supported\n\n- Workspace or global `.claude/skills/<name>/SKILL.md` directories discovered by\n the normal skill registry.\n- GitHub or tarball installs that contain one selected skill directory such as\n `skills/<name>/SKILL.md`, `.agents/skills/<name>/SKILL.md`,\n `.claude/skills/<name>/SKILL.md`, or a nested package layout ending in\n `skills/<name>/SKILL.md`.\n- Companion files inside the selected skill directory, such as `references/`,\n `examples/`, or scripts that are only used after the skill is explicitly\n loaded and trusted.\n\n## Compatible plugin bundles\n\nSince v0.9.13, `/plugin install` accepts the declarative subset described in\n[Installing plugins](PLUGINS.md): nested `.claude-plugin/plugin.json` metadata,\nroot skills, commands and agent profiles, plus inline MCP declarations or\n`.mcp.json`. Local sources in Claude marketplace catalogs resolve from the\nrepository root outside `.claude-plugin`.\n\nExact `${ENV_NAME}` MCP header references map to Codewhale's environment-backed\ncredentials, including `Authorization: Bearer ${ENV_NAME}`. Literal credential\nvalues and compound header templates are rejected; importing never reads the\nreferenced environment variables.\n\nThe importer uses Codewhale's existing adapters, review hashes and enablement.\nA Claude marketplace's labels or defaults grant no authority. Bundles are not\nscanned from another application's plugin roots or activated automatically.\n\n## Unsupported runtime features\n\nThe importer rejects hooks, LSP declarations, custom MCP file paths, and\n`${CLAUDE_PLUGIN_ROOT}` expansion. It does not run plugin build steps,\nTypeScript agents, dashboard servers, shared plugin state, or token-gated\nservice processes. Claude-specific frontmatter behavior such as `model: inherit`\nis not an additional runtime contract. Remote MCP authentication still follows\nCodewhale's existing plugin credential boundary; installation does not complete\nOAuth or borrow another application's credentials.\n\n`/skill install` remains a separate one-skill operation: it rejects multi-skill\nplugin archives rather than silently selecting one directory and dropping other\ncomponents. Use `/plugin install` for supported bundles, or migrate one explicit\nskill directory when the repository depends on unsupported runtime behavior.\n\nSee [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md) for discovery, review and activation.\n"
}
],
"missing": []
},
{
"id": "claude-code",
"slug": "claude-code",
"sources": [
{
"path": "docs/CLAUDE_PLUGIN_COMPAT.md",
"sha256": "d1c66e79af9ca257d883c503f36a892a5d270326c7e1a79a5322e4b4a7057f24",
"content": "# Claude Plugin Compatibility\n\nCodewhale treats Claude Code skill folders as instruction bundles when they are\nplain `SKILL.md` directories. It does not run Claude Code plugin runtimes.\n\n## Supported\n\n- Workspace or global `.claude/skills/<name>/SKILL.md` directories discovered by\n the normal skill registry.\n- GitHub or tarball installs that contain one selected skill directory such as\n `skills/<name>/SKILL.md`, `.agents/skills/<name>/SKILL.md`,\n `.claude/skills/<name>/SKILL.md`, or a nested package layout ending in\n `skills/<name>/SKILL.md`.\n- Companion files inside the selected skill directory, such as `references/`,\n `examples/`, or scripts that are only used after the skill is explicitly\n loaded and trusted.\n\n## Compatible plugin bundles\n\nSince v0.9.13, `/plugin install` accepts the declarative subset described in\n[Installing plugins](PLUGINS.md): nested `.claude-plugin/plugin.json` metadata,\nroot skills, commands and agent profiles, plus inline MCP declarations or\n`.mcp.json`. Local sources in Claude marketplace catalogs resolve from the\nrepository root outside `.claude-plugin`.\n\nExact `${ENV_NAME}` MCP header references map to Codewhale's environment-backed\ncredentials, including `Authorization: Bearer ${ENV_NAME}`. Literal credential\nvalues and compound header templates are rejected; importing never reads the\nreferenced environment variables.\n\nThe importer uses Codewhale's existing adapters, review hashes and enablement.\nA Claude marketplace's labels or defaults grant no authority. Bundles are not\nscanned from another application's plugin roots or activated automatically.\n\n## Unsupported runtime features\n\nThe importer rejects hooks, LSP declarations, custom MCP file paths, and\n`${CLAUDE_PLUGIN_ROOT}` expansion. It does not run plugin build steps,\nTypeScript agents, dashboard servers, shared plugin state, or token-gated\nservice processes. Claude-specific frontmatter behavior such as `model: inherit`\nis not an additional runtime contract. Remote MCP authentication still follows\nCodewhale's existing plugin credential boundary; installation does not complete\nOAuth or borrow another application's credentials.\n\n`/skill install` remains a separate one-skill operation: it rejects multi-skill\nplugin archives rather than silently selecting one directory and dropping other\ncomponents. Use `/plugin install` for supported bundles, or migrate one explicit\nskill directory when the repository depends on unsupported runtime behavior.\n\nSee [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md) for discovery, review and activation.\n"
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "a42ae2b33032c404995a3fc2f34cf14c25698313e8fb0ea998dfd388b5d742bf",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `<codewhale_user_constitution>` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. Instruction files written for other tools (`CLAUDE.md`,\n `.claude/instructions.md`, `.cursorrules`, `.github/copilot-instructions.md`\n and similar) are read only when you opt in with the top-level\n `project_instruction_imports` key, for example `[\"claude\"]`, `[\"cursor\"]` or\n `[\"all\"]` (see `config.example.toml`); Codewhale warns when it finds one it\n did not load.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to Line truncated
}
],
"missing": []
},
{
"id": "plugins",
"slug": "plugins",
"sources": [
{
"path": "docs/PLUGINS.md",
"sha256": "66a354c06964da0c786e3e29d5b79fee8b090ad2fbb53170a75d62e241759867",
"content": "# Installing plugins\n\n> 阅读简体中文版:[zh_hans/PLUGINS.md](zh_hans/PLUGINS.md)。\n\nTo create a bundle, start with [Write your first Codewhale plugin](PLUGIN_AUTHORING.md)\nand its runnable Skills example.\n\nThis is the walkthrough for the `/plugin install` on-ramp (v0.9.4, #5182).\n[PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md) remains the contract for the bundle\nformat (`plugin.json`, compatible `kimi.plugin.json` or\n`.claude-plugin/plugin.json`, or legacy `plugin.toml`), discovery, validation, and\nthe trust/enable lifecycle — this document covers how bits get onto disk in\nthe first place.\n\n`/plugin suggest <task>` is a local, read-only companion: it ranks already\ninstalled bundles (name, keywords, description, bundled skill names, declared\nhosts) and any marketplace catalogs you have added with `/plugin marketplace add`.\nIt explains the match and gives the next review, enable, or catalog-install\nstep, but never installs, trusts, or enables a bundle on its own.\n\n## How Codewhale offers plugins\n\nCodewhale is helpful about plugins, not pushy. The rules:\n\n- **One proactive surface.** Sending a task can show one quiet toast when the\n prompt matches an installed-but-idle plugin or a catalog candidate you do\n not have yet, for example `/plugin trust supabase` or\n `/plugin marketplace install <catalog> supabase`. Nothing appears while you\n type, and nothing is appended to your message to advertise plugins.\n- **One switch.** With `contextual_tips` off, no plugin guidance appears\n anywhere. Required notices and your own `/plugin` commands still work.\n- **One budget.** Plugin offers share the per-session guidance budget with\n other tips. In the interactive TUI the model can call\n `request_plugin_install` once per session to ask you to review a plugin the\n task needs; a second call fails. Exec, ACP, and runtime-API sessions do not\n get the tool.\n- **Built-ins are never advertised.** Bundled plugins such as Computer Use\n appear only in `/plugin list` and Extensions.\n- **Specific terms only.** Generic words (accessibility, browser, chrome,\n docs, screenshot, web, wiki, …) and Codewhale's own vocabulary (agent, code,\n data, MCP, model, session, tool) never trigger an offer, so asking why an\n MCP server is stuck does not suggest a vendor's plugin. Distinctive names,\n phrases and domains still do. The matcher and the marketplace's\n `check-marketplace.mjs` share one stoplist.\n- **Only what runs here.** Plugins whose `when.os` excludes this OS are not\n offered.\n- **Review before acting.** A model-requested row offers Review. Its button\n opens the existing Extensions inventory: Plugins for an installed bundle,\n Marketplace for a suggestion that is not installed. Nothing is installed,\n trusted, or enabled by that click. Choose the inventory's explicit action;\n trust still reviews the exact installed content before activation.\n- **Reversible dismissal.** Esc clears a non-empty draft first, then hides the\n row for this session only. \"Don't suggest again\" is the explicit,\n persisted choice. `/plugin dismissals` lists both kinds, and\n `/plugin dismissals reset [<name>]` lets suggestions offer a plugin again.\n- **Discovery is passive.** Find new plugins in these docs,\n `/plugin marketplace list`, Extensions, and the browser guide below.\n\nCodewhale does not invent a remote plugin URL; missing plugins are suggested\nonly from catalogs you added. On-disk bundle changes still toast\n`/plugin reload` on send and between turns.\n\n## Browser: pick one\n\nSeveral options can drive a browser. They differ in whose browser it is and\nwhat it can see.\n\n| Option | Whose browser | Good for |\n| --- | --- | --- |\n| `chrome-devtools` MCP (`/mcp recommendations`) | A Chrome it drives, which can include signed-in pages | DevTools-level inspection and performance work |\n| Playwright MCP (`/mcp recommendations`) | A fresh, isolated profile with `--isolated` | Scripted flows and testing without your identity |\n| Computer Use `browser_*` tools (bundled, off until reviewed) | One it launches, in a profile of its own | Browser steps inside a wider desktop task |\n| Chromewhale (developer preview, `codewhale-hq/codewhale-plugin-marketplace`) | Yours, already open, in your own Chrome profile; load unpacked | Reading or acting on the tab you are looking at, one granted site at a time |\n\nNone of these is offered to you proactively. Add the one that fits the job.\n\n## Sources\n\n`codewhale install <spec>` and `/plugin install <spec>` use the same installer.\nThe shell entry does not start a model or require model credentials. Both keep\nbundles disabled and untrusted until you explicitly review and enable them.\n\n```sh\ncodewhale install git:github.com/example/plugin@v1.0.0\ncodewhale install npm:@example/plugin@1.0.0\ncodewhale install ./local-plugin\n```\n\nGit shorthand supports GitHub archives only; refs must be a single safe segment.\nULine truncated
},
{
"path": "docs/PLUGIN_BUNDLES.md",
"sha256": "7138da9a999fc8e94222a2cffae3b9ebdeb41ed19ab8941d24ff57a96d39d086",
"content": "# Plugin bundles\n\n> 阅读简体中文版:[zh_hans/PLUGIN_BUNDLES.md](zh_hans/PLUGIN_BUNDLES.md)。\n\nCodewhale supports a deliberately small plugin-bundle boundary. The boundary\nwas drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may\ncontribute declarative Skills, MCP configuration, Commands, Agent profiles,\nand Hooks through Codewhale's existing engines. Unsupported declarations stay\ninventoried instead of disabling a mixed bundle. Discovery alone never\nexecutes, enables, trusts, downloads, updates, or installs anything.\n\nThis document owns the bundle formats, discovery,\nvalidation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)\nowns how bits get onto and off disk — the `/plugin install`, `update`,\n`uninstall`, and `suggest` on-ramp added in v0.9.4 (#5182). Compatible Claude Code bundles use the same native adapters; their supported subset is\n[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor a runnable native example and explicit OpenCode/DSH data conversion, see\n[Write your first plugin](PLUGIN_AUTHORING.md).\n\n## Discovery and precedence\n\nCodewhale scans only its own roots, looking in each `<name>/` directory for a\nmanifest named `plugin.json` (the native Agent Plugins v1.0.0 format, since\nv0.9.4), `kimi.plugin.json` (the compatible Kimi Skills/MCP subset, since\nv0.9.8), `plugin.toml` (the legacy Codewhale format, still fully readable), or\n`.claude-plugin/plugin.json` (the compatible Claude subset, since v0.9.13):\n\n- User: `~/.codewhale/plugins/<name>/`\n- Workspace: `<workspace>/.codewhale/plugins/<name>/`\n\nA bundle that publishes multiple formats is read through `plugin.json` first,\nthen `kimi.plugin.json`, then the legacy `plugin.toml`, then\n`.claude-plugin/plugin.json`.\nComputer Use ships as a built-in bundle; it still requires review and\nenablement before activation. The internal precedence order is\nbuilt-in, user, then workspace; the first bundle with a given name wins. This\nprevents a repository from shadowing an explicitly installed user bundle.\nSymbolic-link roots, manifests, component paths, and nested component files\nfail closed.\n\nThe embedded Computer Use files are materialized under\n`$CODEWHALE_HOME/builtin-plugins/snapshots/computer-use-<bundle-digest>/computer-use`.\nEach process captures only its own embedded digest's discovery root. Concurrent\nbuilds therefore keep separate, complete source trees; publishers never delete\nor replace an existing snapshot. Reuse checks every embedded byte, directory\nentry, file type, executable flag, and the stamp. A partial or altered snapshot\nis rejected without repair. Interrupted private staging directories are not\ndiscovered or reused.\n\nThe existing path-bound plugin identity and trust rules apply: identical embedded\nbytes at the same home reuse the same identity; changed bytes require a fresh\nreview and enablement. Moving from the older mutable\n`builtin-plugins/computer-use` layout also requires one fresh review. Legacy\nbundles and receipts remain intact for running older binaries; no trust is\nmigrated. Diagnostics do not create a missing Codewhale home.\nThe selected home may itself be a symlink: its resolved directory is pinned\nbefore creating any built-in paths. Links in the owned built-in cache paths\nstill fail closed.\n\nNew user and workspace bundles are always untrusted and disabled. Discovery is\nread-only and does not inspect any other application's extension or credential\ndirectories: ambient roots such as `.claude/plugins` or `.cursor/plugins` are\nnever scanned.\n\nPre-v0.9.1 `overrides.json` enablement was intentionally not imported as\ntrust; every bundle activates only through the content-hash and\n`codewhale-plugin-capabilities-v3` activation-policy review below.\n\n## Manifest\n\nAll supported encodings parse into the same internal manifest, so validation, hashing,\nreview, and runtime behavior are identical downstream. On-disk auto-migration\nbetween them is deliberately not performed; `/plugin export <name>\n<target-dir>` publishes a loaded bundle as a spec-valid Agent Plugins v1.0.0\ndirectory without modifying the installed one.\n\n### `plugin.json` (Agent Plugins v1.0.0)\n\nThe standard's manifest root is closed: `$schema`, `name`, and the optional\nwell-known fields (`version`, `description`, `author`, `homepage`,\n`repository`, `license`, `keywords`, `extensions`). An unknown root key is a\nparse error. Client-specific data lives under `extensions`, keyed by\nreverse-domain namespace: unknown vendor namespaces are ignored, never\nrejected — that is what lets a bundle authored for another client load here —\nwhile unknown keys inside Codewhale's own `extensions[\"net.codewhale\"]`\nnamespace are rejected rather than silently dropped.\n\nNames follow the standard's rule: 1–64 lowercase ASCII letters, digits, or\ninternal single `-`/`.`, starting and ending alphanumeric, never `--` or `..`.\nA `skills/` dLine truncated
},
{
"path": "docs/PLUGIN_MARKETPLACE.md",
"sha256": "14cb2dbf8720a19a3f1fc49322bedb564cbf6025785ffb9504ca52c2e91aa2ed",
"content": "# First-party plugin marketplace\n\nCodewhale includes an offline snapshot of the `codewhale` catalog in the same\nmarketplace store consumed by the terminal, Extensions, recommendations, and\nRuntime API. It lists Computer Use, WhaleWiki, Whalesong, Cloudflare Docs, the\nCodewhale skill bundle, and Codewhale for Chrome (Chromewhale). Browsing does\nnot fetch or execute anything.\n\n```text\n/plugin marketplace list\n/plugin marketplace show codewhale\n/plugin marketplace install codewhale whalewiki\n/plugin show whalewiki\n/plugin trust whalewiki\n/plugin enable whalewiki\n/plugin update whalewiki\n```\n\nReview the manifest and capabilities before trust and enablement. Installation\nuses the existing size-limited, traversal-safe installer and starts disabled\nand untrusted. An update with changed bytes requires review again. Official\ncatalog provenance grants no execution or network permission. Removing the\ncatalog persists the choice and leaves installed plugins untouched; a locally\nadded catalog named `codewhale` takes precedence over the bundled snapshot.\n\nThe bundle source uses a gzip tarball URL with `#path=plugins/whalewiki` (or\n`#path=skills`). The fragment selects exactly one bundle inside the shared\nrepository archive. Only that subtree is installed. Empty paths, traversal,\nambiguous roots, links, oversized archives, and changed plugin identities are\nrejected. The install receipt preserves the source, including its selector,\nso `/plugin update` retains the same bundle selector and reviewed revision.\n\n## Built-in Computer Use across upgrades\n\nComputer Use also ships inside the binary as a built-in bundle. Each build\nwrites its own copy under `$CODEWHALE_HOME/builtin-plugins`, so an upgrade\npresents it as a new bundle. Your review carries over when the capability\nhash is unchanged: a bundle you trusted and enabled stays trusted and enabled\non the new build. When the capabilities changed, it shows\n`capabilities-changed` and stays off until you review it again with\n`/plugin show computer-use` and `/plugin trust computer-use`. If you revoked\ntrust after your most recent review, nothing carries and the new build waits\nfor a fresh review; once you review a build again, later upgrades carry that\nreview. User and workspace plugins never carry trust: changed bytes\nalways need review.\n\n## Chromewhale\n\nChromewhale (listed as \"Codewhale for Chrome\") is in the catalog bundled with\nCore from marketplace revision `ae3dd22` on, where its checks pass on macOS,\nLinux and Windows. It is a developer preview: installing the plugin does not\ninstall the browser side, so you load its bundled Chrome extension unpacked\nyourself. It then reads and acts on the tab you are looking at, one granted\nsite at a time. Like every catalog entry, it installs disabled and untrusted\nuntil you review it.\n\n## Keeping the repositories current\n\n| Content | Authoritative source | Copies to check |\n| --- | --- | --- |\n| Catalog, WhaleWiki, Whalesong, Cloudflare Docs | `codewhale-hq/codewhale-plugin-marketplace` | Core catalog snapshot |\n| Bundled skills | Core active catalog and `crates/tui/assets/skills` | Marketplace `skills` and `skills/upstream.json` |\n| Computer Use | `Hmbown/codewhale-cu-plugin` | Marketplace plugin and Core bundled runtime |\n\nThe `Marketplace connection` workflow validates the exact catalog revision on\ncatalog changes. Its weekly and manual runs compare the current public\nmarketplace, bundled snapshot, skills, and Computer Use runtime. Drift fails\nthe check with a maintenance instruction; it does not rewrite user installs\nor grant new permissions. It uses read-only repository access.\n\nFor each intentional update, review the upstream diff, synchronize the\nsource-owned copies, and run the marketplace checks:\n\n```sh\n# From codewhale-plugin-marketplace, with sibling source checkouts:\n# After committing canonical skill changes, when intentionally updating skills:\nnpm run sync:skills\nnpm run check -- --core ../codewhale\nnpm run check:cu-sync\nnpm test && npm run check:web\n```\n\nCommit the reviewed marketplace changes, then update Core from that committed\nrevision (the generator never copies an uncommitted marketplace document):\n\n```sh\n# From codewhale:\npython3 scripts/sync-marketplace.py --marketplace ../codewhale-plugin-marketplace\npython3 scripts/sync-marketplace.py --marketplace ../codewhale-plugin-marketplace --check\nnpm test && npm run check:web\n```\n\nReview the generated snapshot and rebuild Core. Its provenance records the\nexact marketplace commit, and each generated bundle URL pins that immutable\nrevision. A later marketplace change requires refreshing the Core snapshot and\nrebuilding; an existing pinned install does not silently follow `main`. Push the\nreviewed marketplace revision before publishing a Core release that references it. Hosted CI must be green for the\nactual published revisions; local checks do not prove Line truncated
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "3b7c422e6cd06a6eb5642fdc56576e3d74f6f280c183e6de8e0bf05c36bc13a8",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"host\" # experimental SDK backend; default is \"rust\"\n```\n\n`mcp_backend = \"host\"` selects the pinned MCP SDK independently of\n`[features] extension_host`. That feature controls optional Native extensions;\nselecting Host MCP does not activate them. The SDK for existing stdio, HTTP and legacy SSE server connections in a separate\nbuiltin host process. Rust retains process/network authority, credential\nresolution, catalog admission, tool approval and cancellation. HTTP/SSE uses\nRust's guarded HTTP client; the SDK receives opaque session selectors and\nexact operation grants. Request IDs remain the Rust client's strings through\nan adapter bound to each grant. Explicit Host selection never falls back to\nRust or replays an uncertain operation. The default remains `\"rust\"` while\nrecorded transport parity is qualified. The Host path uses the same Rust\nGET session preflight and bounded reactive OAuth refresh as the default\nbackend. An explicit incompatible HTTP response can negotiate the real SDK\nSSE transport only with a fresh exact Rust operation grant; a stale-session\nrefusal remains a typed Rust recovery decision. Credentials, configured URLs,\nOAuth browser/login operations and Computer Use decision keys stay in Rust.\nBoth backends run against the same recorded MCP goldens. Passing that suite\nand the actual broker acceptance tests is required before any default cutover.\nThe default also requires the platform isolation and measured Phase 3 gates\nin Ops CURRENT_DECISIONS §26. The native Rust protocol adapters remain for oneLine truncated
}
],
"missing": []
},
{
"id": "runtime-api",
"slug": "runtime-api",
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "82b8b74821dfefa8a6a3f16bec96a937fc146311cbd13e6bbb4df7333e486f4a",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. Bare `app-server`, `--stdio` and `--socket` select smaller\ncompatibility interfaces, not the full Engine API.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Compatibility controls and explicit tool-free turns; no full Engine event or approval/input channel. |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Compatibility `/healthz`, `/thread`, `/app`, `/prompt`. Turn requests require explicit `allowed_tools: []`; interactive execution fails before a turn starts. Use `app-server --http` for tools, approvals, clarification, goal execution, steering and the full event journal. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `watch` (the default when omitted) or explicit\n`drive`. Labels do not grant authority. Watch permits ordinary GET/HEAD reads,\nComputer displLine truncated
}
],
"missing": []
},
{
"id": "web",
"slug": "web",
"sources": [
{
"path": "docs/WEB.md",
"sha256": "e38dbc33c3f768fa9ea208a2c6231445406fe8f6421c56c961c21afa7382901e",
"content": "# Local browser client\n\n> 阅读简体中文版:[zh_hans/WEB.md](zh_hans/WEB.md)。\n\n`codewhale web` opens Codewhale's embedded browser client over the canonical\nRuntime API. It is a local surface: the server always binds to\n`127.0.0.1`, cannot be rebound to a LAN address, and cannot run with Runtime\nauthentication disabled.\n\n## Start it\n\nFrom the workspace Codewhale should operate in, run:\n\n```bash\ncodewhale web\n```\n\nThe default address is `http://127.0.0.1:7878`. To avoid a local port\ncollision, choose another loopback port:\n\n```bash\ncodewhale web --port 8788\n```\n\nCodewhale starts the Runtime API, serves the dependency-free client embedded\nin the installed binary, prints a single-use launch URL, and asks the operating\nsystem to open that URL in the default browser. If the browser does not open,\nuse the printed URL within ten minutes. Stop the process with `Ctrl+C`; the\nbrowser session ends with it.\n\n## What the browser can do\n\nThe embedded client provides a responsive thread and search rail, Runtime-owned\nsession facts, transcript and tool receipts, and a composer. It can create,\nselect, rename, and archive threads; start or steer turns; interrupt work;\nresolve approvals; and answer Runtime user-input requests.\n\nThe browser is another view of the same local Runtime. It does not create a\nsecond cloud account, copy provider credentials into browser storage, or\nweaken the configured approval and sandbox policies.\n\n## Authentication boundary\n\nThe browser-launch URL contains a random, short-lived, one-time bootstrap\ncapability. It never contains the Runtime bearer token. A loopback request\nexchanges the capability for an `HttpOnly`, `SameSite=Strict`, process-local\nsession cookie and immediately invalidates the capability.\n\nReused, expired, malformed, and non-loopback bootstrap attempts fail closed.\nThe Runtime token is not placed in rendered HTML, browser storage, URL\nqueries or fragments, or browser-launch arguments. The one-time bootstrap\nvalue is printed in the local terminal and briefly passes through the operating\nsystem's browser launcher. It is single-use and expires after ten minutes, but\na hostile process already running as the same OS user remains inside the local\ntrust boundary.\n\nCookie-authenticated state-changing requests must also present the exact\nlocal web origin. Cross-origin browser requests are rejected. Existing\nexplicit bearer and Runtime-token-header clients retain their normal Runtime\nAPI behavior.\n\n## Local means local\n\n`codewhale web` accepts only `--port`; there is no `--host` or insecure-auth\noption on this command. Do not treat it as a public website or expose its port\ndirectly through router forwarding, a public reverse proxy, or a tunnel.\n\nThe separate `codewhale app-server --mobile` and `--http` modes have different\ndeployment and authentication contracts. Read [RUNTIME_API.md](RUNTIME_API.md)\nbefore operating either one, especially before selecting a non-loopback bind.\n\n## Troubleshooting\n\n- If port `7878` is occupied, pass an unused `--port` value.\n- If the browser does not open, copy the printed single-use bootstrap URL into\n a browser on the same machine within ten minutes. Start `codewhale web` again\n if that URL has already been used or expired.\n- If the page loads but a provider is unavailable, inspect `codewhale doctor`\n and `/provider`; the web command does not configure or move provider\n credentials.\n- If a session expired, stop and restart `codewhale web` to mint a new\n process-local session. Reusing an old bootstrap URL is expected to fail.\n- A new tab opened by typing the address is not signed in: the session's\n request proof stays in the tab that opened it. Use that tab, duplicate it,\n or restart `codewhale web`.\n\nFor integration endpoints, headers, events, and the complete web-session\ncontract, see [RUNTIME_API.md](RUNTIME_API.md).\n"
}
],
"missing": []
},
{
"id": "troubleshooting",
"slug": "troubleshooting",
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "e8d15c92107272146ad38f66c487bda9c821a4fd85b46c0a1c884907acc6803a",
"content": "# Installing Codewhale\n\n> 阅读简体中文版:[zh_hans/INSTALL.md](zh_hans/INSTALL.md)\n\nCodewhale is an open-source coding agent that runs in your terminal. You give\nit a task (\"fix the failing test\", \"add a CLI flag\"). It reads your repository,\nedits files and runs commands. In the default **Ask** posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. **DeepSeek** is the default.\n\nThe command is `codewhale`. `codew` is a shorter alias for the same program.\n\nThe installation paths were first checked with **v0.10.0** (released 2026-09-22) on a\nfresh **Ubuntu 24.04 x86_64** machine. The dated\n[install receipts](https://github.com/codewhale-hq/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md) record the commands and output checked at that time. Steps that\ncould not be run on that machine are marked **(untested on this VM: reason)**.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on **macOS 26.1 (Apple silicon)**; see\n[macOS notes](#macos-notes). Steps that need a model call were not re-run\nthere.\n\nInstall commands that use `latest` resolve to the latest **published** GitHub\nRelease or package. Between releases, `main` may already describe the next\nversion (for example a v0.10.2 source candidate before its tag). A\nprebuilt candidate is available through the official installer only after its\ntag, checksums and release assets exist. Contributors can build `main` now.\n\n## No account needed\n\nCodewhale needs no account. Keys you save with `/provider` or\n`codewhale auth set` stay on your machine; see\n[API keys and providers](#8-api-keys-and-providers). New Codewhale accounts\nare not open, so this guide does not cover account key storage.\n\n## Test and contribute to current source\n\n`latest` selects the latest published release. To test unreleased repairs,\nbuild canonical GitHub `main`, or CNB `main` after verifying its commit matches.\nThe source version number alone does not establish a published package.\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/CodeWhale.git\ncd CodeWhale\ncargo build --release --locked -p codewhale-cli\ntarget/release/codewhale --version\n```\n\nSee the Cargo section for build prerequisites; use the repository's stable Rust\nselection (declared minimum 1.89). Windows uses `target/release/codewhale.exe`\nand needs MSVC. A development build is separate from a signed release package.\nFor mainland-China downloads and source validation, see [CNB mirror](CNB_MIRROR.md).\nThe [Tencent Lighthouse bootstrap](../scripts/tencent-lighthouse/README.md) is\nbilingual. An npm registry mirror serves the wrapper; native binary downloads\nstill need an available release asset source.\n\n---\n\n## 60-second quickstart (Linux or macOS)\n\n```bash\n# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).\ncurl -fsSL https://codewhale.net/install.sh | sh\n\n# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.\necho 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc # zsh: use ~/.zshrc\nexport PATH=\"$HOME/.local/bin:$PATH\"\ncodewhale --version # -> codewhale 0.10.0 (1be1a703b975)\n\n# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).\ncodewhale auth set --provider deepseek # prompts for the key; nothing is echoed\ncodewhale auth status --provider deepseek # \"active source: secret store\"\n\n# 4. Run your first task inside a git repository.\ncd ~/your-project\ncodewhale\n```\n\nIn the TUI, type something concrete:\n\n```text\ncreate a Python file primes.py that prints the first 10 primes, run it, and show me the output\n```\n\nCodewhale writes the file, shows you a diff, then asks **APPROVAL: bash\npython3 primes.py – Do you want to proceed?** Press `y` to allow it once. It\nruns the command and reports the output. Press `Ctrl-D` (with an empty input\nbox) to quit. It prints the command to resume the session later.\n\n> **If nothing happens after you send your first message,** you have no key\n> configured. v0.10.0 doesn't warn you in that case. Press **F3**. If DeepSeek\n> shows `missing key`, press Enter, paste the key, then pick a model and\n> confirm.\n\n---\n\n## Contents\n\n1. [Before you start](#1-before-you-start)\n2. [Install: recommended installer](#2-recommended-installer-curl--sh)\n3. [Install: manual download from GitHub Releases](#3-manual-download-from-github-releases)\n4. [Install: npm](#4-npm)\n5. [Install: Cargo / build from source](#5-cargo-and-building-from-source)\n6. [Install: Homebrew (Linux) and Nix](#6-homebrew-on-linux-and-nix)\n7. [Updating and rolling back]Line truncated
},
{
"path": "docs/OPERATIONS_RUNBOOK.md",
"sha256": "e466c8ffd55a0754167e29c64530943577ab8758645061248d671b26f2062d34",
"content": "# codewhale Operations Runbook\n\n> 阅读简体中文版:[zh_hans/OPERATIONS_RUNBOOK.md](zh_hans/OPERATIONS_RUNBOOK.md)。\n\nThis runbook covers practical debugging and incident response for the local CLI/TUI runtime.\n\n## Quick Triage\n\n1. Confirm binary + config:\n - `cargo run -- --version`\n - `cat ~/.codewhale/config.toml` (or inspect configured profile)\n2. Enable verbose logs:\n - `RUST_LOG=codewhale_tui=debug cargo run`\n - For HTTP retries/reconnects: `RUST_LOG=codewhale_tui::client=debug cargo run`\n3. Capture current state:\n - `ls ~/.codewhale/sessions`\n - `ls ~/.codewhale/sessions/checkpoints`\n - `ls ~/.codewhale/tasks`\n\n## Incident: Turn Hangs or Stream Stops\n\nSymptoms:\n- TUI remains in loading state\n- partial assistant output with no completion\n\nChecks:\n1. Inspect retry/health logs (`codewhale_tui::client`)\n2. Verify endpoint connectivity:\n - `curl -sS https://api.deepseek.com/beta/models -H \"Authorization: Bearer $DEEPSEEK_API_KEY\"`\n3. Confirm no local sandbox/permission deadlock in tool output\n\nActions:\n1. If a foreground shell command is running, press `Ctrl+B` to move it to the background (the turn keeps running and the command becomes a background job under `/jobs`); use `Ctrl+C` instead if you want to cancel the turn.\n2. If the command was started in the background, ask the assistant to use `Bash` with `action: \"cancel\"` and the returned process id.\n3. Use `Esc` or `Ctrl+C` to interrupt the current turn when you want to stop the request itself.\n4. Retry prompt; if still failing, restart TUI.\n5. On restart, verify the previous queued/in-flight runtime turn is shown as interrupted rather than left in a running state.\n\n## Incident: Network Outage / Offline Behavior\n\nExpected behavior:\n- New prompts are queued while offline mode is active\n- Queue state persists per session to\n `~/.codewhale/sessions/checkpoints/<session-id>.offline_queue.json`; a legacy\n global `offline_queue.json` is adopted once on upgrade\n\nChecks:\n1. Open queue in TUI: `/queue list`\n2. Confirm persisted queue file exists and updates timestamp\n\nActions:\n1. Restore connectivity\n2. Re-send queued entries (from `/queue edit <n>` + Enter, or normal input flow)\n3. Ensure queue file clears when queue is empty\n\n## Incident: Crash Recovery Needed\n\nExpected behavior:\n- Each session checkpoints to `~/.codewhale/sessions/checkpoints/<session-id>.json`;\n a legacy `latest.json` is still read for recovery but is no longer written\n- Startup begins a fresh session unless `--resume`/`--continue` is supplied\n\nActions:\n1. Resume prior work explicitly via `codewhale --resume <id>` (alias\n `codewhale resume <id>`; `codewhale --continue` recovers the newest\n interrupted checkpoint for the workspace) or `Ctrl+R` in TUI\n2. If checkpoint inspection is needed, inspect `checkpoints/<session-id>.json` (or a leftover legacy\n `latest.json`) for schema mismatch/details\n3. If schema is newer than binary supports, upgrade binary or remove stale checkpoint\n\n## Incident: Persistent State Schema Errors\n\nSymptoms:\n- Errors like `schema vX is newer than supported vY`\n\nAffected stores:\n- sessions (`~/.codewhale/sessions/*.json`)\n- runtime thread/turn/item records\n- tasks (`~/.codewhale/tasks/tasks/*.json`)\n\nActions:\n1. Confirm binary version and migration expectations\n2. Back up the state directory before editing\n3. Either:\n - run with a newer compatible binary, or\n - archive incompatible records and regenerate state\n\n## Incident: MCP/Tool Execution Failures\n\nChecks:\n1. Validate `~/.codewhale/mcp.json` schema and server command paths\n2. Confirm server process can start manually\n3. Check sandbox denials in TUI history / logs\n\nActions:\n1. Retry with required approvals (or YOLO only when appropriate)\n2. Temporarily disable failing MCP server and isolate issue\n3. Re-enable after verification with `/mcp` diagnostics\n\n## Post-Incident Checklist\n\n1. Preserve logs and relevant state files\n2. Record trigger, impact, and mitigation\n3. Add or update regression tests (retry/recovery/schema)\n4. Update this runbook and architecture docs if behavior changed\n"
},
{
"path": "docs/DOCKER.md",
"sha256": "3845d9e1bc72942ab1f6d714e6d6ed4bd204ccc2f40b29a87fa333893767ea79",
"content": "# Docker\n\n> 阅读简体中文版:[zh_hans/DOCKER.md](zh_hans/DOCKER.md)。\n\nCodewhale publishes a multi-arch Linux image to GitHub Container Registry\nfor each release.\n\nThe latest published image is 0.10.0 at `ghcr.io/hmbown/codewhale`.\nTo test the current `main` source, use [Building locally](#building-locally)\nin a checkout of [`codewhale-hq/Codewhale`](https://github.com/codewhale-hq/Codewhale).\n\n```bash\ndocker pull ghcr.io/hmbown/codewhale:latest\n```\n\n## Quick start\n\nRun the published image with a Docker-managed data volume:\n\n```bash\ndocker volume create codewhale-home\n\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nUse a pinned release tag for reproducible installs:\n\n```bash\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:v0.10.0\n```\n\nThe pinned example uses the [0.10.0 release](https://github.com/codewhale-hq/Codewhale/releases/tag/v0.10.0).\n\n## Default image contract\n\n`ghcr.io/hmbown/codewhale:latest` and the semver tags are conservative runtime\nimages:\n\n- the container runs as the non-root `codewhale` user with UID/GID `1000:1000`\n- the image does not grant passwordless `sudo`\n- the image is meant to run Codewhale against mounted workspaces, not to mutate\n the base operating system at runtime\n- user state belongs in a volume mounted at `/home/codewhale/.codewhale`\n\nThat default is intentional. Keep using it for the smallest trust boundary. If a\nproject needs `apt-get`, compiler toolchains, Node/Python package managers,\ncustom CA certificates, or other host-like setup inside Docker, build an\nexplicit toolbox image instead of changing the default image contract.\n\n## Opt-in toolbox/custom image\n\nThe repository includes an example\n[`docs/examples/Dockerfile.toolbox`](examples/Dockerfile.toolbox) that extends\nthe official image with passwordless `sudo` and common development packages.\nBuild it with a pinned Codewhale tag when you want repeatable project\nenvironments:\n\n```bash\ndocker build -f docs/examples/Dockerfile.toolbox \\\n --build-arg CODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:v0.10.0 \\\n --build-arg TOOLBOX_PACKAGES=\"git openssh-client curl build-essential pkg-config python3 python3-pip nodejs npm\" \\\n -t codewhale-toolbox:my-project .\n```\n\nUse `latest` only for throwaway testing. For shared projects, keep the\n`CODEWHALE_IMAGE` value pinned and review package additions like any other\ndevelopment-environment change.\n\nRun the toolbox image with the same workspace and state mounts:\n\n```bash\ndocker volume create codewhale-my-project-home\n\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-my-project-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n codewhale-toolbox:my-project\n```\n\nInside this opt-in image, Codewhale can use commands such as\n`sudo apt-get update` and `sudo apt-get install -y <package>`. For repeatable\ncontainers, prefer baking those packages into the toolbox Dockerfile instead of\nletting a long-lived container drift.\n\nDo not bake API keys, SSH private keys, or other secrets into custom images.\nPass API keys at runtime and mount any SSH material deliberately, preferably\nread-only and only for projects that need it.\n\n### Compose toolbox template\n\nIf you prefer a repeatable `docker compose` entry point, use\n[`docs/examples/compose.toolbox.yml`](examples/compose.toolbox.yml). It builds\nthe toolbox image from [`docs/examples/Dockerfile.toolbox`](examples/Dockerfile.toolbox)\nand keeps the project state volume explicit:\n\n```bash\nCODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:v0.10.0 \\\nCODEWHALE_TOOLBOX_IMAGE=codewhale-toolbox:my-project \\\nCODEWHALE_HOME_VOLUME=codewhale-my-project-home \\\nCODEWHALE_WORKSPACE=\"$PWD\" \\\ndocker compose -f docs/examples/compose.toolbox.yml run --rm codewhale\n```\n\nUse a different `CODEWHALE_TOOLBOX_IMAGE` and `CODEWHALE_HOME_VOLUME` for each\nproject that needs an independent toolchain or independent `.codewhale` state.\nThe Compose file also shows opt-in, read-only mounts for SSH material and local\nCA certificates; keep those commented out unless the project needs them.\n\n## Multiple independent projects\n\nUse one named state volume per project so sessions, config, skills, memory, and\nthe offline queue do not bleed across workspaces:\n\n```bash\nproject=\"$(basename \"$PWD\")\"\nimage=\"codewhale-toolbox:${project}\"\ndocker volume create \"codewhale-${project}-home\"\n\ndocker run --rm -it \\\n --name \"codewhale-${project}\" \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v \"codewhale-${project}-home:/home/codewhale/.codewhale\" \\\n -v \"$PLine truncated
}
],
"missing": []
},
{
"id": "contribution",
"slug": "contribution",
"sources": [
{
"path": "CONTRIBUTING.md",
"sha256": "e6ce0e044f2e61f290fdffee83879e965b4c6fc40f8d61f9f2b2037e692cfa79",
"content": "# Contributing to codewhale\n\nThank you for your interest in contributing to codewhale! This document provides guidelines and instructions for contributing.\n\n## Getting Started\n\n### Prerequisites\n\n- Rust 1.89 or later (edition 2024)\n- Cargo package manager\n- Git\n\n### Setting Up Development Environment\n\n1. Fork and clone the repository:\n ```bash\n git clone https://github.com/YOUR_USERNAME/CodeWhale.git\n cd CodeWhale\n ```\n\n2. Build the current engine and terminal:\n ```bash\n CODEWHALE_BUILD_SHA=\"$(git rev-parse HEAD)\" cargo build --locked -p codewhale-cli\n ```\n\n3. Run the tests near your change (see [Fast local loop](#fast-local-loop)):\n ```bash\n scripts/dev-test.sh tui your_test_filter\n ```\n\n4. Run with development settings:\n ```bash\n ./target/debug/codewhale --version\n ./target/debug/codewhale\n ```\n\n### Testing the latest source\n\nThe canonical source is [`codewhale-hq/Codewhale`'s `main` branch](https://github.com/codewhale-hq/Codewhale/tree/main).\nRelease candidates land there after their CI gates pass, so contributors can\ntest and build on the same source. Tagged downloads remain the latest published\nrelease; their version can lag the development version on `main`.\n\nFor a fresh checkout of the current source:\n\n```bash\ngit clone --branch main https://github.com/codewhale-hq/Codewhale.git\ncd Codewhale\nCODEWHALE_BUILD_SHA=\"$(git rev-parse HEAD)\" cargo build --release --locked -p codewhale-cli\n./target/release/codewhale --version\n./target/release/codewhale\n```\n\nInclude the commit shown by `--version` when reporting a problem. If you work\nfrom a fork, add the canonical repository as `upstream` and fetch `upstream/main`\nbefore starting a change; preserve any local work when updating your branch.\n\n## Development Workflow\n\n### Code Style\n\n- Run `cargo fmt` before committing to ensure consistent formatting\n- Run `cargo clippy` and address all warnings\n- Follow Rust naming conventions (snake_case for functions/variables, CamelCase for types)\n- Add documentation comments for public APIs\n\n### Testing\n\n- Write tests for new functionality\n- Run the tests near your change (see [Fast local loop](#fast-local-loop));\n your pull request runs the Linux workspace suite and a Windows build for\n you (see [What happens after you open a pull request](#what-happens-after-you-open-a-pull-request))\n- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`\n modules), and add integration tests under the owning crate's `tests/`\n directory (for example `crates/tui/tests/` or `crates/state/tests/`). The\n repository root `tests/` directory is not used\n\n### Pre-push verification\n\nThese are the Rust commands your pull request runs on Linux (the `Lint` and\n`Test (ubuntu-latest)` checks). You do not need all of them before every\npush: run `cargo fmt`, then check and test the crates you touched (see\n[Fast local loop](#fast-local-loop)). Run the full set locally when a change\nspans many crates, or let the pull request run it for you:\n\n```bash\ncargo fmt --all -- --check\ncargo clippy --workspace --all-targets --all-features --locked -- \\\n -D warnings \\\n -A clippy::uninlined_format_args \\\n -A clippy::too_many_arguments \\\n -A clippy::unnecessary_map_or\ncargo test --workspace --all-features --locked\n```\n\nClippy lints test, bench and example targets too (`--all-targets`), and the\nthree allowances above are the only ones CI and the release lane accept.\n\n#### Fast local loop\n\nThe full gate above is what CI enforces, but you do not need it for every\nedit. `crates/tui` is a ~750k-line crate, so the loop that stays fast is\nthe one that avoids rebuilding it more than necessary (numbers and the\nreasoning are in [`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md)):\n\n```bash\n# 1. Type-check first (seconds after the first build; no codegen, no link).\nscripts/dev-cargo.sh check -p codewhale-tui\n\n# 2. Run only the tests near your change (one crate, one filter).\nscripts/dev-test.sh tui fleet_setup\n# or: scripts/dev-test.sh crates/command-contract/src/elapsed.rs\n\n# 3. Run a whole crate's unit suite. scripts/dev-test.sh uses nextest when\n# it is installed (one process per test, all cores busy, slow tests\n# named; ~100 s here vs ~270 s with libtest).\ncargo install cargo-nextest --locked # once\nscripts/dev-test.sh tui\nscripts/dev-cargo.sh nextest run --workspace --all-features --locked\n\n# 4. The Linux suite your PR runs:\ncargo test --workspace --all-features --locked\n```\n\n`.config/nextest.toml` already serializes the PTY suite and bounds the\nintegration tests that spawn the real binary, so `cargo nextest run` is\nsafe to use on the whole workspace (nextest does not run doctests; the\nauthoritative `cargo test` gate does). Tests must not depend on running in\nthe same process as another test (nextest gives every test its own\nprocess); if a tesLine truncated
},
{
"path": "docs/AGENT_ETHOS.md",
"sha256": "d9cd36fc7b6d9d236c68e62ffc92f41d3f3fab6e2e5eb9bf2bb2ae1d53f82331",
"content": "# Agent Ethos\n\n> 阅读简体中文版:[zh_hans/AGENT_ETHOS.md](zh_hans/AGENT_ETHOS.md)。\n\nCodewhale is maintained with agents, but it is not maintained by automation\nalone. Treat community reports and patches as real collaboration: people are\nbringing us machines, providers, regions, shells, packages, and edge cases we\ncould not cover by ourselves.\n\n## Stewardship\n\n- Verify live truth before acting. Check the current branch, release state,\n registry state, CI, and linked issues instead of trusting a handoff.\n- Issues are intake, not a privilege boundary. Do not auto-close good-faith\n issues because the reporter is not allowlisted. Ask for missing reproduction\n detail and leave room for maintainer triage.\n- PR gates exist for code review, CI load, and trust-boundary safety. They are\n not a quality judgment on the contributor. Keep dry-run mode unless a\n maintainer deliberately enables enforcement, and use warm copy when the gate\n comments.\n- Be generous with recurring contributors. When someone repeatedly brings\n useful reports or patches, use `/lgtmi` for issue access or `/lgtm` for PR\n access so the automation gets out of their way.\n- Preserve contributor credit. When harvesting work, inspect the PR and linked\n issues, keep author/co-author attribution where possible, add\n `Harvested from PR #N by @handle`, and credit the contributor in the\n changelog or release notes.\n- Make credit machine-readable. If a harvested commit cannot preserve the\n contributor as the author, add a `Co-authored-by` trailer with the GitHub\n numeric noreply address from `.github/AUTHOR_MAP` or\n `gh api users/<login> --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`.\n Do not use `.local`, placeholder, bot/tool, or raw third-party emails for\n human contributor credit.\n- Deferral is a maintainer action, not a dismissal. If a PR or issue is not\n ready, say what is blocked, what evidence would change the decision, and\n which part of the work remains valuable.\n\n## Agent Workflow\n\n- Use sub-agents for exploration, review, and verification, but keep a human\n maintainer posture in the parent session. Sub-agent output is evidence; the\n parent is responsible for the final decision.\n- Personally review community PRs before merging, harvesting, closing, or\n deferring them. Do not close work based only on title, labels, or an agent's\n summary.\n- Prefer narrow, reversible changes that match the existing codebase. Avoid\n drive-by refactors while harvesting community work.\n- Run the smallest meaningful validation first, then broaden tests when a\n change touches shared behavior, release plumbing, auth, sandboxing,\n providers, or UI workflows.\n- Do not tag, publish, push release artifacts, or create GitHub releases\n without explicit maintainer approval.\n\n## Product Tone\n\nCodewhale should feel like a capable coding harness with a public community,\nnot a closed queue. Automation should reduce maintainer load while making\ncontributors feel seen, credited, and able to keep helping.\n"
},
{
"path": "docs/CONTRIBUTORS.md",
"sha256": "2a0a80012465c19a3d2ef1d3aeb48547381c9cd965379db21950dfe0c3aa11cc",
"content": "# Contributors\n\nCodewhale is built in the open with a growing community of contributors. Every\nissue report and pull request is real project work — welcome at any experience\nlevel. This is the full per-PR contributor record, grouped into release/time\nbands with the newest band first so it stays scannable. Expand any band to see\neveryone.\n\nFor the live list, see the\n[GitHub contributors page](https://github.com/codewhale-hq/CodeWhale/graphs/contributors),\n[`AUTHOR_MAP`](https://github.com/codewhale-hq/CodeWhale/blob/main/.github/AUTHOR_MAP),\nand [CHANGELOG.md](../CHANGELOG.md).\n\n<!-- contributor-wall:start -->\n## Everyone\n\n[![421 people built Codewhale](assets/contributors.png)](#everyone)\n\n421 people have code, co-authored commits, carried-forward pull requests or credited reports in Codewhale. GitHub's contributor list shows only commit authors whose email is linked to their account, so it counts 203 of them. If your commits are missing there, add your commit email to your GitHub account and they will link.\n\n[0gl20shk0sbt36](https://github.com/0gl20shk0sbt36) · [1Git2Clone](https://github.com/1Git2Clone) · [20bytes](https://github.com/20bytes) · [47Cid](https://github.com/47Cid) · [7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) · [8sero](https://github.com/8sero) · [A-Corner](https://github.com/A-Corner) · [aboimpinto](https://github.com/aboimpinto) · [adity982](https://github.com/adity982) · [AdityaVG13](https://github.com/AdityaVG13) · [AGSaturn](https://github.com/AGSaturn) · [ai-dengwsh](https://github.com/ai-dengwsh) · [aiapienthusiast](https://github.com/aiapienthusiast) · [Aitensa](https://github.com/Aitensa) · [AiurArtanis](https://github.com/AiurArtanis) · [alitvak69](https://github.com/alitvak69) · [all-lopezg](https://github.com/all-lopezg) · [alvin1](https://github.com/alvin1) · [amuthantamil](https://github.com/amuthantamil) · [AnaheimEX](https://github.com/AnaheimEX) · [Andrea-Bruno](https://github.com/Andrea-Bruno) · [Angel-Hair](https://github.com/Angel-Hair) · [angus-guo](https://github.com/angus-guo) · [angziii](https://github.com/angziii) · [AnonymousUser443](https://github.com/AnonymousUser443) · [antarikshraya](https://github.com/antarikshraya) · [Anyexyz](https://github.com/Anyexyz) · [Apeiron0w0](https://github.com/Apeiron0w0) · [aqilaziz](https://github.com/aqilaziz) · [AresNing](https://github.com/AresNing) · [ArronAI007](https://github.com/ArronAI007) · [Artenx](https://github.com/Artenx) · [asdfg314284230](https://github.com/asdfg314284230) · [Asteria1028](https://github.com/Asteria1028) · [asto18089](https://github.com/asto18089) · [atmosuwiryo](https://github.com/atmosuwiryo) · [axobase001](https://github.com/axobase001) · [aznikline](https://github.com/aznikline) · [baendlorel](https://github.com/baendlorel) · [banqii](https://github.com/banqii) · [Beltran12138](https://github.com/Beltran12138) · [bevis-wong](https://github.com/bevis-wong) · [BH8GCJ](https://github.com/BH8GCJ) · [BigBenLabs](https://github.com/BigBenLabs) · [Biilow](https://github.com/Biilow) · [bistack](https://github.com/bistack) · [BrathonBai](https://github.com/BrathonBai) · [bruce6135](https://github.com/bruce6135) · [BryonGo](https://github.com/BryonGo) · [buiducnhat](https://github.com/buiducnhat) · [buko](https://github.com/buko) · [BX166](https://github.com/BX166) · [c020627](https://github.com/c020627) · [cacdcaecawae](https://github.com/cacdcaecawae) · [caeserchen](https://github.com/caeserchen) · [caiyilian](https://github.com/caiyilian) · [CCChisato](https://github.com/CCChisato) · [ccomma](https://github.com/ccomma) · [cenab](https://github.com/cenab) · [ChaceLyee2101](https://github.com/ChaceLyee2101) · [chatman-media](https://github.com/chatman-media) · [chennest](https://github.com/chennest) · [chinaqy110](https://github.com/chinaqy110) · [chnjames](https://github.com/chnjames) · [chuntseevolving](https://github.com/chuntseevolving) · [ci4ic4](https://github.com/ci4ic4) · [cian-lien](https://github.com/cian-lien) · [cmdcorp6534](https://github.com/cmdcorp6534) · [cmyyy](https://github.com/cmyyy) · [codepgq](https://github.com/codepgq) · [Colorful-glassblock](https://github.com/Colorful-glassblock) · [ComeFromTheMars](https://github.com/ComeFromTheMars) · [CrepuscularIRIS](https://github.com/CrepuscularIRIS) · [ct-jaryn](https://github.com/ct-jaryn) · [ctxyao](https://github.com/ctxyao) · [cy2311](https://github.com/cy2311) · [cyq1017](https://github.com/cyq1017) · [czf0718](https://github.com/czf0718) · [dajiaohuang](https://github.com/dajiaohuang) · [DarrellThomas](https://github.com/DarrellThomas) · [demian-welt](https://github.com/demian-welt) · [Dev-next-gen](https://github.com/Dev-next-gen) · [dfwqdyl-ui](https://github.com/dfwqdyl-ui) · [DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI) · [DieMoe233](https://github.com/DieMoe233) · [DingYong42Line truncated
},
{
"path": "docs/RELEASE_CHECKLIST.md",
"sha256": "16f73dc31c7a96671ce89bd73d92d54be383666605003378586629aedb544907",
"content": "# Release Checklist\n\nFollow this sequence from the canonical `/Volumes/VIXinSSD/CW/codewhale`\ncheckout on `main`. Agent-owned preparation lands as verified slices on `main`;\nit requires no release branch, worktree, nested clone or preparation PR.\nContributor PRs still land as PRs. Treat any unchecked required gate as a release\nblocker.\n\nFor deeper context on the underlying tools (preflight scripts, npm smoke,\npublish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md).\nFor larger milestone releases, add any version-specific acceptance matrix to\nthe existing release packet before tagging; use it for provider routes, feature\ngates, GUI/runtime smoke, remote-workbench decisions, and credit hygiene that the\ngeneric checklist does not enumerate. Record its candidate SHA, owners, local\ncheck results, hosted run URLs, artifact inventory, install/provider receipts,\nknown limitations and publication approval. These proofs are separate.\n\n## 0. Reconcile the canonical checkout\n\n- [ ] Inspect the branch, status, upstream and alternate checkouts before editing:\n ```bash\n git branch --show-current\n git status --short --branch\n git log -5 --oneline\n git worktree list\n git fetch origin main\n git log --left-right --oneline main...origin/main\n ```\n- [ ] The canonical checkout is on `main`. Recover stranded work only after\n preserving dirty/untracked files and comparing commits with current\n `origin/main`. Keep a recoverable backup and unresolved inventory; do not\n reset away work, stash it indefinitely or create another development lane.\n- [ ] Partition files among active writers. Check status before staging and\n stage only owned paths. Historical branches/checkouts are inventoried;\n deleting them requires explicit Hunter approval even if a helper calls\n their tips safe to delete.\n\n## 1. CHANGELOG entry exists for the version\n\n- [ ] `CHANGELOG.md` has a `## [X.Y.Z] - YYYY-MM-DD` heading at the top\n- [ ] The entry credits every external contributor, harvested PR author,\n linked issue reporter, reproduction/log provider, reviewer, and\n verification helper whose work materially shaped this version. Get the\n commit list with:\n ```\n git log vPREV..HEAD --no-merges --format=\"%h %an <%ae> %s\" \\\n | grep -v '<your-email@…>'\n ```\n For each contributor, link both their display name and (when known)\n `@github-handle`. Then inspect linked issues and harvested PRs so\n reporters/helpers are not lost just because they did not author commits.\n Keep `docs/CONTRIBUTORS.md` and `web/lib/release-credits.ts` consistent.\n- [ ] The entry uses the Keep a Changelog headers — `Added`, `Changed`,\n `Fixed`, `Security`, `Removed`, `Deprecated`. Add `Known issues` only\n if there is something material the user must work around.\n- [ ] The entry mentions all referenced issue/PR numbers as `#NNNN` so the\n auto-linker on GitHub picks them up.\n- [ ] Run `scripts/sync-changelog.sh` to regenerate `crates/tui/CHANGELOG.md`\n (the recent-releases slice embedded in the binary for `/change`). Do\n not edit that file by hand, and do not copy the full root changelog\n into it — older entries live in `docs/CHANGELOG_ARCHIVE.md`.\n- [ ] Run `scripts/release/check-feature-release-notes.sh vPREV HEAD`. Every\n issue-linked `feat` commit must leave a receipt in `CHANGELOG.md` or the\n archive; the Version drift CI gate runs the same check with full history.\n\n## 2. Version pins are in sync\n\n- [ ] Use `./scripts/release/prepare-release.sh X.Y.Z` when version pins or\n generated sources need preparation. Reuse a valid preparation receipt\n when those sources are already current. The helper bumps the\n workspace version, every per-crate dependency pin, the npm wrapper\n (`version` + `codewhaleBinaryVersion`), Runtime SDK, VS Code extension\n and lock, remote-smoke default, public source-candidate facts, and README\n install-tag examples; it refreshes the Cargo/npm locks, regenerates\n `crates/tui/CHANGELOG.md` and `web/lib/facts.generated.ts`, and ends\n by running the version and OHOS gates. Write the CHANGELOG entry\n **before** running it. The helper is safe to rerun at the requested\n workspace version; it skips replacements but refreshes both generated\n files and reruns the gates.\n- [ ] `npm/deepseek-tui/package.json` remains private/compatibility-only and\n is **not** bumped or published.\n- [ ] `./scripts/release/check-versions.sh` reports\n `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, npm-binary=X.Y.Z, lockfile in sync.`\n- [ ] `./scripts/release/check-ohos-deps.sh` reports that the OpenHarmony\n Windows linker keeps the target/sysroot flags, the target enables the\n `rquickjs-sys` bindgen edge, and its graph does not pull the unsuppLine truncated
}
],
"missing": []
},
{
"id": "runtime-reference",
"slug": "runtime-reference",
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "82b8b74821dfefa8a6a3f16bec96a937fc146311cbd13e6bbb4df7333e486f4a",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. Bare `app-server`, `--stdio` and `--socket` select smaller\ncompatibility interfaces, not the full Engine API.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Compatibility controls and explicit tool-free turns; no full Engine event or approval/input channel. |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Compatibility `/healthz`, `/thread`, `/app`, `/prompt`. Turn requests require explicit `allowed_tools: []`; interactive execution fails before a turn starts. Use `app-server --http` for tools, approvals, clarification, goal execution, steering and the full event journal. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `watch` (the default when omitted) or explicit\n`drive`. Labels do not grant authority. Watch permits ordinary GET/HEAD reads,\nComputer displLine truncated
}
],
"missing": [
"docs/RUNTIME_ROUTES.md"
]
},
{
"id": "workflow-tutorial",
"slug": "workflow-tutorial",
"sources": [
{
"path": "docs/FLEET_WORKFLOW_TUTORIAL.md",
"sha256": "6a6a3ac2554db4e590042e684eed7ff91ae984af8ecaa232c2005a7765ffcaf2",
"content": "# Fleet + Workflow Tutorial\n\n> 阅读简体中文版:[zh_hans/FLEET_WORKFLOW_TUTORIAL.md](zh_hans/FLEET_WORKFLOW_TUTORIAL.md)。\n\nFleet and Workflow are meant to work together, but they solve different parts\nof the problem:\n\n- **Fleet** configures and manages the same sub-agents: reusable roles, model\n routes, permissions, logs, artifacts, and status/restart/stop controls.\n- **Workflow** describes orchestration: phases, branches, reducers, loops, and\n agent leaves that can dispatch through the fleet/sub-agent runtime.\n\n**Default product path:** ask in natural language. Operate handles small or\ntightly coupled work directly under the active posture. Multi-step delegation\nuses a compact Workflow plan with named steps, dependencies, bounded scopes,\nand completion checks; results and evidence pass to the steps that need them.\nOne bounded, independent task can use a direct background agent. Reuse that\nagent with `followup` for continued work. Background work keeps the composer\navailable, and ordinary multi-agent work does not require workflow files. Details:\n[Automatic Workflows](AUTOMATIC_WORKFLOWS.md).\n\nThis tutorial covers the **manual** fleet task-spec / checked-in Workflow path\nfor operators who want durable host workers and reviewable specs. A\none-sentence request should still not silently generate `tasks.json`; worker\ncards and permission posture make dispatch visible without exposing authoring\nmechanics.\n\nThe examples use `codewhale fleet` and `/fleet`.\nOn-disk paths, config keys, and the Workflow `--fleet` flag use the Fleet name.\n\n## 1. Prepare The Workspace\n\nRun fleet from the workspace you want workers to inspect or modify:\n\n```sh\ncodewhale fleet init\n```\n\nThis creates the workspace ledger at `.codewhale/fleet.jsonl`. Worker logs and\nbounded artifacts live under `.codewhale/fleet/`; host adapter logs live under\n`.codewhale/fleet-host/`.\n\nIf you want named reusable workers, open the TUI and run:\n\n```text\n/fleet setup\n```\n\nPick a role, choose whether that profile inherits the operator route or pins a\nspecific provider/model, choose where the profile lives (**This project** →\n`.codewhale/agents/<role>.toml`, or **Personal** →\n`$CODEWHALE_HOME/agents/<role>.toml`, available across repositories while a\nsame-id project profile remains the higher-priority override), then review the\nexact file, permissions/tools/route posture, and save. The save control names\nits effect (\"Save to this project\" / \"Save as Personal profile\"), and\nreplacing an existing file always asks for a second confirmation. fleet task\nspecs can reference either resolved profile with `worker.agent_profile` or the\nshorter `worker.profile` alias.\n\nThis makes the fleet definition cross-repository, not the authority of one\nrunning session. For a multi-repository operation, launch Codewhale from a\nshared parent workspace. Profile availability does not grant filesystem access;\nthe session's workspace, explicit trusted paths, trust mode, and permission\nposture remain authoritative.\n\n## 2. Write A fleet Task Spec\n\n`codewhale fleet run` accepts JSON or TOML. The checked-in\n`docs/examples/fleet-dogfood.toml` file is the realistic manual smoke example;\nthe JSON below shows the same authoring shape with one read-only reviewer and\none bounded docs-note worker. The live Runtime policy controls secrets and\ntrust; fleet identity carries neither.\n\n```json\n{\n \"name\": \"docs readiness check\",\n \"labels\": {\n \"kind\": \"tutorial\"\n },\n \"tasks\": [\n {\n \"id\": \"map-docs\",\n \"name\": \"Map current docs\",\n \"objective\": \"Find the docs that describe fleet and Workflow.\",\n \"instructions\": \"Read docs/FLEET.md and docs/WORKFLOW_AUTHORING.md. Report the command surfaces, current limitations, and any confusing gaps.\",\n \"worker\": {\n \"role\": \"reviewer\",\n \"profile\": \"reviewer\",\n \"tools\": [\"rg\", \"sed\", \"git\"],\n \"model\": \"deepseek-v4-flash\"\n },\n \"workspace\": {\n \"required_files\": [\"docs/FLEET.md\", \"docs/WORKFLOW_AUTHORING.md\"],\n \"writable_paths\": [],\n \"environment\": {\n \"required\": [],\n \"allowlist\": []\n }\n },\n \"input_files\": [\"docs/FLEET.md\", \"docs/WORKFLOW_AUTHORING.md\"],\n \"expected_artifacts\": [\"log\", \"report\"],\n \"scorer\": {\n \"kind\": \"manual\"\n },\n \"retry_policy\": {\n \"max_attempts\": 1\n }\n },\n {\n \"id\": \"draft-gap-note\",\n \"name\": \"Draft gap note\",\n \"objective\": \"Draft a short local note for any missing tutorial steps.\",\n \"instructions\": \"Write a concise Markdown note with the missing fleet + Workflow tutorial steps. Do not edit public docs unless explicitly asked.\",\n \"worker\": {\n \"role\": \"builder\",\n \"tools\": [\"rg\", \"sed\Line truncated
}
],
"missing": []
},
{
"id": "plugin-authoring",
"slug": "plugin-authoring",
"sources": [
{
"path": "docs/PLUGIN_AUTHORING.md",
"sha256": "da01099cf15b9cb59c6aab86832e71616e66acb30629730a0702a292c45988c0",
"content": "# Write your first Codewhale plugin\n\n> 阅读简体中文版:[zh_hans/PLUGIN_AUTHORING.md](zh_hans/PLUGIN_AUTHORING.md)\n\nStart with a skill: a Markdown instruction file inside a small plugin bundle.\nThe [hello-codewhale example](examples/plugins/hello-codewhale/plugin.json)\ncontains two files, declares no server or hook, and asks for no tool use.\nThis walkthrough takes it from source files to a reviewed, enabled skill.\n\nFor a custom AI provider with host-owned OAuth, see [Plugin providers](PLUGIN_PROVIDERS.md)\nand the [provider example](examples/plugins/oauth-provider/plugin.json).\n\n## 1. Create the bundle\n\nUse the checked-in example, or create this directory outside an installed\nplugins directory:\n\n```text\nhello-codewhale/\n├── plugin.json\n└── skills/\n └── hello/\n └── SKILL.md\n```\n\n`plugin.json`:\n\n```json\n{\n \"$schema\": \"https://agent-plugins.org/schemas/plugin.json\",\n \"name\": \"hello-codewhale\",\n \"version\": \"0.1.0\",\n \"description\": \"A minimal, explicitly invoked greeting skill.\"\n}\n```\n\n`skills/hello/SKILL.md`:\n\n```markdown\n---\nname: hello\ndescription: Greet the user when they explicitly try the hello-codewhale example.\ninvocation: explicit-only\n---\n\nRespond with one short greeting in the user's language. Include the exact text\n`hello-codewhale:hello` so they can identify the example they invoked.\n\nUse only the conversation. Do not call tools, run commands, read or write files,\nor contact external services.\n```\n\nCodewhale finds `skills/` automatically. `explicit-only` keeps this example out\nof the model's automatic skill catalogue; you load it by name. See\n[Skills](SKILLS.md#invocation-and-alias-metadata) for invocation metadata and\n[Plugin bundles](PLUGIN_BUNDLES.md#manifest) for the authoritative manifest\ncontract. Keep new native bundles in `plugin.json`; no second manifest is\nneeded.\n\n## 2. Install, inspect, and trust\n\nStart Codewhale in the repository root. Enter these commands **inside the\nCodewhale session**, one at a time:\n\n```text\n/plugin install ./docs/examples/plugins/hello-codewhale\n/plugin validate hello-codewhale\n/plugin show hello-codewhale\n```\n\nFor your own bundle, replace the install path with its directory. Installation\ncopies it to `~/.codewhale/plugins/hello-codewhale/`, disabled and untrusted.\nReview the installed source, skill inventory, and permissions. This example\nshould declare only Skills, with no MCP server, hook, or requested network host.\n\nThe install review prints a command containing two full hashes:\n\n```text\n/plugin trust hello-codewhale <full-content-sha256>.<full-capability-sha256>\n```\n\nRun the exact command printed by your review; the angle-bracket text above is\na placeholder. If you need a fresh review, use `/plugin trust hello-codewhale`\nwithout a token. Trust records the reviewed content and capability hashes and\ncreates a runtime snapshot. It does not enable the plugin.\n\n```text\n/plugin enable hello-codewhale\n/skills hello-codewhale:\n/skills inspect\n```\n\nThe skill is named `hello-codewhale:hello`: the bundle name qualifies the skill\nname. `/skills inspect` identifies its reviewed plugin snapshot.\n\n## 3. Invoke it and turn it off\n\n```text\n/skill hello-codewhale:hello\n```\n\nCodewhale confirms activation. Then send `Say hello.` as a normal message.\nThe reply should be a short greeting containing `hello-codewhale:hello`.\nThe example contributes instructions only; the reply still uses your selected\nmodel and its normal provider connection. The local install, review, and\nactivation steps do not need a model call.\n\n```text\n/plugin disable hello-codewhale\n```\n\nDisabling removes the plugin's contributions while preserving its trust\nreceipt. A subsequent `/skill hello-codewhale:hello` must not activate it.\nEnable it again when needed, provided its reviewed hashes still match.\n\n## 4. Iterate and review changes\n\nThe installed bundle is a copy. Editing the example's original source does\nnot update that copy. To try a changed local source, disable and uninstall the\ninstalled example, then install the source directory again:\n\n```text\n/plugin disable hello-codewhale\n/plugin uninstall hello-codewhale\n/plugin install ./docs/examples/plugins/hello-codewhale\n/plugin validate hello-codewhale\n```\n\nUninstall removes the installed copy; it leaves the original example source\nalone. Review the new token, trust it, and enable it again. For bundles\ninstalled from a remote source, use `/plugin update <name>`; see\n[Installing plugins](PLUGINS.md#update-and-uninstall).\n\nWhen files in a discovered bundle change directly, `/plugin reload` refreshes\nthe registry. Changed content invalidates the old receipt, even if you leave\nthe version unchanged. Reload does not grant trust. Use `/plugin revoke <name>`\nto remove trust explicitly.\n\n## Add only the components you need\n\nAll components use thLine truncated
}
],
"missing": []
},
{
"id": "vocabulary",
"slug": "vocabulary",
"sources": [
{
"path": "docs/FLEET.md",
"sha256": "134c6a358522c6367a8f004813daab56150a371e9433811236524fb2978ea0cf",
"content": "# Agent fleet\n\n> Baca terjemahan bahasa Indonesia: [id/FLEET.md](id/FLEET.md)\n\nAgent fleet is the local-first roster and member-selection layer for durable\nmulti-worker runs. It does not execute or authorize work. After fleet resolves\nwho should participate, the delegated coordinator launches a headless\n`codewhale exec` run and the Runtime tracks it durably. See\n[AGENT_RUNTIME.md](AGENT_RUNTIME.md) for how sub-agents, `exec`, and\nfleet-backed workers converge on one runtime. In product language, a user may\nstill \"open a sub-agent\"; in architecture language, durable nested work uses a\nfleet member identity with delegated runtime execution.\n\n## Naming and compatibility boundary\n\n**Fleet** is the public product noun. The durable ledger, saved rosters, config\ntables, and `--fleet` flag share that name:\n\n| Surface | Canonical |\n| --- | --- |\n| CLI | `codewhale fleet …` |\n| Slash command | `/fleet …` |\n\nThese shared names are load-bearing wherever changing them would break\nexisting workspaces, receipts, or scripts:\n\n- the durable ledger `.codewhale/fleet.jsonl` and the log directories\n `.codewhale/fleet/` and `.codewhale/fleet-host/`;\n- saved rosters `fleets/<name>.toml` and their `schema = \"fleet\"` header, under\n `$CODEWHALE_HOME/` or the workspace's `.codewhale/` (checked-in rosters at the\n workspace root's `fleets/` are still read);\n- the `[fleet]` config table (inline `[fleets.*]` tables were removed in 0.9.14; named fleets live in `fleets/<name>.toml` files);\n- the `codewhale workflow run --fleet <name>` flag;\n- wire, receipt, and control-plane operation ids such as `fleet.status`.\n\nThe rest of this document uses fleet as the public product noun and retains\nthese literal paths, keys, flags, and ids.\n\nUse a fleet roster rather than anonymous short-lived `agent` fanout whenever a\ndelegated run needs stable member identities across retries, sleep/restart,\nremote execution, receipts, or a ledgered audit trail. The initial CLI surface\nis:\n\nFor a guided start-to-monitor walkthrough that combines fleet task specs with\nWorkflow authoring, see [fleet + Workflow Tutorial](FLEET_WORKFLOW_TUTORIAL.md).\n\n```sh\ncodewhale fleet init\ncodewhale fleet run tasks.json --check # validate only; nothing is created or launched\ncodewhale fleet run tasks.json --max-workers 4\ncodewhale fleet status\ncodewhale fleet inspect <worker-id>\ncodewhale fleet logs <worker-id>\ncodewhale fleet artifacts <worker-id>\ncodewhale fleet interrupt <worker-id>\ncodewhale fleet restart <worker-id>\ncodewhale fleet resume <run-id>\ncodewhale fleet stop --all\n```\n\n`codewhale fleet resume <run-id>` is the restart-recovery verb: it replays the\nledger, reconciles any in-flight lease whose worker stopped heartbeating\n(retrying within the task's budget, else failing and escalating per the alert\npolicy), and prints the post-resume status. It launches no new work and is\nidempotent, so it is safe to run after a manager exit, laptop sleep, or runtime\nrestart.\n\nCoordinator state for fleet-backed runs is stored under the workspace in\n`.codewhale/fleet.jsonl`. Worker logs and adapter logs are stored under\n`.codewhale/fleet/` and `.codewhale/fleet-host/`.\n\n## Public contract: identity, membership, and selection\n\n**Fleet** = the user's model inventory: who is in the roster and which member is selected.\n\nA public fleet identity consists only of:\n\n- a stable member id and an optional user-facing name;\n- a semantic role, such as `explore`, `implement`, or `reviewer` (the legacy\n spellings `worker`, `scout`, `builder`, `verifier`, `consultant`, and `oracle`\n are still accepted on input and map to `general`, `explore`, `implement`,\n `test`, and `advisor`);\n- an exact provider/model identity, or an explicit inherited route;\n- visible roster state or origin.\n\nProject or workspace trust, filesystem and network reach, secret access,\napproval mode, sandboxing, tool authorization, and every other form of runtime\nauthority are separate delegated-coordination and Runtime policy inputs. They\nare never fleet identity fields, and they never select or reroute a fleet\nmember. The Runtime applies and clamps those policies only after member\nselection; if the selected member cannot run inside the effective envelope,\nlaunch fails closed instead of choosing somebody else.\n\nNatural-language member selection is deterministic. A caller may name:\n\n- an exact member id, optionally as `member:<id>` or `id:<id>`;\n- a unique user-facing member name, optionally as `name:<name>`;\n- a unique semantic role, for example `explore` or `role:explore`;\n- an exact pinned model id, for example `deepseek-v4-flash`, or its offline\n display name, for example `DeepSeek V4 Flash`; or\n- an exact `route:<provider>/<model>`.\n\nAn unqualified exact member id wins. Every other match succeeds only when it\nidentifies one distinct roster member. Multiple matches produce an ambiguity\nerroLine truncated
},
{
"path": "docs/MODES.md",
"sha256": "ea1e371421770a8582195f4c80c70fe9d067b3800ef143c7fea1fdcf444f0ecd",
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not part of\nthe `Tab` cycle.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nPress `Tab` to complete composer menus or cycle through the visible modes\nwhen the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends\nor queues composer text; use `Enter` to send or queue it.\nPress `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work`,\n`/mode plan`, or `/mode operate`.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate**: Work at full strength, the \"ultra\" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work | Operate |\n|:---|:---:|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |\n| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |\n| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |\n\nOperate changes scheduling emphasis, not authority. It neither adds a\nmode-specific tool denial nor bypasses the active approval, sandbox, shell,\nask-rule, repository-law, or managed-policy boundary. Plan remains the\nmode-specific execution boundary for shell and write-capable tools; that\nauthority difference does not require a different primitive vocabulary.\n\n### Operate loop (one screen)\n\n```text\nUseLine truncated
},
{
"path": "docs/public-surface-facts.json",
"sha256": "9651f09b589b1979cc217b67afe8fbcd42a37e5c4c4bd51f02d0c949a4b5d583",
"content": "{\n \"schemaVersion\": 2,\n \"product\": {\n \"name\": \"Codewhale\",\n \"description\": \"Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.\",\n \"positioning\": \"Terminal-native, model/provider-neutral, local-first coding agent\",\n \"license\": \"MIT\",\n \"terminology\": {\n \"Fleet\": \"the user's model inventory: who is in the roster and which member is selected\",\n \"Workflow\": \"what order the work follows\",\n \"Lane\": \"one running Workflow instance\",\n \"Runtime\": \"where, how, and with what authority selected work executes\"\n },\n \"sources\": [\n \"Cargo.toml\",\n \"LICENSE\",\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"docs/FLEET.md\"\n ]\n },\n \"sourceCandidate\": {\n \"version\": \"0.10.2\",\n \"providerCount\": 50,\n \"toolCount\": 79,\n \"sandboxBackends\": [\n \"seatbelt (macOS, when available)\",\n \"bubblewrap (Linux, default when installed and working)\"\n ],\n \"sources\": [\n \"Cargo.toml\",\n \"crates/tui/src/config.rs\",\n \"crates/tui/src/sandbox/mod.rs\",\n \"web/scripts/derive-facts.mjs\"\n ],\n \"providerCountDefinition\": \"Website descriptor identities in this source snapshot, excluding Custom and retired identities; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or providers in any published release.\"\n },\n \"latestPublishedRelease\": {\n \"tag\": \"v0.10.1\",\n \"version\": \"0.10.1\",\n \"publishedAt\": \"2026-10-08T01:47:44Z\",\n \"url\": \"https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.1\",\n \"sources\": [\n \"web/data/latest-published-release.json\"\n ]\n },\n \"install\": {\n \"recommended\": \"curl -fsSL https://codewhale.net/install.sh | sh\",\n \"binaries\": [\n \"codewhale\",\n \"codew\"\n ],\n \"channels\": {\n \"npm\": \"published releases only\",\n \"cargo\": \"published crates only\",\n \"prebuiltArchives\": \"published GitHub Releases only\",\n \"cnbMirror\": \"documented targets only\"\n },\n \"androidTermux\": {\n \"status\": \"preview\",\n \"npm\": \"preview\",\n \"requiresMatchingPublishedAssets\": true,\n \"sourceBuild\": true\n },\n \"sources\": [\n \"README.md\",\n \"npm/codewhale/package.json\",\n \"npm/codewhale/README.md\",\n \"npm/codewhale/scripts/artifacts.js\",\n \"docs/INSTALL.md\",\n \"docs/CNB_MIRROR.md\"\n ]\n },\n \"control\": {\n \"modes\": [\n \"Plan\",\n \"Work\",\n \"Operate\"\n ],\n \"permissionPostures\": [\n \"Ask\",\n \"Auto-Review\",\n \"Full Access\"\n ],\n \"shortcuts\": {\n \"mode\": {\n \"chord\": \"Tab\",\n \"when\": \"composer empty\"\n },\n \"permissionPosture\": {\n \"chord\": \"Shift+Tab\",\n \"when\": \"always (suppressed only under a non-Config modal)\"\n }\n },\n \"sources\": [\n \"docs/MODES.md\",\n \"docs/KEYBINDINGS.md\",\n \"crates/tui/src/settings.rs\"\n ]\n },\n \"toolSurface\": {\n \"defaultActive\": [\n \"read\",\n \"write\",\n \"edit\",\n \"file_search\",\n \"bash\",\n \"agent\",\n \"workflow\",\n \"todo_write\",\n \"create_goal\",\n \"get_goal\",\n \"update_goal\",\n \"task_shell_wait\",\n \"load_skill\"\n ],\n \"schemas\": {\n \"read\": [\n \"path\",\n \"offset?\",\n \"limit?\"\n ],\n \"write\": [\n \"path\",\n \"content\"\n ],\n \"edit\": [\n \"path\",\n \"edits\"\n ],\n \"bash\": [\n \"command\",\n \"timeout?\"\n ]\n },\n \"deferred\": {\n \"Web\": [\n \"search\",\n \"fetch\",\n \"wait\"\n ]\n },\n \"compatibility\": {\n \"legacyAliases\": \"hidden-exact\",\n \"modelVisible\": false,\n \"toolSearchDiscoverable\": false\n },\n \"activationCache\": {\n \"maximumNames\": 8,\n \"maximumSchemaBytes\": 16384,\n \"scope\": \"per conversation; each subagent owns an independent policy-filtered cache\"\n },\n \"agentConcurrency\": {\n \"defaultConfigured\": 64,\n \"maximumConfigured\": 128,\n \"maximumAdmitted\": 1024\n },\n \"sources\": [\n \"docs/TOOL_SURFACE.md\",\n \"docs/RUNTIME_SIMPLIFICATION_DESIGN.md\",\n \"crates/tui/src/core/engine/tool_catalog.rs\",\n \"crates/tui/src/tools/registry.rs\",\n \"crates/tui/src/config/subagent_limits.rs\"\n ]\n },\n \"surfaces\": {\n \"availableInSourceCandidate\": [\n \"TUI\",\n \"codewhale exec\",\n \"Web client\",\n \"Runtime API\",\n \"MCP\",\n \"Fleet\"\n ],\n \"sources\": [\n \"docLine truncated
}
],
"missing": []
},
{
"id": "constitution",
"slug": "constitution",
"sources": [
{
"path": "docs/ARCHITECTURE.md",
"sha256": "8f591d2d7996be6073ddb1d46f7ad9ea989e203a5fd000a32b47cb1f12ad4e8f",
"content": "# Codewhale Architecture\n\n> 阅读简体中文版:[zh_hans/ARCHITECTURE.md](zh_hans/ARCHITECTURE.md)。\n\nCodewhale Engine is the existing Rust execution runtime. The\n[Runtime API](RUNTIME_API.md) is its client interface, and\n[TypeScript mods](EXTENSIONS.md) contribute reviewed extensions.\n\nCurrent boundary note (read the workspace version from `Cargo.toml`; this\nboundary has held since v0.9.1):\n- `crates/tui` is still the live end-user runtime for the TUI, runtime API, task manager, and tool execution loop.\n- Other workspace crates are being split out incrementally, but they are not yet the sole runtime source of truth.\n\n<img src=\"assets/architecture.webp\" alt=\"Codewhale architecture: clients reach the one turn loop, Engine::run_turn, through the CLI or the Runtime API; the engine uses crates/core to build requests, runs tools under approval and sandbox policy, routes to any provider through crates/config, and logs everything model-visible to the session store.\" width=\"1000\">\n\nThe diagram draws the Runtime API and the task manager as their own boxes; today\nboth are hosted inside `crates/tui`, as the note above says. The GPUI desktop\napp and the VS Code extension live outside this repository. The arrow from the\nengine to the session log follows the \"model-visible means logged\" contract\nrather than a traced write path.\n- `crates/tui` is being split into crates sized as agent work partitions:\n contracts, utilities, services, providers, runtime, leaf feature crates,\n TUI views and hosts, in the order `docs/design/TUI_DECONSTRUCTION.md`\n records. The TUI stays the only crate that writes to the terminal. Until a\n module has moved, its path under `crates/tui/src` is still where it lives.\n- The LSP subsystem (`crates/tui/src/lsp/`) is fully wired into the engine's\n post-tool-execution path (`core/engine/lsp_hooks.rs`), providing inline\n diagnostics after `File` write, edit, and patch actions.\n- The swarm agent system was removed in v0.8.5. The active sub-agent surface is\n the single `agent` tool; persistent RLM sessions are available through the\n deferred `rlm` action family.\n No model-visible swarm tool remains in the active codebase.\n\n## High-Level Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ User Interface │\n│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │\n│ │ TUI (ratatui) │ │ One-shot Mode │ │ Config/CLI │ │\n│ └────────┬────────┘ └────────┬────────┘ └────────┬───────┘ │\n└───────────┼─────────────────────┼────────────────────┼──────────┘\n │ │ │\n ▼ ▼ ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Core Engine │\n│ ┌─────────────────────────────────────────────────────────┐ │\n│ │ Agent Loop (core/engine.rs) │ │\n│ │ ┌─────────┐ ┌─────────────┐ ┌──────────────────────┐ │ │\n│ │ │ Session │ │ Turn Mgmt │ │ Tool Orchestration │ │ │\n│ │ └─────────┘ └─────────────┘ └──────────────────────┘ │ │\n│ └─────────────────────────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────────────┘\n │ │ │\n ▼ ▼ ▼\n┌─────────────────────────────────────────────────────Line truncated
}
],
"missing": []
},
{
"id": "tools",
"slug": "tools",
"sources": [
{
"path": "docs/TOOL_SURFACE.md",
"sha256": "94238d1fc469c647f9e0f2727950bdd74cba0e1d64c93f828e26b101d2d3fbd5",
"content": "# Tool surface\n\n> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。\n\nThis document describes the current model-facing tool contract. The v0.9.1\ncutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;\nread the workspace version from `Cargo.toml`, not from this line. The registry\nremains larger than the first-turn catalog so\nsaved transcripts can replay and uncommon capabilities can be loaded on demand.\nThe model should learn one canonical name for each common operation.\n\nImplementation sources:\n\n- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.\n- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases.\n- `crates/tui/src/tools/{file,file_tool,shell}.rs` own the small foreground\n primitive behavior and schemas; the other native tools remain searchable.\n- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.\n\n## Default-active contract\n\nNew turns start with thirteen eager native names plus synthetic `tool_search`:\n\n1. `read`\n2. `write`\n3. `edit`\n4. `file_search`\n5. `bash`\n6. `agent`\n7. `workflow`\n8. `todo_write`\n9. `create_goal`\n10. `get_goal`\n11. `update_goal`\n12. `task_shell_wait`\n13. `load_skill`\n14. `tool_search` (synthetic, always active)\n\nThe thirteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in\n`crates/tui/src/core/engine/tool_catalog.rs`, pinned by\n`default_active_contract_keeps_discovery_and_core_tools_eager`. An authority\nboundary may remove `agent` at the maximum child depth, and Plan registers only\nthe foreground shell surface (so no `task_shell_wait`), but route size alone\nmust not change this core vocabulary.\n\nThe direct schemas deliberately stay small:\n\n| Tool | Input | Purpose |\n|---|---|---|\n| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |\n| `write` | `path`, `content` | Create or replace a file. |\n| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |\n| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |\n| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |\n| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |\n| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |\n| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |\n| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |\n| `get_goal` | none | Read the active goal and its progress. |\n| `update_goal` | terminal status | Mark the goal complete or blocked. |\n| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |\n| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name=\"list\"` to list eligible skills, or use `query` to search names and descriptions. |\n| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |\n\nMode is an authority decision, not a synonym system. Plan, Work, and Operate\nuse the same primitive identities. Plan centrally refuses `write`, `edit`, and\n`bash`; Work and Operate still pass those calls through approval, sandbox,\ntrusted-path, repository-law, and managed-policy gates. Full Access changes\nordinary approval behavior but does not bypass hard safety or repository law.\n\n`update_plan` remains registered only for saved-artifact compatibility and is\nnot model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other\nspecialized capabilities are searchable rather than first-turn ceremony.\n\nOn the TUI's default (calm) shell card the Bash description is the headline,\nshown as written apart from stripped terminal escapes, collapsed whitespace and\na length bound, and the command sits on its own `$` row directly under it in\nevery state and at every width, including automatic runs. A card without a\ndescription leads with the `$` command instead. The labeled card\n(`show_tool_details = true` with `calm_mode = false`) keeps the command in its\nheader and the description as a muted `note:` row. The description never\nreplaces the command and is excluded from execution, exact Line truncated
},
{
"path": "docs/RUNTIME_SIMPLIFICATION_DESIGN.md",
"sha256": "50f637f0380c9ab05bfe71a7e863f339b0d8e218369bbb044b9b7f09138f2c59",
"content": "# Codewhale Runtime Simplification Design\n\n**Status:** Pre-implementation design record for the v0.9.1 cutover, kept for its\n\"Rejected alternatives\" provenance. It is **not** current runtime documentation\nand it shipped differently in two ways:\n\n- Goal 3 below (\"keeping every legacy tool name registered but hidden\") was\n reversed. The per-action file/git/run/web and `exec_shell*` names were\n **removed**, not hidden — `crates/tui/src/tools/registry.rs` asserts they\n must stay unregistered. Only `apply_patch` and the\n `task_*` / `github_*` / `automation_*` / `rlm_*` / `checklist_*` families\n survive as hidden aliases.\n- The default-active policy is nine names, not ten. `update_plan` and `Web` are\n not in `DEFAULT_ACTIVE_NATIVE_TOOLS`\n (`crates/tui/src/core/engine/tool_catalog.rs:44-58`).\n\nFor the current contract see [`TOOL_SURFACE.md`](TOOL_SURFACE.md).\n\n## Goal\n\nMake the model-facing runtime smaller, calmer, and easier for models to use by:\n\n1. Collapsing the long tail of single-purpose file, git, run, and web tools into\n a few canonical action-based tools.\n2. Shrinking the system prompt to durable behavioral invariants and per-turn\n permission deltas.\n3. Keeping every legacy tool name registered but hidden so old transcripts,\n saved sessions, and recorded automation replay without migration.\n\n## Target model-facing surface (default active)\n\n| Tool | Actions / Niche |\n|---|---|\n| `Bash` | `run`, `wait`, `interact`, `cancel` (existing) |\n| `File` | `read`, `list`, `search_name`, `search_content`, `write`, `edit`, `patch` |\n| `Git` | `status`, `diff`, `log`, `show`, `blame` |\n| `Run` | `tests`, `verifiers` |\n| `Web` | `search`, `fetch`, `wait` (deferred unless network is enabled; hidden aliases for legacy names) |\n| `tasks` | durable task family (existing action-based surface) |\n| `github` | durable GitHub family (existing; deferred by default) |\n| `automation` | durable automation family (existing; deferred by default) |\n| `rlm` | durable RLM family (existing; deferred by default) |\n| `agent` | sub-agent dispatch |\n| `remember` | opt-in durable user-memory capture; eager whenever registered |\n| `todo_write` | progress / plan-of-work updates |\n| `update_plan` | plan artifact updates |\n| `tool_search` | on-demand discovery of deferred tools |\n\nDefault-active policy: **10 names** (vs. ~18 before the simplification), with\n`remember` registered only for built-in-memory users and the durable families\nand `Web` discoverable via `tool_search` when needed. `tool_search` itself is a\nsynthetic always-active catalog entry.\n\n## Rejected alternatives\n\n- **Keep every tool but defer the rare ones.** This only changes what is\n advertised, not how many distinct schemas the model must learn. It also\n leaves duplicated guidance in the prompt.\n- **Route search and git through `Bash`.** `grep_files`, `file_search`, and the\n git tools return structured, workspace-aware output and respect sandbox,\n `.gitignore`, and network policy. Shell would force the model to re-parse\n free-form text and lose those guarantees, so dedicated tools win.\n- **One mega `File` tool plus a separate `Edit` tool.** A single `File` tool is\n only slightly larger than a read/edit pair and keeps the boundary the model\n already understands (`read` is cheap, `edit` requires prior read). Splitting\n would re-introduce a two-tool alias for the same underlying operations.\n- **Delete legacy tools.** Saved transcripts and replay tests rely on the old\n names. Removing them would require a config migration and break reproducibility.\n Hidden aliases avoid both.\n\n## Compatibility\n\n- Legacy names (`read_file`, `write_file`, `edit_file`, `list_dir`, `file_search`,\n `grep_files`, `apply_patch`, `git_status`, `git_diff`, `git_log`, `git_show`,\n `git_blame`, `run_tests`, `run_verifiers`, `web_search`, `fetch_url`,\n `wait_for_dev_server`) stay registered with `model_visible = false`.\n- The engine resolves calls by name, so old transcripts replay without changes.\n- `DEFAULT_ACTIVE_NATIVE_TOOLS` is updated to list the new canonical names only;\n hidden legacy tools are ignored by catalog construction.\n\n## Prompt simplification\n\n- Replace the tool-calling recipe sections in `AGENT_MODE` and\n `SUBAGENT_OUTPUT_FORMAT` with short references to the canonical tools.\n- Reduce mode deltas to permission statements (Act = write requires approval,\n Plan = no writes or shell, Full Access = auto-approved, Operate = coordinate from\n ordinary messages).\n- Keep the `BASE_PROMPT` behavioral invariants, `LANGUAGE_PROMPT`, and\n `OUTPUT_PROMPT` intact.\n- Move detailed templates (`COMPACT_TEMPLATE`, sub-agent brief format, planning\n artifact template) out of the stable prefix and into tool schemas or\n conditional blocks.\n\n## Validation\n\n- Provider-free: `scripts/measure-runtime-contract.py` reports active tool count\n and prompt bytes before andLine truncated
},
{
"path": "docs/LSP_PHP_CUSTOM.md",
"sha256": "2dc48d7688a92844d6a5adf682e457b9bcb30f54c64c838af254d35feb4b659f",
"content": "# LSP: PHP Support & Custom Language Server Extension\n\n> 阅读简体中文版:[zh_hans/LSP_PHP_CUSTOM.md](zh_hans/LSP_PHP_CUSTOM.md)\n>\n> v0.8.65+ | `codex/lsp-php-custom-servers`\n\n## Overview\n\nThis feature adds **PHP** to the built-in LSP language registry and introduces a\n`[lsp.custom]` config section so users can register arbitrary LSP servers by\nfile extension — covering languages absent from the built-in `Language` enum\n(Ruby, C#, Swift, Lua, etc.).\n\n## Changes\n\n### 1. PHP built-in support\n\n- `Language::Php` variant added to the enum in `crates/tui/src/lsp/registry.rs`\n- `.php` files are detected and routed to `intelephense --stdio` by default\n- User can override via `[lsp.servers].php`\n\n### 2. Custom LSP server extension\n\nNew struct `CustomLspDef` (defined in `crates/tui/src/lsp/mod.rs` and mirrored\nin `crates/config/src/lib.rs`):\n\n```rust\npub struct CustomLspDef {\n pub language_id: String, // LSP languageId for textDocument/didOpen\n pub command: String, // executable to spawn\n pub args: Vec<String>, // arguments (default empty)\n}\n```\n\nNew config field `LspConfig.custom: HashMap<String, CustomLspDef>` — keyed by\nfile extension (without dot), e.g. `\"rb\"`, `\"cs\"`, `\"swift\"`.\n\nIn `LspManager::diagnostics_for`, when the built-in registry returns\n`Language::Other`, the manager checks the user's custom table before giving up.\nCustom servers get their own lazy-spawn transport map and once-per-extension\nmissing-binary warnings (no log spam).\n\n### 3. Transport generalization\n\n`StdioLspTransport::spawn` now accepts `&str language_id` instead of\n`Language`, so both built-in and custom servers share the same transport\nimplementation. The old `Language` import is removed from `client.rs`.\n\n### 4. Polling pipeline extracted\n\n`poll_diagnostics` is a new private method that both built-in and custom\ndiagnostics paths share — eliminating duplicated call/wait/filter/sort/truncate\nlogic.\n\n## Configuration\n\n### Built-in PHP (enabled by default if `intelephense` is on PATH)\n\n```toml\n# No config needed — PHP .php files are detected automatically.\n# Override the server if desired:\n[lsp.servers]\nphp = [\"phpactor\", \"language-server\"]\n```\n\n### Custom language servers\n\n```toml\n[lsp.custom.rb]\ncommand = \"ruby-lsp\"\nargs = [\"--stdio\"]\nlanguage_id = \"ruby\"\n\n[lsp.custom.cs]\ncommand = \"csharp-ls\"\nlanguage_id = \"csharp\"\n\n[lsp.custom.swift]\ncommand = \"sourcekit-lsp\"\nlanguage_id = \"swift\"\n```\n\nKeys are file extensions (no leading dot). The `args` field defaults to empty.\n`language_id` must match what the LSP server expects in `textDocument/didOpen`.\n\n## Architecture\n\n```\nedit_file / write_file / apply_patch success\n │\n ▼\n LspManager.diagnostics_for(file)\n │\n ├── custom_for_extension(file) ── found? ──► transport_for_custom(ext, def)\n │ │\n ├── detect_language(file) ── Other? ──► return None (skip)\n │\n └── transport_for(lang)\n │\n ▼\n poll_diagnostics(file, text, transport)\n │\n ▼\n DiagnosticBlock → injected into session message stream\n```\n\n## Verification\n\n```\ncargo test -p codewhale-tui --lib lsp::\n# 32 tests passed (3 new: detects_php_extension, language_ids_for_php,\n# server_for_php_is_intelephense)\ncargo clippy -p codewhale-tui --lib\n# lsp module: zero new warnings\n```\n\n## Files touched\n\n| File | Change |\n|------|--------|\n| `crates/tui/src/lsp/registry.rs` | +Php variant, detection, server mapping, tests |\n| `crates/tui/src/lsp/mod.rs` | +CustomLspDef, LspConfig.custom, LspManager custom fallback |\n| `crates/tui/src/lsp/client.rs` | spawn accepts &str language_id |\n| `crates/tui/src/config.rs` | LspConfigToml.custom + into_runtime |\n| `crates/config/src/lib.rs` | LspConfigToml.custom + CustomLspDef |\n| `config.example.toml` | PHP docs + custom extension examples |\n"
}
],
"missing": []
}
],
"omitted": []
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { GET as getTopicMarkdown, generateStaticParams } from "../app/docs-markdown/[topic]/route";
import { GET as getFullText } from "../app/llms-full.txt/route";
import { checkReleaseDocs, refreshReleaseDocs, releaseDocsIndex } from "../scripts/release-docs-lib.mjs";
import { RELEASE_DOCS, buildTopicMarkdown } from "./release-docs";
import { DOC_TOPICS } from "./docs-map";
import { FACTS } from "./facts.generated";
import { REPO_URL } from "./i18n/links";
const release = FACTS.latestPublishedRelease!;
const UNRELEASED = ["0.10.3", "codewhale uninstall", "update.mode", "Accept Edits"];
describe("documents at the released tag", () => {
it("is a snapshot of the released version and nothing else", () => {
expect(RELEASE_DOCS.tag).toBe(release.tag);
expect(RELEASE_DOCS.version).toBe(release.version);
});
it("serves the full text and every topic without the markers of unreleased work", async () => {
const full = await getFullText().text();
const topics = generateStaticParams().map(({ topic }) => topic);
expect(topics.length).toBe(RELEASE_DOCS.topics.length);
expect(full.length).toBeGreaterThan(1_000_000);
for (const marker of UNRELEASED) expect(full, marker).not.toContain(marker);
for (const topic of topics) {
const response = await getTopicMarkdown(new Request(`https://example.test/docs-markdown/${topic}`), { params: Promise.resolve({ topic }) });
const body = await response.text();
for (const marker of UNRELEASED) expect(body, `${topic} ${marker}`).not.toContain(marker);
}
});
it("links each source at the tag and never at main", () => {
const body = buildTopicMarkdown("mcp")!;
expect(body).toContain(`Source: ${REPO_URL}/blob/${release.tag}/docs/MCP.md`);
expect(body).not.toContain("Source: " + REPO_URL + "/blob/main/");
});
it("says plainly when a registered document is not part of the release", () => {
const topic = RELEASE_DOCS.topics.find((entry) => entry.missing.length > 0)!;
expect(topic).toBeDefined();
expect(buildTopicMarkdown(topic.slug)).toContain(`${topic.missing[0]} was added after ${release.tag} and is not part of this release`);
});
it("answers 404 for a topic that is not in the snapshot", async () => {
const response = await getTopicMarkdown(new Request("https://example.test/docs-markdown/nope.md"), { params: Promise.resolve({ topic: "nope.md" }) });
expect(response.status).toBe(404);
});
it("fails its check on a tag mismatch and passes on the released tag", () => {
const snapshot = JSON.parse(JSON.stringify(RELEASE_DOCS));
expect(checkReleaseDocs(snapshot, release, DOC_TOPICS, null)).toEqual([]);
const next = { tag: "v0.10.3", version: "0.10.3" };
const problems = checkReleaseDocs(snapshot, next, DOC_TOPICS, null);
expect(problems.some((line) => line.includes(`is for ${release.tag}`) && line.includes("v0.10.3"))).toBe(true);
expect(checkReleaseDocs({ ...snapshot, tag: "v0.10.3" }, release, DOC_TOPICS, null).length).toBeGreaterThan(0);
expect(checkReleaseDocs(null, release, DOC_TOPICS, null).length).toBeGreaterThan(0);
});
it("fails its check when a document is altered or a topic is dropped", () => {
const altered = JSON.parse(JSON.stringify(RELEASE_DOCS));
altered.topics[0].sources[0].content += "\nedited";
expect(checkReleaseDocs(altered, release, DOC_TOPICS, null).join("\n")).toContain("hash");
const dropped = JSON.parse(JSON.stringify(RELEASE_DOCS));
dropped.topics.pop();
expect(checkReleaseDocs(dropped, release, DOC_TOPICS, null).join("\n")).toContain("neither in the snapshot");
});
it("fails its check when the topic index disagrees with the snapshot", () => {
expect(checkReleaseDocs(RELEASE_DOCS, release, DOC_TOPICS, null, releaseDocsIndex(RELEASE_DOCS))).toEqual([]);
const stale = { ...releaseDocsIndex(RELEASE_DOCS), slugs: [] };
expect(checkReleaseDocs(RELEASE_DOCS, release, DOC_TOPICS, null, stale).join("\n")).toContain("release-index");
});
it("refuses to generate, and writes nothing, when the tag is missing and the snapshot is for another release", () => {
expect(() => refreshReleaseDocs(new URL("..", import.meta.url).pathname, { tag: "v9.9.9", version: "9.9.9" })).toThrow(/v9\.9\.9/);
expect(() => refreshReleaseDocs(new URL("..", import.meta.url).pathname, null)).not.toThrow();
});
});
-14
View File
@@ -1,17 +1,3 @@
import generated from "@/data/agent-docs.generated.json";
import { DOC_TOPICS } from "./docs-map";
import { REPO_URL } from "./i18n/links";
export const AGENT_DOCS = generated;
export function buildTopicMarkdown(slug: string): string | null {
const document = AGENT_DOCS.find((entry) => entry.slug === slug);
const topic = DOC_TOPICS.find((entry) => entry.slug === slug);
if (!document || !topic) return null;
return [`# ${topic.label.en}`, "", topic.description.en, "", ...document.sources.flatMap((source) => [
`Source: ${REPO_URL}/blob/main/${source.path}`,
`SHA-256: ${source.sha256}`, "",
source.path.split("#", 1)[0].endsWith(".json") ? `\`\`\`json\n${source.content}\n\`\`\`` : source.content,
"",
])].join("\n");
}
+1 -1
View File
@@ -330,7 +330,7 @@ export const DOC_TOPICS: DocTopic[] = [
id: "runtime-reference",
slug: "runtime-reference",
label: { en: "Runtime API reference", zh: "Runtime API 参考" },
description: { en: "Read the HTTP routes, request fields and event contracts in the repository's main branch.", zh: "查阅仓库 main 分支中的 HTTP 路由、请求字段和事件约定。" },
description: { en: "Read the HTTP routes, request fields and event contracts in the repository.", zh: "查阅仓库中的 HTTP 路由、请求字段和事件约定。" },
repoSource: ["docs/RUNTIME_API.md", "docs/RUNTIME_ROUTES.md"],
hasPage: true,
category: "reference",
+3 -3
View File
@@ -102,11 +102,11 @@ describe("the version a visitor gets on install", () => {
expect(isPublishedRelease(FACTS.latestPublishedRelease)).toBe(true);
const llms = buildLlmsTxt();
expect(llms).toContain(`Latest published release: v${published.version}, published ${published.publishedAt.slice(0, 10)}`);
expect(llms).toContain(`The latest release is v${published.version}.`);
expect(llms).toContain(`read ${published.readAt.slice(0, 10)}`);
expect(llms).toContain("The install commands above install this release.");
expect(llms).toContain(`This installs the latest published release, v${published.version}.`);
if (FACTS.version !== published.version) {
expect(llms.split("\n").filter((line) => line.includes("install this release")).join("\n")).not.toContain(`v${FACTS.version}`);
expect(llms.split("\n").filter((line) => line.includes("latest published release")).join("\n")).not.toContain(`v${FACTS.version}`);
}
expect(buildSoftwareApplicationJsonLd(published).softwareVersion).toBe(published.version);
});
+157 -24
View File
@@ -1,48 +1,162 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import InstallPage from "../app/[locale]/install/page";
import { GET as getTopicMarkdown } from "../app/docs-markdown/[topic]/route";
import { GET as getFullText } from "../app/llms-full.txt/route";
import { GET } from "../app/llms.txt/route";
import { DOC_TOPICS, docTopicHref } from "./docs-map";
import { DISCORD_URL, REPO_URL } from "./i18n/links";
import { IDENTITY_PHRASE, SITE_NAME, SITE_URL } from "./page-meta";
import { buildLlmsTxt } from "./llms-txt";
import robots from "../app/robots";
import { RELEASE_DOCS } from "./release-docs";
import { claimAvailability, runtimeCapabilityContext } from "./capabilities";
import { INSTALL_COMMANDS, INSTALL_COPY, INSTALL_PAGE_COMMANDS } from "./content/install";
import { PRODUCT_MAP_STATE_KEY, PRODUCT_MAP_TEXT_KEYS, productMap } from "./content/product-map";
import { REFERENCE_GUIDE_COPY } from "./content/reference-guides";
import type { DesktopReleaseFact } from "./desktop-release";
import { DOC_TOPICS, docTopicHref } from "./docs-map";
import type { RepoFacts } from "./facts";
import { FACTS } from "./facts.generated";
import { EN_HOME, fill } from "./i18n/dictionaries";
import { DISCORD_URL, REPO_URL } from "./i18n/links";
import { buildInstallMarkdown, buildLlmsTxt } from "./llms-txt";
import { SITE_NAME, SITE_URL } from "./page-meta";
import { REDIRECTED_ROUTES } from "./route-redirects";
const release = FACTS.latestPublishedRelease!;
const desktopRelease: DesktopReleaseFact = {
tag: "v1.2.3",
version: "1.2.3",
publishedAt: "2026-12-01T10:00:00Z",
readAt: "2026-12-01T11:00:00Z",
url: "https://github.com/codewhale-hq/codewhale-app/releases/tag/v1.2.3",
assets: [
{
name: "Codewhale-1.2.3-arm64.dmg",
platform: "macos",
size: 123_456_789,
sha256: "a".repeat(64),
signed: true,
notarized: true,
url: "https://github.com/codewhale-hq/codewhale-app/releases/download/v1.2.3/Codewhale-1.2.3-arm64.dmg",
},
],
};
const PRICED = /[$€£¥¥]\s?\d|\b\d[\d,.]*\s?(?:USD|CNY|RMB|EUR|GBP)\b|\bper (?:month|seat|user|year)\b|\b(?:Free|Pro|Plus|Team|Teams|Max|Starter|Premium|Business|Enterprise|Individual)\s+(?:plan|tier|subscription)s?\b|\bpricing\b|\bbilled\b|\bfree trial\b|\ballowances?\b/i;
const DATED = /\b\d{4}-\d{2}-\d{2}\b|\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.? \d{1,2}\b|\b(?:19|20)\d{2}\b/;
const mapLine = (id: keyof typeof PRODUCT_MAP_TEXT_KEYS, state: keyof typeof PRODUCT_MAP_STATE_KEY) =>
`- ${EN_HOME[PRODUCT_MAP_TEXT_KEYS[id].name]}: ${EN_HOME[PRODUCT_MAP_STATE_KEY[state]]}.`;
const withRelease = (version: string): RepoFacts => ({
...FACTS,
latestPublishedRelease: { ...release, tag: `v${version}`, version, url: release.url.replace(release.tag, `v${version}`) },
});
describe("llms.txt", () => {
it("indexes first-party docs topics plus the remaining sitemap pages", () => {
it("opens with the locked headline and subhead and indexes the docs topics and the pages the sitemap keeps", () => {
const body = buildLlmsTxt();
expect(body.startsWith(`# ${SITE_NAME}`)).toBe(true);
expect(body).toContain(`> ${IDENTITY_PHRASE}`);
expect(body.startsWith(`# ${SITE_NAME}\n\n> ${EN_HOME.heroTitle}\n\n${fill(EN_HOME.heroIntro, { brand: SITE_NAME })}\n`)).toBe(true);
expect(body).toContain(REPO_URL);
expect(body).toContain(DISCORD_URL);
for (const topic of DOC_TOPICS) {
if (topic.hasPage) {
expect(body, topic.id).toContain(`${SITE_URL}${docTopicHref(topic, "en")}`);
expect(body, topic.id).toContain(topic.description.en);
expect(body, topic.id).toContain(topic.id === "install" ? INSTALL_COPY.metaDescription.en : topic.description.en);
} else {
expect(body, topic.id).toContain(topic.label.en);
}
}
for (const path of [
"/en/app",
"/en/faq",
"/en/runtime",
"/en/computer-use",
"/en/constitution",
"/en/roadmap",
"/en/feed",
"/en/digest",
"/en/community",
"/en/contribute",
"/en/docs",
"/en/legal/terms",
"/en/legal/privacy",
]) {
for (const path of ["/en/install", "/en/docs/guide", "/en/models", "/en/changelog", "/en/app", "/en/faq", "/en/computer-use", "/en/contribute", "/en/docs", "/en/ratatui", "/en/legal/terms", "/en/legal/privacy"]) {
expect(body, path).toContain(`${SITE_URL}${path}`);
}
expect(body).not.toContain("/pricing");
const linked = [...body.matchAll(/https:\/\/codewhale\.net\/en\/([a-z-]+)/g)].map((match) => match[1]);
expect(linked.length).toBeGreaterThan(DOC_TOPICS.length);
for (const segment of linked) expect(REDIRECTED_ROUTES, segment).not.toContain(segment);
});
it("names the published release and never the source version", () => {
const body = buildLlmsTxt();
expect(body).toContain(`The latest release is v${release.version}.`);
expect(body).toContain(`codewhale ${release.version}`);
if (FACTS.version && FACTS.version !== release.version) expect(body).not.toContain(FACTS.version);
const other = buildLlmsTxt({ facts: withRelease("9.8.7") });
expect(other).toContain("The latest release is v9.8.7.");
expect(other).toContain("codewhale 9.8.7");
expect(other).not.toContain(release.version);
});
it("gives every install command and the same steps as the install page", async () => {
const html = renderToStaticMarkup(await InstallPage({ params: Promise.resolve({ locale: "en" }) }));
const page = html.replace(/<[^>]+>/g, "").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&#x27;", "'").replaceAll("&amp;", "&");
const stated = [
...Object.values(INSTALL_COPY).map((copy) => fill(copy.en, { version: release.version }).replaceAll("`", "")),
...Object.values(INSTALL_COMMANDS),
...Object.values(INSTALL_PAGE_COMMANDS),
].filter((text) => page.includes(text));
expect(stated.length).toBeGreaterThan(Object.keys(INSTALL_COPY).length / 2);
for (const text of [buildLlmsTxt(), buildInstallMarkdown()]) {
const plain = text.replaceAll("`", "");
for (const statement of stated) expect(plain, statement).toContain(statement);
for (const command of Object.values(INSTALL_COMMANDS)) expect(text, command).toContain(`\`${command}\``);
const uninstallReleased = claimAvailability({ feature: "unix-uninstall" }, runtimeCapabilityContext(FACTS)) === "released";
expect(text.includes(INSTALL_PAGE_COMMANDS.uninstall)).toBe(uninstallReleased);
}
});
it("states each part of the product with its computed state, and a desktop release flips only the desktop line", () => {
const body = buildLlmsTxt({ surface: { desktopRelease: null } });
const rows = productMap({ surface: { desktopRelease: null }, capabilities: runtimeCapabilityContext(FACTS) });
expect(rows).toHaveLength(6);
for (const row of rows) {
expect(body, row.id).toContain(mapLine(row.id, row.state));
expect(body.includes(EN_HOME[PRODUCT_MAP_TEXT_KEYS[row.id].sentence]), row.id).toBe(row.sentenceHolds);
}
expect(body).toContain(`${mapLine("desktop", "developing")} ${EN_HOME.mapDesktopSentence}`);
const flipped = buildLlmsTxt({ surface: { desktopRelease } });
expect(flipped).toContain(mapLine("desktop", "released"));
expect(flipped).not.toContain(mapLine("desktop", "developing"));
expect(flipped).not.toContain(EN_HOME.mapDesktopSentence);
for (const row of rows.filter((entry) => entry.id !== "desktop")) expect(flipped, row.id).toContain(mapLine(row.id, row.state));
});
it("carries no price, plan, date outside the provenance stamp, or retired headline", () => {
for (const text of [buildLlmsTxt(), buildInstallMarkdown()]) {
const lines = text.split("\n");
const stamp = lines.filter((line) => /^(?:- )?Facts generated /.test(line));
expect(stamp).toHaveLength(1);
expect(stamp[0]).toContain(FACTS.generatedAt.slice(0, 10));
const rest = lines.filter((line) => line !== stamp[0]).join("\n");
expect(rest.match(PRICED)?.[0]).toBeUndefined();
expect(rest.match(DATED)?.[0]).toBeUndefined();
expect(rest).not.toContain("agent you can read");
}
});
it("says unknown, or leaves the line out, when the release or a count is unknown", () => {
const unreadable: RepoFacts = { ...FACTS, latestPublishedRelease: null };
const body = buildLlmsTxt({ facts: unreadable });
expect(body).toContain("The latest release is unknown");
expect(body).not.toContain("Built-in provider routes");
expect(body).not.toContain(mapLine("manyAgents", "released"));
for (const text of [body, buildInstallMarkdown({ facts: unreadable })]) {
expect(text).toContain(INSTALL_COPY.verifyBodyUnknown.en);
expect(text).toContain(INSTALL_COPY.removeFilesOnlyUnknown.en);
expect(text).not.toContain(INSTALL_PAGE_COMMANDS.uninstall);
expect(text.match(/\d+\.\d+\.\d+|\{\w+\}|undefined|\bnull\b|NaN/)?.[0]).toBeUndefined();
}
const untagged = buildLlmsTxt({ facts: withRelease("9.8.7") });
expect(untagged).not.toContain("Built-in provider routes");
expect(untagged).not.toMatch(/\(0\)/);
});
it("serves the generated body as text/plain from the well-known route", async () => {
@@ -51,6 +165,25 @@ describe("llms.txt", () => {
expect(await response.text()).toBe(buildLlmsTxt());
});
it("serves the install steps as markdown, alone and inside the full text", async () => {
const install = await getTopicMarkdown(new Request(`${SITE_URL}/docs-markdown/install.md`), { params: Promise.resolve({ topic: "install.md" }) });
expect(install.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
expect(await install.text()).toBe(buildInstallMarkdown());
const full = await getFullText().text();
expect(full.startsWith(buildLlmsTxt())).toBe(true);
expect(full).toContain(buildInstallMarkdown());
});
it("serves the documents as they were at the published release tag", async () => {
const full = await getFullText().text();
const label = ` at the published release ${release.tag}.`;
expect(RELEASE_DOCS.tag).toBe(release.tag);
expect(full.split(label).length - 1).toBe(RELEASE_DOCS.topics.length - 1);
expect(full).not.toContain(REFERENCE_GUIDE_COPY.scope.en.slice(0, 40));
expect(full).not.toContain(`Source: ${REPO_URL}/blob/main/`);
});
it("advertises /llms.txt from robots without inventing a robots field", () => {
const spec = robots();
expect(spec.rules).toEqual([
+168 -101
View File
@@ -1,29 +1,28 @@
import { CHANGELOG } from "./changelog.generated";
import { GETTING_STARTED_STEPS } from "./content/getting-started";
import { DOC_TOPICS, REPO_DOCS_BASE, docTopicHref } from "./docs-map";
import { releaseView } from "./capabilities";
import releasedIndex from "../data/agent-docs.release-index.json";
import { claimAvailability, releaseView, runtimeCapabilityContext } from "./capabilities";
import { APP_COPY } from "./content/app-page";
import { INSTALL_COMMANDS, INSTALL_COPY as I, INSTALL_PAGE_COMMANDS as C } from "./content/install";
import { PRODUCT_MAP_STATE_KEY, PRODUCT_MAP_TEXT_KEYS, productMap } from "./content/product-map";
import { DOC_TOPICS, REPO_DOCS_BASE, docTopicHref, getTopic, type DocTopic } from "./docs-map";
import { BUILD_FACTS, type RepoFacts } from "./facts";
import { EN_HOME as home, fill } from "./i18n/dictionaries";
import { computerUse } from "./i18n/dictionaries/en/computer-use";
import { DISCORD_URL, REPO_URL } from "./i18n/links";
import { IDENTITY_PHRASE, SITE_NAME, SITE_URL } from "./page-meta";
import { DISCORD_URL, REPO_RELEASES_URL, REPO_URL } from "./i18n/links";
import { SITE_NAME, SITE_URL } from "./page-meta";
import { stateLabel, surfaceState, type SurfaceContext } from "./surface-states";
export interface AgentTextInput {
facts?: RepoFacts;
surface?: SurfaceContext;
}
type Copy = { readonly en: string };
/**
* First-party routes that are in the sitemap but are not a docs-map topic.
* English canonicals only — this file is the machine-readable index.
*/
const EXTRA_PAGES: readonly { path: string; title: string; description: string }[] = [
{
path: "/product",
title: "Product",
description:
"What Codewhale is and what a person gains: their own models, capable agents, and control on their own machine — with availability stated per surface.",
},
{
path: "/app",
title: "Desktop and browser apps",
description:
"The desktop app is a development build and the browser app is not open yet; the terminal and the local browser client are released.",
},
{
path: "/docs",
title: "Documentation",
@@ -36,44 +35,12 @@ const EXTRA_PAGES: readonly { path: string; title: string; description: string }
description:
"Frequently asked questions: install, config, providers, models, modes, security, and privacy.",
},
{
path: "/runtime",
title: "Runtime & Integrations",
description:
"Local Runtime API over HTTP/SSE, ACP stdio over the existing Engine, MCP servers, an early VS Code companion, and a community VS Code GUI.",
},
{
// The page's own English copy, so the index cannot drift from it (W01-03).
path: "/computer-use",
title: computerUse.title,
description: computerUse.metaDescription,
},
{
path: "/constitution",
title: "Three layers of law",
description:
"Nested constitution: bundled base law, standing law (/constitution), and the repo's .codewhale/constitution.json.",
},
{
path: "/roadmap",
title: "Roadmap",
description: "Shipped, underway, considered, and ruled-out work.",
},
{
path: "/feed",
title: "Activity",
description: "Recent repository issues, pull requests, and releases.",
},
{
path: "/digest",
title: "Community digest",
description: "The weekly project record, reviewed by a maintainer.",
},
{
path: "/community",
title: "Community",
description: "Release contributors, helpers, and ways to take part.",
},
{
path: "/contribute",
title: "Contribute",
@@ -91,12 +58,6 @@ const EXTRA_PAGES: readonly { path: string; title: string; description: string }
description:
"Explore Codewhale’s reusable Ratatui components. Compare terminal themes and widths, inspect real rendered previews, and use the Rust examples in your app.",
},
{
path: "/signin",
title: "Sign in",
description:
"Sign in to Codewhale with GitHub, Google or an email code. Cloud computers are invite-only; the terminal needs no account.",
},
{
path: "/legal/terms",
title: "Terms of service",
@@ -109,32 +70,130 @@ const EXTRA_PAGES: readonly { path: string; title: string; description: string }
},
];
const UNRELEASED_HEADLINE_SECTIONS = new Set(["Added", "Changed", "Fixed", "Security"]);
function appPage(surface: SurfaceContext): string {
const state = (id: "desktop" | "hosted-web") => stateLabel(surfaceState(id, surface), "en");
return `- [Desktop and browser apps](${SITE_URL}/en/app): ${APP_COPY.surfaces.desktop.en}: ${state("desktop")}. ${APP_COPY.surfaces.browser.en}: ${state("hosted-web")}.`;
}
function unreleasedHeadlines(): string[] {
const unreleased = CHANGELOG.find((release) => release.unreleased);
if (!unreleased) return [];
const headlines: string[] = [];
for (const section of unreleased.sections) {
if (!UNRELEASED_HEADLINE_SECTIONS.has(section.heading)) continue;
for (const item of section.items) {
const colon = item.indexOf(": ");
if (colon <= 0 || colon > 140) continue;
const headline = item.slice(0, colon).trim();
if (/\(internal\)/i.test(headline)) continue;
headlines.push(`${section.heading}: ${headline}`);
}
}
return headlines;
function installLines(facts: RepoFacts): string[] {
const release = facts.latestPublishedRelease;
const version = release?.version ?? null;
const ref = release?.tag ?? "main";
const uninstallReleased = claimAvailability({ feature: "unix-uninstall" }, runtimeCapabilityContext(facts)) === "released";
const t = (copy: Copy) => fill(copy.en, { version: version ?? "" });
const p = (copy: Copy) => [t(copy), ""];
const h = (copy: Copy, level = 2) => [`${"#".repeat(level)} ${t(copy)}`, ""];
const code = (cmd: string) => ["```", cmd, "```", ""];
const link = (copy: Copy, href: string) => [`[${t(copy)}](${href})`, ""];
const bullets = (rows: readonly Copy[]) => [...rows.map((row) => `- ${t(row)}`), ""];
return [
`- macOS and Linux: \`${INSTALL_COMMANDS.shell}\``,
`- ${t(I.windowsTitle)}: \`${INSTALL_COMMANDS.windows}\``,
` ${t(I.windowsNote)}`,
`- ${t(I.npmTitle)}: \`${INSTALL_COMMANDS.npm}\``,
`- ${t(I.cargoTitle)}: \`${INSTALL_COMMANDS.cargo}\``,
` ${t(I.cargoNote)}`,
"",
...link(I.scriptLink, `${SITE_URL}/install.sh`),
...(version ? p(I.releaseLine) : []),
...h(I.pathTitle),
...p(I.pathPlaces),
...bullets([I.pathZsh, I.pathBashLinux, I.pathBashMac, I.pathOther, I.pathFish]),
...p(I.pathBlockIntro),
...code(C.pathBlock),
...p(I.pathBlockAfter),
...code(C.pathNow),
...p(I.pathOptOut),
...code(C.noModifyPath),
...p(I.pathManualIntro),
...code(C.pathManual),
...h(I.verifyTitle),
...p(version ? I.verifyBody : I.verifyBodyUnknown),
...code(C.version),
...h(I.firstTitle),
...[I.firstStep1, I.firstStep2, I.firstStep3].map((row, index) => `${index + 1}. ${t(row)}`),
"",
...link(I.quickstart, `${SITE_URL}/en/docs/guide`),
...h(I.usageTitle),
...p(I.usageBody),
...code(C.telemetryOff),
...p(I.usageInstaller),
...code(C.installTelemetryOff),
...link(I.usageLink, `${SITE_URL}/en/legal/privacy#usage-counting`),
...h(I.updateTitle),
...p(I.updateBody),
...code(C.update),
...h(I.removeTitle),
...(uninstallReleased
? [...p(I.removeNow), ...code(C.uninstall), ...p(I.removeFilesNow)]
: p(version ? I.removeFilesOnly : I.removeFilesOnlyUnknown)),
...code(C.removeFiles),
...p(I.removePath),
...p(I.removeData),
...code(C.removeData),
...(version ? p(I.removeGuide) : []),
...h(I.troubleTitle),
...bullets([I.troubleNotFound, I.troubleQueued, I.troubleLegacy]),
...link(I.troubleMore, `${SITE_URL}/en/docs/troubleshooting`),
...h(I.channelsTitle),
...p(version ? I.channelsLead : I.channelsLeadUnknown),
...h(I.windowsTitle, 3),
...code(INSTALL_COMMANDS.windows),
...p(I.windowsVerify),
...p(I.windowsPackage),
...p(I.windowsSandbox),
...p(I.windowsUpdate),
...h(I.npmTitle, 3),
...code(INSTALL_COMMANDS.npm),
...p(I.npmBody),
...p(I.npmEacces),
...code(C.npmPrefix),
...p(I.npmUpdate),
...h(I.cargoTitle, 3),
...code(INSTALL_COMMANDS.cargo),
...p(I.cargoBody),
...p(I.cargoUpdate),
...code(C.cargoUpdate),
...h(I.brewTitle, 3),
...code(C.brewInstall),
...p(I.brewBody),
...p(I.brewUpdate),
...code(C.brewRemove),
...h(I.mirrorsTitle),
...p(I.mirrorsNpm),
...p(I.mirrorsCnb),
...code(C.cnbVerify),
...link(I.mirrorsGuide, `${REPO_URL}/blob/${ref}/docs/CNB_MIRROR.md`),
...link(I.fullGuide, `${REPO_URL}/blob/${ref}/docs/INSTALL.md`),
];
}
function mapLines(facts: RepoFacts, surface: SurfaceContext): string[] {
return productMap({ surface, capabilities: runtimeCapabilityContext(facts) }).map((row) => {
const keys = PRODUCT_MAP_TEXT_KEYS[row.id];
return `- ${home[keys.name]}: ${home[PRODUCT_MAP_STATE_KEY[row.state]]}.${row.sentenceHolds ? ` ${home[keys.sentence]}` : ""}`;
});
}
function sitePage(id: string): string {
const topic = getTopic(id);
if (!topic) throw new Error(`missing docs topic: ${id}`);
return `- [${topic.label.en}](${SITE_URL}${docTopicHref(topic, "en")})`;
}
function topicDescription(topic: DocTopic): string {
return topic.id === "install" ? I.metaDescription.en : topic.description.en;
}
function provenance(facts: RepoFacts): string {
const read = facts.latestPublishedRelease?.readAt;
return `Facts generated ${facts.generatedAt.slice(0, 10)}${facts.sourceRevision ? ` from source revision ${facts.sourceRevision}` : ""}${read ? `; published release read ${read.slice(0, 10)}` : ""}`;
}
function factLines(facts: RepoFacts): string[] {
const release = facts.latestPublishedRelease;
const lines = [
release
? `- Latest published release: v${release.version}, published ${release.publishedAt.slice(0, 10)}${release.readAt ? `, read ${release.readAt.slice(0, 10)}` : ""} (${release.url}). The install commands above install this release.`
: "- Latest published release: unknown at build time; see https://github.com/codewhale-hq/CodeWhale/releases.",
];
const lines: string[] = [];
if (release) lines.push(`- Published release: ${release.tag} (${release.url}).`);
if (facts.license) lines.push(`- License: ${facts.license} (${REPO_URL}/blob/main/LICENSE). Free to use; you pay your model provider directly, and local models need no key.`);
if (facts.defaultModel) lines.push(`- Default model id: \`${facts.defaultModel}\` (DeepSeek). Every provider and model is selectable with /provider and /model.`);
const released = releaseView(facts);
@@ -142,34 +201,41 @@ function factLines(facts: RepoFacts): string[] {
lines.push(`- Built-in provider routes in ${release?.tag ?? "the published release"} (${released.counts.providerRoutes}): ${released.providers.map((provider) => provider.label).join(", ")}. Any OpenAI-compatible endpoint also works.`);
}
if (facts.sandboxBackends.length > 0) lines.push(`- OS command sandbox backends: ${facts.sandboxBackends.join("; ")}.`);
lines.push(`- Facts generated ${facts.generatedAt.slice(0, 10)}${facts.sourceRevision ? ` from source revision ${facts.sourceRevision}` : ""}; feature registry: ${SITE_URL}/features.json.`);
lines.push(`- ${provenance(facts)}; feature registry: ${SITE_URL}/features.json.`);
return lines;
}
export function buildLlmsTxt(): string {
const facts = BUILD_FACTS;
export function buildInstallMarkdown(input: AgentTextInput = {}): string {
const facts = input.facts ?? BUILD_FACTS;
return [
`# ${I.metaTitle.en}`, "",
`${home.sectionStartHeading} ${home.sectionStartBody}`, "",
...installLines(facts),
`${provenance(facts)}.`, "",
].join("\n");
}
export function buildLlmsTxt(input: AgentTextInput = {}): string {
const facts = input.facts ?? BUILD_FACTS;
const surface = input.surface ?? {};
const release = facts.latestPublishedRelease;
const headlines = unreleasedHeadlines();
const lines = [
`# ${SITE_NAME}`, "", `> ${IDENTITY_PHRASE}`, "",
"Read, edit and test a project with a hosted or local model. Modes, approvals and sandbox policy control tool access.", "",
`# ${SITE_NAME}`, "", `> ${home.heroTitle}`, "",
fill(home.heroIntro, { brand: SITE_NAME }), "",
release ? `The latest release is v${release.version}.` : `The latest release is unknown; see ${REPO_RELEASES_URL}.`, "",
"## Install", "",
`- macOS and Linux: \`${GETTING_STARTED_STEPS[0].commands[0]}\``,
"- npm (wraps the same release binaries): `npm install -g codewhale`",
"- Cargo (crates.io; can trail GitHub Releases): `cargo install codewhale-cli --locked`",
"- Windows x64: `winget install HunterBown.CodeWhale`",
`- Then run \`codewhale\` from the root of a git repository. Full guide: ${SITE_URL}/en/install`, "",
...installLines(facts),
`## ${home.mapHeading}`, "",
home.sectionDirectionBody, "",
...mapLines(facts, surface), "",
"## Main pages", "",
sitePage("install"),
sitePage("guide"),
sitePage("providers"),
`- [Changelog](${SITE_URL}/en/changelog)`,
`- [${home.readTheCode}](${REPO_URL})`, "",
"## Facts", "",
...factLines(facts), "",
];
if (headlines.length > 0) {
lines.push(
`## Unreleased on main${release ? ` (after v${release.version})` : ""}`, "",
`Merged on main and not in a published release yet; the install commands do not include these. Changelog headlines, not the full list: ${SITE_URL}/en/changelog`, "",
...headlines.map((headline) => `- ${headline}`), "",
);
}
lines.push(
"## Agent entry points", "",
`- [Feature catalogue](${SITE_URL}/en/features): Source capabilities, status, surfaces and documentation.`,
`- [Feature JSON](${SITE_URL}/features.json): The generated feature registry, including its source hash.`,
@@ -182,14 +248,15 @@ export function buildLlmsTxt(): string {
`- [Computer Use](${SITE_URL}/en/computer-use): Mac helper setup, CLI-version limitations and its separate open-source repository.`,
`- [Public plugin catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace): Installed CLI releases bundle a dated snapshot; current catalog entries are not proof of installed versions or end-to-end qualification.`,
"", "## Documentation", "",
);
for (const topic of DOC_TOPICS) {
lines.push(`- [${topic.label.en}](${SITE_URL}/docs-markdown/${topic.slug}.md): ${topic.description.en}`);
];
for (const topic of DOC_TOPICS.filter((entry) => releasedIndex.slugs.includes(entry.slug))) {
lines.push(`- [${topic.label.en}](${SITE_URL}/docs-markdown/${topic.slug}.md): ${topicDescription(topic)}`);
}
lines.push("", "## Optional", "");
for (const topic of DOC_TOPICS) {
lines.push(`- [${topic.label.en} on the site](${topic.hasPage ? `${SITE_URL}${docTopicHref(topic, "en")}` : `${REPO_DOCS_BASE}/${Array.isArray(topic.repoSource) ? topic.repoSource[0] : topic.repoSource}`}): ${topic.description.en}`);
lines.push(`- [${topic.label.en} on the site](${topic.hasPage ? `${SITE_URL}${docTopicHref(topic, "en")}` : `${REPO_DOCS_BASE}/${Array.isArray(topic.repoSource) ? topic.repoSource[0] : topic.repoSource}`}): ${topicDescription(topic)}`);
}
lines.push(appPage(surface));
for (const page of EXTRA_PAGES) {
lines.push(`- [${page.title}](${SITE_URL}/en${page.path}): ${page.description}`);
}
+26
View File
@@ -0,0 +1,26 @@
import released from "@/data/agent-docs.release.json";
import { DOC_TOPICS } from "./docs-map";
import { REPO_URL } from "./i18n/links";
import { buildInstallMarkdown } from "./llms-txt";
export const RELEASE_DOCS = released;
export function buildTopicMarkdown(slug: string): string | null {
const document = RELEASE_DOCS.topics.find((entry) => entry.slug === slug);
const topic = DOC_TOPICS.find((entry) => entry.slug === slug);
if (!document || !topic) return null;
if (slug === "install") return buildInstallMarkdown();
const { tag } = RELEASE_DOCS;
const many = document.sources.length > 1;
return [
`# ${topic.label.en}`, "", topic.description.en, "",
`The document${many ? "s" : ""} below ${many ? "are" : "is"} the repository's file${many ? "s" : ""} as ${many ? "they were" : "it was"} at the published release ${tag}.`, "",
...document.missing.flatMap((path) => [`${path} was added after ${tag} and is not part of this release; the document below is what ${tag} has.`, ""]),
...document.sources.flatMap((source) => [
`Source: ${REPO_URL}/blob/${tag}/${source.path}`,
`SHA-256: ${source.sha256}`, "",
source.path.split("#", 1)[0].endsWith(".json") ? `\`\`\`json\n${source.content}\n\`\`\`` : source.content,
"",
]),
].join("\n");
}
+3 -3
View File
@@ -5,13 +5,13 @@
"type": "module",
"description": "Community site for Codewhale — codewhale.net",
"scripts": {
"dev": "node scripts/opennext-config.mjs && node scripts/derive-facts.mjs && node scripts/derive-changelog.mjs && node scripts/derive-install.mjs && next dev",
"prebuild": "node scripts/derive-facts.mjs && node scripts/derive-changelog.mjs && node scripts/derive-install.mjs",
"dev": "node scripts/opennext-config.mjs && node scripts/derive-facts.mjs && node scripts/derive-changelog.mjs && node scripts/derive-install.mjs && node scripts/derive-release-docs.mjs && next dev",
"prebuild": "node scripts/derive-facts.mjs && node scripts/derive-changelog.mjs && node scripts/derive-install.mjs && node scripts/derive-release-docs.mjs",
"build": "next build",
"start": "next start",
"test": "node --test scripts/dependency-security.test.mjs && vitest run",
"check": "npm run check:facts && npm run check:latest-release && npm run prebuild && npm run check:docs && npm run check:tokens && npm run lint && tsc --noEmit && npm run build",
"check:facts": "node scripts/check-facts.mjs && node scripts/check-cloud-facts.mjs",
"check:facts": "node scripts/check-facts.mjs && node scripts/check-cloud-facts.mjs && node scripts/check-release-docs.mjs",
"sync:latest-release": "node scripts/sync-latest-release.mjs",
"sync:repo-stats": "node scripts/sync-repo-stats.mjs",
"check:latest-release": "node scripts/sync-latest-release.mjs --check",
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { readDocTopics } from "./docs-sources-lib.mjs";
import { checkReleaseDocs, currentRelease, deriveReleaseDocs, readCommittedReleaseDocs, readCommittedReleaseDocsIndex, tagResolves } from "./release-docs-lib.mjs";
const webDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const release = currentRelease();
const snapshot = readCommittedReleaseDocs();
const derived = release && tagResolves(release.tag) ? deriveReleaseDocs(webDir, release) : null;
if (release && !derived) console.warn(`[check-release-docs] warning: tag ${release.tag} is not in this checkout, so the snapshot was not recomputed`);
const problems = checkReleaseDocs(snapshot, release, readDocTopics(webDir), derived, readCommittedReleaseDocsIndex());
if (problems.length > 0) {
for (const problem of problems) console.error(`[check-release-docs] FAIL — ${problem}`);
process.exit(1);
}
console.log(`[check-release-docs] OK — snapshot is for ${snapshot.tag}, the published release`);
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env node
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { currentRelease, refreshReleaseDocs } from "./release-docs-lib.mjs";
const webDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
let snapshot;
try {
snapshot = refreshReleaseDocs(webDir, currentRelease());
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (snapshot) {
console.log(`[release-docs] ${snapshot.tag}: ${snapshot.topics.length} topics, ${snapshot.omitted.length} omitted`);
}
+20
View File
@@ -0,0 +1,20 @@
export interface ReleaseDocSource { path: string; sha256: string; content: string }
export interface ReleaseDocTopic { id: string; slug: string; sources: ReleaseDocSource[]; missing: string[] }
export interface ReleaseDocsJson {
format: string;
tag: string;
version: string;
topics: ReleaseDocTopic[];
omitted: { id: string; slug: string; missing: string[] }[];
}
type Release = { tag: string; version: string };
export const RELEASE_DOCS_PATH: string;
export function tagResolves(tag: string): boolean;
export function deriveReleaseDocs(webDir: string, release: Release | null): ReleaseDocsJson | null;
export function serializeReleaseDocs(snapshot: ReleaseDocsJson): string;
export function readCommittedReleaseDocs(): ReleaseDocsJson | null;
export function refreshReleaseDocs(webDir: string, release: Release | null): ReleaseDocsJson | null;
export function checkReleaseDocs(snapshot: ReleaseDocsJson | null, release: Release | null, registry: { slug: string }[], derived: ReleaseDocsJson | null, index?: unknown): string[];
export function releaseDocsIndex(snapshot: ReleaseDocsJson): { tag: string; slugs: string[]; omitted: string[] };
export function readCommittedReleaseDocsIndex(): { tag: string; slugs: string[]; omitted: string[] } | null;
export function currentRelease(): Release | null;
+110
View File
@@ -0,0 +1,110 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { REPO_ROOT, deriveLatestPublishedRelease } from "./facts-lib.mjs";
import { readDocTopics } from "./docs-sources-lib.mjs";
const here = dirname(fileURLToPath(import.meta.url));
export const RELEASE_DOCS_PATH = resolve(here, "..", "data", "agent-docs.release.json");
export const RELEASE_DOCS_INDEX_PATH = resolve(here, "..", "data", "agent-docs.release-index.json");
export const RELEASE_DOCS_FORMAT = "codewhale.release-docs.v1";
function gitShow(tag, path) {
const result = spawnSync("git", ["-C", REPO_ROOT, "show", `${tag}:${path}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return result.status === 0 ? result.stdout : null;
}
function tagResolves(tag) {
if (!/^v\d+\.\d+\.\d+$/.test(tag)) return false;
return spawnSync("git", ["-C", REPO_ROOT, "rev-parse", "--verify", "--quiet", `${tag}^{commit}`], { encoding: "utf8" }).status === 0;
}
export { tagResolves };
export function deriveReleaseDocs(webDir, release) {
if (!release || !tagResolves(release.tag)) return null;
const topics = [];
const omitted = [];
for (const topic of readDocTopics(webDir)) {
const sources = [];
const missing = [];
for (const path of topic.repoSource) {
const content = gitShow(release.tag, path.split("#", 1)[0]);
if (content === null) missing.push(path);
else sources.push({ path, sha256: createHash("sha256").update(content).digest("hex"), content });
}
if (sources.length === 0) omitted.push({ id: topic.id, slug: topic.slug, missing });
else topics.push({ id: topic.id, slug: topic.slug, sources, missing });
}
return { format: RELEASE_DOCS_FORMAT, tag: release.tag, version: release.version, topics, omitted };
}
export function serializeReleaseDocs(snapshot) {
return `${JSON.stringify(snapshot, null, 2)}\n`;
}
export function releaseDocsIndex(snapshot) {
return { tag: snapshot.tag, slugs: snapshot.topics.map((topic) => topic.slug), omitted: snapshot.omitted.map((topic) => topic.slug) };
}
export function readCommittedReleaseDocsIndex() {
if (!existsSync(RELEASE_DOCS_INDEX_PATH)) return null;
try { return JSON.parse(readFileSync(RELEASE_DOCS_INDEX_PATH, "utf8")); } catch { return null; }
}
export function readCommittedReleaseDocs() {
if (!existsSync(RELEASE_DOCS_PATH)) return null;
try { return JSON.parse(readFileSync(RELEASE_DOCS_PATH, "utf8")); } catch { return null; }
}
export function refreshReleaseDocs(webDir, release) {
const committed = readCommittedReleaseDocs();
const fresh = deriveReleaseDocs(webDir, release);
if (!fresh) {
const note = !release ? "no published release" : committed?.tag === release.tag
? `tag ${release.tag} is not available in this checkout, keeping the committed snapshot`
: `tag ${release.tag} is not available in this checkout and the committed snapshot is for ${committed?.tag ?? "nothing"}`;
if (release && committed?.tag !== release.tag) throw new Error(`[release-docs] ${note}`);
console.warn(`[release-docs] ${note}`);
return committed;
}
const text = serializeReleaseDocs(fresh);
if (!existsSync(RELEASE_DOCS_PATH) || readFileSync(RELEASE_DOCS_PATH, "utf8") !== text) writeFileSync(RELEASE_DOCS_PATH, text);
const indexText = `${JSON.stringify(releaseDocsIndex(fresh), null, 2)}\n`;
if (!existsSync(RELEASE_DOCS_INDEX_PATH) || readFileSync(RELEASE_DOCS_INDEX_PATH, "utf8") !== indexText) writeFileSync(RELEASE_DOCS_INDEX_PATH, indexText);
return fresh;
}
export function checkReleaseDocs(snapshot, release, registry, derived, index) {
const problems = [];
if (!release) return ["the published release is unknown"];
if (!snapshot || snapshot.format !== RELEASE_DOCS_FORMAT || !Array.isArray(snapshot.topics) || !Array.isArray(snapshot.omitted)) {
return ["data/agent-docs.release.json is missing or malformed; run npm run prebuild in a checkout that has the tag"];
}
if (snapshot.tag !== release.tag) {
problems.push(`data/agent-docs.release.json is for ${snapshot.tag}, the published release is ${release.tag}; run npm run prebuild in a checkout that has the tag`);
}
if (snapshot.version !== release.version) problems.push(`snapshot version ${snapshot.version} differs from the published release ${release.version}`);
for (const topic of snapshot.topics) {
for (const source of topic.sources) {
if (createHash("sha256").update(source.content).digest("hex") !== source.sha256) problems.push(`${topic.slug}: ${source.path} does not match its recorded hash`);
}
}
const covered = new Set([...snapshot.topics, ...snapshot.omitted].map((topic) => topic.slug));
for (const topic of registry) {
if (!covered.has(topic.slug)) problems.push(`docs topic ${topic.slug} is neither in the snapshot nor recorded as omitted`);
}
if (index !== undefined && JSON.stringify(index) !== JSON.stringify(releaseDocsIndex(snapshot))) {
problems.push("data/agent-docs.release-index.json does not match data/agent-docs.release.json; run npm run prebuild");
}
if (derived && problems.length === 0 && serializeReleaseDocs(derived) !== serializeReleaseDocs(snapshot)) {
problems.push(`data/agent-docs.release.json differs from the documents at ${release.tag}; run npm run prebuild`);
}
return problems;
}
export function currentRelease() {
return deriveLatestPublishedRelease();
}