Compare commits

...
50 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Opus 5.5 ef9ec08cbc chore(changelog): sync the TUI changelog slice for the DSH-04 and installer landing (CW-7875)
Regenerated with scripts/sync-changelog.sh after adding the CW-7959 and
CW-8036 lines to the root CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:49:58 -08:00
CodeWhale BotandClaude Opus 5.5 e6e3d79582 chore(web): regenerate the agent docs after the reviewed-items landing (CW-7875)
origin/main cf4464f72a changed docs/RUNTIME_API.md, docs/CONTRIBUTORS.md and
docs/RELEASE_CHECKLIST.md without regenerating web/data/agent-docs.generated.json,
so web/scripts/check-facts.mjs failed with "generated agent docs are stale".
Regenerated with node web/scripts/derive-facts.mjs; no hand edits. Only those
three docs' entries change.

check-facts: OK after regeneration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:49:57 -08:00
CodeWhale BotandClaude Sonnet 5.5 083f18c045 docs(install): say the PATH edit starts with 0.10.3 archives; v0.10.2 archives need the manual block (CW-8036)
The v0.10.2 archive install.sh takes no options and never edits a profile, and its install.ps1 only tells the user to add the directory. Qualify the archive and Windows claims in INSTALL.md and zh_hans/INSTALL.md, point v0.10.2 readers to adding the three lines by hand, and regenerate agent-docs.generated.json. Checks: check-docs PASS, check:facts OK, git diff --check clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 368a2b3407 fix(installer): make the printed PATH undo safe on BSD sed at end of file (CW-8036)
The undo sed used N;N, which on BSD sed quits without printing at EOF, so a lone start marker
within two lines of the end dropped the marker and the next line before the profile was
overwritten. Use {$!N;$!N;...} in both installers, INSTALL.md and zh_hans, regenerate
agent-docs, and add three lone-marker cases per installer (71 install-safety tests pass).
Also drop the new code comment lines from the installers.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 b066e6f996 feat(installer): add the marked PATH block to the shell profile by default, with an opt-out (CW-8036)
Founder decision 138.7: the Unix installers (web/public/install.sh and the
archive install.sh) append the three-line "# >>> codewhale PATH >>>" block to the
detected shell's profile when the install directory is not on PATH, print the
file, the lines and a portable sed command that removes exactly that block, and
skip it with --no-modify-path or CODEWHALE_NO_MODIFY_PATH=1. Unknown options now
fail instead of being ignored.

Safety: only the block is appended (newline-safe); nothing is edited when the
directory is already on PATH, the profile is a symlink or not a writable regular
file, its folder is missing, SHELL is empty or unrecognised (fish prints
fish_add_path), or the directory name has shell-special characters. A reinstall
sees the marker and leaves the file as is. zsh honours an absolute ZDOTDIR. The
end-of-output block (export for this shell, then --version) is unchanged, and the
uninstall sentence stays gated on the installed binary reporting an uninstall
command.

install.ps1 adds the install directory to the user PATH (HKCU Environment Path,
REG_EXPAND_SZ preserved) by default, prints how to undo it, skips with
-NoModifyPath, and never duplicates the entry. Not run on Windows.

Evidence: install-safety.test.js 65 pass / 0 fail (adds default add for zsh, bash
and sh, fresh profile, undo command, flag and env skip, unknown option, already
on PATH, reinstall, symlink, unwritable, undetermined shell, for both
installers); public-installer-contract.test.ts 33 pass; assemble-release-assets
7 pass; check:facts and check-docs pass. A clean Ubuntu 24.04 container run with
a fixture release: bash and zsh, web and archive installers, new non-login
interactive shell finds codewhale with no manual step, reinstall keeps one block,
--no-modify-path leaves the profile alone, GNU sed undo verified.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 c1b8a81e55 feat(extension-host): sessions and systemPrompt as one reviewed adapter, with its own checkpoint ticket check (CW-7959)
Replaces the inline SessionsShim and SystemPromptShim in root.ts with
src/dsh/session-prompt.ts registered through DSH_ADAPTERS. sessions.flush
now requires a non-empty checkpoint ticket and the invocation session itself
instead of relying on AdapterHost.liveInvocation. Behaviour is otherwise
unchanged: systemPrompt stays child-agent-only.

No dsh_sessions.rs and no `mod dsh_sessions;` line: by coordinator ruling
(CW-7875) the module is dropped from DSH-04, because with only
session/checkpoint available it would be empty dead code. systemPrompt
stays child-agent-only, as reviewed.

extension-host: typecheck clean; npm test 427 tests, 420 pass, 0 fail, 7 skipped;
test:bun 421 pass, 6 skip, 0 fail.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:44:18 -08:00
CodeWhale BotandClaude Opus 5.5 cf4464f72a chore(changelog): sync the TUI changelog slice for the reviewed-items landing (CW-7875)
Regenerated with scripts/sync-changelog.sh after the LEG-04, EA-04,
ENG-UNDO and PROV-7252 CHANGELOG edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:22:07 -08:00
CodeWhale BotandClaude Sonnet 5.5 aced082f0e fix(engine): encode the Anthropic object-form tool_choice name
The encoding fix 0d1ffc1cb2 covers tool declarations, replayed tool_use
blocks, string tool_choice and decoding, but an object tool_choice
({"type":"tool","name":"web.run"}) still went out raw and would 400 against
^[a-zA-Z0-9_-]{1,128}$. It now goes through to_api_tool_name (function-shaped
choices are normalised to the Anthropic tool shape). Adds one regression test
covering every outbound name and both decode paths. Refs #7252.

Evidence: farm cargo test -p codewhale-engine --lib client::anthropic: 38
passed, 0 failed; the new test failed (0 passed, 1 failed) before the fix.

Landing: the existing 0.10.3 CHANGELOG entry from 0d1ffc1cb2 now covers the
forced object-form tool_choice, cites #7252 and thanks the reporter
@ychu824; no second entry. @ychu824 is credited in the 0.10.3 CHANGELOG
Contributors block, docs/CONTRIBUTORS.md (v0.10.3 band) and RELEASE_HELPERS
in web/lib/release-credits.ts. AUTHOR_MAP is not needed: the reporter
authored no commit. #7252 stays open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:53 -08:00
CodeWhale BotandClaude Sonnet 5.5 ca3c2fd5be docs(changelog): remove the 0.10.3-beta.2 and App Server v2 claims
The work-in-progress paragraph still named beta.2 as a near-term
prerelease and kept App Server v2 in 0.10.3. Decision 96 dropped beta.2
and decision 124 paused v2. The paragraph now says the next release is the
full 0.10.3, v2 is paused, legacy JSON-RPC is not retired, the coarse
split and DSH all-33 stay, and nothing is declared complete.
crates/tui/CHANGELOG.md regenerated with scripts/sync-changelog.sh;
--check passes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:51 -08:00
CodeWhale BotandClaude Sonnet 5.5 839ba08856 docs(release): drop the 0.10.3-beta.2 example and the no-worktree rule
The prerelease checkpoint section named 0.10.3-beta.2 as its example, but
section 96 dropped that release: the next Engine release is the full
0.10.3. Use a neutral 0.11.0-beta.1 example, keep the generic prerelease
path, and say 0.10.3 does not use it. The checklist and runbook still told
agents to avoid worktrees; section 93 has agents build in short-lived
worktrees and land on main, with freeze, tag and publication from the
clean canonical main.

Prose only. Checks: git diff --check clean; scripts/sync-changelog.sh
--check up to date; relative links in both files resolve. No Rust build.
COARSE_SPLIT.md left to REF-01 (patch proposal in the review packet).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:49 -08:00
CodeWhale BotandClaude Sonnet 5.5 b87cb4ad2e fix(undo): full-turn patch-undo restores in the default posture
The Runtime's patch-undo refused with restore_requires_trust whenever the
thread was not in trust mode or Full Access, so a default (Ask) session could
never undo a turn's files even though the TUI's /undo already does so. The
ownership attribution, preimage and later-edit proofs decide what may be
reverted, so the trust gate is removed from the helper, its public signature
and the error-code table; the TUI caller drops its constant argument. The
doc comment and two test doc comments the change made false are rewritten.

Checks (review rerun, farm 20261110T153702Z-undo-review-11b834, base
9731fbbf1c): cargo test -p codewhale-app-server -p codewhale-tui --lib --
patch_undo undo_that_cannot_restore restore_undo: app-server 10 passed,
0 failed; tui 9 passed, 0 failed. Not run: Windows /undo, a process-restart
journey, macOS native, hosted CI.

Landing: squashes 2cc2da435f and its fixup 8aa3ec56c8, and adds the
CHANGELOG line from the review packet.

Landing: rustfmt reflows the patch_undo_workspace_files call in
debug_operations.rs after the dropped argument (format only).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:47 -08:00
CodeWhale BotandClaude Sonnet 5.5 f24a6e88be chore(engine,app-server): drop new code comments from ACP options slice, add CHANGELOG (CW-7879 EA-04)
Review repair: removes the 25 doc/comment lines the first commit added
(module doc in external_agent_options.rs, item docs in fleet/acp.rs and
manager/external_agent.rs) per the no-new-code-comments rule. The fixture
header in acp-options-agent.mjs follows the existing acp-agent.mjs
convention and stays. Adds the CHANGELOG line for the new route.

Reasoning that lived in the removed comments:
- GET /v1/agents/external/{id}/options?cwd= answers with the agent's own
  session/new offer, read on a throwaway process stopped before return.
  Nothing comes from the Codewhale provider catalog.
- Known limitations: probing starts a real agent session in cwd, which an
  agent may keep in its own history. The route is gated like the rest of
  the Runtime API and does not tie cwd to a thread owner.
- ControlWrite is the one ACP method that writes a control, so a choice is
  never sent through a different door than the agent offered it on.
- Choices are the only values a caller may write; names and descriptions
  are display text only. Over-limit or control-character values are
  dropped, never truncated into authorization input.
- A config option that names a model or mode owns that control; the legacy
  models/modes blocks fill only what no config option claimed.
- Usage is what the agent reported on the prompt answer; absent fields are
  unknown, cost is always unknown.

Shared-file touches runtime_api.rs (+5: mod and one route) and
runtime_api/tests.rs (+2: mod) are the minimum to register the route;
the integrator should accept them explicitly.

Known follow-up: apply_choices still sends a blind set_config_option
("model") when no controls parsed, including when every configOptions
entry was malformed; the agent validates and a refusal is typed
choice_rejected. A follow-up can require malformed_controls == 0.

Checks: clippy -D warnings on engine and app-server, run after this edit.

Landing: rustfmt drops the blank first line the comment removal left in
external_agent_options.rs (format only).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 10ca1deee0 feat(engine,app-server): ACP options route, typed control refusals and agent-reported usage (CW-7879 EA-04)
GET /v1/agents/external/{id}/options?cwd= answers with the agent's own
session/new offer (models, modes, config options, capabilities) read on a
throwaway process that is stopped on every path, plus which Codewhale
actions it carries. A saved model choice is validated against the complete
offer at setup and written through the method it was offered on; a choice
the agent does not offer or no longer offers, and per-turn controls an
agent cannot honor, are stable typed refusals instead of strings.
Agent-reported usage is recorded as turn.agent_usage events: context
window, a per-turn token total, cost unknown, and unknown (not zero) when
the agent reports nothing. A started agent process is now owned by a guard
that stops it on error or panic and logs a failed stop.

Checks (Boat farm, CARGO_INCREMENTAL=0): engine --lib external_agent
fleet::acp 20 passed 0 failed; app-server --lib external_agent 3 passed
0 failed; clippy -D warnings engine+app-server --all-targets clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 3da52ab015 test(plugins): bind DSH refusals to the pinned basis, drop self-asserted engine provenance (CW-7051)
Refused rows now require the Engine message to carry the DSH-00 pinned
peerDependencies refusal; the validator recomputes the match. --engine-kind is
required, source_sha is removed, and dev builds cannot yield an accepted
verdict. Selftest 15 passed (wrong-reason and dev-build cases added); pins 33 ok.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 f623d362b4 test(plugins): pinned DSH plugin acceptance driver and receipt validator (CW-7051)
One stdlib driver runs a pinned row's lifecycle L through the installed
Engine's /v1/apps/plugins routes (no second loader), writes a receipt, and a
validator recomputes the verdict from the pins: a missing required step,
zero-count PASS, evidence-less PASS, unnamed UNAVAILABLE, source-build engine
or hash mismatch is INVALID; provider/desktop gaps are UNAVAILABLE, never PASS.
Checks: selftest 13/13, pins 33/33 hashes, row 1 real run on macOS.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:35 -08:00
CodeWhale BotandClaude Sonnet 5.5 c71f0efefb fix(tui): bound @mention directory and line-range scan work (CW-7875)
Recovers the scan-effort guarantees from 51f321 on the current owner:
directory enumeration stops after 10,000 entries (listing marked
truncated, "more exist" only when entries remain), and a ranged mention
past the byte prefix stops after 64 MiB of scanning, reported truncated
with no text instead of beyond-eof. Real EOF still reads beyond-eof.
Multi-range and CJK mention fixes untouched.

Checks: cargo test -p codewhale-tui --lib -- file_mention:: 46 passed, 0 failed.

Landing: CHANGELOG line added from the review packet; the new doc comments
on the two scan constants and the new test are dropped (no new code comments).
The rewritten directory_listing, read_file_content and
read_line_range_past_prefix docs stay: they replace comments the change
made false.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:33 -08:00
CodeWhale BotandClaude Sonnet 5.5 1daa4aa6db test(engine): prove the side-browser account fence admits and refuses (CW-7841)
Adds a test-only account-epoch seam to profile_constitution.rs (like
TEST_ACCOUNT_LOAD_FAILURE) and one regression for admit_desktop_browser_call:
no fence refused, a changed epoch refused (A to B, B back to A), an unreadable
store refused, an unchanged epoch admitted, a newly admitted turn under the new
account admitted, and non-browser tools unaffected throughout.

Checks: codewhale-engine --lib desktop_browser_call_is_fenced 1 passed, 0 failed
(7142 filtered). Negative control with the fence check short-circuited to Ok:
the same test fails ("call must be refused"). No user-visible change, so no
CHANGELOG line. The live A-to-B-to-A proof (cw7841-fence-proof run.sh) was not
run here.

Landing: the two-line doc comment on pin_account_epoch_for_test is dropped
(no new code comments).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 5cc8f8a8fd web: show a capability only when the published release has it, with counts at the tag (WEB-FACTS-C)
Every capability the site lists as available is now keyed to a docs/features.toml row and gated by the published Engine release.

- scripts/release-baseline-lib.mjs reads the registry, provider descriptors, model catalog and tool implementations at the published tag with git show and commits the result as data/release-baseline.json. derive-facts refreshes it when the tag is in the checkout and keeps the committed copy otherwise; check-facts fails when the baseline is for another tag or differs from the tagged source. Tag v0.10.2: 78 registry rows, 50 provider routes, 91 catalog models, 79 tool implementations.
- lib/capabilities.ts decides released, in_source or hidden per row: since above the release (or unreleased) is in_source, a row with no recorded since is judged by whether it is in the tagged registry, an unknown release hides everything. Released rows show the wording the tag had. A claim that describes the current row (Accept Edits as the default) is held back when the row's wording changed since the tag.
- Founder switches added, off by default: cloudComputersBeyondInvite (registry rows for cloud computers) and namedVendorAgentsOverAcp (rows naming other vendors' agents over ACP); plansPublished now also hides rows that describe Codewhale plans or pricing.
- New typed surface state in_source with copy in 18 locales (own drafts, need native review). /features and /features.json list only released and in-source rows, /product keys its work patterns, modes and permissions to rows, and structured-data featureList uses the same gate.
- Counts print the tag's values with what they count: provider routes, catalog models, tool implementations. Home, /models, /runtime, llms.txt and the FAQ read them; main-only models are no longer listed; a count that cannot be computed omits its sentence. /api/facts gains releaseCounts without changing existing fields.
- lib/capabilities.test.ts (402 tests incl. a per-file scan that no dictionary or content module carries a literal count).

Checks from web/: npm run check exit 0; check:locales PASS; tsc 0; lint 0; npm test 84 files, 1266 tests passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 17:26:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 a34117942a chore(changelog): sync the TUI changelog slice for the website facts landing (CW-7933)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:50:30 -08:00
CodeWhale BotandClaude Sonnet 5.5 b0378f9b22 fix(installer): regenerate agent docs, translate zh_hans PATH note, test installer endings (CW-8030)
Review repairs: web/data/agent-docs.generated.json regenerated so check:facts passes; the zh_hans INSTALL PATH note is translated and mirrors the release-archive "last output" clause; install-safety.test.js now asserts the last three lines of both installers and that the macOS Computer Use notice precedes the PATH block (37 pass).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:50:26 -08:00
CodeWhale BotandClaude Sonnet 5.5 fe4548cdf1 fix(installer): end the output with the PATH command and the verify command (CW-8030)
When PATH does not select the install, the last lines the installer prints
are now the copy-paste `export PATH=...; hash -r` line and
`codewhale --version`, after the persistent profile line. When PATH already
selects it, the last line is `Next: codewhale --version`. The Computer Use
notice moves above the PATH block and is printed on macOS only. Both
installer copies change; the installer still never edits shell profiles.

Checks: web installer contract + static installer + public surface tests 45
passed; scripts/release/install-safety.test.js 33 passed. Clean Ubuntu 24.04
container, non-root user, v0.10.2 download: output ends with the export line
and `codewhale --version`; pasting the line then `codewhale --version`
prints `codewhale 0.10.2 (ff466b2ed0)`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 b0a1c8c00c fix(installer): print uninstall and update-mode lines only when the installed binary supports them (CW-8029)
Both installers printed `codewhale uninstall --yes`, the PATH-block marker
sentence and the `update.mode` sentence unconditionally. Releases before
0.10.3 have neither command nor key, and an unknown first word starts a chat
that sends it to the model. The installer now asks the binary it just
installed (`--help` lists an `uninstall` command) and otherwise prints the
manual removal. `update.mode` shares the same gate: both shipped together.
INSTALL.md and its zh_hans copy say "0.10.3 and later" and give the manual route.

Proof: install-safety.test.js 37/37 (4 new, website and archive, stub help
with and without uninstall); web vitest installer contract files 39/39;
Docker ubuntu:24.04 arm64: real v0.10.2 asset prints the manual removal and
no uninstall or update.mode text, a main-source build prints the uninstall line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 285db03a2a web: typed surface states, founder switches, desktop download gate (WEB-FACTS-B)
Each surface's status is now a typed state (released, available, beta, dev,
open, invite_only, not_open, not_public) looked up from the chrome dictionary
in all 18 locales instead of free text. Cloud invitations and plan publication
are committed values in lib/founder-switches.ts, default off, with the decision
beside each and no environment lookup. The desktop row shows a download only
when data/desktop-published-release.json holds a signed, notarized macOS asset
with a checksum (the object is null today). The partner strip expiry uses
lib/expiry.ts at render time. Snapshot numbers carry an "as of" title and
accessible label.

Checks (web/): check 0, check:locales PASS, tsc 0, lint 0, npm test 83 files
864 tests pass, offline build 0. Not deployed; not pushed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 3b0634321c web: drop new comments, print the release read date, soften asset-only drift (WEB-FACTS-A)
Review repairs for 064b1a38b6.

Comments removed per the no-new-comments rule; their reasoning lives here:
- withBuildProvenance: next.config.ts stamps revision, commit time and
  generatedAt in one step. They apply together or not at all, so a fresh
  revision never sits beside the tracked file's older date, and a partial stamp
  leaves the tracked snapshot's own consistent trio untouched.
- RepoStats: a number that could not be read is null, never 0 and never a
  floor. source says whether values were read live or come from the committed
  snapshot; fetchedAt is when they were read (null when nothing was).
- releaseAssets: the checked-in release record also carries each asset's name,
  size and sha256 digest plus the read time. Only the four core fields are
  mirrored to docs/ and the cloud facts.

llms.txt now prints "read YYYY-MM-DD" beside the latest published release, so
the offline build says when the release was read.

check:latest-release no longer exits 1 when only the asset list differs and
the tag, version and URL match: it warns (and emits a GitHub Actions warning)
and exits 0. Verified by perturbing one asset size locally: warning, exit 0.

Checks: vitest facts-invariants, github, sync-latest-release,
public-surface-contract 4 files 62 tests pass; tsc --noEmit exit 0;
sync-latest-release --check exit 0; check:facts OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 fae936a8c5 web: read facts from published truth, never 0 or a floor (WEB-FACTS-A)
Published release: data/latest-published-release.json now also carries the
asset list (name, size, sha256) and the time it was read, written by
sync-latest-release.mjs; docs/ and cloud-facts mirrors still get the four
core fields only. Unknown renders nothing: RepoStats numbers are null when
unreadable, MIN_KNOWN_CONTRIBUTORS is removed, the production-build path
reads the committed repo-stats-snapshot.json (sync-repo-stats.mjs) instead
of returning zeros, and the browser fallback rejects a missing count rather
than defaulting it to 0. Provenance: next.config.ts stamps the generation
time with the revision, and withBuildProvenance applies revision, commit
time and generatedAt together or not at all. The desktop app is no longer
called native (availability row and app page, en and zh). The
SoftwareApplication structured data drops its price offer.

Invariant tests (lib/facts-invariants.test.ts, 18): installed-version line
equals the published-release object; no price, currency amount or plan name
on 19 pages in en and zh, in structured data or in the sitemap; home and
/contribute render with every GitHub call failing; a failed read is never 0.
Checks from web/: check 0, check:locales 0, tsc 0, lint 0, npm test 82 files
805 tests pass, offline build passes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 f3f09e4421 changelog: drop duplicate exec prompt input line left by the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:54 -08:00
CodeWhale BotandClaude Sonnet 5.5 3790c538a7 docs: ACP sessions have no plugins, DSH or MCP attached yet (no release promise)
Founder decision: attaching them is in progress; state the current fact only.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:52 -08:00
CodeWhale BotandClaude Sonnet 5.5 82a5774e42 web: regenerate agent docs after the EXTENSIONS.md ACP wording
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 47f0a35e3e docs: ACP sessions do not run plugins, MCP or DSH; changelog line
Landing additions for the ACP server slice.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 a0b59be23e fix(acp): refuse unsupported prompt blocks, relative cwd; classify write/edit tools; add stdio-style journey test (CW-7941)
The ACP server advertises image=false but silently dropped image/audio/blob
prompt blocks while running the rest of the prompt; it now answers invalid
params naming the block type. session/new and session/load accept only an
absolute cwd (ACP requires it; session/list already did). Permission requests
and tool-call cards for the real tool names write/edit/read carried kind
"other"; they now carry edit/read so editors render and gate them correctly.

capture_frontend and CapturedAcpFrontend::serve become pub so a protocol-level
journey test can drive the real server loop over line-framed duplex streams:
initialize, refusals, new, permission allow, cancel, list, load with replay,
and a prompt in the loaded session whose model request carries the saved history.

Checks (Boat farm): cargo test --locked -p codewhale-app-server --test acp_journey: 1 passed; 0 failed.
--lib acp_server with RUST_MIN_STACK=16777216: 38 passed; 0 failed (790 filtered out).
codewhale-core --test single_turn_loop: 14 passed; 0 failed. Journey runs in-process over duplex
streams with a mock model on a current-thread runtime; no real editor was run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 42b1972743 changelog: plugin activation names core-owned services
Landing addition. After the stack, npm run build leaves crates/engine/extension-host/dist clean;
npm test there: 423 tests, 416 pass, 0 fail, 7 skipped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 39a240bc8b feat(extension-host): one registration table for DSH services and a named adapter seam (CW-7051)
HostRoot now installs every service from a single table and refuses to
construct when compatibility.json's provided_services and the installed
services disagree in either direction, so a service can never be advertised
without a shim or shipped without being declared. Reviewed adapters plug in
through DshAdapter (src/dsh/adapters.ts, DSH_ADAPTERS, empty today) and an
AdapterHost facade (tier, rpc, ownerOf, liveInvocation, log); HostRoot takes
the adapter list as an optional third argument so tests can supply one.
liveInvocation applies the owner/active/abort/disposing checks the inline
shims repeat. An adapter that duplicates an inline service or names an
undeclared one is refused.

An inject of a core-owned service the host does not provide (llm, credentials,
fs, ...) now says the Codewhale core owns it and no reviewed bridge exposes it
yet. No service, protocol method or compatibility entry is added, so
compatibility.json, the protocol table and generated parity are unchanged.

Checks: tsc clean; npm run build reproducible; npm test 416 pass / 0 fail / 7
skipped; bun test 417 pass / 0 fail (6 skipped counted in baseline); cargo
extension_host::protocol 4/4; extension_host 173 pass, 1 timing flake under
load that passes alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 e203847684 fix(plugins): flag concatenated client specifiers as computed, drop doc comments, add changelog (CW-7054)
Review repairs for 4619f6d030: require/import whose quoted literal is followed by anything but ) or , is now a computed specifier (concat and concat-import cases added to the regression loop); removed the two new doc comments; the sibling-package refusal no longer says it leaves the package; CHANGELOG line for the client review outcome and client_closures.
Checks: codewhale-plugins --lib install:: 38 passed, 0 failed.
new URL(import.meta.url), import.meta.resolve and new Worker are still unscanned; CW-7704 must serve only listed, hash-matching files.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 6a96c2983c feat(plugins): retain and review the dsh.client closure in the DSH importer (CW-7054)
The importer no longer drops `dsh.client` with a blanket "not converted"
note. For the root package and every selected nested package that
declares a client, it resolves the client entry (exports["./client"]),
walks the static require/import closure over the bytes already copied
under source/, and records path plus SHA-256 for every file in
CONVERSION.json (`client_closures`), DshConversion and a `client`
outcome. Host-provided modules (react, react-dom, @deepseek-ai/*) are
listed, not resolved. Ambient npm packages, Node built-ins, URL or
absolute specifiers, escapes from the package and computed
require/import make only the client half skipped, with the exact
reason; the server half still imports. Serving stays refused by the
App Server route until its bootstrap admits the closure.

Evidence: codewhale-plugins --lib 208 passed, 0 failed, 1 ignored
(new regression dsh_client_closure_is_retained_hashed_and_refuses_ambient_dependencies
included); the `dsh` filter selects 7; clippy -D warnings clean.
Probe over the 36 survey packages (temporary test, removed): 19
retained, 5 skipped with reasons, the rest refused earlier by existing
server-side gates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:53 -08:00
CodeWhale BotandClaude Sonnet 5.5 b6c20fb2ae changelog: exec prompt input line without tracker id
Landing addition. Reviewer Mac run: codewhale-tui --test integration exec_:
23 passed; 0 failed (101 filtered).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:50 -08:00
CodeWhale BotandClaude Sonnet 5.5 b2adfe48ba fix(exec): working stdin note, accurate doc comment, CHANGELOG line (CW-7891)
The grace-expiry note advised --prompt-file -, which clap rejects next to a
prompt argument. It now suggests piping the prompt together with the slow
producer, which does work. The resolve_exec_prompt doc comment no longer
claims a pipe always reaches the prompt. The reader thread left behind after
a timeout is unchanged: nothing else in the exec path reads stdin.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 67558c8a48 fix(exec): an open pipe no longer hangs an explicit prompt; typographic flags after the prompt are rejected (CW-7891)
With a prompt argument, exec waits 2 seconds for the first byte of piped stdin. If none arrives it runs on the argument alone and says so on stderr; any data is read to EOF and appended as before. A prompt-less exec still reads stdin to EOF.

The flag-after-prompt check now folds full-width ASCII, typographic dashes and a single em or en dash to the ASCII long flag, so an IME or autocorrected "--auto" is rejected instead of sent to the model as text. The exec help no longer says plain exec has no tools.

Checks on the Boat farm at base 733c34fb32: new exec_prompt_input subprocess tests 5/5; integration exec_ filter 23/23; cli+tui lib filter "exec stdin" 14 + 79 passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 aaabfeef64 changelog: pet browser view one-time code
Landing addition. Reviewer farm run tui lib pet_watch: 37 passed; 0 failed;
npm --prefix pet run check: 76 tests, 76 pass, 0 fail.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 baac9e1986 fix(pet): send the pairing code as X-Pet-Code from the generated browser page source
pet/public/shared.html is the source of crates/tui/src/tui/pet_watch/shared.html;
15456465ff edited only the generated copy, so 'npm --prefix pet run check' failed
on a byte difference and the next sync would have reverted the browser half of
the pairing change. Mirrors the one-line connect() change into the source.

Checked: cmp of source and copy identical; npm --prefix pet run check exit 0
(76 node tests passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 fe4b8cbc21 fix(tui): open the pet browser view with a one-time code instead of the owner token
The pet owner now mints a 30 s single-use pairing code over an authorized
POST /v1/pair; the launcher opens the browser at that code, and shared.html
redeems it once at /v1/attach for the existing HttpOnly SameSite=Strict
cookie. The long-lived owner token no longer appears in argv or the URL.
Adds regression tests for authorized() (wrong, missing, cookie, Host and
Origin pinning) and for single-use and expiry of the pairing code.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 ed8a0faa6d changelog: file-tree shortcut needs explicit Shift
Landing addition. tui lib file_tree_shortcut: 1 passed; 0 failed (reviewer farm run);
worker filter shortcut: 36 passed; 0 failed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 27cfad7332 fix(tui): require explicit Shift for the file-tree shortcut
Windows crossterm 0.29 reports Ctrl+E with Caps Lock on as Char('E') with
CONTROL and no SHIFT, so plain Ctrl+E (composer end-of-line) opened the file
tree. The toggle now needs SHIFT in every form (Ctrl+Shift+E, Cmd+Shift+E).
The pinned test asserts the Caps Lock event shape no longer toggles and that
Ctrl+Shift+E still does.

Proof: file_tree_shortcut_does_not_steal_plain_ctrl_e fails on old code
(farm run, 0 passed 1 failed) and `cargo test -p codewhale-tui --lib shortcut`
passes with the fix.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:15 -08:00
CodeWhale BotandClaude Fable 5.2 5af8a9a245 chore(changelog): sync the TUI changelog slice after the web maintainer entry (CW-7933)
The Main landing gate has failed on main since 0481377c4c: the root
CHANGELOG gained the "Website maintainer" line and crates/tui/CHANGELOG.md
was not regenerated. This runs scripts/sync-changelog.sh, which adds that
one line.

Checked: bash scripts/preflight.sh --check -> preflight: OK (rustfmt, TUI
changelog slice, README translations 18 in sync, locale link symmetry,
dead-code budget 121 at budget, blocking-calls budget 658 sites within
budget, release-note receipts).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 15:22:55 -08:00
CodeWhale BotandClaude Sonnet 5.5 0481377c4c fix(web): harden the web maintainer after review (CW-7933)
Explicit file allowlists for copy lanes plus a deny list for legal, constitution,
enterprise, partner, merch, availability, pricing, roadmap and project copy; PR
bodies list every changed file. Fail-closed bubblewrap probe before the model
runs; scope check now fails on ignored files and changed .git config or hooks;
web checks run in a fresh checkout with no token; publish mints the App token
only when a patch exists; shared action pins with version comments; every job
repeats the WEB_MAINTAINER_ENABLED gate. Adds publish-prs.sh, which the previous
commit left out because *.sh is gitignored. Test fixtures renamed from the
retired dispatch key; adds an overlapping digest generation test.

Checks: actionlint clean, tsc and eslint clean, vitest 81 files / 787 tests, next build ok. Workflow never run live.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:59 -08:00
CodeWhale BotandClaude Sonnet 5.5 1e45725ded feat(web): PR-only DeepSeek website maintainer, retire Worker drafting tasks (CW-7933)
Adds .github/workflows/web-maintainer.yml (disabled unless WEB_MAINTAINER_ENABLED=true): a deterministic derived-files lane and three codewhale exec lanes on deepseek-flash with a post-run path allowlist, web checks, and a separate App-token publish job opening one PR per lane. Removes the Worker triage, pr-review, stale, dupes and curate cron tasks, their prompts, dispatch KV plumbing and tests; digest, facts-drift and content watchers stay.

Checks: actionlint clean, tsc clean, eslint clean, vitest 81 files / 786 tests passed, next build ok. Workflow never run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:58 -08:00
CodeWhale BotandClaude Opus 5.5 a6bbf51093 docs(design): the public repo vendors only token files (CW-7930)
The vendored design copy holds only what the site and scripts read: tokens.json,
its generated tokens.css/.ts/.rs/.dtcg.json, generate.py and the four inputs
generate.py --check needs (whale/source/spec.json, figma/sync.template.js,
figma/variables.json, figma/sync.js). docs/design/DESIGN.md now says
so, and warns against vendoring the design system's documentation here.

Checks: vendor generate.py --check verified; scripts/export-design-tokens.py
--check up to date (158 tokens); grep for file keys and local paths in
vendor/: none; web vitest 793 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 15:07:56 -08:00
CodeWhale BotandClaude Sonnet 5.5 435c80e7e1 fix(web): drop the stale warm-paper hex from the token comment; format the changelog line (CW-7933)
Review fixes. Verified: tsc, eslint, check:locales, check:tokens, check-facts, vitest 793/793 in 81 files, build ok. Reviewed the plugins and 390px dark screenshots.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 ad287f3f49 feat(web): move codewhale.net to the 5.3 design tokens and Geist (CW-7933)
Syncs vendor/codewhale-design to 5.3.0 with codewhale-design sync-to.sh, exports the water gradients from export-design-tokens.py, maps the site roles to the 5.3 light and dark tokens, self-hosts Geist and Geist Mono in place of Shannon Sans, and retires the warm-paper kit values. Radii follow the tokens (10/18/pill).

Checks: tsc, eslint, check:locales, check:tokens, check-facts clean; vitest 793/793 (81 files); next build ok; screenshots of /en, /en/models, /en/plugins, /en/app at 1440 and 390 in both themes reviewed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:35 -08:00
CodeWhale BotandClaude Opus 5.5 71478f76f8 chore(ci): budget the v2 contract generator and pinned Codex export (CW-7704)
The main landing preflight has failed on every landing since acc0e30f65
(CW-7704 V2-01) because two ratchets counted new sites that are correct
where they are:

- blocking-calls: crates/app-server-protocol/src/bin/contract.rs makes 5
  std::fs calls. It is a synchronous command-line codegen binary
  (`--write` / `--check`) with no async runtime, so tokio::fs or
  spawn_blocking would add nothing. Its sibling bin/export.rs is already
  budgeted the same way.
- dead-code: the 2 #[allow(dead_code)] in
  crates/app-server-protocol/tests/pinned/codex/common.rs are in the
  unmodified pinned upstream Codex export that V2-01's frozen contract
  reads byte for byte; editing it would break the pin. App Server v2 is
  paused (§124) and its scaffolding is kept.

Raised with each script's --update. `scripts/preflight.sh --check`: OK
(dead-code 121 at budget, blocking calls 658 sites within budget).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:17:42 -08:00
CodeWhale BotandClaude Opus 5.5 773861823d docs(web): accounts are open, cloud computers invite-only (CURRENT_DECISIONS §132) (CW-7933)
Founder, 2026-11-10: new Codewhale accounts are open; cloud computers stay
invite-only. The sign-in entry page, its metadata and llms.txt no longer
say "new accounts are not open"; they name GitHub, Google and email-code
sign-in and say cloud computers are invite-only. /docs/account and the
docs-auth account note (en, zh) drop the "people who already have an
account" framing, and /app's cloud row says invite-only.

Checks in web/: tsc pass; check:locales pass; check-facts pass; vitest 793
passed. Not deployed (deploy waits for the Worker GITHUB_TOKEN, §132.2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:16:42 -08:00
193 changed files with 15955 additions and 2391 deletions

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
name: Web maintainer
on:
schedule:
- cron: '43 6 * * *'
workflow_dispatch:
inputs:
lane:
description: Which lane to run
required: true
default: all
type: choice
options:
- all
- derived
- copy-freshness
- link-health
- docs-drift
permissions: {}
concurrency:
group: web-maintainer
cancel-in-progress: false
env:
WEB_MAINTAINER_CLI_VERSION: v0.10.2
jobs:
plan:
name: Plan
if: github.repository == 'codewhale-hq/Codewhale' && github.ref == 'refs/heads/main' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
outputs:
lanes: ${{ steps.plan.outputs.lanes }}
derived: ${{ steps.plan.outputs.derived }}
steps:
- id: plan
env:
INPUT_LANE: ${{ inputs.lane || 'all' }}
run: |
derived=false
lanes='[]'
case "$INPUT_LANE" in
all) derived=true; lanes='["copy-freshness","link-health","docs-drift"]' ;;
derived) derived=true ;;
copy-freshness|link-health|docs-drift) lanes="[\"${INPUT_LANE}\"]" ;;
*) echo "::error::unknown lane"; exit 1 ;;
esac
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
echo "lanes=${lanes}" >> "$GITHUB_OUTPUT"
echo "Web maintainer plan: derived=${derived} lanes=${lanes}" >> "$GITHUB_STEP_SUMMARY"
derived:
name: Derived files
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.derived == 'true' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Install web dependencies
working-directory: web
run: npm ci --no-audit --no-fund
- name: Regenerate derived files
working-directory: web
env:
GH_TOKEN: ${{ github.token }}
run: |
node scripts/sync-latest-release.mjs
node scripts/derive-facts.mjs
node scripts/derive-changelog.mjs
node scripts/derive-install.mjs
node scripts/render-terminal-capture.mjs
- name: Check what changed
id: drift
run: |
mkdir -p "$RUNNER_TEMP/out"
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "::error::regeneration produced untracked files"
git ls-files --others --exclude-standard
exit 1
fi
git diff --name-status | node web/maintainer/check-allowlist.mjs web/maintainer/derived.txt derived
if [ -n "$(git diff --summary)" ]; then
echo "::error::regeneration created, deleted or changed modes"
exit 1
fi
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
git diff --stat >> "$GITHUB_STEP_SUMMARY"
else
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "Derived files are current." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Run the web checks on the regenerated tree
if: steps.drift.outputs.drift == 'true'
working-directory: web
run: |
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.drift.outputs.drift == 'true'
with:
name: web-maintainer-derived
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
lane:
name: Lane ${{ matrix.lane }}
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.lanes != '[]' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
lane: ${{ fromJSON(needs.plan.outputs.lanes) }}
env:
LANE: ${{ matrix.lane }}
steps:
- name: Require the DeepSeek key
env:
KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::error::WEB_MAINTAINER_DEEPSEEK_API_KEY is not set. Create a dedicated DeepSeek key with a provider-side monthly cap and add it as that repository secret, or unset WEB_MAINTAINER_ENABLED. See web/maintainer/README.md."
exit 1
fi
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Prove the command sandbox works or stop
run: |
sudo apt-get install -y --no-install-recommends bubblewrap util-linux >/dev/null
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null || true
probe="$GITHUB_WORKSPACE/.sandbox-probe"
rm -f "$probe" /usr/local/sandbox-probe
if ! timeout 10 bash -c 'exec 3<>/dev/tcp/api.deepseek.com/443'; then
echo "::error::the runner cannot reach api.deepseek.com, so the network denial cannot be proven."
exit 1
fi
if ! /usr/bin/bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--bind "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE" --chdir "$GITHUB_WORKSPACE" -- \
bash -c 'touch .sandbox-probe && ! touch /usr/local/sandbox-probe 2>/dev/null && ! timeout 10 bash -c "exec 3<>/dev/tcp/api.deepseek.com/443" 2>/dev/null'; then
echo "::error::bubblewrap did not confine a child on this runner (writes outside the workspace or outbound network were not denied). Refusing to give a model a shell."
exit 1
fi
if [ ! -e "$probe" ] || [ -e /usr/local/sandbox-probe ]; then
echo "::error::sandbox probe left unexpected state. Refusing to give a model a shell."
exit 1
fi
rm -f "$probe"
echo "Sandbox probe passed: workspace-only writes, no outbound network." >> "$GITHUB_STEP_SUMMARY"
- name: Stage trusted tooling and the pinned CLI
run: |
mkdir -p "$RUNNER_TEMP/trusted" "$RUNNER_TEMP/home"
cp web/maintainer/allowlist.txt web/maintainer/check-allowlist.mjs "$RUNNER_TEMP/trusted/"
cp "web/maintainer/lanes/${LANE}.md" "$RUNNER_TEMP/trusted/prompt.md"
CODEWHALE_VERSION="$WEB_MAINTAINER_CLI_VERSION" CODEWHALE_INSTALL_DIR="$RUNNER_TEMP/bin" sh web/public/install.sh
"$RUNNER_TEMP/bin/codewhale" --version
- name: Probe outbound links
if: matrix.lane == 'link-health'
run: node web/maintainer/link-report.mjs "$GITHUB_WORKSPACE/.git/maintainer-input/link-report.json"
- name: Record the git state the model must not change
run: |
git config --local --list | sort > "$RUNNER_TEMP/git-config.before"
find .git/hooks -type f ! -name '*.sample' > "$RUNNER_TEMP/hooks.before"
test ! -s "$RUNNER_TEMP/hooks.before"
- name: Run the lane on DeepSeek
timeout-minutes: 25
env:
WEB_MAINTAINER_DEEPSEEK_API_KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
env -i \
PATH="$PATH" \
HOME="$RUNNER_TEMP/home" \
CODEWHALE_HOME="$RUNNER_TEMP/home/.codewhale" \
CODEWHALE_SECRET_BACKEND=file \
CODEWHALE_MEMORY=false \
CODEWHALE_TELEMETRY=false \
DEEPSEEK_API_KEY="$WEB_MAINTAINER_DEEPSEEK_API_KEY" \
"$RUNNER_TEMP/bin/codewhale" \
--no-project-config \
--skip-onboarding \
--workspace "$GITHUB_WORKSPACE" \
exec \
--provider deepseek \
--model deepseek-flash \
--reasoning-effort low \
--auto \
--sandbox workspace-write \
--max-turns 40 \
--output-format stream-json \
-- "$(cat "$RUNNER_TEMP/trusted/prompt.md")" \
> "$RUNNER_TEMP/exec.jsonl"
echo "Model run finished: $(wc -l < "$RUNNER_TEMP/exec.jsonl") stream events." >> "$GITHUB_STEP_SUMMARY"
- name: Enforce the path allowlist
id: scope
run: |
if git status --porcelain --untracked-files=all --ignored | grep -qv '^ M '; then
echo "::error::the lane left untracked, ignored, staged or non-edit entries"
git status --porcelain --untracked-files=all --ignored | grep -v '^ M '
exit 1
fi
git config --local --list | sort | diff - "$RUNNER_TEMP/git-config.before" || { echo "::error::the lane changed .git/config"; exit 1; }
find .git/hooks -type f ! -name '*.sample' | diff - "$RUNNER_TEMP/hooks.before" || { echo "::error::the lane added git hooks"; exit 1; }
git diff --name-status | node "$RUNNER_TEMP/trusted/check-allowlist.mjs" "$RUNNER_TEMP/trusted/allowlist.txt" "$LANE"
if [ -n "$(git diff --summary)" ]; then
echo "::error::the lane created, deleted or changed modes"
exit 1
fi
mkdir -p "$RUNNER_TEMP/out"
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ ! -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lane ${LANE}: no change proposed." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$(wc -c < "$RUNNER_TEMP/out/change.patch")" -gt 262144 ]; then
echo "::error::patch exceeds 256 KiB"
exit 1
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
{ echo "Lane ${LANE} proposes:"; echo '```text'; git diff --stat; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Fresh checkout for verification
if: steps.scope.outputs.changed == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
path: verify
persist-credentials: false
- name: Run the web checks on the proposal in a clean tree without secrets
if: steps.scope.outputs.changed == 'true'
env:
PATCH: ${{ runner.temp }}/out/change.patch
run: |
cd verify
git apply --index --whitespace=nowarn "$PATCH"
cd web
npm ci --no-audit --no-fund
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.scope.outputs.changed == 'true'
with:
name: web-maintainer-${{ matrix.lane }}
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
publish:
name: Open pull requests
needs: [plan, derived, lane]
if: always() && needs.plan.result == 'success' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
continue-on-error: true
with:
pattern: web-maintainer-*
path: ${{ runner.temp }}/patches
- name: Look for proposed patches
id: patches
run: |
if [ -n "$(find "$RUNNER_TEMP/patches" -name change.patch -size +0c 2>/dev/null)" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No lane proposed a change; no pull request opened." >> "$GITHUB_STEP_SUMMARY"
fi
- if: steps.patches.outputs.found == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- if: steps.patches.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Open or update one pull request per lane
if: steps.patches.outputs.found == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
ARTIFACTS_DIR: ${{ runner.temp }}/patches
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
mkdir -p "$RUNNER_TEMP/trusted"
cp web/maintainer/allowlist.txt web/maintainer/derived.txt web/maintainer/check-allowlist.mjs web/maintainer/publish-prs.sh "$RUNNER_TEMP/trusted/"
TRUSTED_DIR="$RUNNER_TEMP/trusted" bash "$RUNNER_TEMP/trusted/publish-prs.sh"
+27 -6
View File
@@ -11,24 +11,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
### Changed
- **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.
- 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)
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
### Fixed
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
## [0.10.3] - 2026-11-08
@@ -37,11 +56,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Work in progress and known limits
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
decision keeps App Server v2, the coarse Engine split and DSH all-33
compatibility in 0.10.3; the final tag waits for that full list. The near-term
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
tag. Items moved to 0.10.4 by name are marked below.
This source candidate is not the final 0.10.3 tag. There is no
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
for the full scope list. Codex App Server v2 is paused and is not part of
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
terminal sessions and background jobs owned by that Runtime thread. The
@@ -584,7 +604,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
@@ -1056,6 +1076,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
## [0.10.2] - 2026-10-22
+37 -9
View File
@@ -54,7 +54,7 @@ pub async fn run_acp_server(
.await
}
pub(crate) struct CapturedAcpFrontend {
pub struct CapturedAcpFrontend {
config: Config,
model: String,
cwd: PathBuf,
@@ -63,7 +63,7 @@ pub(crate) struct CapturedAcpFrontend {
config_path: Option<PathBuf>,
config_profile: Option<String>,
}
pub(crate) fn capture_frontend(
pub fn capture_frontend(
config: Config,
model: String,
cwd: PathBuf,
@@ -84,7 +84,7 @@ pub(crate) fn capture_frontend(
}))
}
impl CapturedAcpFrontend {
pub(crate) fn serve<'a>(
pub fn serve<'a>(
&'a self,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
@@ -385,6 +385,11 @@ impl AcpServer {
if !self.sessions.contains_key(id) {
return Err(AcpError::invalid_params("unknown sessionId"));
}
if let Some(kind) = unsupported_prompt_block(params.get("prompt")) {
return Err(AcpError::invalid_params(format!(
"unsupported prompt content block `{kind}`: this agent accepts text, resource and resource_link blocks only"
)));
}
let prompt = extract_prompt_text(params.get("prompt"))
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
@@ -407,11 +412,7 @@ impl AcpServer {
}
async fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
validate_mcp_servers(&params)?;
let cwd = params
.get("cwd")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| self.default_cwd.clone());
let cwd = absolute_cwd(&params, "session/new")?.unwrap_or_else(|| self.default_cwd.clone());
let identity = self
.config
.active_provider_identity()
@@ -506,6 +507,7 @@ impl AcpServer {
async fn load_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
validate_mcp_servers(&params)?;
absolute_cwd(&params, "session/load")?;
let requested = params
.get("sessionId")
.and_then(Value::as_str)
@@ -971,7 +973,8 @@ fn tool_call_kind(call: &PendingToolCall) -> &'static str {
Some("write" | "edit" | "patch") => "edit",
_ => "read",
},
"apply_patch" => "edit",
"apply_patch" | "write" | "edit" | "write_file" | "edit_file" => "edit",
"read" | "read_file" => "read",
"Git" => "read",
"bash" | "Bash" | "terminal/run" | "terminal/send" | "terminal/wait"
| "terminal/cancel" | "terminal/reset" => "execute",
@@ -1196,6 +1199,31 @@ fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
}
}
fn unsupported_prompt_block(prompt: Option<&Value>) -> Option<String> {
let Value::Array(blocks) = prompt? else {
return None;
};
blocks.iter().find_map(|block| {
let kind = block
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown");
content_block_text(block)
.is_none()
.then(|| kind.to_string())
})
}
fn absolute_cwd(params: &Value, method: &str) -> std::result::Result<Option<PathBuf>, AcpError> {
match params.get("cwd") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(path)) if Path::new(path).is_absolute() => Ok(Some(PathBuf::from(path))),
Some(_) => Err(AcpError::invalid_params(format!(
"{method} cwd must be an absolute path"
))),
}
}
fn content_block_text(block: &Value) -> Option<String> {
match block.get("type").and_then(Value::as_str)? {
"text" => block
+15 -25
View File
@@ -117,6 +117,7 @@ mod avatars;
mod computer_display;
mod context;
mod diagnostics;
mod external_agent_options;
mod external_agents;
mod git;
mod jobs;
@@ -3462,6 +3463,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/agents/external",
get(external_agents::list_external_agents),
)
.route(
"/v1/agents/external/{id}/options",
get(external_agent_options::get_external_agent_options),
)
.route("/v1/providers", get(list_providers))
.route("/v1/providers/{id}/models", get(list_provider_models))
.route(
@@ -9211,14 +9216,13 @@ async fn preview_patch_undo_thread_turn(
.iter()
.map(|turn| turn.turn_id.clone())
.collect();
let trusted = thread.trust_mode || thread.auto_approve;
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let (result, paths) = tokio::task::spawn_blocking(move || {
let _reservation = _reservation;
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, trusted, true, None)
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, true, None)
})
.await
.map_err(|error| ApiError::internal(format!("Patch undo preview failed: {error}")))??;
@@ -9255,10 +9259,6 @@ async fn patch_undo_thread_turn(
// Validate the selected turn/history before touching any file, so an invalid
// undo request cannot leave a half-applied workspace.
let prepared = prepare_patch_undo(&state, &id, &req).await?;
// File rollback is a workspace mutation, so it needs the trust the
// TUI's `/undo` requires. Read from the thread's own record: the
// client does not get to assert it.
let trusted = thread.trust_mode || thread.auto_approve;
let workspace = thread.workspace.clone();
// The restore points come from the dropped turns' own records, not
// from the thread's saved-session binding or a scan of the shared
@@ -9270,7 +9270,7 @@ async fn patch_undo_thread_turn(
let patch_result = tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&workspace, &dropped_turns, trusted, false, req.expected_paths.as_deref()).map(|(result, _)| result)
patch_undo_workspace_files_impl(&workspace, &dropped_turns, false, req.expected_paths.as_deref()).map(|(result, _)| result)
})
.await
.map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
@@ -9313,7 +9313,6 @@ const PATCH_UNDO_NO_RESTORE_POINT: &str = "restore_point_unavailable";
const PATCH_UNDO_RESTORE_POINT_PRUNED: &str = "restore_point_pruned";
const PATCH_UNDO_PATH_NOT_SNAPSHOTTED: &str = "path_not_snapshotted";
const PATCH_UNDO_WORKSPACE_CHANGED: &str = "workspace_changed_since_turn";
const PATCH_UNDO_UNTRUSTED: &str = "restore_requires_trust";
const PATCH_UNDO_WORKSPACE_UNAVAILABLE: &str = "workspace_unavailable";
/// One pre-turn → post-turn window of a dropped turn, resolved to the trees
@@ -9545,21 +9544,23 @@ fn no_restore_point(turn_id: &str, why: &str) -> ApiError {
/// point, or a path changed since the turn is a `409` with a stable
/// `error.code`, never a `201` that forks while the files stay changed.
///
/// `trusted` mirrors the gate the TUI's `patch_undo()` applies
/// (`yolo || trust_mode`), evaluated once a real change is known.
/// # Posture
///
/// No trust mode or Full Access is required. Undo reverses only what the
/// dropped turns' own tool calls wrote, and only while every such path still
/// holds exactly the bytes the turn left; the same ownership and later-edit
/// proofs refuse anything else, so the default posture cannot lose work the
/// turn did not make.
pub fn patch_undo_workspace_files(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
) -> Result<PatchUndoResult, ApiError> {
patch_undo_workspace_files_impl(workspace, dropped_turns, trusted, false, None)
.map(|(result, _)| result)
patch_undo_workspace_files_impl(workspace, dropped_turns, false, None).map(|(result, _)| result)
}
fn patch_undo_workspace_files_impl(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
preview: bool,
expected_paths: Option<&[String]>,
) -> Result<(PatchUndoResult, Vec<String>), ApiError> {
@@ -9816,17 +9817,6 @@ fn patch_undo_workspace_files_impl(
));
}
// Restoring is a workspace mutation. Gate it exactly where the TUI gates
// it — after a real, owned change is known — so the two surfaces cannot
// drift into "one refuses, the other half-undoes".
if !trusted {
return Err(ApiError::conflict(
"Refusing to undo workspace files outside trusted mode. \
Turn on /trust or switch this thread to Full Access, then undo again.",
)
.with_code(PATCH_UNDO_UNTRUSTED));
}
let paths: Vec<String> = to_restore
.iter()
.map(|(path, _, _)| path.display().to_string())
@@ -0,0 +1,72 @@
use std::path::PathBuf;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
use codewhale_engine::fleet::acp::AgentOptions;
use codewhale_engine::fleet::agents::ExecutorUnavailable;
use super::{ApiError, RuntimeApiState, external_agent_refusal};
const MAX_AGENT_ID_BYTES: usize = 64;
const MAX_CWD_BYTES: usize = 4096;
#[derive(Debug, Deserialize)]
pub(super) struct OptionsQuery {
cwd: Option<PathBuf>,
}
fn valid_agent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_AGENT_ID_BYTES
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub(super) async fn get_external_agent_options(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Query(query): Query<OptionsQuery>,
) -> Result<Json<AgentOptions>, ApiError> {
if !valid_agent_id(&id) {
return Err(ApiError::bad_request(
"the agent id must name an agent from GET /v1/agents/external",
));
}
let cwd = query
.cwd
.ok_or_else(|| ApiError::bad_request("cwd is required"))?;
let text = cwd.to_string_lossy();
if text.len() > MAX_CWD_BYTES || text.chars().any(char::is_control) || !cwd.is_absolute() {
return Err(ApiError::bad_request(
"cwd must be an absolute directory path",
));
}
let probe = cwd.clone();
let is_dir = tokio::task::spawn_blocking(move || probe.is_dir())
.await
.map_err(|_| ApiError::internal("cwd check failed"))?;
if !is_dir {
return Err(ApiError::not_found(
"cwd is not a directory on this computer",
));
}
match state
.runtime_threads
.external_agent_options(&id, &cwd)
.await
{
Ok(options) => Ok(Json(options)),
Err(error) => {
let mut refusal = external_agent_refusal(&error);
if let Some(refusal) = refusal.as_mut()
&& refusal.code == Some(ExecutorUnavailable::UNKNOWN_AGENT)
{
refusal.status = axum::http::StatusCode::NOT_FOUND;
}
Err(refusal.unwrap_or_else(|| ApiError::internal(error.to_string())))
}
}
}
+45 -108
View File
@@ -18,6 +18,8 @@ use uuid::Uuid;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
#[cfg(unix)]
mod external_agent_options;
mod headless_catalog;
#[cfg(unix)]
mod owner_bootstrap;
@@ -8192,18 +8194,13 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
let snapshot_count = repo.list(usize::MAX)?.len();
let (preview, paths) =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, true, None)
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, None)
.expect("a trusted preview should succeed");
assert!(!preview.files_restored);
assert_eq!(paths, vec!["a.txt", "made/new.txt"]);
let refused = patch_undo_workspace_files_impl(
&workspace,
std::slice::from_ref(&turn),
true,
false,
Some(&[]),
)
.expect_err("an unreviewed file plan must refuse before restoring");
let refused =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), false, Some(&[]))
.expect_err("an unreviewed file plan must refuse before restoring");
assert_eq!(refused.status, StatusCode::CONFLICT);
assert_eq!(refused.code, Some("undo_selection_changed"));
@@ -8212,7 +8209,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
assert_eq!(fs::read_to_string(&other)?, "later work");
assert_eq!(repo.list(usize::MAX)?.len(), snapshot_count);
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect("a trusted rollback should succeed");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&a)?, "before", "both writes are undone");
@@ -8232,7 +8229,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
// Undoing again finds the files already at their pre-turn state.
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect("already restored is provably nothing to do");
assert!(!again.files_restored);
Ok(())
@@ -8266,7 +8263,7 @@ fn patch_undo_helper_refuses_to_overwrite_later_edits() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
let err = patch_undo_workspace_files(&workspace, &[turn], true)
let err = patch_undo_workspace_files(&workspace, &[turn])
.expect_err("a later edit must not be overwritten");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
@@ -8322,7 +8319,7 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
),
];
for (turn, code) in cases {
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err(&turn.turn_id);
assert_eq!(err.status, StatusCode::CONFLICT, "{}", turn.turn_id);
assert_eq!(err.code, Some(code), "{}: {}", turn.turn_id, err.message);
@@ -8330,20 +8327,16 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
assert_eq!(fs::read_to_string(&a)?, "after");
}
let chat_only = patch_undo_workspace_files(
&workspace,
&[dropped_turn("chat", false, Vec::new())],
false,
)
.expect("a turn that ran no tools changed no files");
let chat_only =
patch_undo_workspace_files(&workspace, &[dropped_turn("chat", false, Vec::new())])
.expect("a turn that ran no tools changed no files");
assert!(!chat_only.files_restored);
// A prune that drops the restore point's tree fails closed too.
let receipts = shell_turn_receipts(&repo, &pre, &post, "thr_a");
repo.prune_older_than(Duration::ZERO)?;
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)], true)
.expect_err("a pruned restore point cannot be restored");
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)])
.expect_err("a pruned restore point cannot be restored");
assert_eq!(err.code, Some(PATCH_UNDO_RESTORE_POINT_PRUNED));
assert_eq!(fs::read_to_string(&a)?, "after");
Ok(())
@@ -8387,7 +8380,6 @@ fn patch_undo_helper_survives_a_prune_that_rewrites_commit_ids() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
true,
)
.expect("the tree still resolves the restore point");
assert!(restored.files_restored);
@@ -8481,7 +8473,6 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(gap_writer))],
true,
)
.expect_err("another writer's change must not be reverted");
assert_eq!(err.status, StatusCode::CONFLICT);
@@ -8508,12 +8499,9 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
None,
Some(changed_between(&repo, &post_tool, &clean_post)),
);
let restored = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, clean.clone())],
true,
)
.expect("the turn's own write restores");
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, clean.clone())])
.expect("the turn's own write restores");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8521,9 +8509,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut shell = clean.clone();
shell[1].write_paths = None;
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)], true)
.expect("an undeclared span owns its changes");
let restored = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)])
.expect("an undeclared span owns its changes");
assert!(restored.files_restored);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8531,9 +8518,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut unknown = clean;
unknown[2].changed_paths = None;
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)], true)
.expect_err("an unaccounted span fails closed");
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)])
.expect_err("an unaccounted span fails closed");
assert_eq!(
err.code,
Some(PATCH_UNDO_NO_RESTORE_POINT),
@@ -8594,7 +8580,6 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(&[declared]))],
true,
)
.expect_err(declared);
assert_eq!(err.status, StatusCode::CONFLICT, "{declared}");
@@ -8609,24 +8594,24 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
// The same declared write from a call recorded as failed wrote nothing.
let mut failed = dropped_turn("turn_1", true, receipts(&[".env.local"]));
failed.unrun_tool_calls.insert("call-1".to_string());
let result = patch_undo_workspace_files(&workspace, &[failed], true)
.expect("a failed call wrote nothing");
let result =
patch_undo_workspace_files(&workspace, &[failed]).expect("a failed call wrote nothing");
assert!(!result.files_restored);
// Declared through the turn's items rather than a receipt.
let mut from_items = dropped_turn("turn_1", true, receipts(&[]));
from_items.declared_writes.push(".env.local".to_string());
let err = patch_undo_workspace_files(&workspace, &[from_items], true)
let err = patch_undo_workspace_files(&workspace, &[from_items])
.expect_err("an item-declared ignored write refuses too");
assert_eq!(err.code, Some(PATCH_UNDO_PATH_NOT_SNAPSHOTTED));
Ok(())
}
/// The gate the TUI's `/undo` applies, stated as a contract: an untrusted
/// thread may not roll files back. When there *is* something to roll back the
/// whole undo aborts — nothing is changed, and the caller must not fork the
/// conversation either.
/// Default posture (no trust mode, no Full Access): the Runtime does not
/// gate on trust at all, because the ownership and later-edit proofs already
/// decide what may be reverted. A later edit in the same workspace still
/// refuses and leaves every byte alone.
#[test]
fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Result<()> {
fn patch_undo_helper_restores_in_the_default_posture_and_still_refuses_later_edits() -> Result<()> {
if git_missing() {
return Ok(());
}
@@ -8651,69 +8636,20 @@ fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Resu
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), false)
.expect_err("an untrusted rollback must be refused");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_UNTRUSTED));
assert!(
err.message.contains("outside trusted mode"),
"got: {}",
err.message
);
assert_eq!(
fs::read_to_string(&file)?,
"after",
"a refusal must leave the workspace exactly as it was"
);
fs::write(&file, "edited later by the person")?;
let refused = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err("a later edit must not be overwritten");
assert_eq!(refused.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
assert_eq!(fs::read_to_string(&file)?, "edited later by the person");
// The identical call once trusted performs the rollback.
let restored = patch_undo_workspace_files(&workspace, &[turn], true)
.expect("a trusted rollback should succeed");
fs::write(&file, "after")?;
let restored = patch_undo_workspace_files(&workspace, &[turn])
.expect("the turn's own change restores without trust");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&file)?, "before");
Ok(())
}
/// ...but with provably nothing to roll back, an untrusted undo is still
/// allowed: the conversation half needs no trust, and skipping it would make
/// Undo useless in Ask / Auto-Review for no safety gain.
#[test]
fn patch_undo_helper_allows_an_untrusted_undo_with_nothing_to_roll_back() -> Result<()> {
if git_missing() {
return Ok(());
}
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
fs::create_dir_all(&home)?;
let _home = EnvVarGuard::set("HOME", &home);
let workspace = root.path().join("workspace");
fs::create_dir_all(&workspace)?;
let repo = codewhale_snapshot::SnapshotRepo::open_or_init(&workspace)?;
let file = workspace.join("a.txt");
fs::write(&file, "only-state")?;
let pre = repo.take_snapshot("pre-turn:1", Some("thr_a"))?;
// The turn ran a tool but changed nothing.
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
let result = patch_undo_workspace_files(
&workspace,
&[dropped_turn(
"turn_1",
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
false,
)
.expect("nothing to roll back is not a refusal");
assert!(!result.files_restored);
assert!(result.summary.is_some());
assert_eq!(fs::read_to_string(&file)?, "only-state");
Ok(())
}
fn sha256_of(path: &FsPath) -> String {
format!(
"sha256:{}",
@@ -26506,8 +26442,9 @@ mod thread_snapshot_ownership {
/// An undo that cannot restore files fails honestly: snapshots disabled,
/// a legacy seeded turn with tool calls and no receipts, a restore point
/// pruned from the store, and an untrusted thread. Each is a 409 with a
/// code, no fork, and unchanged files.
/// pruned from the store. Each is a 409 with a code, no fork, and
/// unchanged files. A default-posture thread (no trust, no Full Access)
/// with an intact restore point is not among them: it restores.
#[tokio::test]
async fn undo_that_cannot_restore_files_is_refused() -> Result<()> {
let _env = lock_test_env();
@@ -26590,7 +26527,7 @@ mod thread_snapshot_ownership {
assert_eq!(fx.thread_count().await?, before + 1);
drop(fx);
// Snapshots on again: a pruned restore point and an untrusted thread.
// Snapshots on again: a pruned restore point, then a default-posture thread.
let root = test_root("honest-on");
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
let Some(fx) = fixture(&root, TestServerOverrides::default()).await? else {
@@ -26612,9 +26549,9 @@ mod thread_snapshot_ownership {
.await?
.error_for_status()?;
let before = fx.thread_count().await?;
refused(&fx, untrusted, "restore_requires_trust").await?;
assert_eq!(fx.read("u.txt").as_deref(), Some("u"));
assert_eq!(fx.thread_count().await?, before);
fx.undo_restoring(&untrusted, 0).await?;
assert_eq!(fx.read("u.txt"), None);
assert_eq!(fx.thread_count().await?, before + 1);
let _ = &fx.root;
Ok(())
}
@@ -0,0 +1,184 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
fn options_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../engine/tests/fixtures/acp-options-agent.mjs")
.canonicalize()
.expect("the options fixture agent")
}
fn with_query(base: &str, pairs: &[(&str, &str)]) -> reqwest::Url {
let mut url = reqwest::Url::parse(base).expect("a url");
for (key, value) in pairs {
url.query_pairs_mut().append_pair(key, value);
}
url
}
fn received(workspace: &Path) -> Vec<Value> {
fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_for_usage_total(
manager: &SharedRuntimeThreadManager,
thread_id: &str,
) -> Result<RuntimeEventRecord> {
for _ in 0..600 {
let events = manager.events_since_async(thread_id, None).await?;
if let Some(event) = events.into_iter().find(|event| {
event.event == "turn.agent_usage" && event.payload["kind"] == "turn_total"
}) {
return Ok(event);
}
sleep(Duration::from_millis(50)).await;
}
bail!("the agent's usage was never recorded")
}
#[tokio::test]
async fn the_options_route_offers_the_agents_choices_and_a_chosen_one_reaches_it() -> Result<()> {
let _env = lock_test_env();
let _fixture = EnvVarGuard::set("CODEWHALE_ACP_FIXTURE_AGENT", options_fixture());
let Some((addr, manager, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let workspace = tempfile::tempdir()?;
let cwd = workspace.path().canonicalize()?;
let url = format!("http://{addr}/v1/agents/external/fixture/options");
let offered: Value = client
.get(with_query(&url, &[("cwd", cwd.to_str().unwrap())]))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(offered["agent"]["id"], "fixture");
let controls = offered["controls"].as_array().unwrap();
let model = controls.iter().find(|c| c["id"] == "model").unwrap();
let values: Vec<&str> = model["choices"]
.as_array()
.unwrap()
.iter()
.map(|c| c["value"].as_str().unwrap())
.collect();
assert!(values.len() >= 2, "{values:?}");
assert!(controls.iter().any(|c| c["id"] == "mode"));
assert!(
controls.iter().any(|c| c["id"] == "config:verbose"
&& c["unavailable"]["code"] == "control_type_unsupported")
);
assert!(
offered["actions"]
.as_array()
.unwrap()
.iter()
.any(|a| a["action"] == "steering" && a["supported"] == false)
);
let leaked = fs::read_to_string(cwd.join("options-env.json"))?;
assert!(!leaked.contains("CODEWHALE_ACP_FIXTURE_AGENT"));
for (query, status, code) in [
(vec![], HttpStatus::BAD_REQUEST, None),
(vec![("cwd", "relative/dir")], HttpStatus::BAD_REQUEST, None),
(
vec![("cwd", "/definitely/not/here")],
HttpStatus::NOT_FOUND,
None,
),
] {
let response = client.get(with_query(&url, &query)).send().await?;
assert_eq!(response.status(), status, "{query:?}");
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"].as_str(), code, "{body}");
}
let unknown = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/nope/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert_eq!(unknown.status(), HttpStatus::NOT_FOUND);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_agent");
let bad_id = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/..%2Fsh/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert!(bad_id.status().is_client_error());
let chosen = values[1];
let created: Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"permission_posture": "ask",
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": chosen}}
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"].as_str().unwrap().to_string();
let refused = client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "x", "model": "other"}))
.send()
.await?;
assert_eq!(refused.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = refused.json().await?;
assert_eq!(body["error"]["code"], "unsupported_control");
client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "hello"}))
.send()
.await?
.error_for_status()?;
let total = wait_for_usage_total(&manager, &thread_id).await?;
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
let log = received(&cwd);
let prompt = log
.iter()
.find(|e| e["method"] == "session/prompt")
.unwrap();
assert_eq!(prompt["state"]["model"], chosen);
let stale = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": "model-z"}}
}))
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let stale_id = stale["id"].as_str().unwrap();
let response = client
.post(format!("http://{addr}/v1/threads/{stale_id}/turns"))
.json(&json!({"prompt": "x"}))
.send()
.await?;
assert_eq!(response.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"], "choice_not_offered");
handle.abort();
Ok(())
}
+410
View File
@@ -0,0 +1,410 @@
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
const STEP: Duration = Duration::from_secs(30);
fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
struct Client {
lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
write: WriteHalf<DuplexStream>,
next_id: u64,
}
impl Client {
async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
Ok(())
}
async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
Ok(serde_json::from_str(&line)?)
}
async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
#[test]
fn editor_drives_a_session_through_permission_cancel_and_load() -> 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), journey())
.await
.map_err(|_| anyhow!("journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("journey thread panicked"))?
}
async fn journey() -> Result<()> {
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
let config = fixture_config();
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
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,
},
plugins,
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"provider-id-not-authority",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"provider-id-two",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("resumed in the real session"),
]));
manager.set_test_model_client(provider.clone());
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
sessions_dir,
None,
None,
)?;
let connect = || {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
},
agent_read,
agent_write,
)
};
let (mut client, agent_read, agent_write) = connect();
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{"terminal":false}}),
)
.await?;
let capabilities = &initialized["result"]["agentCapabilities"];
assert_eq!(initialized["result"]["protocolVersion"], 1);
assert_eq!(capabilities["loadSession"], true);
assert_eq!(capabilities["promptCapabilities"]["image"], false);
assert_eq!(capabilities["promptCapabilities"]["audio"], false);
assert_eq!(capabilities["mcpCapabilities"]["http"], false);
assert_eq!(capabilities["mcpCapabilities"]["sse"], false);
assert!(capabilities["sessionCapabilities"]["list"].is_object());
assert!(capabilities["sessionCapabilities"].get("resume").is_none());
let (relative, _) = client
.request("session/new", json!({"cwd":"relative/dir"}))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (mcp, _) = client
.request(
"session/new",
json!({"cwd":workspace,"mcpServers":[{"name":"x","command":"x","args":[],"env":[]}]}),
)
.await?;
assert_eq!(mcp["error"]["code"], -32602);
let (resume, _) = client
.request("session/resume", json!({"sessionId":"x","cwd":workspace}))
.await?;
assert_eq!(resume["error"]["code"], -32601);
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();
let (image, _) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":[
{"type":"text","text":"look"},
{"type":"image","data":"AAAA","mimeType":"image/png"}
]}),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
assert_eq!(
provider.call_count(),
0,
"a refused prompt never reaches the provider"
);
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":"write under Ask"}]}}),
)
.await?;
let (permission, before) = client.until_permission().await?;
assert_eq!(permission["params"]["sessionId"], session.as_str());
assert_eq!(permission["params"]["toolCall"]["kind"], "edit");
let kinds: Vec<_> = permission["params"]["options"]
.as_array()
.unwrap()
.iter()
.map(|option| option["kind"].as_str().unwrap().to_string())
.collect();
assert_eq!(kinds, ["allow_once", "reject_once"]);
assert!(
!updates(&before, "tool_call").is_empty(),
"the tool call is announced before approval is requested"
);
assert!(
updates(&before, "tool_call_update")
.iter()
.all(|frame| frame.pointer("/params/update/status") != Some(&json!("in_progress"))),
"nothing dispatches before the person decides"
);
assert!(!workspace.join("allowed.txt").exists());
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
let (done, after) = client.until_response(prompt_id).await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert_eq!(
std::fs::read_to_string(workspace.join("allowed.txt"))?,
"approved"
);
assert!(
updates(&after, "tool_call_update")
.iter()
.any(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
);
assert_eq!(
text_of(&after, "agent_message_chunk"),
"settled after approval"
);
client.next_id += 1;
let cancel_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":"write then cancel"}}),
)
.await?;
let (second, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":session}}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":second["id"],
"result":{"outcome":{"outcome":"cancelled"}}}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
assert!(!workspace.join("cancelled.txt").exists());
let (listed, _) = client
.request("session/list", json!({"cwd":workspace}))
.await?;
assert!(
listed["result"]["sessions"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["sessionId"] == session.as_str())
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(session)
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
let thread = manager
.list_threads(Default::default(), None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow!("no runtime thread was created"))?;
let detail = manager.get_thread_detail(&thread.id).await?;
assert!(
detail.pending_approvals.is_empty(),
"cancel settles the approval"
);
assert_eq!(detail.turns.len(), 2);
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
let (mut client, agent_read, agent_write) = connect();
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let (missing, _) = client
.request(
"session/load",
json!({"sessionId":"no-such-session","cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
json!({"sessionId":session,"cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(loaded["result"]["sessionId"], session.as_str());
assert!(text_of(&replay, "user_message_chunk").contains("write under Ask"));
assert!(text_of(&replay, "agent_message_chunk").contains("settled after approval"));
assert!(!updates(&replay, "tool_call").is_empty());
let (resumed, frames) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":"continue"}),
)
.await?;
assert_eq!(resumed["result"]["stopReason"], "end_turn");
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"resumed in the real session"
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
let requests = provider.captured_requests();
let last = serde_json::to_string(requests.last().ok_or_else(|| anyhow!("no model request"))?)?;
assert!(
last.contains("write under Ask") && last.contains("settled after approval"),
"the loaded session continues from its real history"
);
manager.shutdown_and_wait().await?;
Ok(())
}
+7 -8
View File
@@ -265,17 +265,16 @@ Common forwarded flags:
--output-format <FORMAT> Output format: text or stream-json
--hooks Opt in to configured hooks (tool_call_before, shell_env)
Plain `codewhale exec` is a one-shot model response: one Engine turn with the
same system prompt as every other run, and no tools. Use `--auto` for
non-interactive agent-with-tools execution. Tools are offered only with
`--auto`, `--yolo`, `--allowed-tools`, or when resuming a session; limits such
as `--max-turns`, `--disallowed-tools` or `--sandbox`, and the output format,
never add tools. A reply cut off at the provider's output limit is continued
in the same turn, at most 8 model steps unless `--max-turns` says otherwise.
Plain `codewhale exec` runs the agent with the normal tool surface under the
configured sandbox and approval posture. Use `--no-tools` for one answer with
no tools. `--auto` selects automatic approvals and Full Access by default. A
reply cut off at the provider's output limit is continued in the same turn, at
most 8 model steps unless `--max-turns` says otherwise.
--auto defaults to Full Access: no shell sandbox and trust mode on. Use --sandbox workspace-write to keep the shell sandbox and file-tool workspace restrictions; stricter configured policies also apply. A denied sandbox retry requires --allow-sandbox-elevation or --sandbox danger-full-access.
Any --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Plain answer-only text/--json runs are not saved unless compaction needs recovery.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Answer-only (--no-tools) text/--json runs are not saved unless compaction needs recovery.
Put flags before the prompt. Put -- before a prompt starting with - or containing literal exec flags.
Piped stdin is the prompt when no prompt argument is given, or is appended to it; with a prompt argument, stdin that sends nothing for 2 seconds is ignored with a note on stderr.
")]
Exec(TuiPassthroughArgs),
/// Manage durable Agent fleet runs.
+20 -1
View File
@@ -13580,6 +13580,23 @@ var init_rpc = __esm({
}
});
// src/dsh/session-prompt.ts
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_protocol();
init_rpc();
}
});
// src/dsh/adapters.ts
var init_adapters = __esm({
"src/dsh/adapters.ts"() {
"use strict";
init_session_prompt();
}
});
// node_modules/@deepseek-ai/dsh-util-values/lib/index.js
function isWhitespace(c) {
return c === " " || c === "\n" || c === "\r" || c === " ";
@@ -14313,11 +14330,12 @@ var init_commands = __esm({
// src/root.ts
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_composition();
init_adapters();
init_protocol();
init_rpc();
init_dsh_message_compat();
@@ -14339,6 +14357,7 @@ var init_root = __esm({
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ownerStorage = new AsyncLocalStorage2();
}
});
+119 -64
View File
@@ -16425,6 +16425,82 @@ var init_rpc = __esm({
}
});
// src/dsh/session-prompt.ts
function checkpointCall(host2, ctx, session) {
const invocation = host2.liveInvocation(ctx);
const ticket = invocation?.ticket;
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
}
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
init_protocol();
init_rpc();
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
sessionsAdapter = {
service: "sessions",
define(host2) {
class SessionsService extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const ctx = this.ctx;
const call = checkpointCall(host2, ctx, session);
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const owner = call.owner;
const live = () => checkpointCall(host2, ctx, session) === call && !call.signal.aborted;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host2.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
Object.freeze(SessionsService.prototype);
return SessionsService;
}
};
systemPromptAdapter = {
service: "systemPrompt",
define() {
class SystemPromptService extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
}
Object.freeze(SystemPromptService.prototype);
return SystemPromptService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
"src/dsh/adapters.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_session_prompt();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter];
}
});
// src/shims/core.ts
function failureFor(error) {
if (error instanceof CoreCallError) return error;
@@ -17300,12 +17376,13 @@ function pendingFibers(fiber) {
}
return out;
}
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_composition();
init_adapters();
init_lib2();
init_protocol();
init_rpc();
@@ -17329,11 +17406,12 @@ var init_root = __esm({
OWNER = /* @__PURE__ */ Symbol.for("codewhale.extension-host.owner");
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ACTIVATE_DEADLINE_MS = 5e3;
DISPOSE_DEADLINE_MS = 2e3;
ownerStorage = new AsyncLocalStorage2();
HostRoot = class {
constructor(rpc2, tier) {
constructor(rpc2, tier, adapters = DSH_ADAPTERS) {
this.rpc = rpc2;
this.tier = tier;
const root = new Context();
@@ -17471,28 +17549,6 @@ var init_root = __esm({
return this.api().delete(key);
}
}
class SessionsShim extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const invocation = host2.toolInvocations.getStore();
const owner = this.ctx[OWNER];
const live = () => invocation && invocation.active && !invocation.signal.aborted && invocation.owner === owner && owner?.state === "active" && !owner.disposing && invocation.ticket !== void 0 && session === invocation.session && session !== void 0;
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const call = invocation;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host2.rpc.request("session/checkpoint", { owner: call.owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
class UserQuestionsShim extends Service {
constructor(ctx) {
super(ctx, "userQuestions");
@@ -17747,49 +17803,47 @@ var init_root = __esm({
super(ctx, "agents");
}
}
class SystemPromptShim extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
}
}
Object.freeze(SubagentsShim.prototype);
Object.freeze(AgentsShim.prototype);
Object.freeze(SystemPromptShim.prototype);
Object.freeze(UserQuestionsShim.prototype);
Object.freeze(SessionsShim.prototype);
Object.freeze(StorageShim.prototype);
shimClasses.set("tools", ToolsShim);
shimClasses.set("commands", CommandsShim);
shimClasses.set("prompt", PromptShim);
shimClasses.set("storage", StorageShim);
shimClasses.set("sessions", SessionsShim);
shimClasses.set("userQuestions", UserQuestionsShim);
shimClasses.set("subagents", SubagentsShim);
shimClasses.set("agents", AgentsShim);
shimClasses.set("systemPrompt", SystemPromptShim);
shimClasses.set("skills", SkillsShim);
shimClasses.set("avatars", AvatarsShim);
shimClasses.set("mcp", McpShim);
shimClasses.set("shellHooks", ShellHooksShim);
root.plugin(ToolsShim);
root.plugin(CommandsShim);
root.plugin(PromptShim);
root.plugin(StorageShim);
root.plugin(SessionsShim);
root.plugin(UserQuestionsShim);
root.plugin(SubagentsShim);
root.plugin(AgentsShim);
root.plugin(SystemPromptShim);
root.plugin(SkillsShim);
root.plugin(AvatarsShim);
root.plugin(McpShim);
root.plugin(ShellHooksShim);
const adapterHost = {
tier,
rpc: rpc2,
ownerOf: (ctx) => ctx[OWNER],
liveInvocation: (ctx) => {
const invocation = host2.toolInvocations.getStore();
const owner = ctx[OWNER];
return invocation && invocation.active && !invocation.signal.aborted && owner !== void 0 && invocation.owner === owner && owner.state === "active" && !owner.disposing ? invocation : void 0;
},
log: (level, message, owner) => host2.log(level, message, owner)
};
const shims = /* @__PURE__ */ new Map([
["tools", ToolsShim],
["commands", CommandsShim],
["prompt", PromptShim],
["storage", StorageShim],
["userQuestions", UserQuestionsShim],
["subagents", SubagentsShim],
["agents", AgentsShim],
["skills", SkillsShim],
["avatars", AvatarsShim],
["mcp", McpShim],
["shellHooks", ShellHooksShim]
]);
for (const adapter of adapters) {
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`);
shims.set(adapter.service, adapter.define(adapterHost));
}
const mismatched = [
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`)
];
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join("; ")}`);
for (const [name, shim] of shims) {
shimClasses.set(name, shim);
root.plugin(shim);
}
shimClasses.set("loader", ReviewedLoader);
installCompositionLoader(root);
}
@@ -17910,7 +17964,8 @@ var init_root = __esm({
const plugin = pickPlugin(module);
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name));
if (missing.length > 0) {
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase`);
const owned = missing.filter((name) => REFUSED_SERVICES.has(name));
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase` + (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(", ")} belong${owned.length === 1 ? "s" : ""} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? "it" : "them"} yet` : ""));
}
const ownerCtx = this.root.extend({ [OWNER]: owner });
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}));
@@ -13256,6 +13256,23 @@ var init_rpc = __esm({
}
});
// src/dsh/session-prompt.ts
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_protocol();
init_rpc();
}
});
// src/dsh/adapters.ts
var init_adapters = __esm({
"src/dsh/adapters.ts"() {
"use strict";
init_session_prompt();
}
});
// node_modules/@deepseek-ai/dsh-util-values/lib/index.js
function isWhitespace(c) {
return c === " " || c === "\n" || c === "\r" || c === " ";
@@ -13989,11 +14006,12 @@ var init_commands = __esm({
// src/root.ts
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_composition();
init_adapters();
init_protocol();
init_rpc();
init_dsh_message_compat();
@@ -14015,6 +14033,7 @@ var init_root = __esm({
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ownerStorage = new AsyncLocalStorage2();
}
});
@@ -0,0 +1,20 @@
import type { Service } from '@deepseek-ai/cordis'
import type { OwnerRecord, ToolInvocation } from '../root.ts'
import type { RpcPeer } from '../rpc.ts'
import type { HostTier } from '../tier.ts'
import { sessionsAdapter, systemPromptAdapter } from './session-prompt.ts'
export interface AdapterHost {
readonly tier: HostTier
readonly rpc: RpcPeer
ownerOf(ctx: unknown): OwnerRecord | undefined
liveInvocation(ctx: unknown): ToolInvocation | undefined
log(level: 'info' | 'warn' | 'error', message: string, owner?: OwnerRecord): void
}
export interface DshAdapter {
readonly service: string
define(host: AdapterHost): new (ctx: any) => Service
}
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter]
@@ -0,0 +1,58 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { OwnerRecord, ToolInvocation } from '../root.ts'
import type { AdapterHost, DshAdapter } from './adapters.ts'
const CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context"
function checkpointCall(host: AdapterHost, ctx: unknown, session: unknown): ToolInvocation | undefined {
const invocation = host.liveInvocation(ctx)
const ticket = invocation?.ticket
return invocation !== undefined && typeof ticket === 'string' && ticket.length > 0
&& session !== undefined && session === invocation.session
? invocation
: undefined
}
export const sessionsAdapter: DshAdapter = {
service: 'sessions',
define(host) {
class SessionsService extends Service {
constructor(ctx: any) { super(ctx, 'sessions') }
async flush(session: unknown): Promise<boolean> {
const ctx = this.ctx
const call = checkpointCall(host, ctx, session)
if (call === undefined) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
const owner: OwnerRecord = call.owner
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: owner.ref, ticket: call.ticket }, call.signal)
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
return result
}
const result = call.pending.then(run)
call.pending = result.then(() => undefined, () => undefined)
return result
}
}
Object.freeze(SessionsService.prototype)
return SessionsService
},
}
export const systemPromptAdapter: DshAdapter = {
service: 'systemPrompt',
define() {
class SystemPromptService extends Service {
constructor(ctx: any) { super(ctx, 'systemPrompt') }
section() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
}
Object.freeze(SystemPromptService.prototype)
return SystemPromptService
},
}
+47 -57
View File
@@ -1,4 +1,5 @@
import { ReviewedLoader, installCompositionLoader } from './dsh/composition.ts'
import { DSH_ADAPTERS, type AdapterHost, type DshAdapter } from './dsh/adapters.ts'
/**
* The Cordis root that plugin fibers run under.
*
@@ -68,6 +69,7 @@ export const REFUSED_SERVICES = new Set(compatibility.refused_services)
/** Services the root provides; `inject` of anything else fails activation. */
const PROVIDED_SERVICES = new Set(Object.values(compatibility.provided_services).flat())
const CORDIS_SERVICES = new Set(compatibility.provided_services.cordis)
const ACTIVATE_DEADLINE_MS = 5_000
const DISPOSE_DEADLINE_MS = 2_000
@@ -134,7 +136,7 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
}
interface ToolInvocation {
export interface ToolInvocation {
owner: OwnerRecord
session: object | undefined
agent: object | undefined
@@ -165,6 +167,7 @@ export class HostRoot {
private readonly rpc: RpcPeer,
/** The trust tier this process serves; it activates only owners of that tier. */
readonly tier: HostTier,
adapters: readonly DshAdapter[] = DSH_ADAPTERS,
) {
const root: any = new Context()
this.root = root
@@ -299,28 +302,6 @@ export class HostRoot {
set(key: string, value: Json) { return this.api().set(key, value) }
delete(key: string) { return this.api().delete(key) }
}
class SessionsShim extends Service {
constructor(ctx: any) { super(ctx, 'sessions') }
async flush(session: unknown): Promise<boolean> {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
const live = () => invocation && invocation.active && !invocation.signal.aborted
&& invocation.owner === owner && owner?.state === 'active' && !owner.disposing
&& invocation.ticket !== undefined && session === invocation.session && session !== undefined
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
const call = invocation!
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: call.owner.ref, ticket: call.ticket }, call.signal)
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
return result
}
const result = call.pending.then(run)
call.pending = result.then(() => undefined, () => undefined)
return result
}
}
class UserQuestionsShim extends Service {
constructor(ctx: any) { super(ctx, 'userQuestions') }
async ask(request: { agent?: unknown; questions: unknown; signal?: AbortSignal; wait?: unknown }) {
@@ -567,43 +548,50 @@ export class HostRoot {
class AgentsShim extends Service {
constructor(ctx: any) { super(ctx, 'agents') }
}
class SystemPromptShim extends Service {
constructor(ctx: any) { super(ctx, 'systemPrompt') }
section() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
}
Object.freeze(SubagentsShim.prototype)
Object.freeze(AgentsShim.prototype)
Object.freeze(SystemPromptShim.prototype)
Object.freeze(UserQuestionsShim.prototype)
Object.freeze(SessionsShim.prototype)
Object.freeze(StorageShim.prototype)
shimClasses.set('tools', ToolsShim)
shimClasses.set('commands', CommandsShim)
shimClasses.set('prompt', PromptShim)
shimClasses.set('storage', StorageShim)
shimClasses.set('sessions', SessionsShim)
shimClasses.set('userQuestions', UserQuestionsShim)
shimClasses.set('subagents', SubagentsShim)
shimClasses.set('agents', AgentsShim)
shimClasses.set('systemPrompt', SystemPromptShim)
shimClasses.set('skills', SkillsShim)
shimClasses.set('avatars', AvatarsShim)
shimClasses.set('mcp',McpShim)
shimClasses.set('shellHooks',ShellHooksShim)
root.plugin(ToolsShim)
root.plugin(CommandsShim)
root.plugin(PromptShim)
root.plugin(StorageShim)
root.plugin(SessionsShim)
root.plugin(UserQuestionsShim)
root.plugin(SubagentsShim)
root.plugin(AgentsShim)
root.plugin(SystemPromptShim)
root.plugin(SkillsShim)
root.plugin(AvatarsShim)
root.plugin(McpShim)
root.plugin(ShellHooksShim)
const adapterHost: AdapterHost = {
tier,
rpc,
ownerOf: (ctx) => (ctx as { [OWNER]?: OwnerRecord })[OWNER],
liveInvocation: (ctx) => {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (ctx as { [OWNER]?: OwnerRecord })[OWNER]
return invocation && invocation.active && !invocation.signal.aborted && owner !== undefined
&& invocation.owner === owner && owner.state === 'active' && !owner.disposing
? invocation
: undefined
},
log: (level, message, owner) => host.log(level, message, owner),
}
const shims = new Map<string, new (ctx: any) => Service>([
['tools', ToolsShim],
['commands', CommandsShim],
['prompt', PromptShim],
['storage', StorageShim],
['userQuestions', UserQuestionsShim],
['subagents', SubagentsShim],
['agents', AgentsShim],
['skills', SkillsShim],
['avatars', AvatarsShim],
['mcp', McpShim],
['shellHooks', ShellHooksShim],
])
for (const adapter of adapters) {
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`)
shims.set(adapter.service, adapter.define(adapterHost))
}
const mismatched = [
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`),
]
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join('; ')}`)
for (const [name, shim] of shims) {
shimClasses.set(name, shim)
root.plugin(shim)
}
shimClasses.set('loader', ReviewedLoader)
installCompositionLoader(root)
}
@@ -714,7 +702,9 @@ export class HostRoot {
const plugin = pickPlugin(module)
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name))
if (missing.length > 0) {
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`)
const owned = missing.filter((name) => REFUSED_SERVICES.has(name))
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`
+ (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(', ')} belong${owned.length === 1 ? 's' : ''} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? 'it' : 'them'} yet` : ''))
}
const ownerCtx = this.root.extend({ [OWNER]: owner })
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}))
@@ -0,0 +1,59 @@
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))
const rpc = { request: async () => ({}), notify: () => {}, tier: 'plugin' }
let HostRoot
let Service
let DSH_ADAPTERS
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-adapters-'))
const outfile = join(dir, 'root.mjs')
await build({
entryPoints: [join(here, '../src/root.ts')],
outfile,
bundle: true,
platform: 'node',
format: 'esm',
packages: 'external',
absWorkingDir: join(here, '..'),
logLevel: 'silent',
})
const adapters = join(dir, 'adapters.mjs')
await build({
entryPoints: [join(here, '../src/dsh/adapters.ts')],
outfile: adapters,
bundle: true,
platform: 'node',
format: 'esm',
packages: 'external',
absWorkingDir: join(here, '..'),
logLevel: 'silent',
})
;({ HostRoot } = await import(pathToFileURL(outfile).href))
;({ DSH_ADAPTERS } = await import(pathToFileURL(adapters).href))
;({ Service } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
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'])
assert.ok(new HostRoot(rpc, 'plugin'))
assert.ok(new HostRoot(rpc, 'builtin'))
})
test('an adapter for a service compatibility.json does not declare is refused at construction', () => {
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('llm')]), /llm is installed but not declared in compatibility\.json/)
})
test('an adapter may not replace a service the root already provides', () => {
assert.throws(() => new HostRoot(rpc, 'plugin', [adapterFor('storage')]), /duplicates a service the host root already provides/)
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('sessions')]), /duplicates a service/)
})
@@ -174,6 +174,7 @@ test('injecting a service the host does not provide fails with its name', async
const { result } = await activate(host, 'needs-secrets', plugin.entry)
assert.equal(result.status, 'failed')
assert.match(result.diagnostic, /requires `secrets`/)
assert.doesNotMatch(result.diagnostic, /belongs? to the Codewhale core/)
const provided = tempPlugin("export const inject = ['shellHooks', 'sessions', 'userQuestions']; export function apply(ctx) { if (!ctx.shellHooks || typeof ctx.sessions.flush !== 'function' || typeof ctx.userQuestions.ask !== 'function') throw new Error('missing provided service') }\n")
t.after(() => provided.cleanup())
const available = await activate(host, 'needs-shell-hooks', provided.entry)
@@ -187,6 +188,7 @@ export function apply() { createUserMessage({ content: [], source: { kind: 'fixt
const refused = (await activate(host, `pure-needs-${service}`, pure.entry)).result
assert.equal(refused.status, 'failed')
assert.ok(refused.diagnostic.includes('requires `' + service + '`'), refused.diagnostic)
assert.match(refused.diagnostic, /`llm` belongs to the Codewhale core and no reviewed bridge exposes it yet/)
}
})
@@ -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 sessionsAdapter
let systemPromptAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-session-prompt-'))
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)
}
;({ sessionsAdapter, systemPromptAdapter } = await bundle('../src/dsh/session-prompt.ts', 'session-prompt.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))
function flushHarness(invocation, requests) {
const session = {}
const host = {
tier: 'plugin',
rpc: { request: async (method, params) => { requests.push({ method, params }); return true }, notify() {} },
ownerOf: () => invocation?.owner,
liveInvocation: () => invocation,
log() {},
}
const ctx = new Context()
ctx.plugin(sessionsAdapter.define(host))
return { ctx, session }
}
const live = (extra = {}) => {
const session = {}
return { owner: { ref: { plugin_id: 'p' } }, session, ticket: 'ticket', signal: new AbortController().signal, pending: Promise.resolve(), ...extra }
}
test('sessions.flush refuses a live invocation that carries no checkpoint ticket', async () => {
const requests = []
const invocation = live({ ticket: undefined })
const { ctx } = flushHarness(invocation, requests)
await settle()
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
assert.equal(requests.length, 0)
})
test('sessions.flush refuses a session other than the invocation session and sends nothing', async () => {
const requests = []
const invocation = live()
const { ctx } = flushHarness(invocation, requests)
await settle()
await assert.rejects(ctx.sessions.flush({}), (error) => error.code === ErrorCode.NotAvailable)
await assert.rejects(ctx.sessions.flush(undefined), (error) => error.code === ErrorCode.NotAvailable)
assert.equal(requests.length, 0)
})
test('sessions.flush sends the exact owner and ticket, and a stale invocation is refused before the receipt returns', async () => {
const requests = []
const invocation = live()
let current = invocation
const host = { tier: 'plugin', rpc: { request: async (method, params) => { requests.push({ method, params }); current = undefined; return true }, notify() {} }, ownerOf: () => invocation.owner, liveInvocation: () => current, log() {} }
const ctx = new Context()
ctx.plugin(sessionsAdapter.define(host))
await settle()
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
assert.deepEqual(requests, [{ method: 'session/checkpoint', params: { owner: invocation.owner.ref, ticket: 'ticket' } }])
})
test('systemPrompt stays child-only: the root context refuses sections', async () => {
const ctx = new Context()
ctx.plugin(systemPromptAdapter.define({}))
await settle()
assert.throws(() => ctx.systemPrompt.section({ name: 'x', text: 'y' }), (error) => error.code === ErrorCode.NotAvailable)
assert.throws(() => ctx.systemPrompt.getSectionOrder('X'), (error) => error.code === ErrorCode.NotAvailable)
})
+100 -1
View File
@@ -797,7 +797,18 @@ fn anthropic_tool_choice(tool_choice: &Value, model: &str) -> Value {
Some("none") => json!({ "type": "none" }),
Some("any" | "required") => json!({ "type": "any" }),
Some(name) => json!({ "type": "tool", "name": to_api_tool_name(name) }),
None => tool_choice.clone(),
None => match forced_tool_name(tool_choice) {
Some(name) => {
let mut choice = tool_choice.clone();
choice["type"] = json!("tool");
choice["name"] = json!(to_api_tool_name(name));
if let Some(object) = choice.as_object_mut() {
object.remove("function");
}
choice
}
None => tool_choice.clone(),
},
}
}
@@ -1489,6 +1500,94 @@ mod tests {
CodewhaleClient::new(&config).expect("Model Studio Messages client constructs")
}
#[test]
fn dotted_tool_names_stay_inside_the_anthropic_pattern_and_decode_back() {
let client = test_client();
let mut request = request_with("claude-sonnet-4-6", None, None, None);
let tool = |name: &str| Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
};
request.tools = Some(vec![tool("read_file"), tool("web.run")]);
request.tool_choice = Some(json!({"type": "tool", "name": "web.run"}));
request.messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "toolu_w".to_string(),
name: "web.run".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
});
let body = client.build_anthropic_body(&request, true);
let valid = |name: &str| {
(1..=128).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
};
let mut sent = Vec::new();
for tool in body["tools"].as_array().expect("tools") {
sent.push(tool["name"].as_str().expect("tool name").to_string());
}
sent.push(
body.pointer("/tool_choice/name")
.and_then(Value::as_str)
.expect("forced tool name")
.to_string(),
);
for message in body["messages"].as_array().expect("messages") {
for block in message["content"].as_array().into_iter().flatten() {
if block["type"] == "tool_use" {
sent.push(block["name"].as_str().expect("tool_use name").to_string());
}
}
}
assert!(sent.iter().all(|name| valid(name)), "{sent:?}");
assert!(sent.iter().any(|name| name != "read_file"), "{sent:?}");
let wire = to_api_tool_name("web.run");
let message = decode_anthropic_message(json!({
"id": "m", "type": "message", "role": "assistant",
"model": "claude-sonnet-4-6", "stop_reason": "tool_use",
"content": [{"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}}],
"usage": {"input_tokens": 1, "output_tokens": 1},
}))
.unwrap();
let ContentBlock::ToolUse { name, .. } = &message.content[0] else {
panic!("expected tool_use")
};
assert_eq!(name, "web.run");
let event = convert_anthropic_sse_data(
&json!({
"type": "content_block_start", "index": 0,
"content_block": {"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}},
})
.to_string(),
)
.unwrap()
.unwrap();
let StreamEvent::ContentBlockStart {
content_block: codewhale_models::ContentBlockStart::ToolUse { name, .. },
..
} = event
else {
panic!("expected tool_use start")
};
assert_eq!(name, "web.run");
}
#[test]
fn body_keeps_native_cache_control_on_system_and_tools() {
let client = test_client();
+655 -11
View File
@@ -22,6 +22,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, sync_channel};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
@@ -32,6 +33,21 @@ const METHOD_NOT_FOUND: i64 = -32601;
pub const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
pub const AUTH_REQUIRED: i64 = -32000;
const ABORT_POLL: Duration = Duration::from_millis(25);
pub const MAX_CONTROLS: usize = 32;
pub const MAX_CHOICES: usize = 256;
pub const MAX_VALUE_BYTES: usize = 128;
const MAX_NAME_CHARS: usize = 200;
const MAX_DESCRIPTION_CHARS: usize = 400;
pub mod refusal {
pub const CONTROL_NOT_OFFERED: &str = "control_not_offered";
pub const CONTROL_UNAVAILABLE: &str = "control_unavailable";
pub const CHOICE_NOT_OFFERED: &str = "choice_not_offered";
pub const CHOICE_REJECTED: &str = "choice_rejected";
pub const UNSUPPORTED_CONTROL: &str = "unsupported_control";
pub const CONTROL_TYPE_UNSUPPORTED: &str = "control_type_unsupported";
pub const CONTROL_HAS_NO_CHOICES: &str = "control_has_no_choices";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionPolicy {
@@ -117,6 +133,470 @@ pub struct AgentIdentity {
pub protocol_version: u64,
pub load_session: bool,
pub resume_session: bool,
pub capabilities: AgentCapabilities,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AgentCapabilities {
pub load_session: bool,
pub resume_session: bool,
pub fork_session: bool,
pub list_sessions: bool,
pub prompt_image: bool,
pub prompt_audio: bool,
pub prompt_embedded_context: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Choice {
pub value: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlKind {
Model,
Mode,
Config,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "method")]
pub enum ControlWrite {
#[serde(rename = "session/set_config_option")]
SetConfigOption { config_id: String },
#[serde(rename = "session/set_model")]
SetModel,
#[serde(rename = "session/set_mode")]
SetMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ControlRefusal {
pub code: &'static str,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Control {
pub id: String,
pub kind: ControlKind,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<String>,
pub choices: Vec<Choice>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub write: Option<ControlWrite>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable: Option<ControlRefusal>,
}
impl Control {
pub fn offers(&self, value: &str) -> bool {
self.unavailable.is_none() && self.choices.iter().any(|choice| choice.value == value)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SessionOffer {
pub controls: Vec<Control>,
pub malformed_controls: usize,
}
impl SessionOffer {
pub fn control(&self, id: &str) -> Option<&Control> {
self.controls.iter().find(|control| control.id == id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActionSupport {
pub action: &'static str,
pub supported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_reports: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OptionsAgent {
pub id: String,
pub name: String,
pub version: String,
pub protocol_version: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentOptions {
pub agent: OptionsAgent,
pub cwd: String,
pub capabilities: AgentCapabilities,
pub controls: Vec<Control>,
pub malformed_controls: usize,
pub actions: Vec<ActionSupport>,
}
impl AgentOptions {
pub fn new(identity: &AgentIdentity, id: &str, cwd: &Path, offer: SessionOffer) -> Self {
let actions = action_support(&identity.capabilities, &offer);
Self {
agent: OptionsAgent {
id: id.to_string(),
name: identity.name.clone(),
version: identity.version.clone(),
protocol_version: identity.protocol_version,
},
cwd: cwd.display().to_string(),
capabilities: identity.capabilities.clone(),
controls: offer.controls,
malformed_controls: offer.malformed_controls,
actions,
}
}
}
fn action_support(capabilities: &AgentCapabilities, offer: &SessionOffer) -> Vec<ActionSupport> {
let offered = |kind: ControlKind| {
offer
.controls
.iter()
.any(|control| control.kind == kind && control.unavailable.is_none())
};
let choice = |action, kind| {
let supported = offered(kind);
ActionSupport {
action,
supported,
code: (!supported).then_some(refusal::CONTROL_NOT_OFFERED),
reason: (!supported).then_some("The agent did not offer this control."),
agent_reports: None,
}
};
let refused = |action, reason, agent_reports| ActionSupport {
action,
supported: false,
code: Some(refusal::UNSUPPORTED_CONTROL),
reason: Some(reason),
agent_reports,
};
vec![
choice("model_choice", ControlKind::Model),
choice("mode_choice", ControlKind::Mode),
choice("config_choice", ControlKind::Config),
refused(
"steering",
"Agent Client Protocol has no way to steer a running turn; interrupt it and send a new message.",
None,
),
refused(
"compaction",
"Codewhale cannot compact another agent's context.",
None,
),
refused(
"fork",
"Codewhale does not fork another agent's session.",
Some(capabilities.fork_session),
),
refused(
"images",
"Codewhale sends text prompts to another agent only.",
Some(capabilities.prompt_image),
),
refused(
"dynamic_tools",
"The agent uses its own tools; Codewhale tools cannot be added.",
None,
),
refused(
"output_limit",
"Codewhale cannot cap another agent's output tokens.",
None,
),
refused(
"provider_override",
"Codewhale providers do not apply; choose the agent's own model.",
None,
),
refused(
"remote_placement",
"The agent runs on this computer only.",
None,
),
]
}
pub struct NewSession {
pub session_id: String,
pub offer: SessionOffer,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AgentTokenUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub thought_tokens: Option<u64>,
pub cached_read_tokens: Option<u64>,
pub cached_write_tokens: Option<u64>,
}
impl AgentTokenUsage {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
fn from_result(result: &Value) -> Option<Self> {
let usage = result.get("usage")?.as_object()?;
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
let parsed = Self {
input_tokens: field("inputTokens"),
output_tokens: field("outputTokens"),
total_tokens: field("totalTokens"),
thought_tokens: field("thoughtTokens"),
cached_read_tokens: field("cachedReadTokens"),
cached_write_tokens: field("cachedWriteTokens"),
};
(!parsed.is_empty()).then_some(parsed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptReply {
pub stop_reason: String,
pub usage: Option<AgentTokenUsage>,
}
fn bounded_text(value: Option<&Value>, max_chars: usize) -> Option<String> {
let text: String = value?
.as_str()?
.chars()
.filter(|ch| !ch.is_control())
.take(max_chars)
.collect();
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn offered_value(value: Option<&Value>) -> Option<String> {
let value = value?.as_str()?;
(!value.is_empty() && value.len() <= MAX_VALUE_BYTES && !value.chars().any(char::is_control))
.then(|| value.to_string())
}
fn push_choice(
choices: &mut Vec<Choice>,
truncated: &mut bool,
value: Option<String>,
name: Option<&Value>,
description: Option<&Value>,
) {
let Some(value) = value else {
*truncated = true;
return;
};
if choices.iter().any(|choice| choice.value == value) {
return;
}
if choices.len() >= MAX_CHOICES {
*truncated = true;
return;
}
choices.push(Choice {
name: bounded_text(name, MAX_NAME_CHARS).unwrap_or_else(|| value.clone()),
description: bounded_text(description, MAX_DESCRIPTION_CHARS),
value,
});
}
fn config_choices(options: &[Value]) -> (Vec<Choice>, bool) {
let mut choices = Vec::new();
let mut truncated = false;
for option in options {
if let Some(group) = option.get("options").and_then(Value::as_array) {
for grouped in group {
push_choice(
&mut choices,
&mut truncated,
offered_value(grouped.get("value")),
grouped.get("name"),
grouped.get("description"),
);
}
} else {
push_choice(
&mut choices,
&mut truncated,
offered_value(option.get("value")),
option.get("name"),
option.get("description"),
);
}
}
(choices, truncated)
}
impl SessionOffer {
pub fn from_session_result(result: &Value) -> Self {
let mut offer = Self::default();
let mut model_claimed = false;
let mut mode_claimed = false;
let options = result
.get("configOptions")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for option in options {
if offer.controls.len() >= MAX_CONTROLS {
offer.malformed_controls += 1;
continue;
}
let Some(config_id) = offered_value(option.get("id")) else {
offer.malformed_controls += 1;
continue;
};
let category = bounded_text(option.get("category"), 64);
let kind = if !model_claimed
&& (config_id == "model" || category.as_deref() == Some("model"))
{
model_claimed = true;
ControlKind::Model
} else if !mode_claimed && (config_id == "mode" || category.as_deref() == Some("mode"))
{
mode_claimed = true;
ControlKind::Mode
} else {
ControlKind::Config
};
let id = match kind {
ControlKind::Model => "model".to_string(),
ControlKind::Mode => "mode".to_string(),
ControlKind::Config => format!("config:{config_id}"),
};
if offer.control(&id).is_some() {
offer.malformed_controls += 1;
continue;
}
let is_select = option.get("type").and_then(Value::as_str) == Some("select");
let (choices, truncated) = if is_select {
config_choices(
option
.get("options")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default(),
)
} else {
(Vec::new(), false)
};
let unavailable = if !is_select {
Some(ControlRefusal {
code: refusal::CONTROL_TYPE_UNSUPPORTED,
reason: "Codewhale can only write select-type agent options.".to_string(),
})
} else if choices.is_empty() {
Some(ControlRefusal {
code: refusal::CONTROL_HAS_NO_CHOICES,
reason: "The agent offered this option with no choices.".to_string(),
})
} else {
None
};
offer.controls.push(Control {
name: bounded_text(option.get("name"), MAX_NAME_CHARS)
.unwrap_or_else(|| config_id.clone()),
description: bounded_text(option.get("description"), MAX_DESCRIPTION_CHARS),
category,
current: offered_value(option.get("currentValue")),
write: unavailable
.is_none()
.then_some(ControlWrite::SetConfigOption { config_id }),
id,
kind,
choices,
truncated,
unavailable,
});
}
if !model_claimed
&& let Some(models) = result.get("models").filter(|value| value.is_object())
{
offer.push_legacy(
ControlKind::Model,
models.get("currentModelId"),
models.get("availableModels"),
"modelId",
ControlWrite::SetModel,
);
}
if !mode_claimed && let Some(modes) = result.get("modes").filter(|value| value.is_object())
{
offer.push_legacy(
ControlKind::Mode,
modes.get("currentModeId"),
modes.get("availableModes"),
"id",
ControlWrite::SetMode,
);
}
offer
}
fn push_legacy(
&mut self,
kind: ControlKind,
current: Option<&Value>,
available: Option<&Value>,
value_key: &str,
write: ControlWrite,
) {
let mut choices = Vec::new();
let mut truncated = false;
for entry in available
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
push_choice(
&mut choices,
&mut truncated,
offered_value(entry.get(value_key)),
entry.get("name"),
entry.get("description"),
);
}
if choices.is_empty() {
self.malformed_controls += 1;
return;
}
let (id, name) = match kind {
ControlKind::Model => ("model", "Model"),
_ => ("mode", "Mode"),
};
self.controls.push(Control {
id: id.to_string(),
kind,
name: name.to_string(),
description: None,
category: None,
current: offered_value(current),
choices,
truncated,
write: Some(write),
unavailable: None,
});
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -456,7 +936,25 @@ impl AcpClient {
.and_then(Value::as_u64)
.filter(|version| *version == 1)
.ok_or_else(|| AcpError::new("the agent did not negotiate ACP protocol version 1"))?;
let flag = |pointer: &str| {
capabilities
.pointer(pointer)
.is_some_and(|value| !value.is_null() && value != &Value::Bool(false))
};
let capability_report = AgentCapabilities {
load_session: capabilities
.get("loadSession")
.and_then(Value::as_bool)
.unwrap_or(false),
resume_session: flag("/sessionCapabilities/resume"),
fork_session: flag("/sessionCapabilities/fork"),
list_sessions: flag("/sessionCapabilities/list"),
prompt_image: flag("/promptCapabilities/image"),
prompt_audio: flag("/promptCapabilities/audio"),
prompt_embedded_context: flag("/promptCapabilities/embeddedContext"),
};
Ok(AgentIdentity {
capabilities: capability_report,
name: info
.get("name")
.and_then(Value::as_str)
@@ -478,7 +976,7 @@ impl AcpClient {
})
}
pub fn new_session(&mut self, cwd: &Path, timeout: Duration) -> Result<String, AcpError> {
pub fn new_session(&mut self, cwd: &Path, timeout: Duration) -> Result<NewSession, AcpError> {
let deadline = Instant::now() + timeout;
let result = self.request(
"session/new",
@@ -488,11 +986,15 @@ impl AcpClient {
&mut DiscardHandler,
None,
)?;
result
let session_id = result
.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| AcpError::new("session/new did not name a session"))
.ok_or_else(|| AcpError::new("session/new did not name a session"))?;
Ok(NewSession {
session_id,
offer: SessionOffer::from_session_result(&result),
})
}
pub fn reopen_session(
@@ -501,7 +1003,7 @@ impl AcpClient {
session: &str,
cwd: &Path,
timeout: Duration,
) -> Result<(), AcpError> {
) -> Result<SessionOffer, AcpError> {
let method = if identity.resume_session {
"session/resume"
} else if identity.load_session {
@@ -520,6 +1022,44 @@ impl AcpClient {
&mut DiscardHandler,
None,
)
.map(|result| SessionOffer::from_session_result(&result))
}
pub fn apply_control(
&mut self,
session: &str,
control: &Control,
value: &str,
timeout: Duration,
) -> Result<(), AcpError> {
let deadline = Instant::now() + timeout;
let (method, params) = match control.write.as_ref() {
Some(ControlWrite::SetConfigOption { config_id }) => (
"session/set_config_option",
json!({"sessionId": session, "configId": config_id, "value": value}),
),
Some(ControlWrite::SetModel) => (
"session/set_model",
json!({"sessionId": session, "modelId": value}),
),
Some(ControlWrite::SetMode) => (
"session/set_mode",
json!({"sessionId": session, "modeId": value}),
),
None => {
return Err(AcpError::new(
"the agent did not offer this control for writing",
));
}
};
self.request(
method,
params,
Some(session),
deadline,
&mut DiscardHandler,
None,
)
.map(|_| ())
}
@@ -549,7 +1089,7 @@ impl AcpClient {
timeout: Duration,
handler: &mut dyn AcpHandler,
cancel: Option<&CancellationToken>,
) -> Result<String, AcpError> {
) -> Result<PromptReply, AcpError> {
let deadline = Instant::now() + timeout;
let result = self.request(
"session/prompt",
@@ -559,12 +1099,16 @@ impl AcpClient {
handler,
cancel,
)?;
result
let stop_reason = result
.get("stopReason")
.and_then(Value::as_str)
.filter(|reason| !reason.is_empty())
.map(str::to_string)
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))?;
Ok(PromptReply {
stop_reason,
usage: AgentTokenUsage::from_result(&result),
})
}
pub fn prompt(
@@ -575,7 +1119,7 @@ impl AcpClient {
timeout: Duration,
) -> Result<PromptOutcome, AcpError> {
let mut outcome = PromptOutcome::default();
let stop_reason = self.prompt_with(
let reply = self.prompt_with(
session,
text,
timeout,
@@ -585,7 +1129,7 @@ impl AcpClient {
},
None,
)?;
outcome.stop_reason = stop_reason;
outcome.stop_reason = reply.stop_reason;
Ok(outcome)
}
@@ -644,6 +1188,104 @@ mod tests {
request
}
#[test]
fn a_session_offer_is_parsed_bounded_and_never_widened() {
let many: Vec<Value> = (0..(MAX_CHOICES + 40))
.map(|index| json!({"value": format!("v{index}"), "name": "x".repeat(900)}))
.collect();
let offer = SessionOffer::from_session_result(&json!({
"configOptions": [
{"id": "model", "name": "Model", "category": "model", "type": "select",
"currentValue": "a",
"options": [{"group": "g", "name": "G", "options": [
{"value": "a", "name": "A"}, {"value": "a", "name": "dup"}, {"value": "b", "name": "B", "description": "d"}]},
{"value": "c", "name": "C"}]},
{"id": "flag", "name": "Flag", "type": "boolean", "currentValue": true},
{"id": "none", "name": "None", "type": "select", "options": []},
{"id": "", "name": "No id", "type": "select", "options": []},
{"id": "big", "name": "Big", "type": "select", "currentValue": "v0", "options": many},
{"id": "bad\nid", "name": "Control char", "type": "select", "options": [{"value": "x"}]}
],
"modes": {"currentModeId": "m1", "availableModes": [{"id": "m1", "name": "M1"}, {"id": "", "name": "bad"}]},
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
}));
let ids: Vec<&str> = offer.controls.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
ids,
["model", "config:flag", "config:none", "config:big", "mode"]
);
assert_eq!(offer.malformed_controls, 2);
let model = offer.control("model").unwrap();
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
values,
["a", "b", "c"],
"groups flatten and duplicates collapse"
);
assert_eq!(model.choices[1].description.as_deref(), Some("d"));
assert!(model.offers("c") && !model.offers("legacy") && !model.offers(""));
assert_eq!(
offer
.control("config:flag")
.unwrap()
.unavailable
.as_ref()
.unwrap()
.code,
refusal::CONTROL_TYPE_UNSUPPORTED
);
assert!(!offer.control("config:flag").unwrap().offers("true"));
assert_eq!(
offer
.control("config:none")
.unwrap()
.unavailable
.as_ref()
.unwrap()
.code,
refusal::CONTROL_HAS_NO_CHOICES
);
let big = offer.control("config:big").unwrap();
assert_eq!(big.choices.len(), MAX_CHOICES);
assert!(big.truncated);
assert!(big.choices[0].name.chars().count() <= MAX_NAME_CHARS);
assert!(!big.offers(&format!("v{}", MAX_CHOICES + 1)));
let mode = offer.control("mode").unwrap();
assert_eq!(mode.write, Some(ControlWrite::SetMode));
assert_eq!(mode.choices.len(), 1);
assert!(
mode.truncated,
"a dropped malformed choice marks the list incomplete"
);
let legacy = SessionOffer::from_session_result(&json!({
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
}));
assert_eq!(
legacy.control("model").unwrap().write,
Some(ControlWrite::SetModel)
);
assert!(
SessionOffer::from_session_result(&json!({}))
.controls
.is_empty()
);
}
#[test]
fn prompt_usage_keeps_only_what_the_agent_reported() {
let usage = AgentTokenUsage::from_result(&json!({
"stopReason": "end_turn",
"usage": {"inputTokens": 5, "outputTokens": "many", "totalTokens": 9}
}))
.unwrap();
assert_eq!(usage.input_tokens, Some(5));
assert_eq!(usage.output_tokens, None);
assert_eq!(usage.total_tokens, Some(9));
assert!(AgentTokenUsage::from_result(&json!({"stopReason": "end_turn"})).is_none());
assert!(AgentTokenUsage::from_result(&json!({"usage": {}})).is_none());
}
#[test]
fn a_json_rpc_worker_speaks_acp_end_to_end() {
let Some(_node) = node() else {
@@ -677,7 +1319,8 @@ mod tests {
let session = client
.new_session(workspace.path(), Duration::from_secs(20))
.unwrap();
.unwrap()
.session_id;
let allowed = client
.prompt(
&session,
@@ -821,7 +1464,8 @@ mod tests {
client.initialize(Duration::from_secs(20)).unwrap();
let session = client
.new_session(workspace.path(), Duration::from_secs(20))
.unwrap();
.unwrap()
.session_id;
let canceller = client.canceller(&session);
let prompt_session = session.clone();
let blocked = std::thread::spawn(move || {
+99
View File
@@ -70,6 +70,10 @@ impl AccountFence {
}
async fn read_account_epoch(profile: Option<String>) -> Option<AccountEpoch> {
#[cfg(any(test, feature = "test-support"))]
if let Some(seam) = TEST_ACCOUNT_EPOCH.with(|cell| cell.borrow().clone()) {
return seam;
}
tokio::task::spawn_blocking(move || account_epoch(profile.as_deref()))
.await
.ok()?
@@ -271,6 +275,43 @@ pub fn fail_account_load_for_test(message: &'static str) -> AccountLoadFailureGu
AccountLoadFailureGuard
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static TEST_ACCOUNT_EPOCH: std::cell::RefCell<Option<Option<AccountEpoch>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(any(test, feature = "test-support"))]
pub fn pin_account_epoch_for_test(account_id: Option<&str>) -> AccountEpochGuard {
let guard = AccountEpochGuard;
guard.set(account_id);
guard
}
#[cfg(any(test, feature = "test-support"))]
pub struct AccountEpochGuard;
#[cfg(any(test, feature = "test-support"))]
impl AccountEpochGuard {
pub fn set(&self, account_id: Option<&str>) {
let epoch = account_id.map(|id| AccountEpoch {
api_base: runtime_account_api_base(),
state: AccountSessionState::Authenticated,
account_id: Some(id.to_owned()),
session_id: Some(format!("session_{id}")),
signed_out_revision: None,
});
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = Some(epoch));
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for AccountEpochGuard {
fn drop(&mut self) {
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = None);
}
}
#[cfg(any(test, feature = "test-support"))]
pub struct AccountLoadFailureGuard;
@@ -384,4 +425,62 @@ mod tests {
document.notes = "unsafe\0control".into();
assert!(document.as_user_constitution().is_err());
}
#[tokio::test]
async fn desktop_browser_call_is_fenced_to_the_admitting_account() {
const BROWSER: &str = "mcp_codewhale-browser_navigate";
const OTHER: &str = "mcp_other_navigate";
let refused = |result: Result<(), crate::tools::spec::ToolError>| {
result.expect_err("call must be refused").to_string()
};
let epoch = pin_account_epoch_for_test(Some("acct_a"));
assert!(
refused(admit_desktop_browser_call(BROWSER, None).await)
.contains("could not confirm which account started this turn")
);
let fence = AccountFence::capture(None).await;
admit_desktop_browser_call(BROWSER, Some(&fence))
.await
.expect("unchanged epoch is admitted");
admit_desktop_browser_call(OTHER, Some(&fence))
.await
.expect("non-browser tool is unaffected");
admit_desktop_browser_call("read_file", None)
.await
.expect("non-browser tool needs no fence");
epoch.set(Some("acct_b"));
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence)).await)
.contains("account changed after this turn started")
);
admit_desktop_browser_call(OTHER, Some(&fence))
.await
.expect("non-browser tool still unaffected after a switch");
let fence_b = AccountFence::capture(None).await;
admit_desktop_browser_call(BROWSER, Some(&fence_b))
.await
.expect("a turn admitted under the new account is admitted");
epoch.set(Some("acct_a"));
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
.contains("account changed after this turn started")
);
epoch.set(None);
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
.contains("could not confirm the signed-in account")
);
let unconfirmed = AccountFence::capture(None).await;
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&unconfirmed)).await)
.contains("could not confirm which account started this turn")
);
admit_desktop_browser_call(OTHER, Some(&unconfirmed))
.await
.expect("non-browser tool unaffected by an unreadable store");
}
}
@@ -1,7 +1,10 @@
use std::sync::mpsc as std_mpsc;
use super::*;
use crate::fleet::acp::{AcpCanceller, AcpClient, AcpHandler, AgentIdentity, PermissionAnswer};
use crate::fleet::acp::{
AcpCanceller, AcpClient, AcpError, AcpHandler, AgentIdentity, AgentOptions, AgentTokenUsage,
PermissionAnswer, PromptReply, SessionOffer, refusal,
};
use crate::fleet::agents::ExternalAgentLaunch;
use crate::fleet::host::{
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
@@ -19,6 +22,11 @@ const MAX_ID_BYTES: usize = 128;
const MAX_TOOL_ITEMS_PER_TURN: usize = 512;
const MAX_LOCATIONS: usize = 32;
const MAX_OPTIONS: usize = 16;
const MAX_CONCURRENT_OPTION_PROBES: usize = 4;
static OPTIONS_PROBES: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
std::sync::LazyLock::new(|| {
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_OPTION_PROBES))
});
const KILLED_REASON: &str = "The agent did not stop; Codewhale ended its process.";
pub(crate) struct ExternalThreadState {
@@ -47,7 +55,7 @@ enum AgentSignal {
params: Value,
reply: std_mpsc::Sender<PermissionAnswer>,
},
Finished(Result<String, String>),
Finished(Result<PromptReply, String>),
}
pub(crate) struct ExternalSession {
@@ -145,6 +153,149 @@ struct SessionStart {
session_id: String,
}
struct AgentProcess {
host: Option<LocalProcessFleetHostAdapter>,
worker_id: String,
}
impl AgentProcess {
fn spawn(
log_root: &Path,
worker_id: String,
workspace: &Path,
launch: &ExternalAgentLaunch,
) -> Result<(Self, AcpClient)> {
let mut host = LocalProcessFleetHostAdapter::new(log_root);
let mut request = FleetWorkerStartRequest::new(
worker_id.clone(),
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.to_path_buf());
request.env_allowlist = launch.env.keys().cloned().collect();
request.env = launch.env.clone();
host.start_worker(request)
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
let io = host.take_worker_io(&worker_id);
let process = Self {
host: Some(host),
worker_id,
};
let Some(io) = io else {
bail!("{} started without a protocol channel", launch.agent_name);
};
Ok((process, AcpClient::new(io)))
}
fn release(mut self) -> LocalProcessFleetHostAdapter {
self.host.take().expect("an agent process is released once")
}
fn stop(&mut self) {
if let Some(mut host) = self.host.take()
&& let Err(error) = host.stop_worker(&self.worker_id)
{
tracing::warn!(worker = %self.worker_id, %error, "stopping an external agent failed");
}
}
}
impl Drop for AgentProcess {
fn drop(&mut self) {
self.stop();
}
}
fn sign_in_or(
error: anyhow::Error,
agent_name: &str,
login: Option<&'static str>,
) -> anyhow::Error {
if error
.downcast_ref::<AcpError>()
.is_some_and(AcpError::is_auth_required)
{
let hint = login
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
.unwrap_or_default();
return crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
format!("{agent_name} needs you to sign in to it first.{hint}"),
)
.into();
}
if error
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
.is_some()
{
return error;
}
anyhow!("{agent_name}: {error}")
}
fn executor_choices(executor: &ExecutorRecord) -> Vec<(&'static str, String)> {
executor
.model
.iter()
.map(|model| ("model", model.clone()))
.collect()
}
fn refuse(code: &'static str, message: impl Into<String>) -> anyhow::Error {
crate::fleet::agents::ExecutorUnavailable::new(code, message).into()
}
fn apply_choices(
client: &mut AcpClient,
session_id: &str,
offer: &SessionOffer,
choices: &[(&'static str, String)],
) -> Result<()> {
for (id, value) in choices {
let shown = label(Some(&Value::String(value.clone())), "");
if offer.controls.is_empty() && *id == "model" {
client
.set_config_option(session_id, "model", value, SETUP_TIMEOUT)
.map_err(|error| rejected_choice(id, &shown, error))?;
continue;
}
let Some(control) = offer.control(id) else {
return Err(refuse(
refusal::CONTROL_NOT_OFFERED,
format!("The agent does not offer a {id} choice, so `{shown}` was not applied."),
));
};
if let Some(why) = control.unavailable.as_ref() {
return Err(refuse(refusal::CONTROL_UNAVAILABLE, why.reason.clone()));
}
if !control.offers(value) {
return Err(refuse(
refusal::CHOICE_NOT_OFFERED,
format!(
"The agent no longer offers `{shown}` for {id}; choose one of its current options."
),
));
}
if control.current.as_deref() == Some(value.as_str()) {
continue;
}
client
.apply_control(session_id, control, value, SETUP_TIMEOUT)
.map_err(|error| rejected_choice(id, &shown, error))?;
}
Ok(())
}
fn rejected_choice(id: &str, shown: &str, error: AcpError) -> anyhow::Error {
if error.is_auth_required() {
return error.into();
}
refuse(
refusal::CHOICE_REJECTED,
format!("The agent refused {id} `{shown}`: {error}"),
)
}
fn start_session_blocking(
log_root: PathBuf,
thread_id: String,
@@ -155,77 +306,44 @@ fn start_session_blocking(
abandoned: CancellationToken,
) -> Result<SessionStart> {
let worker_id = format!("acp-{thread_id}");
let mut host = LocalProcessFleetHostAdapter::new(&log_root);
let mut request = FleetWorkerStartRequest::new(
worker_id.clone(),
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.clone());
request.env_allowlist = launch.env.keys().cloned().collect();
request.env = launch.env.clone();
host.start_worker(request)
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
let Some(io) = host.take_worker_io(&worker_id) else {
let _ = host.stop_worker(&worker_id);
bail!("{} started without a protocol channel", launch.agent_name);
};
let mut client = AcpClient::new(io);
let (process, mut client) =
AgentProcess::spawn(&log_root, worker_id.clone(), &workspace, &launch)?;
client.abort_requests_on(Some(abandoned));
let setup = (|| {
let choices = executor_choices(&executor);
let setup = (|| -> Result<(AgentIdentity, String)> {
let identity = client.initialize(SETUP_TIMEOUT)?;
let session_id = match executor.foreign_session_id.as_deref() {
let (session_id, offer) = match executor.foreign_session_id.as_deref() {
Some(session_id) => {
client.reopen_session(&identity, session_id, &workspace, SETUP_TIMEOUT)?;
session_id.to_string()
let offer =
client.reopen_session(&identity, session_id, &workspace, SETUP_TIMEOUT)?;
(session_id.to_string(), offer)
}
None => {
let started = client.new_session(&workspace, SETUP_TIMEOUT)?;
(started.session_id, started.offer)
}
None => client.new_session(&workspace, SETUP_TIMEOUT)?,
};
if let Some(model) = executor.model.as_deref() {
client.set_config_option(&session_id, "model", model, SETUP_TIMEOUT)?;
}
Ok::<_, crate::fleet::acp::AcpError>((identity, session_id))
apply_choices(&mut client, &session_id, &offer, &choices)?;
Ok((identity, session_id))
})();
let (identity, session_id) = match setup {
Ok(setup) => setup,
Err(error) => {
let _ = host.stop_worker(&worker_id);
if error.is_auth_required() {
let hint = login
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
.unwrap_or_default();
return Err(crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
format!(
"{} needs you to sign in to it first.{hint}",
launch.agent_name
),
)
.into());
}
bail!("{}: {error}", launch.agent_name);
}
};
let (identity, session_id) =
setup.map_err(|error| sign_in_or(error, &launch.agent_name, login))?;
if !valid_id(&session_id) {
let _ = host.stop_worker(&worker_id);
bail!("{} named an invalid session id", launch.agent_name);
}
client.abort_requests_on(None);
let canceller = client.canceller(&session_id);
let (commands, receiver) = std_mpsc::channel();
let session_for_thread = session_id.clone();
if let Err(error) = std::thread::Builder::new()
std::thread::Builder::new()
.name(format!("acp-{}", launch.agent_id))
.spawn(move || run_session(client, session_for_thread, receiver))
{
let _ = host.stop_worker(&worker_id);
bail!("starting the agent session thread failed: {error}");
}
.map_err(|error| anyhow!("starting the agent session thread failed: {error}"))?;
Ok(SessionStart {
session: ExternalSession {
commands: parking_lot::Mutex::new(Some(commands)),
canceller,
host: parking_lot::Mutex::new(host),
host: parking_lot::Mutex::new(process.release()),
worker_id,
},
identity,
@@ -233,6 +351,31 @@ fn start_session_blocking(
})
}
fn probe_options_blocking(
log_root: PathBuf,
workspace: PathBuf,
launch: ExternalAgentLaunch,
login: Option<&'static str>,
abandoned: CancellationToken,
) -> Result<AgentOptions> {
let worker_id = format!("acp-options-{}", runtime_record_id("probe"));
let (mut process, mut client) = AgentProcess::spawn(&log_root, worker_id, &workspace, &launch)?;
client.abort_requests_on(Some(abandoned));
let probed = (|| -> Result<AgentOptions> {
let identity = client.initialize(SETUP_TIMEOUT)?;
let started = client.new_session(&workspace, SETUP_TIMEOUT)?;
Ok(AgentOptions::new(
&identity,
&launch.agent_id,
&workspace,
started.offer,
))
})();
drop(client);
process.stop();
probed.map_err(|error| sign_in_or(error, &launch.agent_name, login))
}
fn valid_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_ID_BYTES
@@ -394,6 +537,8 @@ struct ExternalProjection {
plan: Option<TurnItemRecord>,
title: Option<String>,
failure: Option<String>,
context: Option<(Option<u64>, Option<u64>)>,
tokens: Option<AgentTokenUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -637,6 +782,48 @@ impl RuntimeThreadManager {
Ok(session)
}
pub async fn external_agent_options(&self, agent_id: &str, cwd: &Path) -> Result<AgentOptions> {
self.ensure_accepting_execution()?;
let agent = crate::fleet::agents::find(agent_id).ok_or_else(|| {
crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::UNKNOWN_AGENT,
format!("No external agent is named `{agent_id}`."),
)
})?;
let workspace = tokio::fs::canonicalize(cwd)
.await
.map_err(|error| anyhow!("cwd {} is unavailable: {error}", cwd.display()))?;
anyhow::ensure!(
tokio::fs::metadata(&workspace)
.await
.is_ok_and(|metadata| metadata.is_dir()),
"cwd {} is not a directory",
workspace.display()
);
let launch = crate::fleet::agents::launch(agent.id).await?;
let log_root = self
.session_store_binding()
.data_dir
.join("external-agents");
tokio::fs::create_dir_all(&log_root)
.await
.context("creating the external agent log directory failed")?;
let abandoned = self.cancel_token.child_token();
let _abandon = abandoned.clone().drop_guard();
let permit = tokio::select! {
permit = Arc::clone(&OPTIONS_PROBES).acquire_owned() => permit?,
() = abandoned.cancelled() => bail!("Runtime is shutting down; execution admission is closed"),
};
self.ensure_accepting_execution()?;
let login = agent.login;
tokio::task::spawn_blocking(move || {
let _permit = permit;
probe_options_blocking(log_root, workspace, launch, login, abandoned)
})
.await
.context("external agent options worker failed")?
}
pub(super) async fn external_thread_loaded(&self, thread_id: &str) -> bool {
self.active.lock().await.external.contains_key(thread_id)
}
@@ -724,27 +911,36 @@ impl RuntimeThreadManager {
matches!(input_source, RuntimeTurnInputSource::ExternalUser),
"this kind of turn is not available in a session with another agent"
);
anyhow::ensure!(
req.images.is_empty(),
"image inputs are not available in a session with another agent"
);
anyhow::ensure!(
req.dynamic_tools.is_empty(),
"dynamic tools are not available in a session with another agent"
);
anyhow::ensure!(
req.model.is_none()
&& req.reasoning_effort.is_none()
&& req.max_output_tokens.is_none()
&& req.model_provider.is_none()
&& req.model_provider_id.is_none()
&& req.account_model_owner.is_none(),
"a session with another agent uses its saved agent model; per-turn model controls are unavailable"
);
anyhow::ensure!(
req.allowed_tools.is_none(),
"a session with another agent uses that agent's own tools; tool allowlists are unavailable"
);
if !req.images.is_empty() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"Image inputs are not available in a session with another agent.",
));
}
if !req.dynamic_tools.is_empty() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"Dynamic tools are not available in a session with another agent.",
));
}
if req.model.is_some()
|| req.reasoning_effort.is_some()
|| req.max_output_tokens.is_some()
|| req.model_provider.is_some()
|| req.model_provider_id.is_some()
|| req.account_model_owner.is_some()
{
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"A session with another agent keeps its saved agent model; per-turn model, reasoning, output and provider controls are unavailable.",
));
}
if req.allowed_tools.is_some() {
return Err(refuse(
refusal::UNSUPPORTED_CONTROL,
"A session with another agent uses that agent's own tools; tool allowlists are unavailable.",
));
}
if req.environment_id.is_some() {
return Err(crate::fleet::agents::ExecutorUnavailable::new(
crate::fleet::agents::ExecutorUnavailable::ENVIRONMENT_NOT_SUPPORTED,
@@ -1041,10 +1237,21 @@ impl RuntimeThreadManager {
};
let _ = reply.send(answer);
}
Some(AgentSignal::Finished(result)) => break result,
Some(AgentSignal::Finished(result)) => {
break result.map(|reply| {
projection.tokens = reply.usage;
reply.stop_reason
});
}
None => break Err("the agent session ended".to_string()),
}
};
if let Err(error) = self
.emit_agent_usage(&thread.id, &turn_id, &projection)
.await
{
tracing::warn!(thread_id = %thread.id, turn_id = %turn_id, %error, "recording an external agent's usage failed");
}
if let Err(error) = self
.settle_external_turn(&thread, &turn_id, projection, outcome)
.await
@@ -1053,6 +1260,32 @@ impl RuntimeThreadManager {
}
}
async fn emit_agent_usage(
&self,
thread_id: &str,
turn_id: &str,
projection: &ExternalProjection,
) -> Result<()> {
let tokens = projection
.tokens
.as_ref()
.filter(|tokens| !tokens.is_empty());
let reported = tokens.is_some() || projection.context.is_some();
let payload = json!({
"kind": "turn_total",
"source": if reported { "agent reported" } else { "none" },
"tokens": tokens.map_or_else(|| json!("unknown"), |tokens| json!(tokens)),
"context": projection.context.map_or_else(
|| json!("unknown"),
|(used, size)| json!({ "used": used, "size": size }),
),
"cost": "unknown",
});
self.emit_event(thread_id, Some(turn_id), None, "turn.agent_usage", payload)
.await?;
Ok(())
}
async fn open_external_item(
&self,
thread_id: &str,
@@ -1265,12 +1498,13 @@ impl RuntimeThreadManager {
let used = update.get("used").and_then(Value::as_u64);
let size = update.get("size").and_then(Value::as_u64);
if used.is_some() || size.is_some() {
projection.context = Some((used, size));
self.emit_event(
thread_id,
Some(turn_id),
None,
"turn.agent_usage",
json!({ "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
json!({ "kind": "context_window", "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
)
.await?;
}
@@ -715,3 +715,390 @@ async fn an_abandoned_external_startup_stops_its_agent_inside_the_shutdown_barri
);
Ok(())
}
fn options_fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp-options-agent.mjs")
}
fn received(workspace: &Path) -> Vec<Value> {
std::fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
fn pid_alive(pid: u64) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn refusal_code(error: &anyhow::Error) -> Option<&'static str> {
error
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
.map(|refusal| refusal.code)
}
fn options_thread_request(workspace: &Path, model: &str) -> CreateThreadRequest {
let mut request = fixture_thread_request(workspace, "ask");
request.executor.as_mut().unwrap().config.model = Some(model.to_string());
request
}
#[cfg(unix)]
#[tokio::test]
async fn an_options_probe_reports_the_agents_own_offer_and_leaves_no_process() -> 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, options_fixture());
let _secret = crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_SECRET", "must-not-travel");
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let options = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await?;
let json = serde_json::to_value(&options)?;
let controls: Vec<&str> = options.controls.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
controls,
["model", "config:effort", "config:verbose", "mode"]
);
let model = &options.controls[0];
assert_eq!(model.current.as_deref(), Some("model-a"));
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
assert_eq!(values, ["model-a", "model-b", "model-c"]);
assert_eq!(
json["controls"][0]["write"]["method"],
"session/set_config_option"
);
assert_eq!(json["controls"][3]["write"]["method"], "session/set_mode");
let verbose = &options.controls[2];
assert_eq!(
verbose.unavailable.as_ref().map(|u| u.code),
Some("control_type_unsupported")
);
assert!(verbose.write.is_none());
assert_eq!(json["capabilities"]["prompt_image"], true);
assert_eq!(json["capabilities"]["fork_session"], true);
let action = |name: &str| {
json["actions"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["action"] == name)
.cloned()
.unwrap()
};
assert_eq!(action("model_choice")["supported"], true);
assert_eq!(action("steering")["code"], "unsupported_control");
assert_eq!(action("images")["supported"], false);
assert_eq!(action("images")["agent_reports"], true);
let log = received(&workspace);
let pid = log[0]["pid"].as_u64().expect("the probe agent's pid");
assert!(!pid_alive(pid), "the probe's agent process was stopped");
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
let dumped = std::fs::read_to_string(workspace.join("options-env.json"))?;
assert!(!dumped.contains("CODEWHALE_TEST_SECRET"));
let wrong = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace.join("missing"))
.await
.unwrap_err();
assert!(wrong.to_string().contains("unavailable"), "{wrong}");
let unknown = manager
.external_agent_options("nope", &workspace)
.await
.unwrap_err();
assert_eq!(refusal_code(&unknown), Some("unknown_agent"));
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"auth_required":true}"#,
)?;
let unauthenticated = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await
.unwrap_err();
assert_eq!(refusal_code(&unauthenticated), Some("needs_sign_in"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn an_options_probe_ends_when_the_runtime_shuts_down() -> 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, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(workspace.join("options-fixture.json"), r#"{"hang":true}"#)?;
let manager = test_manager(root.path().join("runtime"))?;
let probing = manager.clone();
let probe_workspace = workspace.clone();
let probe = tokio::spawn(async move {
probing
.external_agent_options(FIXTURE_AGENT_ID, &probe_workspace)
.await
});
let deadline = Instant::now() + Duration::from_secs(30);
let pid = loop {
if let Some(pid) = received(&workspace)
.iter()
.find(|entry| entry["method"] == "session/new")
.and_then(|entry| entry["pid"].as_u64())
{
break pid;
}
anyhow::ensure!(
Instant::now() < deadline,
"the probe never reached session/new"
);
sleep(Duration::from_millis(20)).await;
};
assert!(pid_alive(pid));
manager.close_execution_admission().await;
let ended = tokio::time::timeout(Duration::from_secs(20), probe).await??;
assert!(ended.is_err());
let deadline = Instant::now() + Duration::from_secs(10);
while pid_alive(pid) {
anyhow::ensure!(
Instant::now() < deadline,
"the probe's agent outlived shutdown"
);
sleep(Duration::from_millis(50)).await;
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn a_chosen_model_reaches_the_agent_survives_reopen_and_usage_is_agent_reported() -> 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, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let offered = manager
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
.await?;
let chosen = &offered.controls[0].choices[1].value;
assert_eq!(chosen, "model-b");
let thread = manager
.create_thread(options_thread_request(&workspace, chosen))
.await?;
let turn = manager.start_turn(&thread.id, prompt("first")).await?;
let settled = wait_for_turn(&manager, &turn.id).await?;
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
let detail = manager.get_thread_detail(&thread.id).await?;
let message = detail
.items
.iter()
.find(|item| item.kind == TurnItemKind::AgentMessage)
.and_then(|item| item.detail.clone())
.unwrap_or_default();
assert!(message.contains("model=model-b"), "{message}");
let usage: Vec<_> = events(&manager, &thread.id)
.await?
.into_iter()
.filter(|event| event.event == "turn.agent_usage")
.collect();
let window = usage
.iter()
.find(|event| event.payload["kind"] == "context_window")
.expect("a context window update");
assert_eq!(window.payload["used"], 1200);
assert_eq!(window.payload["size"], 200000);
let total = usage
.iter()
.find(|event| event.payload["kind"] == "turn_total")
.expect("a turn total");
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
assert_eq!(total.payload["tokens"]["input_tokens"], 100);
assert_eq!(total.payload["context"]["used"], 1200);
assert!(manager.stop_external_agent(&thread.id).await?);
let reopened = manager.start_turn(&thread.id, prompt("second")).await?;
let settled = wait_for_turn(&manager, &reopened.id).await?;
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
let log = received(&workspace);
let sets: Vec<_> = log
.iter()
.filter(|entry| entry["method"] == "session/set_config_option")
.collect();
assert_eq!(sets.len(), 2, "{log:?}");
assert!(sets.iter().all(|entry| entry["value"] == "model-b"));
assert_ne!(
sets[0]["pid"], sets[1]["pid"],
"a new agent process took the saved choice"
);
let prompts: Vec<_> = log
.iter()
.filter(|entry| entry["method"] == "session/prompt")
.collect();
assert_eq!(prompts.len(), 2);
assert!(log.iter().any(|entry| entry["method"] == "session/load"));
assert_eq!(prompts[1]["state"]["model"], "model-b");
assert_eq!(
manager
.store
.load_thread(&thread.id)?
.executor
.and_then(|record| record.model)
.as_deref(),
Some("model-b")
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn an_agent_that_reports_no_usage_says_unknown() -> 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, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"no_usage":true}"#,
)?;
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("quiet")).await?;
wait_for_turn(&manager, &turn.id).await?;
let events = events(&manager, &thread.id).await?;
let usage: Vec<_> = events
.iter()
.filter(|event| event.event == "turn.agent_usage")
.collect();
assert_eq!(usage.len(), 1, "{usage:?}");
assert_eq!(usage[0].payload["tokens"], "unknown");
assert_eq!(usage[0].payload["context"], "unknown");
assert_eq!(usage[0].payload["source"], "none");
assert_eq!(usage[0].payload["cost"], "unknown");
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn a_stale_or_withdrawn_choice_is_refused_before_any_prompt() -> 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, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let invented = manager
.create_thread(options_thread_request(&workspace, "model-z"))
.await?;
let error = manager
.start_turn(&invented.id, prompt("never"))
.await
.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("choice_not_offered"),
"{error:#}"
);
std::fs::write(
workspace.join("options-fixture.json"),
r#"{"withdraw":["model-b"]}"#,
)?;
let withdrawn = manager
.create_thread(options_thread_request(&workspace, "model-b"))
.await?;
let error = manager
.start_turn(&withdrawn.id, prompt("never"))
.await
.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("choice_not_offered"),
"{error:#}"
);
let log = received(&workspace);
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
assert!(
log.iter()
.all(|entry| entry["method"] != "session/set_config_option")
);
for pid in log.iter().filter_map(|entry| entry["pid"].as_u64()) {
assert!(!pid_alive(pid), "a refused setup left agent {pid} running");
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn per_turn_controls_an_agent_cannot_honor_are_typed_refusals() -> 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, options_fixture());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
let manager = test_manager(root.path().join("runtime"))?;
let thread = manager
.create_thread(fixture_thread_request(&workspace, "ask"))
.await?;
for request in [
StartTurnRequest {
allowed_tools: Some(Vec::new()),
..prompt("x")
},
StartTurnRequest {
model: Some("model-b".to_string()),
..prompt("x")
},
StartTurnRequest {
reasoning_effort: Some("high".to_string()),
..prompt("x")
},
] {
let error = manager.start_turn(&thread.id, request).await.unwrap_err();
assert_eq!(
refusal_code(&error),
Some("unsupported_control"),
"{error:#}"
);
}
assert!(
received(&workspace).is_empty(),
"no agent was started for a refused turn"
);
Ok(())
}
+165
View File
@@ -0,0 +1,165 @@
// A deterministic Agent Client Protocol agent that advertises its own models,
// a mode, a config option and one control Codewhale cannot write, and reports
// token usage. Behavior is steered by `options-fixture.json` in the session
// cwd; everything it receives is appended to `options-received.jsonl` there.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const plan = () => {
const path = join(process.cwd(), 'options-fixture.json');
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
};
const record = (entry) =>
appendFileSync(join(process.cwd(), 'options-received.jsonl'), `${JSON.stringify({ pid: process.pid, ...entry })}\n`);
writeFileSync(join(process.cwd(), 'options-env.json'), JSON.stringify(process.env));
const MODELS = [
{ value: 'model-a', name: 'Model A', description: 'Fast' },
{ value: 'model-b', name: 'Model B', description: 'Careful' },
{ value: 'model-c', name: 'Model C' },
];
const EFFORTS = [
{ value: 'low', name: 'Low' },
{ value: 'high', name: 'High' },
];
const MODES = [
{ id: 'default', name: 'Default' },
{ id: 'review', name: 'Review only', description: 'Reads, never edits' },
];
let buffer = '';
let sessionId = null;
const state = { model: 'model-a', effort: 'low', mode: 'default' };
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
const failure = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
function offer() {
const withdrawn = new Set(plan().withdraw ?? []);
const models = MODELS.filter((model) => !withdrawn.has(model.value));
const configOptions = [
{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: state.model, options: models },
{ id: 'effort', name: 'Reasoning effort', category: 'thought_level', type: 'select', currentValue: state.effort, options: EFFORTS },
{ id: 'verbose', name: 'Verbose logging', type: 'boolean', currentValue: false },
];
const reply = {
configOptions,
modes: { currentModeId: state.mode, availableModes: MODES },
};
if (plan().malformed) {
reply.configOptions.push({ id: '', name: 'No id', type: 'select', currentValue: 'x', options: [] });
reply.configOptions.push({ id: 'empty', name: 'Empty', type: 'select', currentValue: 'x', options: [] });
}
if (plan().huge) {
reply.configOptions.push({
id: 'many',
name: 'Many',
type: 'select',
currentValue: 'v0',
options: Array.from({ length: 600 }, (_, index) => ({ value: `v${index}`, name: `Value ${index}` })),
});
}
return reply;
}
function offered(config, value) {
const found = offer().configOptions.find((option) => option.id === config);
return Boolean(found && found.type === 'select' && found.options.some((option) => option.value === value));
}
function prompt(request) {
const text = (request.params?.prompt ?? []).map((block) => block.text ?? '').join('');
record({ method: 'session/prompt', text, state: { ...state } });
notify('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `model=${state.model} effort=${state.effort} mode=${state.mode}` },
},
});
if (!plan().no_usage) {
notify('session/update', { sessionId, update: { sessionUpdate: 'usage_update', used: 1200, size: 200000 } });
}
result(request.id, {
stopReason: 'end_turn',
...(plan().no_usage ? {} : { usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150, thoughtTokens: 7 } }),
});
}
function handle(message) {
if (message.id !== undefined && message.method === undefined) return;
const params = message.params ?? {};
switch (message.method) {
case 'initialize':
result(message.id, {
protocolVersion: 1,
agentInfo: { name: 'options-fixture', version: '1.0.0' },
authMethods: [],
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: true, embeddedContext: true },
sessionCapabilities: { fork: {} },
},
});
break;
case 'session/new':
if (plan().auth_required) {
failure(message.id, -32000, 'Authentication required');
break;
}
sessionId = `options-session-${process.pid}`;
record({ method: 'session/new', cwd: params.cwd });
if (plan().hang) break;
result(message.id, { sessionId, ...offer() });
break;
case 'session/load':
case 'session/resume':
sessionId = params.sessionId ?? null;
record({ method: message.method, sessionId });
result(message.id, offer());
break;
case 'session/set_config_option':
record({ method: message.method, configId: params.configId, value: params.value });
if (!offered(params.configId, params.value)) {
failure(message.id, -32602, 'value not offered');
break;
}
state[params.configId] = params.value;
result(message.id, { configOptions: offer().configOptions });
break;
case 'session/set_mode':
record({ method: message.method, modeId: params.modeId });
if (!MODES.some((mode) => mode.id === params.modeId)) {
failure(message.id, -32602, 'mode not offered');
break;
}
state.mode = params.modeId;
result(message.id, {});
break;
case 'session/prompt':
prompt(message);
break;
case 'session/cancel':
break;
default:
if (message.id !== undefined) failure(message.id, -32601, `no method ${message.method}`);
}
}
process.stdin.on('data', (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
if (!line.trim()) continue;
try {
handle(JSON.parse(line));
} catch (error) {
process.stderr.write(`options fixture: bad line: ${String(error)}\n`);
}
}
});
process.stdin.on('end', () => process.exit(0));
@@ -0,0 +1,657 @@
{
"schema": "codewhale.dsh-acceptance-pins/1",
"issue": "CW-7051",
"source": "DSH-PINNED-ACCEPTANCE.json (DSH-00) re-hashed against the survey tarballs",
"membership": "THE-33 option A, ratified in CURRENT_DECISIONS section 125 item 1 (DSH-H01)",
"target_dsh_version": "0.2.2-rc.1",
"supported_os": [
"macos",
"linux",
"windows"
],
"rows": [
{
"row": 1,
"package": "@michengai/dsh-code-review",
"version": "1.0.9",
"family": "headless tool/command",
"tarball": "michengai-dsh-code-review-1.0.9.tgz",
"tarball_sha256": "09215a2f809327db33074a44d4371439b8163d953f1826b3f2a9481828b6bee3",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [],
"named_brokers_needed": []
},
{
"row": 2,
"package": "@michengai/dsh-btw",
"version": "1.3.7",
"family": "headless command",
"tarball": "michengai-dsh-btw-1.3.7.tgz",
"tarball_sha256": "7cb6a910e82b21362a57e6007ad20df01d50048b6ef789ca6c5ed57be59622dc",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.text-interaction",
"conversation.input.dock",
"conversation.user.text.node",
"plugins.bundle.config",
"plugins.row.config"
],
"named_brokers_needed": []
},
{
"row": 3,
"package": "@linxin666/dsh-liangshen",
"version": "0.4.16",
"family": "prompt/preset",
"tarball": "linxin666-dsh-liangshen-0.4.16.tgz",
"tarball_sha256": "ddbc39bbf88c0bbb724a7b9e129afd55a53f58d6ae68fe9777287c13a9de7b3e",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.input.right",
"plugins.bundle.config",
"settings.plugin.item"
],
"named_brokers_needed": []
},
{
"row": 4,
"package": "dsh-anchored-standard",
"version": "1.2.1",
"family": "prompt/preset",
"tarball": "Jungod1121-dsh-anchored-standard.tar.gz",
"tarball_sha256": "3ff95dbb5e7f8bcba984afdf503f06abe0896b298afb39d533a1eafef2d7bfa2",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": false,
"declared_dsh_slot_keys": [],
"named_brokers_needed": []
},
{
"row": 5,
"package": "@michengai/dsh-agency-agents",
"version": "1.2.27",
"family": "prompt/skills + agents",
"tarball": "michengai-dsh-agency-agents-1.2.27.tgz",
"tarball_sha256": "fafb0c8a645ea088cbf5b427e135a62bf8c506c0fb5be9fc65bce4f635fd0216",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.input.dock",
"plugins.bundle.config",
"settings.section",
"sidebar.panellist"
],
"named_brokers_needed": []
},
{
"row": 6,
"package": "@liustack/modsearch",
"version": "5.10.12",
"family": "headless tool",
"tarball": "liustack-modsearch-5.10.12.tgz",
"tarball_sha256": "c6ebdba4ce896b76bc97351919ec717d71fd28d4a36aa33529016a9dd79c4e91",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.plugin.item"
],
"named_brokers_needed": []
},
{
"row": 7,
"package": "@liustack/modlens",
"version": "3.27.3",
"family": "headless tool (vision)",
"tarball": "liustack-modlens-3.27.3.tgz",
"tarball_sha256": "1cbf6fb0ec1221b8a8af27458128d6972b4083bf570aa263168f5ac1c580c325",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.plugin.item"
],
"named_brokers_needed": [
"llm"
]
},
{
"row": 8,
"package": "@anionex/dsh-vision-toolkit",
"version": "0.1.49",
"family": "headless tool",
"tarball": "anionex-dsh-vision-toolkit-0.1.49.tgz",
"tarball_sha256": "09ffed8a5eb3dddbb61aee896117bf37fd3087837f2a1df2a6ed86a353f8d97d",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.input.dock",
"settings.section",
"tool.call.toolview"
],
"named_brokers_needed": [
"credentials",
"subprocess"
]
},
{
"row": 9,
"package": "dsh-image-gen",
"version": "0.11.1",
"family": "headless tool + panel",
"tarball": "dsh-image-gen-0.11.1.tgz",
"tarball_sha256": "4d8d8add5614d8b14c031b0e9ba04de042a258f53913e23eaa297f6d0a4d0619",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.node",
"conversation.input.right",
"conversation.view",
"main.page-menu",
"plugins.bundle.config",
"settings.plugin.item",
"sidebar.right.pane.tab",
"tool.call.toolview"
],
"named_brokers_needed": [
"credentials"
]
},
{
"row": 10,
"package": "@dfy-plugins/dsh-image-generation",
"version": "0.1.4",
"family": "headless tool",
"tarball": "dfy-plugins-dsh-image-generation-0.1.4.tgz",
"tarball_sha256": "fbd5e5b2bd874914460b9d367dd517936eeee804ce82ddc5d05f3911ab9040ea",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"plugins.row.config",
"tool.call.toolview"
],
"named_brokers_needed": [
"credentials",
"fs"
]
},
{
"row": 11,
"package": "dsh-univer-office",
"version": "0.4.0",
"family": "headless tool + panel",
"tarball": "dsh-univer-office-0.4.0.tgz",
"tarball_sha256": "c5614b3e2dbaa809d6376a19821a108ddf8ae0e79818d4afd77be79de7cac2b8",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.turnTail",
"conversation.input.dock",
"plugins.bundle.config",
"settings.plugin.item",
"sidebar.right.pane.tab"
],
"named_brokers_needed": [
"subprocess"
]
},
{
"row": 12,
"package": "@michengai/dsh-skills-manager",
"version": "1.4.0",
"family": "skills + panel",
"tarball": "michengai-dsh-skills-manager-1.4.0.tgz",
"tarball_sha256": "81f2919071ad554a87e627e068bab0d222afe9f757dd5b9b2e1caf6591544f3c",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.section",
"sidebar.panellist"
],
"named_brokers_needed": [
"fs"
]
},
{
"row": 13,
"package": "@nanmicoder/dsh-agent-teams",
"version": "0.1.29",
"family": "agent-teams",
"tarball": "nanmicoder-dsh-agent-teams-0.1.29.tgz",
"tarball_sha256": "25aba307cb422aa20dbcaf2d25317425bd6f82ab2b1b400a1eda2d659cf57367",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.node",
"conversation.chat.turnTail",
"conversation.session.header.actions",
"sidebar.right.pane.tab"
],
"named_brokers_needed": [
"llm",
"drive-grant"
]
},
{
"row": 14,
"package": "dsh-agent-squad",
"version": "0.1.5",
"family": "agent-teams",
"tarball": "dsh-agent-squad-0.1.5.tgz",
"tarball_sha256": "126c632c68d25ce39f30d477f15f4badf76198688dadfc8f90b9fdb635918c70",
"admission": "refuse",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.node"
],
"named_brokers_needed": [
"llm"
],
"refusal": {
"code": "incompatible-version",
"field": "peerDependencies.@deepseek-ai/dsh-agent",
"range": "^0.1.0-rc.6"
}
},
{
"row": 15,
"package": "@michengai/dsh-automation",
"version": "1.0.22",
"family": "agent-teams / out-of-turn",
"tarball": "michengai-dsh-automation-1.0.22.tgz",
"tarball_sha256": "1b3ac21c82b2e68dd2153444335f1b70c4be02c280ebc2778820c2a3cf832cc3",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.section",
"sidebar.panellist",
"sidebar.session.row.leading",
"sidebar.workspaces"
],
"named_brokers_needed": [
"llm",
"out-of-turn"
]
},
{
"row": 16,
"package": "dsh-mnemon",
"version": "0.5.43",
"family": "memory / out-of-turn",
"tarball": "dsh-mnemon-0.5.43.tgz",
"tarball_sha256": "5c1e6a6c4f3c14fda1c25343de4102dfd2f04f923f2f17551cbf9c8c112e0104",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.assistant-actions",
"conversation.chat.turnTail",
"conversation.view",
"plugins.bundle.config",
"plugins.row.config",
"sidebar.panellist"
],
"named_brokers_needed": [
"egress"
]
},
{
"row": 17,
"package": "@linxin666/dsh-client-ui-task-board",
"version": "0.4.16",
"family": "agent-teams panel",
"tarball": "linxin666-dsh-client-ui-task-board-0.4.16.tgz",
"tarball_sha256": "aee4785ea00613baee05f3ff21a8a2ace50d31c7b856d8140328f69dfc066456",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.plugin.item",
"sidebar.panellist"
],
"named_brokers_needed": [
"llm"
]
},
{
"row": 18,
"package": "@michengai/dsh-im-connect",
"version": "1.0.34",
"family": "IM bridge",
"tarball": "michengai-dsh-im-connect-1.0.34.tgz",
"tarball_sha256": "f4c2e114f1926fecd6f981081d69800420c1dcb21b2eb020259a932378583d26",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"settings.section",
"sidebar.panellist",
"sidebar.session.row.leading",
"sidebar.workspaces"
],
"named_brokers_needed": [
"llm",
"credentials",
"egress"
]
},
{
"row": 19,
"package": "dsh-im",
"version": "1.0.5",
"family": "IM bridge",
"tarball": "dsh-im-1.0.5.tgz",
"tarball_sha256": "23875de57a006a5442cc1585071fc7c0e4d5b1dbd782230a31aa8f5c758f9380",
"admission": "refuse",
"admission_basis": "peerDependencies",
"declares_client": false,
"declared_dsh_slot_keys": [],
"named_brokers_needed": [
"approval"
],
"refusal": {
"code": "incompatible-version",
"field": "peerDependencies.@deepseek-ai/dsh-agent",
"range": "^0.1.0-rc.6"
}
},
{
"row": 20,
"package": "dsh-context",
"version": "0.83.0",
"family": "sidebar/panel",
"tarball": "dsh-context-0.83.0.tgz",
"tarball_sha256": "fc9203b1a85081cf73808ca87e226219d111aebef7e2ce3dfc6a090649b421cc",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.assistant-actions",
"conversation.input.overlay",
"conversation.view",
"plugins.bundle.config",
"sidebar.panellist",
"sidebar.right.pane.tab"
],
"named_brokers_needed": []
},
{
"row": 21,
"package": "dsh-cost-meter",
"version": "1.9.62",
"family": "panel + account",
"tarball": "dsh-cost-meter-1.9.62.tgz",
"tarball_sha256": "7196cc36d95caa7a287ac56d3d3d6819f7b6631915028fda55324c101e9cf676",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.chat.turnTail",
"conversation.composer.dock",
"conversation.input.dock",
"conversation.input.overlay",
"conversation.session.header.actions",
"conversation.view",
"plugins.bundle.config",
"settings.general.item",
"settings.section",
"sidebar.footer.action",
"sidebar.right.pane.tab",
"sidebar.workspaces.session.row.action"
],
"named_brokers_needed": [
"usage-stream-readonly"
]
},
{
"row": 22,
"package": "@changfenhuang/dsh-genui",
"version": "0.16.0",
"family": "panel (genui)",
"tarball": "changfenhuang-dsh-genui-0.16.0.tgz",
"tarball_sha256": "faedeb35fda615f664d217c34d7d23ff076003294e16258352a5dc446d6a1be6",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.input.dock",
"tool.call.toolview"
],
"named_brokers_needed": []
},
{
"row": 23,
"package": "dsh-better-sidebar",
"version": "0.30.1",
"family": "sidebar/panel",
"tarball": "dsh-better-sidebar-0.30.1.tgz",
"tarball_sha256": "3b3deafd3096fdf42b271c4cda0f0e70f024d4f8ef55ffd523b42c6e6d79b41e",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.session.header.utilities",
"main.conversation",
"settings.section",
"sidebar.right.pane.tab"
],
"named_brokers_needed": [
"llm",
"workspace-overlay-rw"
]
},
{
"row": 24,
"package": "@michengai/dsh-codex-ui",
"version": "2.0.21",
"family": "web shell/panel",
"tarball": "michengai-dsh-codex-ui-2.0.21.tgz",
"tarball_sha256": "3c4e1c95652ddc184dc5a581d2de443cf60f2d200ac1f03ae91f8c920fed22ea",
"admission": "admit",
"admission_basis": "engines.dsh",
"declares_client": true,
"declared_dsh_slot_keys": [
"contextMenu.icon",
"conversation.input.dock",
"conversation.input.overlay",
"settings.section",
"sidebar.panellist"
],
"named_brokers_needed": [
"llm"
]
},
{
"row": 25,
"package": "dsh-pet",
"version": "0.4.7",
"family": "panel + llm",
"tarball": "dsh-pet-0.4.7.tgz",
"tarball_sha256": "5337aeb04ce13e230297d96cc8873d49cc8463c807d871a94b9234e1301b579d",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"settings.section",
"sidebar.right.pane.tab"
],
"named_brokers_needed": [
"llm",
"credentials",
"egress"
]
},
{
"row": 26,
"package": "dsh-whale-widget",
"version": "0.4.2",
"family": "panel + account",
"tarball": "dsh-whale-widget-0.4.2.tgz",
"tarball_sha256": "2368de8192c4ba28682a6344f78ace11cb41bac397c13981e36dfca49c0ebc78",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": false,
"declared_dsh_slot_keys": [],
"named_brokers_needed": [
"deepseekAccount-balance-readonly"
]
},
{
"row": 27,
"package": "@linxin666/dsh-client-ui-skill-explorer",
"version": "0.4.16",
"family": "panel",
"tarball": "linxin666-dsh-client-ui-skill-explorer-0.4.16.tgz",
"tarball_sha256": "470064ce42067513c0df08a7159ba853ba4c4a7e2bbce678e4062ac8443b78ce",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"sidebar.panellist"
],
"named_brokers_needed": [
"fs-read"
]
},
{
"row": 28,
"package": "@linxin666/dsh-client-ui-git-graph",
"version": "0.4.14",
"family": "panel",
"tarball": "linxin666-dsh-client-ui-git-graph-0.4.14.tgz",
"tarball_sha256": "5fd0c917316651f303a0bf49784c15aa02e25491f9f05f0289de016288ebab0f",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.input.dock"
],
"named_brokers_needed": [
"git-exec-readonly"
]
},
{
"row": 29,
"package": "dsh-archive-manager",
"version": "1.1.1",
"family": "panel",
"tarball": "dsh-archive-manager-1.1.1.tgz",
"tarball_sha256": "011d9943494645318f3e893174998cf1ebdecd94ce87ff6d04f9815b128b1a0a",
"admission": "refuse",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"sidebar.footer.action",
"sidebar.workspaces"
],
"named_brokers_needed": [],
"refusal": {
"code": "incompatible-version",
"field": "peerDependencies.@deepseek-ai/dsh-agent",
"range": "^0.1.0-rc.6"
}
},
{
"row": 30,
"package": "dshmarket",
"version": "1.73.17",
"family": "market (Engine-brokered)",
"tarball": "dshmarket-1.73.17.tgz",
"tarball_sha256": "b66be433c7952fbbd57d80be47fac82a873039f542ef4457cf858642f8da9564",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config",
"plugins.detail.section",
"plugins.tab",
"settings.plugin.item",
"settings.plugins.tab",
"settings.section",
"sidebar.panellist"
],
"named_brokers_needed": [
"reviewed-install"
]
},
{
"row": 31,
"package": "@deepseek-harness-tui/dsh-tui",
"version": "0.19.0",
"family": "terminal surface (Engine-brokered)",
"tarball": "deepseek-harness-tui-dsh-tui-0.19.0.tgz",
"tarball_sha256": "a5a41b24c69fc68f868ce4d5637feaef227cb7b34a177c0625d938eecab22da7",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": false,
"declared_dsh_slot_keys": [
"settings.plugin.item"
],
"named_brokers_needed": [
"terminal-surface",
"llm",
"approval"
]
},
{
"row": 32,
"package": "dsh-pocket",
"version": "2.12.0",
"family": "remote/mobile surface (Engine-brokered)",
"tarball": "dsh-pocket-2.12.0.tgz",
"tarball_sha256": "aa75689af6a2ded126248e0704ab5000224d2bb755734aa0a2d1aecb41b796d0",
"admission": "admit",
"admission_basis": "no-dsh-declaration",
"declares_client": true,
"declared_dsh_slot_keys": [
"conversation.composer.dock",
"conversation.session.header.actions",
"settings.section",
"sidebar.footer.action"
],
"named_brokers_needed": [
"remote-surface",
"licence-review-GPL-2.0"
]
},
{
"row": 33,
"package": "dsh-plugin-model-proxy",
"version": "0.1.6",
"family": "provider proxy (Engine-brokered)",
"tarball": "dsh-plugin-model-proxy-0.1.6.tgz",
"tarball_sha256": "9a27b24e0fd6152fb6e7b005821298a6eb8d4c4a05cec2ef37fc6b082c2ddf62",
"admission": "admit",
"admission_basis": "peerDependencies",
"declares_client": true,
"declared_dsh_slot_keys": [
"plugins.bundle.config"
],
"named_brokers_needed": [
"provider-routes"
]
}
]
}
+362 -23
View File
@@ -513,6 +513,7 @@ pub struct DshConversion {
/// Ordinary reviewed Native entry; remains experimental and disabled at install.
pub requires_native: bool,
pub native_rows: Vec<String>,
pub client_closures: Vec<DshClientClosure>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -1111,6 +1112,309 @@ fn declares_client(manifest: &Value) -> bool {
.is_some_and(|client| !matches!(client, Value::Null))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DshClientClosure {
pub package: String,
pub root: String,
pub entry: String,
pub platform: Option<String>,
pub inject: Vec<String>,
pub host_provided: Vec<String>,
pub files: BTreeMap<String, String>,
pub bytes: usize,
}
const CLIENT_JS: [&str; 3] = ["js", "mjs", "cjs"];
const CLIENT_PROBES: [&str; 8] = [
"",
".js",
".mjs",
".cjs",
".json",
"/index.js",
"/index.mjs",
"/index.cjs",
];
const NODE_BUILTINS: [&str; 24] = [
"assert",
"buffer",
"child_process",
"cluster",
"crypto",
"dgram",
"dns",
"events",
"fs",
"http",
"http2",
"https",
"module",
"net",
"os",
"path",
"process",
"readline",
"stream",
"tls",
"url",
"util",
"vm",
"zlib",
];
fn normalize_slash(path: &str) -> Option<String> {
if path.contains(['\\', '\0']) {
return None;
}
let mut parts: Vec<&str> = Vec::new();
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
other => parts.push(other),
}
}
Some(parts.join("/"))
}
fn client_entry(manifest: &Value) -> String {
let target = manifest
.get("exports")
.and_then(|exports| exports.get("./client"))
.and_then(|export| match export {
Value::Str(target) => Some(target.as_str()),
Value::Map(_) => ["import", "default", "."]
.iter()
.find_map(|key| export.get(key).and_then(Value::str)),
_ => None,
});
target.unwrap_or("client.js").to_string()
}
fn package_target(manifest: &Value, subpath: &str) -> Option<String> {
let key = if subpath.is_empty() {
".".to_string()
} else {
format!("./{subpath}")
};
let from_exports = manifest.get("exports").and_then(|exports| {
let slot = if matches!(exports, Value::Map(_))
&& exports.keys().iter().any(|name| name.starts_with('.'))
{
exports.get(&key)
} else if subpath.is_empty() {
Some(exports)
} else {
None
};
match slot? {
Value::Str(target) => Some(target.clone()),
conditions @ Value::Map(_) => ["import", "browser", "require", "default"]
.iter()
.find_map(|name| conditions.get(name).and_then(Value::str))
.map(str::to_string),
_ => None,
}
});
from_exports.or_else(|| {
if subpath.is_empty() {
Some(
manifest
.get("main")
.and_then(Value::str)
.unwrap_or("index.js")
.to_string(),
)
} else {
Some(subpath.to_string())
}
})
}
struct ClientScan<'a> {
files: &'a BTreeMap<String, Vec<u8>>,
closure: BTreeMap<String, String>,
host: std::collections::BTreeSet<String>,
bytes: usize,
}
impl ClientScan<'_> {
fn probe(&self, base: &str) -> Option<String> {
CLIENT_PROBES
.iter()
.map(|suffix| format!("{base}{suffix}"))
.find(|candidate| {
candidate.starts_with("source/") && self.files.contains_key(candidate.as_str())
})
}
fn resolve(&mut self, from: &str, specifier: &str) -> Result<Option<String>, String> {
let directory = from.rsplit_once('/').map_or("", |(dir, _)| dir);
if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
let base = normalize_slash(&format!("{directory}/{specifier}"))
.ok_or_else(|| format!("`{specifier}` is outside the reviewed package files"))?;
return self
.probe(&base)
.map(Some)
.ok_or_else(|| format!("`{specifier}` is not in the reviewed package files"));
}
if specifier.starts_with('/') || specifier.contains(':') || specifier.is_empty() {
return Err(format!(
"`{specifier}` is an absolute, URL or built-in specifier"
));
}
let (name, subpath) = match specifier.split_once('/') {
Some((scope, rest)) if scope.starts_with('@') => match rest.split_once('/') {
Some((leaf, sub)) => (format!("{scope}/{leaf}"), sub.to_string()),
None => (specifier.to_string(), String::new()),
},
Some((head, sub)) => (head.to_string(), sub.to_string()),
None => (specifier.to_string(), String::new()),
};
if NODE_BUILTINS.contains(&name.as_str()) {
return Err(format!("`{specifier}` is a Node built-in"));
}
let mut current = directory.to_string();
loop {
let package = format!("{current}/node_modules/{name}");
if let Some(bytes) = self.files.get(&format!("{package}/package.json")) {
let manifest = std::str::from_utf8(bytes)
.ok()
.and_then(|text| parse_json(text).ok())
.ok_or_else(|| format!("`{name}` has an unreadable package.json"))?;
let target = package_target(&manifest, &subpath)
.and_then(|target| normalize_slash(&format!("{package}/{target}")))
.filter(|path| path.starts_with(&format!("{package}/")))
.ok_or_else(|| format!("`{specifier}` has no contained entry"))?;
self.note(format!("{package}/package.json"));
return self
.probe(&target)
.map(Some)
.ok_or_else(|| format!("`{specifier}` is not in the reviewed package files"));
}
match current.rsplit_once('/') {
Some((parent, _)) => current = parent.to_string(),
None => break,
}
}
if name == "react" || name == "react-dom" || name.starts_with("@deepseek-ai/") {
self.host.insert(name);
return Ok(None);
}
Err(format!(
"`{specifier}` is an ambient dependency that is not in the reviewed bundle"
))
}
fn note(&mut self, path: String) {
if let Some(bytes) = self.files.get(&path)
&& !self.closure.contains_key(&path)
{
self.bytes += bytes.len();
self.closure.insert(path, sha256_hex(bytes));
}
}
fn visit(&mut self, entry: String) -> Result<(), String> {
let load = pattern(
r#"(?m)(?:\brequire\s*\(\s*|\bimport\s*\(\s*|(?:^|[;{}])\s*import\s*|\b(?:import|export)\b[^;"'`()]*?\bfrom\b\s*)(?:"([^"\n]*)"|'([^'\n]*)')"#,
);
let dynamic = pattern(
r#"\b(?:require|import)\s*\(\s*(?:[^\s"'\)]|"[^"\n]*"\s*[^\s,\)]|'[^'\n]*'\s*[^\s,\)])"#,
);
let mut pending = vec![entry];
while let Some(path) = pending.pop() {
if self.closure.contains_key(&path) {
continue;
}
require(self.closure.len() < MAX_FILES, "too many client files")
.map_err(|error| error.to_string())?;
self.note(path.clone());
let extension = path
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase());
if !extension
.as_deref()
.is_some_and(|ext| CLIENT_JS.contains(&ext))
{
continue;
}
let text = std::str::from_utf8(&self.files[&path])
.map_err(|_| format!("`{path}` is not UTF-8"))?
.to_string();
if dynamic.is_match(&text) {
return Err(format!("`{path}` loads a module by a computed specifier"));
}
for capture in load.captures_iter(&text) {
let specifier = capture
.get(1)
.or_else(|| capture.get(2))
.map_or("", |m| m.as_str());
if let Some(resolved) = self.resolve(&path, specifier)? {
pending.push(resolved);
}
}
}
Ok(())
}
}
fn client_closure(
files: &BTreeMap<String, Vec<u8>>,
root: &str,
manifest: &Value,
) -> Result<DshClientClosure, String> {
let prefix = if root.is_empty() {
"source".to_string()
} else {
format!("source/{root}")
};
let raw = client_entry(manifest);
let entry = normalize_slash(&format!("{prefix}/{raw}"))
.filter(|path| path.starts_with(&format!("{prefix}/")))
.ok_or_else(|| format!("client entry `{raw}` leaves the package"))?;
if !files.contains_key(&entry) {
return Err(format!(
"client entry `{raw}` is not in the reviewed package files"
));
}
let mut scan = ClientScan {
files,
closure: BTreeMap::new(),
host: Default::default(),
bytes: 0,
};
scan.note(format!("{prefix}/package.json"));
scan.visit(entry.clone())?;
let client = manifest.get("dsh").and_then(|dsh| dsh.get("client"));
let inject = match client.and_then(|client| client.get("inject")) {
Some(Value::Seq(items)) => items
.iter()
.filter_map(|item| item.str().map(refusal_text))
.collect(),
_ => Vec::new(),
};
Ok(DshClientClosure {
package: manifest
.get("name")
.and_then(Value::str)
.map(refusal_text)
.unwrap_or_default(),
root: root.to_string(),
entry,
platform: client
.and_then(|client| client.get("platform"))
.and_then(Value::str)
.map(refusal_text),
inject,
host_provided: scan.host.into_iter().collect(),
files: scan.closure,
bytes: scan.bytes,
})
}
pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
let manifest_bytes = read_file(&package.contained(Path::new("package.json"))?, MAX_DOCUMENT)
.map_err(|_| {
@@ -1127,12 +1431,6 @@ pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
let admitted = admit_host_compatibility(&manifest)?;
let dsh_range = admitted.dsh_range.as_deref();
let mut notes = Vec::new();
if declares_client(&manifest) {
notes.push(
"package declares `dsh.client`; its UI half was not converted and remains disabled because the DSH client runtime is not supported"
.to_string(),
);
}
let declared: Vec<&Value> = match bundle.and_then(|bundle| bundle.get("patch")) {
Some(single @ Value::Str(_)) => vec![single],
Some(Value::Seq(items)) if !items.is_empty() => items.iter().collect(),
@@ -2310,6 +2608,7 @@ fn native_composition_files(
mut document: Json,
output: &mut OutputFiles,
client_outcomes: &mut Vec<DshOutcome>,
client_closures: &mut Vec<DshClientClosure>,
ui: bool,
) -> Result<Vec<String>> {
fn modules(rows: &[Value], names: &mut Vec<String>) -> Result<()> {
@@ -2414,10 +2713,12 @@ fn native_composition_files(
output.add(format!("source/{relative}"), bytes)
})?;
let mut selected_manifests = std::collections::BTreeSet::new();
let mut client_paths: Vec<_> = paths
.iter()
.map(|(name, path)| (name.clone(), path.clone()))
.collect();
let mut client_paths: Vec<_> = vec![(String::new(), package.root.join("index.js"))];
client_paths.extend(
paths
.iter()
.map(|(name, path)| (name.clone(), path.clone())),
);
for name in &names {
if name == presets::AGENT_PRESETS || host_hook_bridge(name) {
let manifest = format!("source/node_modules/{name}/package.json");
@@ -2435,7 +2736,7 @@ fn native_composition_files(
let manifest_path = relative_slash(&parent.join("package.json"), &package.root);
let key = format!("source/{manifest_path}");
if let Some(bytes) = output.files.get(&key) {
if selected_manifests.insert(manifest_path) {
if selected_manifests.insert(manifest_path.clone()) {
require(
bytes.len() <= MAX_DOCUMENT,
"Package manifest exceeds the conversion size limit.",
@@ -2445,18 +2746,52 @@ fn native_composition_files(
})?;
let manifest = parse_json(text)?;
if declares_client(&manifest) {
client_outcomes.push(DshOutcome {
row: Some(refusal_text(&name)),
package: Some(refusal_text(
manifest.get("name").and_then(Value::str).unwrap_or(&name),
)),
kind: "client".into(),
outcome: "skipped".into(),
reason: "Client UI was not converted; the DSH client runtime is not supported."
.into(),
layer: None,
patch: None,
});
let root = manifest_path
.rsplit_once('/')
.map_or("", |(parent, _)| parent);
let row = (!name.is_empty()).then(|| refusal_text(&name));
let package_name = refusal_text(
manifest.get("name").and_then(Value::str).unwrap_or(&name),
);
match client_closure(&output.files, root, &manifest) {
Ok(closure) => {
client_outcomes.push(DshOutcome {
row,
package: Some(package_name),
kind: "client".into(),
outcome: "retained".into(),
reason: format!(
"client closure reviewed and retained: entry {}, {} files, {} bytes{}; it is not served until the Engine client bootstrap admits it",
refusal_text(&closure.entry),
closure.files.len(),
closure.bytes,
if closure.host_provided.is_empty() {
String::new()
} else {
format!(
", host-provided modules {}",
refusal_list(closure.host_provided.iter().cloned())
)
}
),
layer: None,
patch: None,
});
client_closures.push(closure);
}
Err(reason) => client_outcomes.push(DshOutcome {
row,
package: Some(package_name),
kind: "client".into(),
outcome: "skipped".into(),
reason: format!(
"Client UI stays disabled: {}",
refusal_text(&reason)
),
layer: None,
patch: None,
}),
}
}
}
break;
@@ -2549,6 +2884,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
let loaded = load_bundle(&package)?;
let mut output_files = OutputFiles::default();
let mut client_outcomes = Vec::new();
let mut client_closures = Vec::new();
let ui = declares_client(&loaded.manifest);
let native_rows = native_composition_files(
&package,
@@ -2556,6 +2892,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
loaded.composition,
&mut output_files,
&mut client_outcomes,
&mut client_closures,
ui,
)?;
let requires_native = !native_rows.is_empty();
@@ -2783,6 +3120,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
requires_node,
requires_native,
native_rows,
client_closures,
};
output_files.add(
"CONVERSION.md".into(),
@@ -2802,6 +3140,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
"counts": {"skills": conversion.skills.len(), "mcp_servers": components.servers.len(), "native_rows": conversion.native_rows.len()},
"requires_native": conversion.requires_native,
"outcomes": conversion.outcomes,
"client_closures": conversion.client_closures,
"diagnostics": conversion.diagnostics,
"required_manual_ports": conversion.outcomes.iter().filter(|o| o.needs_manual_port()).collect::<Vec<_>>(),
});
+85
View File
@@ -1597,3 +1597,88 @@ fn dsh_admission_validates_engines_peer_ranges_and_platforms_and_admits_cjs_and_
.to_string();
assert!(err.contains(".node"), "{err}");
}
#[test]
fn dsh_client_closure_is_retained_hashed_and_refuses_ambient_dependencies() {
let tmp = tempfile::tempdir().unwrap();
let make = |name: &str, client: &str| -> PathBuf {
let dir = tmp.path().join(name);
fs::create_dir_all(dir.join("lib")).unwrap();
fs::write(
dir.join("package.json"),
serde_json::json!({
"name": format!("@demo/{name}"),
"version": "1.0.0",
"main": "lib/index.cjs",
"exports": { "./client": { "default": "./lib/client.js" } },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "inject": ["@deepseek-ai/dsh-client-ui-primitives"], "platform": "web" }
}
})
.to_string(),
)
.unwrap();
fs::write(
dir.join("cordis.patch.yml"),
"- insert:\n - id: entry\n name: ./lib/index.cjs\n",
)
.unwrap();
fs::write(
dir.join("lib/index.cjs"),
"module.exports = { apply() {} };\n",
)
.unwrap();
fs::write(dir.join("lib/client.js"), client).unwrap();
fs::write(dir.join("lib/util.js"), "exports.v = 1;\n").unwrap();
dir
};
let good = make(
"good",
"const r = require(\"react\");\nconst p = require('@deepseek-ai/dsh-client-ui-primitives');\nconst u = require(\"./util\");\n",
);
let converted = dsh::convert_package(&good, &tmp.path().join("out-good")).unwrap();
assert_eq!(converted.client_closures.len(), 1);
let closure = &converted.client_closures[0];
assert_eq!(closure.entry, "source/lib/client.js");
assert!(closure.files.contains_key("source/lib/util.js"));
assert_eq!(closure.host_provided.len(), 2);
let installed = fs::read(tmp.path().join("out-good/source/lib/util.js")).unwrap();
assert_eq!(
closure.files["source/lib/util.js"],
codewhale_skill_install::install::sha256_hex(&installed)
);
assert!(
converted
.outcomes
.iter()
.any(|o| o.kind == "client" && o.outcome == "retained")
);
assert!(!converted.outcomes.iter().any(|o| o.needs_manual_port()));
for (label, source) in [
("ambient", "require(\"left-pad\");\n"),
("builtin", "require(\"node:fs\");\n"),
("computed", "const n = \"x\"; require(n);\n"),
("concat", "const l = \"en\"; require(\"./locale/\" + l);\n"),
("concat-import", "const l = \"en\"; import(\"./a\" + l);\n"),
("escape", "require(\"../../outside.js\");\n"),
] {
let bad = make(label, source);
let converted =
dsh::convert_package(&bad, &tmp.path().join(format!("out-{label}"))).unwrap();
assert!(converted.client_closures.is_empty(), "{label}");
assert!(
converted
.outcomes
.iter()
.any(|o| o.kind == "client" && o.outcome == "skipped"),
"{label}"
);
assert!(
converted.requires_native,
"{label}: server half still imports"
);
}
}
+27 -6
View File
@@ -11,24 +11,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
### Changed
- **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.
- 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)
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
### Fixed
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
## [0.10.3] - 2026-11-08
@@ -37,11 +56,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Work in progress and known limits
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
decision keeps App Server v2, the coarse Engine split and DSH all-33
compatibility in 0.10.3; the final tag waits for that full list. The near-term
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
tag. Items moved to 0.10.4 by name are marked below.
This source candidate is not the final 0.10.3 tag. There is no
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
for the full scope list. Codex App Server v2 is paused and is not part of
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
terminal sessions and background jobs owned by that Runtime thread. The
@@ -584,7 +604,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
@@ -1056,6 +1076,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
## [0.10.2] - 2026-10-22
@@ -615,12 +615,9 @@ pub(crate) fn restore_undo_range(plan: UndoRangePlan) -> anyhow::Result<String>
if plan.turns.iter().all(|turn| !turn.may_change_files) {
return Ok("Workspace files were left unchanged.".to_string());
}
let result = codewhale_app_server::runtime_api::patch_undo_workspace_files(
&plan.workspace,
&plan.turns,
true,
)
.map_err(|error| anyhow::anyhow!(error.message))?;
let result =
codewhale_app_server::runtime_api::patch_undo_workspace_files(&plan.workspace, &plan.turns)
.map_err(|error| anyhow::anyhow!(error.message))?;
Ok(result
.summary
.unwrap_or_else(|| "No workspace file changes needed restoring.".to_string()))
+78 -6
View File
@@ -453,6 +453,7 @@ fn parse_cli_args(args: &[impl AsRef<str>]) -> Result<Cli, clap::Error> {
{
let command = command.find_subcommand_mut("exec").expect("exec command");
for word in exec.prompt.iter().take_while(|word| *word != "--") {
let word = ascii_flag_spelling(word);
let flag = word.split_once('=').map_or(word.as_str(), |(flag, _)| flag);
if flag.strip_prefix("--").is_some_and(|name| {
command.get_arguments().any(|arg| {
@@ -477,6 +478,25 @@ fn parse_cli_args(args: &[impl AsRef<str>]) -> Result<Cli, clap::Error> {
Ok(cli)
}
fn ascii_flag_spelling(word: &str) -> String {
let typographic = |c: char| matches!(c, '\u{2010}'..='\u{2015}' | '\u{2212}' | '\u{FF0D}');
let folded: String = word
.chars()
.map(|c| match c {
'\u{FF01}'..='\u{FF5E}' => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c),
c if typographic(c) => '-',
c => c,
})
.collect();
let single_typographic = word.chars().next().is_some_and(typographic)
&& !word.chars().nth(1).is_some_and(typographic);
if single_typographic && folded.len() > 1 {
format!("-{folded}")
} else {
folded
}
}
fn headless_exec_args(
resume: Option<String>,
continue_session: bool,
@@ -958,7 +978,8 @@ const MAX_EXEC_PROMPT_BYTES: u64 = 64 * 1024 * 1024;
/// The effective exec prompt: argv words, a `--prompt-file`, or piped stdin.
/// A positional `-` stays literal text. When stdin is not a terminal and no
/// `--prompt-file` is set, a pipe is the prompt, or is appended to argv.
/// `--prompt-file` is set, piped stdin is the prompt, or is appended to the
/// argv prompt when it delivers data within `EXEC_STDIN_GRACE`.
/// `--parent-death-watch` owns stdin and does not read it as a prompt.
fn resolve_exec_prompt(args: &ExecArgs) -> Result<String> {
let Some(path) = &args.prompt_file else {
@@ -990,6 +1011,44 @@ fn resolve_exec_prompt(args: &ExecArgs) -> Result<String> {
Ok(prompt)
}
const EXEC_STDIN_GRACE: Duration = Duration::from_secs(2);
fn read_stdin_context(grace: Duration) -> Result<Option<String>> {
let (tx, rx) = std::sync::mpsc::channel::<Result<Option<String>, String>>();
let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let mut stdin = io::stdin().lock();
let mut first = [0u8; 1];
let read = loop {
match stdin.read(&mut first) {
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
other => break other,
}
};
let _ = started_tx.send(());
let outcome = match read {
Ok(0) => Ok(Some(String::new())),
Ok(_) => read_capped_text(
io::Cursor::new(first).chain(stdin),
MAX_EXEC_PROMPT_BYTES,
"exec prompt on stdin",
)
.map(Some)
.map_err(|error| error.to_string()),
Err(error) => Err(error.to_string()),
};
let _ = tx.send(outcome);
});
if started_rx.recv_timeout(grace).is_err() {
return Ok(None);
}
match rx.recv() {
Ok(Ok(piped)) => Ok(piped),
Ok(Err(message)) => bail!("{message}"),
Err(_) => bail!("stdin reader stopped unexpectedly"),
}
}
fn resolve_argv_or_piped_prompt(args: &ExecArgs) -> Result<String> {
let argv = join_prompt_parts(&args.prompt);
if args.parent_death_watch || io::stdin().is_terminal() {
@@ -998,11 +1057,24 @@ fn resolve_argv_or_piped_prompt(args: &ExecArgs) -> Result<String> {
}
return Ok(argv);
}
let piped = read_capped_text(
io::stdin().lock(),
MAX_EXEC_PROMPT_BYTES,
"exec prompt on stdin",
)?;
let piped = if argv.trim().is_empty() {
read_capped_text(
io::stdin().lock(),
MAX_EXEC_PROMPT_BYTES,
"exec prompt on stdin",
)?
} else {
match read_stdin_context(EXEC_STDIN_GRACE)? {
Some(piped) => piped,
None => {
eprintln!(
"note: stdin produced no data within {}s; running with the prompt argument alone. To wait for a slow producer, pipe the prompt too: {{ echo \"<prompt>\"; cmd; }} | codewhale exec.",
EXEC_STDIN_GRACE.as_secs()
);
String::new()
}
}
};
if argv.trim().is_empty() {
if piped.trim().is_empty() {
bail!("exec prompt from stdin is empty");
+116 -21
View File
@@ -42,6 +42,8 @@ pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
/// Per-directory entry ceiling when inlining a directory listing.
pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
const MAX_DIRECTORY_MENTION_SCAN: usize = 10_000;
const MAX_MENTION_RANGE_SCAN_BYTES: u64 = 64 * 1024 * 1024;
/// Maximum file-mention completion candidates to consider per keypress. Caps
/// the cost of walking large workspaces; subsequent keystrokes narrow further.
@@ -1415,46 +1417,78 @@ fn render_file_mention_context(
}
fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(err) => {
return format!(
"<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
);
match directory_listing(
path,
MAX_DIRECTORY_MENTION_ENTRIES,
MAX_DIRECTORY_MENTION_SCAN,
) {
Ok((body, false)) => {
format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
}
};
Ok((body, true)) => format!(
"<directory mention=\"@{raw}\" path=\"{display_path}\" truncated=\"true\">\n{body}\n</directory>"
),
Err(err) => format!(
"<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
),
}
}
// Keep only the first MAX entries in sort order while counting the rest:
// a max-heap of that size bounds memory to the output instead of
// materializing and sorting the whole directory (U07-03).
let mut total = 0usize;
let mut kept = std::collections::BinaryHeap::with_capacity(MAX_DIRECTORY_MENTION_ENTRIES + 1);
for entry in entries.filter_map(|entry| entry.ok()) {
total += 1;
/// The first `keep` entry names of `path` in sort order (directories marked
/// `/`) and whether enumeration stopped at `scan_limit` with entries left.
/// A max-heap of `keep` names bounds memory (U07-03); the scan limit bounds
/// work, so a partial listing's names are the first among those examined.
fn directory_listing(
path: &Path,
keep: usize,
scan_limit: usize,
) -> std::io::Result<(String, bool)> {
let mut entries = std::fs::read_dir(path)?;
let mut scanned = 0usize;
let mut kept = std::collections::BinaryHeap::with_capacity(keep.saturating_add(1));
let mut partial = false;
loop {
if scanned == scan_limit {
partial = entries.next().is_some();
break;
}
let Some(entry) = entries.next() else {
break;
};
scanned += 1;
let Ok(entry) = entry else {
continue;
};
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
kept.push(format!("{}{}", entry.file_name().to_string_lossy(), marker));
if kept.len() > MAX_DIRECTORY_MENTION_ENTRIES {
if kept.len() > keep {
kept.pop();
}
}
let names = kept.into_sorted_vec();
let mut body = names.join("\n");
if total > MAX_DIRECTORY_MENTION_ENTRIES {
let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
let omitted = scanned.saturating_sub(names.len());
if partial {
let _ = write!(
body,
"\n... listing stopped after {scanned} entries; more exist"
);
} else if omitted > 0 {
let _ = write!(body, "\n... {omitted} more entries");
}
format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
Ok((body, partial))
}
/// Bounded read of a mention's file content, optionally sliced to a line
/// range. Returns `(text, truncated, beyond_eof)`: `truncated` mirrors the
/// full-file byte bound, except for a range that starts past that bound,
/// where it says whether the streamed range itself was cut; `beyond_eof` is
/// set only when the requested range starts past the end of the file.
/// set only when the requested range starts past the end of the file. A range
/// whose scan budget runs out first is `truncated` with no `beyond_eof`.
fn read_file_content(
path: &Path,
range: Option<FileRange>,
@@ -1474,7 +1508,12 @@ fn read_file_content(
// The prefix ended, not the file: read the range from the file
// itself instead of calling it past EOF (U07-04).
std::io::Seek::rewind(&mut file)?;
return read_line_range_past_prefix(file, u64::from(start), u64::from(end));
return read_line_range_past_prefix(
file,
u64::from(start),
u64::from(end),
MAX_MENTION_RANGE_SCAN_BYTES,
);
}
return Ok((String::new(), truncated, true));
}
@@ -1484,20 +1523,27 @@ fn read_file_content(
/// Stream a 1-based inclusive line range that starts beyond the bounded
/// prefix. Skipped lines are scanned, never stored; the range text itself is
/// held to the same byte budget as a whole-file mention.
/// held to the same byte budget as a whole-file mention. At most `scan_limit`
/// bytes are consumed; running out before the first line yields no text and
/// `truncated`, and only a real end of file reads as beyond EOF.
fn read_line_range_past_prefix(
file: impl Read,
start: u64,
end: u64,
scan_limit: u64,
) -> std::io::Result<(String, bool, bool)> {
use std::io::BufRead;
let mut reader = std::io::BufReader::new(file);
let mut line = 1u64;
let mut scanned = 0u64;
while line < start {
let buf = reader.fill_buf()?;
if buf.is_empty() {
return Ok((String::new(), false, true));
}
if scanned >= scan_limit {
return Ok((String::new(), true, false));
}
let consumed = match buf.iter().position(|byte| *byte == b'\n') {
Some(newline) => {
line += 1;
@@ -1505,6 +1551,7 @@ fn read_line_range_past_prefix(
}
None => buf.len(),
};
scanned += consumed as u64;
reader.consume(consumed);
}
if reader.fill_buf()?.is_empty() {
@@ -1522,6 +1569,10 @@ fn read_line_range_past_prefix(
Some(newline) => (newline + 1, true),
None => (buf.len(), false),
};
if scanned >= scan_limit {
truncated = true;
break;
}
let room = budget - buffer.len();
if take > room {
buffer.extend_from_slice(&buf[..room]);
@@ -1529,6 +1580,7 @@ fn read_line_range_past_prefix(
break;
}
buffer.extend_from_slice(&buf[..take]);
scanned += take as u64;
reader.consume(take);
if newline {
line += 1;
@@ -2167,6 +2219,49 @@ mod tests {
assert!(beyond_eof, "the file really ends at line 20000");
}
#[test]
fn mention_scan_bounds_report_partial_not_eof() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("lines.txt");
std::fs::write(&path, "aaaa\nbbbb\ncccc\ndddd\n").expect("write");
let open = || std::fs::File::open(&path).expect("open");
let (text, truncated, beyond_eof) =
read_line_range_past_prefix(open(), 4, 4, 7).expect("bounded read");
assert!(truncated && !beyond_eof && text.is_empty());
let (text, truncated, beyond_eof) =
read_line_range_past_prefix(open(), 4, 4, 1 << 20).expect("unbounded read");
assert!(!truncated && !beyond_eof);
assert_eq!(text, "dddd");
let (_, truncated, beyond_eof) =
read_line_range_past_prefix(open(), 9, 9, 1 << 20).expect("past the end");
assert!(!truncated && beyond_eof);
let (text, truncated, beyond_eof) =
read_line_range_past_prefix(open(), 1, 4, 12).expect("cut short");
assert!(truncated && !beyond_eof, "{text:?}");
assert!(text.starts_with("aaaa\nbbbb"), "{text:?}");
let dir = tmp.path().join("many");
std::fs::create_dir(&dir).expect("mkdir");
for n in 0..30 {
std::fs::write(dir.join(format!("f{n:02}")), "x").expect("write");
}
let (body, partial) = directory_listing(&dir, 5, 100).expect("listing");
assert!(!partial);
assert!(body.ends_with("... 25 more entries"), "{body}");
let (body, partial) = directory_listing(&dir, 5, 12).expect("listing");
assert!(partial);
assert!(
body.ends_with("listing stopped after 12 entries; more exist"),
"{body}"
);
let (_, partial) = directory_listing(&dir, 5, 30).expect("listing");
assert!(
!partial,
"exactly the scan limit with nothing left is complete"
);
}
#[test]
fn ranged_file_mention_slices_lines_and_reports_beyond_eof() {
let tmp = TempDir::new().expect("tempdir");
+2 -4
View File
@@ -42,9 +42,8 @@ pub(super) fn is_copy_shortcut(key: &KeyEvent) -> bool {
/// Toggle the file-tree pane: `Ctrl+Shift+E` on Linux/Windows or
/// `Cmd+Shift+E` on macOS.
pub(super) fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool {
let is_shifted_e = matches!(key.code, KeyCode::Char('E'))
|| (matches!(key.code, KeyCode::Char('e')) && key.modifiers.contains(KeyModifiers::SHIFT));
if !is_shifted_e {
let is_e = matches!(key.code, KeyCode::Char('e' | 'E'));
if !is_e || !key.modifiers.contains(KeyModifiers::SHIFT) {
return false;
}
@@ -53,7 +52,6 @@ pub(super) fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool {
let ctrl_shift_e = key.modifiers.contains(KeyModifiers::CONTROL) && !has_forbidden_modifier;
let cmd_shift_e = key.modifiers.contains(KeyModifiers::SUPER)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT);
+6 -5
View File
@@ -331,16 +331,17 @@ impl Client {
Ok(value)
}
pub fn open_browser(&self) -> io::Result<()> {
let url = format!(
"http://127.0.0.1:{}/#{}",
self.descriptor.port, self.descriptor.token
);
let code = self.post("/v1/pair", &json!({}))?["code"]
.as_str()
.ok_or_else(|| io::Error::other("Pet pairing code unavailable"))?
.to_string();
let url = format!("http://127.0.0.1:{}/#{code}", self.descriptor.port);
#[cfg(all(unix, not(target_os = "macos")))]
{
use std::process::{Command as Process, Stdio};
// `webbrowser` resolves `$BROWSER` and `xdg-open` through the
// environment and `PATH`, where a workspace could shadow the
// launcher and receive this URL's owner token. Use the launcher
// launcher and receive this URL's one-time code. Use the launcher
// from a trusted system prefix only (U06-06).
let launcher = codewhale_notify::audio::trusted_system_executable("xdg-open")?;
let mut child = Process::new(launcher)
+151 -5
View File
@@ -24,6 +24,8 @@ use codewhale_util::files::{WorkspaceFile, same_file};
const LEASE: Duration = Duration::from_secs(2);
const OWNER_IDLE_EXIT: Duration = Duration::from_secs(600);
const MAX_CLIENTS: usize = 4096;
const PAIR_CODE_LIFETIME: Duration = Duration::from_secs(30);
const MAX_PAIR_CODES: usize = 8;
/// Request body limit for every owner route. Clients size batches to it.
pub(super) const MAX_REQUEST_BYTES: usize = 64 * 1024;
/// How long a route waits for the world thread's reply before answering 503.
@@ -158,6 +160,7 @@ struct Service {
descriptor: Descriptor,
frames: Arc<Mutex<VecDeque<Value>>>,
tx: mpsc::SyncSender<Work>,
codes: Arc<Mutex<Vec<(String, Instant)>>>,
}
fn valid_client(id: &str) -> bool {
@@ -181,16 +184,19 @@ fn token_matches(candidate: &[u8], expected: &[u8]) -> bool {
diff == 0
}
fn authorized(headers: &HeaderMap, state: &Service) -> bool {
fn pinned(headers: &HeaderMap, state: &Service) -> bool {
let origin = format!("http://127.0.0.1:{}", state.descriptor.port);
let host = format!("127.0.0.1:{}", state.descriptor.port);
if headers.get("host").and_then(|v| v.to_str().ok()) != Some(host.as_str()) {
return false;
}
if headers
!headers
.get("origin")
.is_some_and(|o| o.as_bytes() != origin.as_bytes())
{
}
fn authorized(headers: &HeaderMap, state: &Service) -> bool {
if !pinned(headers, state) {
return false;
}
let bearer = format!("Bearer {}", state.descriptor.token);
@@ -207,6 +213,35 @@ fn authorized(headers: &HeaderMap, state: &Service) -> bool {
})
}
fn mint_code(state: &Service, now: Instant) -> Option<String> {
let mut codes = state.codes.lock().ok()?;
codes.retain(|(_, issued)| now.duration_since(*issued) < PAIR_CODE_LIFETIME);
if codes.len() >= MAX_PAIR_CODES {
codes.remove(0);
}
let code = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
codes.push((code.clone(), now));
Some(code)
}
fn redeem_code(state: &Service, candidate: &[u8], now: Instant) -> bool {
let Ok(mut codes) = state.codes.lock() else {
return false;
};
codes.retain(|(_, issued)| now.duration_since(*issued) < PAIR_CODE_LIFETIME);
let mut found = None;
for (index, (code, _)) in codes.iter().enumerate() {
if token_matches(candidate, code.as_bytes()) {
found = Some(index);
}
}
found.map(|index| codes.remove(index)).is_some()
}
fn answer(result: Result<Value, String>) -> Response {
match result {
Ok(value) => Json(value).into_response(),
@@ -290,10 +325,23 @@ async fn export(State(state): State<Service>, headers: HeaderMap) -> Response {
}
submit(&state, Work::Export).await
}
async fn attach(State(state): State<Service>, headers: HeaderMap) -> Response {
async fn pair(State(state): State<Service>, headers: HeaderMap) -> Response {
if !authorized(&headers, &state) {
return StatusCode::UNAUTHORIZED.into_response();
}
match mint_code(&state, Instant::now()) {
Some(code) => Json(json!({"code": code})).into_response(),
None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
}
async fn attach(State(state): State<Service>, headers: HeaderMap) -> Response {
let redeemed = pinned(&headers, &state)
&& headers
.get("x-pet-code")
.is_some_and(|v| redeem_code(&state, v.as_bytes(), Instant::now()));
if !redeemed && !authorized(&headers, &state) {
return StatusCode::UNAUTHORIZED.into_response();
}
(
[
(
@@ -394,6 +442,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> {
descriptor: descriptor.clone(),
frames: frames.clone(),
tx,
codes: Arc::new(Mutex::new(Vec::new())),
};
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let (ended_tx, ended_rx) = tokio::sync::oneshot::channel();
@@ -419,7 +468,7 @@ pub fn serve(root: PathBuf, requested_port: u16) -> anyhow::Result<()> {
.route("/pixel-whale.webp", get(|| async { ([("content-type", "image/webp")], include_bytes!("pixel-whale.webp").as_slice()) }))
.route("/v1/frame", get(frame)).route("/v1/action", post(action))
.route("/v1/producer", post(producer)).route("/v1/audio", post(audio))
.route("/v1/export", get(export)).route("/v1/attach", post(attach))
.route("/v1/export", get(export)).route("/v1/attach", post(attach)).route("/v1/pair", post(pair))
.layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES))
.layer(axum::middleware::from_fn(|request: axum::extract::Request, next: axum::middleware::Next| async move {
let mut response = next.run(request).await;
@@ -1015,6 +1064,103 @@ mod tests {
activity
}
fn service(port: u16) -> Service {
let (tx, _rx) = mpsc::sync_channel(1);
Service {
descriptor: Descriptor {
version: 1,
port,
token: "a".repeat(64),
identity: uuid::Uuid::new_v4().to_string(),
},
frames: Arc::new(Mutex::new(VecDeque::new())),
tx,
codes: Arc::new(Mutex::new(Vec::new())),
}
}
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (key, value) in pairs {
map.insert(
axum::http::HeaderName::from_bytes(key.as_bytes()).unwrap(),
axum::http::HeaderValue::from_str(value).unwrap(),
);
}
map
}
#[test]
fn authorized_accepts_only_the_owner_token_on_the_pinned_origin() {
let state = service(4100);
let token = "a".repeat(64);
let bearer = format!("Bearer {token}");
let cookie = format!("theme=dark; cw_pet={token}");
let wrong = format!("Bearer {}", "b".repeat(64));
let host = ("host", "127.0.0.1:4100");
assert!(authorized(
&headers(&[host, ("authorization", &bearer)]),
&state
));
assert!(authorized(&headers(&[host, ("cookie", &cookie)]), &state));
assert!(authorized(
&headers(&[
host,
("origin", "http://127.0.0.1:4100"),
("authorization", &bearer)
]),
&state
));
assert!(!authorized(&headers(&[host]), &state));
assert!(!authorized(
&headers(&[host, ("authorization", &wrong)]),
&state
));
assert!(!authorized(
&headers(&[host, ("authorization", &token)]),
&state
));
assert!(!authorized(
&headers(&[host, ("cookie", &format!("cw_pet={}", "b".repeat(64)))]),
&state
));
assert!(!authorized(
&headers(&[("host", "evil.test:4100"), ("authorization", &bearer)]),
&state
));
assert!(!authorized(&headers(&[("authorization", &bearer)]), &state));
assert!(!authorized(
&headers(&[
host,
("origin", "http://evil.test"),
("authorization", &bearer)
]),
&state
));
}
#[test]
fn pair_code_is_single_use_and_expires() {
let state = service(4100);
let now = Instant::now();
let code = mint_code(&state, now).unwrap();
assert_eq!(code.len(), 64);
assert_ne!(code, state.descriptor.token);
assert!(!redeem_code(&state, b"nope", now));
assert!(redeem_code(&state, code.as_bytes(), now));
assert!(!redeem_code(&state, code.as_bytes(), now));
let stale = mint_code(&state, now).unwrap();
assert!(!redeem_code(
&state,
stale.as_bytes(),
now + PAIR_CODE_LIFETIME
));
for _ in 0..MAX_PAIR_CODES + 3 {
mint_code(&state, now).unwrap();
}
assert_eq!(state.codes.lock().unwrap().len(), MAX_PAIR_CODES);
}
#[test]
fn committed_bundle_projection_satisfies_the_owner_contract() {
let runtime = Runtime::new().unwrap();
+1 -1
View File
@@ -85,6 +85,6 @@ for(let i=0;i<pose.points.length;i++){const q=pose.points[i],p=previous?.points[
let color=m.slice(0,3),alpha=m[3];if(local){if(!a.eventColors)color=a.particle;else if(light){const peak=Math.max(...color,1);color=color.map(v=>Math.round(v*Math.min(1,115/peak)))}alpha=Math.min(1,alpha*a.brightness/(preview?1:f.appearance?.brightness||1))}if(pixel&&pixelWhale.complete&&pixelWhale.naturalWidth)alpha*=morph;if(hollow)alpha=Math.min(alpha,.48);if(stale)alpha*=.55;
const material='rgb('+color.map(Math.round).join(',')+')',depth=.8+.2*Math.min(1,alpha);ctx.fillStyle=ctx.strokeStyle=material;ctx.globalAlpha=alpha;ctx.lineWidth=Math.max(.7,radius*.6);ctx.beginPath();ctx.arc(x,y,radius*depth,0,Math.PI*2);hollow?ctx.stroke():ctx.fill();if(!hollow&&!light&&a.glow>0&&i%5===0){ctx.globalAlpha=alpha*.035*a.glow;ctx.beginPath();ctx.arc(x,y,radius*(1+a.glow*4),0,Math.PI*2);ctx.fill()}}ctx.globalAlpha=1;}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,c=cue(activity),known=c&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?c.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?((preview?'Simulated action':'Observed action')+(c.actionId?' · '+c.actionId:'')):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;const phrase=$('activity-phrase');phrase.hidden=!known||!current.displayPhrase;phrase.lang=current.displayLanguage||'en';if(phrase.textContent!==(current.displayPhrase||''))phrase.textContent=current.displayPhrase||'';$('activity-parallel').textContent=known&&activity.parallelAgentCount?activity.parallelAgentCount+' parallel agent'+(activity.parallelAgentCount===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+'Codewhale'+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(current.displayPhrase?current.displayPhrase+'; '+current.displayLanguage+'; ':'')+(selected==='Custom'?'custom':selected)+' appearance')}}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {Authorization:'Bearer '+token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {'X-Pet-Code':token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
requestAnimationFrame(draw);connect().catch(e=>{$('message').textContent=e.message});
</script></html>
+7 -1
View File
@@ -5878,9 +5878,15 @@ fn file_tree_shortcut_does_not_steal_plain_ctrl_e() {
assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(
&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)
));
assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(
assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(
&KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)
));
assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(
&KeyEvent::new(
KeyCode::Char('E'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)
));
assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(
&KeyEvent::new(
KeyCode::Char('e'),
@@ -0,0 +1,257 @@
#![cfg(unix)]
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use tempfile::TempDir;
use wait_timeout::ChildExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_MODEL: &str = "prompt-input-model";
const RUN_TIMEOUT: Duration = Duration::from_secs(60);
enum Stdin {
Inherit,
Bytes(&'static str),
OpenPipe,
}
struct Outcome {
success: bool,
stdout: String,
stderr: String,
elapsed: Duration,
dispatched: String,
}
fn answer_sse() -> String {
let chunk = |value: Value| format!("data: {}\n\n", value);
[
chunk(json!({
"id": "c1", "object": "chat.completion.chunk", "model": TEST_MODEL,
"choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": null}]
})),
chunk(json!({
"id": "c1", "object": "chat.completion.chunk", "model": TEST_MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
})),
"data: [DONE]\n\n".to_string(),
]
.join("")
}
async fn start_mock_llm() -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"object": "list",
"data": [{ "id": TEST_MODEL, "object": "model" }]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(answer_sse()),
)
.mount(&server)
.await;
server
}
fn drain<R: Read + Send + 'static>(mut reader: R) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut out = Vec::new();
let _ = reader.read_to_end(&mut out);
out
})
}
async fn run_exec(server: &MockServer, exec_args: &[&str], stdin: Stdin) -> Outcome {
let workspace = TempDir::new().expect("workspace");
let home = TempDir::new().expect("home");
std::fs::create_dir_all(home.path().join(".codewhale")).expect("config dir");
let mut command = Command::new(crate::binary::codewhale());
command.env_clear();
for key in ["PATH", "TMPDIR", "TERM", "LANG", "LC_ALL"] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command
.current_dir(workspace.path())
.arg("--workspace")
.arg(workspace.path())
.arg("--no-project-config")
.arg("exec")
.args(exec_args)
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", home.path().join(".config"))
.env(
"CODEWHALE_CONFIG_PATH",
home.path().join(".codewhale").join("config.toml"),
)
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
.env("DEEPSEEK_BASE_URL", server.uri())
.env("CODEWHALE_BASE_URL", server.uri())
.env("DEEPSEEK_MODEL", TEST_MODEL)
.env("CODEWHALE_MODEL", TEST_MODEL)
.env("RUST_LOG", "warn")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match stdin {
Stdin::Inherit => {
command.stdin(Stdio::null());
}
Stdin::Bytes(_) | Stdin::OpenPipe => {
command.stdin(Stdio::piped());
}
}
let started = Instant::now();
let mut child = command.spawn().expect("spawn codewhale exec");
let mut held_open = None;
match stdin {
Stdin::Bytes(text) => {
let mut pipe = child.stdin.take().expect("stdin pipe");
let _ = pipe.write_all(text.as_bytes());
}
Stdin::OpenPipe => held_open = child.stdin.take(),
Stdin::Inherit => {}
}
let stdout = drain(child.stdout.take().expect("stdout"));
let stderr = drain(child.stderr.take().expect("stderr"));
let status = match child.wait_timeout(RUN_TIMEOUT).expect("wait") {
Some(status) => status,
None => {
let _ = child.kill();
let _ = child.wait();
panic!(
"codewhale exec did not exit within {RUN_TIMEOUT:?}\nstderr:\n{}",
String::from_utf8_lossy(&stderr.join().unwrap_or_default())
);
}
};
let elapsed = started.elapsed();
drop(held_open);
let dispatched = server
.received_requests()
.await
.unwrap_or_default()
.iter()
.filter(|request| request.url.path() == "/v1/chat/completions")
.map(|request| String::from_utf8_lossy(&request.body).into_owned())
.collect::<Vec<_>>()
.join("\n");
Outcome {
success: status.success(),
stdout: String::from_utf8_lossy(&stdout.join().unwrap_or_default()).into_owned(),
stderr: String::from_utf8_lossy(&stderr.join().unwrap_or_default()).into_owned(),
elapsed,
dispatched,
}
}
fn assert_rejected_before_dispatch(outcome: &Outcome, flag: &str) {
assert!(
!outcome.success,
"a flag after the prompt must fail\nstdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
assert!(
outcome.stderr.contains(flag),
"stderr must name {flag}\nstderr:\n{}",
outcome.stderr
);
assert!(
outcome.dispatched.is_empty(),
"nothing may reach the provider: {}",
outcome.dispatched
);
}
#[tokio::test(flavor = "multi_thread")]
async fn flags_after_the_prompt_are_rejected_not_sent_as_text() {
let server = start_mock_llm().await;
for args in [
vec!["fix tests", "--auto"],
vec!["fix", "tests", "--auto", "now"],
vec!["fix tests", "--auto=true"],
] {
let outcome = run_exec(&server, &args, Stdin::Inherit).await;
assert_rejected_before_dispatch(&outcome, "--auto");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn full_width_and_typographic_flags_after_the_prompt_are_rejected() {
let server = start_mock_llm().await;
for (word, shown) in [
("\u{FF0D}\u{FF0D}auto", "--auto"),
("\u{2014}auto", "--auto"),
("\u{2013}\u{2013}auto", "--auto"),
("--\u{FF41}\u{FF55}\u{FF54}\u{FF4F}", "--auto"),
] {
let outcome = run_exec(&server, &["fix tests", word], Stdin::Inherit).await;
assert_rejected_before_dispatch(&outcome, shown);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn separator_keeps_a_literal_flag_in_the_prompt() {
let server = start_mock_llm().await;
let outcome = run_exec(&server, &["--", "explain", "--auto"], Stdin::Inherit).await;
assert!(outcome.success, "stderr:\n{}", outcome.stderr);
assert!(
outcome.dispatched.contains("explain --auto"),
"dispatched: {}",
outcome.dispatched
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unrelated_open_pipe_does_not_hang_an_explicit_prompt() {
let server = start_mock_llm().await;
let outcome = run_exec(&server, &["say hello"], Stdin::OpenPipe).await;
assert!(
outcome.success,
"stdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
assert!(outcome.dispatched.contains("say hello"));
assert!(
outcome.stderr.contains("stdin produced no data"),
"the ignored stdin must be reported\nstderr:\n{}",
outcome.stderr
);
assert!(
outcome.elapsed < Duration::from_secs(30),
"elapsed {:?}",
outcome.elapsed
);
}
#[tokio::test(flavor = "multi_thread")]
async fn piped_stdin_is_appended_to_the_prompt_and_is_the_prompt_alone() {
let server = start_mock_llm().await;
let appended = run_exec(
&server,
&["review this"],
Stdin::Bytes("diff --git a/x b/x\n"),
)
.await;
assert!(appended.success, "stderr:\n{}", appended.stderr);
assert!(appended.dispatched.contains("review this"));
assert!(appended.dispatched.contains("diff --git a/x b/x"));
let alone = run_exec(&server, &[], Stdin::Bytes("summarize the pipe prompt\n")).await;
assert!(alone.success, "stderr:\n{}", alone.stderr);
assert!(alone.dispatched.contains("summarize the pipe prompt"));
}
+1
View File
@@ -21,6 +21,7 @@ mod diagnostic_read_only;
mod dotenv_authority;
mod eval_harness;
mod exec_persistent_service;
mod exec_prompt_input;
mod exec_stream_drop_acceptance;
mod exec_turn_usage;
mod integration_mock_llm;
+1
View File
@@ -78,6 +78,7 @@ changelog or release notes, and this contributor record.
- **[asto18089](https://github.com/asto18089)** — diagnosed the NoNewPrivs child-matrix failure on hardened containers ([#7021](https://github.com/codewhale-hq/Codewhale/issues/7021)), dropped the false sandbox claim from the `fetch_url` description ([#7222](https://github.com/codewhale-hq/Codewhale/pull/7222)) and limited nested-repository discovery to the sub-agent worktree anchor ([#7224](https://github.com/codewhale-hq/Codewhale/pull/7224)). Accepted absolute in-workspace paths in sub-agent write claims ([#7227](https://github.com/codewhale-hq/Codewhale/pull/7227)). Repaired pre-0.10.0 topology checkpoint placement on session restore ([#7229](https://github.com/codewhale-hq/Codewhale/pull/7229)). Pointed the `verify` description at the Run tool instead of a retired name ([#7223](https://github.com/codewhale-hq/Codewhale/pull/7223)). Swept stale mode-jump and rail copy from the non-English packs ([#7225](https://github.com/codewhale-hq/Codewhale/pull/7225)), accepted absolute image paths that resolve inside the workspace ([#7226](https://github.com/codewhale-hq/Codewhale/pull/7226)) and reported follow-up delivery truthfully ([#7228](https://github.com/codewhale-hq/Codewhale/pull/7228)); all three were harvested with the original commits kept. Paused the durable task wall clock while an answerable prompt waits ([#7230](https://github.com/codewhale-hq/Codewhale/pull/7230)).
- **[cmdcorp6534](https://github.com/cmdcorp6534)** — reported the Chinese input-method candidate window appearing away from the field being typed into ([#2323](https://github.com/codewhale-hq/Codewhale/issues/2323)).
- **[Asteria1028](https://github.com/Asteria1028)** — reported pasted clipboard images piling up with no way to remove them ([#7221](https://github.com/codewhale-hq/Codewhale/issues/7221)).
- **[ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
</details>
+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. The standalone ACP execution path has no TypeScript host attachment. 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. 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.
## Asking the core to run a tool
+93 -35
View File
@@ -65,9 +65,10 @@ still need an available release asset source.
# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).
curl -fsSL https://codewhale.net/install.sh | sh
# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc # zsh: use ~/.zshrc
export PATH="$HOME/.local/bin:$PATH"
# 2. The installer adds ~/.local/bin to your shell profile (it prints what it added and
# how to undo it; --no-modify-path skips that). Shells that are already open do not
# reread the profile, so use it in this one now, then check it.
export PATH="$HOME/.local/bin:$PATH"; hash -r
codewhale --version # -> codewhale 0.10.0 (1be1a703b975)
# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).
@@ -174,7 +175,10 @@ What it does (verified):
verified release bytes as two separately managed files. When both manifest
entries match, the download staging step uses a hard link when possible and
a temporary copy otherwise.
* It **never uses sudo and never edits your shell profile.** It refuses to
* It **never uses sudo.** When the install directory is not on `PATH`, it appends one
marked three-line block to your shell profile (see [Put it on your
PATH](#put-it-on-your-path)), prints exactly what it added, and skips this with
`--no-modify-path`. It refuses to
install into system or package-manager directories (`/usr/bin`,
`~/.cargo/bin`, Homebrew and MacPorts bin directories, `node_modules`,
`/nix/store`…), into your home directory or `/`, or into a directory that
@@ -195,14 +199,29 @@ Installed checksummed release commands:
/home/you/.local/bin/codew
…
PATH selects no codewhale command; this install is /home/you/.local/bin/codewhale
…
Put /home/you/.local/bin first on PATH in future shells (run once; this installer does not edit shell profiles):
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc
The marker lines let codewhale uninstall remove exactly this block; a bare export line is yours and is never touched.
Then run: . ~/.bashrc (or open a new terminal)
…
codewhale is not on your PATH in this shell yet.
Added to ~/.bashrc so new shells find codewhale (skip this with --no-modify-path):
# >>> codewhale PATH >>>
export PATH="$HOME/.local/bin:$PATH"
# <<< codewhale PATH <<<
Only those three lines were added; nothing else in the file was changed. Shells that are already open do not reread the profile.
To undo, run:
cw_tmp="$(mktemp)" && sed -e '/^# >>> codewhale PATH >>>$/{$!N;$!N;/\n# <<< codewhale PATH <<<$/d;}' ~/.bashrc > "$cw_tmp" && cat "$cw_tmp" > ~/.bashrc; rm -f "$cw_tmp"
PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path
Run this now, in this shell:
export PATH="$HOME/.local/bin:$PATH"; hash -r
Then check it:
codewhale --version
```
The PATH block is always the last thing the installer prints. When your PATH already
selects the install, the last line is `Next: codewhale --version`. When the release you
installed ships an `uninstall` command, the output also says that `codewhale uninstall`
removes the added block; v0.10.2 has no uninstall command, so the printed `sed` command (or
deleting the three marked lines by hand) is how you undo it there.
### macOS notes
Re-checked on macOS 26.1, Apple silicon (`macos-arm64`), with a fresh `HOME`:
@@ -222,31 +241,58 @@ Re-checked on macOS 26.1, Apple silicon (`macos-arm64`), with a fresh `HOME`:
### Put it on your PATH
If the installer says `PATH selects no codewhale command`, `~/.local/bin` isn't
on your PATH **in this shell**. The `codewhale.net/install.sh` installer then
prints the matching line from the block below for your `$SHELL` (zsh, bash,
fish, or a POSIX `sh`; for any other shell, or a directory name with quotes,
`$`, backticks or backslashes, it tells you to add the directory yourself). It
never edits a shell profile itself. The `install.sh` inside a release archive
prints only the current-shell `export` line. On Ubuntu and Debian, `~/.profile` adds
`~/.local/bin`, but only if the directory existed when you *logged in*. So:
on your PATH **in this shell**. The `codewhale.net/install.sh` installer and, in release
archives from 0.10.3 on, the `install.sh` inside the archive then add one marked three-line block to
the profile of your `$SHELL` by default, print the file and the three lines, and
print the command that removes them:
* a new SSH or login shell picks it up automatically;
* a new terminal **window** on a desktop (GNOME Terminal, Ghostty, …) usually
doesn't, until you log out and back in. I hit
`bash: codewhale: command not found` in Ghostty right after installing.
| `$SHELL` | File edited |
| --- | --- |
| zsh | `~/.zshrc` (`$ZDOTDIR/.zshrc` when `ZDOTDIR` is set) |
| bash on Linux | `~/.bashrc` |
| bash on macOS | the first that exists of `~/.bash_profile`, `~/.bash_login`, `~/.profile` (else `~/.bash_profile`) |
| sh, dash, ksh, ash | `~/.profile` |
Fix it once:
Only those three lines are appended (after a newline if the file did not end in one);
no other line is rewritten or reordered. The installer does not edit anything when:
* you pass `--no-modify-path` (or set `CODEWHALE_NO_MODIFY_PATH=1`); with the web installer
write `curl -fsSL https://codewhale.net/install.sh | sh -s -- --no-modify-path`. It then
prints the `printf` command for you to run yourself;
* the install directory is already on `PATH`;
* the profile is a symlink, is not a writable regular file, or its folder is missing. The
command is printed instead;
* your shell is fish or another shell it has no profile for, `$SHELL` is empty, or the
directory name contains quotes, `$`, backticks or backslashes. The installer says what to
add yourself (`fish_add_path ~/.local/bin` for fish).
The v0.10.2 release archive (what `releases/latest` serves until 0.10.3 ships) does
not do this: its `install.sh` takes no options, never edits a profile and only prints the
current-shell `export` line, and its `install.ps1` tells you to add the directory yourself.
Run the `printf` line in the block below to add the same three lines by hand.
Running the installer again never adds a second block: it sees the marker line and leaves
the file as is. A profile change does not reach shells that are already open, so the
installer's last lines give the `export` command for the current shell and `codewhale --version`.
To undo it by hand, delete the three lines between and including the markers, or run the
command the installer printed:
```bash
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc # bash (macOS login bash: ~/.bash_profile, or ~/.profile if only that exists)
# printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.zshrc # zsh
# fish_add_path ~/.local/bin # fish (untested on this VM)
export PATH="$HOME/.local/bin:$PATH"; hash -r
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc # what the installer appended (bash; zsh: ~/.zshrc)
cw_tmp="$(mktemp)" && sed -e '/^# >>> codewhale PATH >>>$/{$!N;$!N;/\n# <<< codewhale PATH <<<$/d;}' ~/.bashrc > "$cw_tmp" && cat "$cw_tmp" > ~/.bashrc; rm -f "$cw_tmp" # undo
export PATH="$HOME/.local/bin:$PATH"; hash -r # this shell only
command -v codewhale codew
```
The three-line block is marked so `codewhale uninstall` can remove exactly it later. A bare
On Ubuntu and Debian, `~/.profile` adds `~/.local/bin`, but only if the directory existed
when you *logged in*, and a new terminal **window** on a desktop (GNOME Terminal, Ghostty, …)
starts a non-login shell that reads `~/.bashrc`, not `~/.profile`. That is why the block goes
in `~/.bashrc` on Linux.
The three-line block is marked so `codewhale uninstall` can remove exactly it. A bare
`export PATH=…` line is yours: uninstall never touches it, because other tools share `~/.local/bin`.
`codewhale uninstall` exists in Codewhale 0.10.3 and later; on 0.10.2 use the command above.
### Options
@@ -291,8 +337,10 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
### Upgrade / uninstall
* Upgrade: automatic for this install (see §7), or `codewhale update` now.
* Uninstall: `~/.local/bin/codewhale uninstall` previews the receipt-owned
files; add `--yes` to remove them. See §12 for data.
* Uninstall (0.10.3 and later): `~/.local/bin/codewhale uninstall` previews the receipt-owned
files; add `--yes` to remove them. On 0.10.2 or earlier there is no such command (the word
would start a chat): run `rm ~/.local/bin/codewhale ~/.local/bin/codew ~/.local/bin/.codewhale-install.sha256`.
See §12 for data.
---
@@ -352,8 +400,11 @@ cd codewhale-linux-x64 && ./install.sh # -> ~/.local/bin; PREFIX=/
```
The archive's `install.sh` never uses sudo, leaves differing existing files
alone, never edits a shell profile, and asks nothing; it prints only the
current-shell `export` line.
alone and asks nothing. In archives from 0.10.3 on, like the web installer, it adds the
marked PATH block to your shell profile when the directory is not on `PATH`
(`./install.sh --no-modify-path` skips that). The v0.10.2 archive does not: it takes no
options and only prints an `export` line for the current shell, so add the block yourself
(see [Put it on your PATH](#put-it-on-your-path)).
**Upgrade:** `codewhale update` works for both 3a and 3b, because they're
"direct binary" installs. **Uninstall:** delete the two files (see §12).
@@ -630,6 +681,8 @@ starts never prompt and never apply an unsigned update.
Turn it down or off:
Needs Codewhale 0.10.3 or later; 0.10.2 has no `update.mode` key.
```bash
codewhale config set update.mode notify # tell me about updates, don't download
codewhale config set update.mode off # no update checks at all
@@ -1005,7 +1058,7 @@ Everything I checked worked in Ghostty with its default config
| Exit restores the terminal (normal screen, cursor, no mouse-reporting garbage) | ✅ |
Ghostty on Linux starts a **non-login** shell, so it reads `~/.bashrc` and not
`~/.profile`. That's why you need the PATH line in `~/.bashrc` (§2).
`~/.profile`. That's why the installer puts its PATH block in `~/.bashrc` (§2).
You may notice small dots and a faint label (e.g. `other · drift`) drifting
across empty space after a turn. That's Codewhale's decorative "ambient life"
@@ -1030,10 +1083,12 @@ codewhale auth clear --provider deepseek
### Step 2: remove the program
The `uninstall` command exists in Codewhale 0.10.3 and later. On 0.10.2 or earlier it is not a command, and typing it starts a chat that sends the word to your model. Remove the files by hand instead, for a Unix installer copy: `rm <dir>/codewhale <dir>/codew <dir>/.codewhale-install.sha256`, where `<dir>` is the install directory (`~/.local/bin` by default).
| Installed with | Remove with |
|---|---|
| current Unix installer (§2) | Run the intended executable by its full path with `uninstall` to inspect the plan, then `uninstall --yes` to remove it. |
| Windows release archive (`install.ps1`) | `"%USERPROFILE%\bin\codewhale.exe" uninstall`, then `uninstall --yes`. Remove `%USERPROFILE%\bin` from your user PATH if you added it only for Codewhale. |
| Windows release archive (`install.ps1`) | `"%USERPROFILE%\bin\codewhale.exe" uninstall`, then `uninstall --yes`. Remove `%USERPROFILE%\bin` from your user PATH; `install.ps1` from 0.10.3 on adds it by default (see below). |
| Windows setup (NSIS) | Settings > Apps > CodeWhale > Uninstall, or `%LOCALAPPDATA%\Programs\CodeWhale\Uninstall.exe`. `codewhale uninstall` prints this path. |
| WinGet | `winget uninstall --id HunterBown.CodeWhale` |
| Scoop | `scoop uninstall codewhale` |
@@ -1053,6 +1108,8 @@ Successful self-update renews an existing command receipt, and writes one for a
On Windows, `codewhale uninstall --yes` removes `codewhale.exe`, `codew.exe`, `codewhale.bat`, any receipted compiled-host files and the receipt itself. Windows cannot delete a running program, so the running `codewhale.exe` is renamed in place to a `.codewhale-host-recovery-…` name and deleted by a helper once the command exits; the output names that file in case it remains. `codewhale.old-…` files left by an earlier self-update are listed, not deleted. A known legacy directory can adopt matching command bytes into a receipt. Other unreceipted archive copies are preserved; reinstall with the current `install.ps1` to obtain a receipt before automatic removal. Windows removal has been compile-checked but not yet exercised on a Windows host.
From 0.10.3 on, the Windows archive's `install.ps1` adds `%USERPROFILE%\bin` to your **user** PATH by default (one registry value, `HKCU\Environment\Path`; it never touches the machine PATH), prints what it added and the PowerShell command that removes just that entry, and does nothing when the directory is already on PATH. Run it with `-NoModifyPath` (or `CODEWHALE_NO_MODIFY_PATH=1`) to skip the edit; it then prints the command to add the entry yourself. Running it again never adds a second entry. Windows terminals that are already open do not see the change, so its last lines give the command for the current window and `codewhale --version`. The v0.10.2 `install.ps1` does none of this and tells you to add the directory yourself. This path has not been run on a Windows host.
Repeating `uninstall` after removal is safe: the removed copy is gone, and any other copy only removes what its own receipt owns. Reinstalling into the same directory afterwards writes a fresh receipt.
### Step 3: remove data. Program removal keeps it by default.
@@ -1301,7 +1358,7 @@ when you want to keep every existing installation exactly as it is:
```bash
mkdir -p "$HOME/.local"
codewhale_install_dir="$(mktemp -d "$HOME/.local/codewhale-release.XXXXXX")"
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$codewhale_install_dir" sh
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$codewhale_install_dir" sh -s -- --no-modify-path
"$codewhale_install_dir/codewhale" --version
export PATH="$codewhale_install_dir:$PATH"
hash -r
@@ -1309,7 +1366,8 @@ command -v codewhale codew
"$codewhale_install_dir/codewhale" update --check
```
After verifying the version and command paths, keep that directory first in your
`--no-modify-path` keeps the installer from touching your profile for a side-by-side
copy. After verifying the version and command paths, keep that directory first in your
shell profile. In PowerShell, use `Get-Command codewhale, codew -All` to inspect
resolution; run the selected executable using its full path. A successful update
only changes its own install directory, so another earlier PATH entry can still
+12 -8
View File
@@ -1,10 +1,11 @@
# Release Checklist
Follow this sequence from the canonical `/Volumes/VIXinSSD/CW/codewhale`
checkout on `main`. Agent-owned preparation lands as verified slices on `main`;
it requires no release branch, worktree, nested clone or preparation PR.
Contributor PRs still land as PRs. Treat any unchecked required gate as a release
blocker.
checkout on `main`. Agent-owned preparation is built in a short-lived agent
worktree and lands as verified slices on `main` (CURRENT_DECISIONS §93); it
needs no release branch, nested clone or preparation PR. Freezing, tagging and
publication run from the clean canonical `main`. Contributor PRs still land as
PRs. Treat any unchecked required gate as a release blocker.
For deeper context on the underlying tools (preflight scripts, npm smoke,
publish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md).
@@ -190,14 +191,17 @@ known limitations and publication approval. These proofs are separate.
## 5a. Prerelease checkpoints (`X.Y.Z-prerelease`)
A semver prerelease such as `0.10.3-beta.2` is prepared and tagged like a stable
A semver prerelease such as `0.11.0-beta.1` is prepared and tagged like a stable
version, but it publishes only a GitHub prerelease and the npm `next` dist-tag.
Use it for optional desktop or Boat checkpoints. The plain `X.Y.Z` path is
unchanged and remains the authority.
The support stays in the tree for an optional checkpoint that the founder
requests. The Engine 0.10.3 release does not use it: there is no
`0.10.3-beta.2` and the next Engine release is the full `0.10.3`
(CURRENT_DECISIONS §96). The plain `X.Y.Z` path is unchanged and remains the
authority.
- [ ] Write the CHANGELOG section as `## [X.Y.Z-prerelease] - YYYY-MM-DD` with a
compare link from the last published tag, for example
`[0.10.3-beta.2]: https://github.com/codewhale-hq/CodeWhale/compare/v0.10.2...v0.10.3-beta.2`.
`[0.11.0-beta.1]: https://github.com/codewhale-hq/CodeWhale/compare/v0.10.3...v0.11.0-beta.1`.
- [ ] Prepare the workspace with
`./scripts/release/prepare-release.sh X.Y.Z-prerelease`, commit the bump
on `main`, and check `./scripts/release/check-versions.sh --require-dated-release`
+4 -3
View File
@@ -7,9 +7,10 @@ Use [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md) as the ordered release
sequence: reconcile the canonical checkout, prepare and land verified slices,
freeze one SHA, verify Cargo tarballs and exact-SHA hosted CI/RC, obtain Hunter's
publication approval, tag that SHA, then verify each public destination.
Agent-owned work stays in `/Volumes/VIXinSSD/CW/codewhale` on `main`; do not
create release branches, worktrees or nested clones. Contributor PRs still land
as PRs. Coordinate file ownership and preserve dirty work before any recovery.
Agent-owned preparation is built in a short-lived agent worktree and lands on
`main` (CURRENT_DECISIONS §93); do not create release branches or nested clones.
Freeze, tag and publish from the clean canonical `/Volumes/VIXinSSD/CW/codewhale`
`main`. Contributor PRs still land as PRs. Coordinate file ownership and preserve dirty work before any recovery.
Keep evidence in the existing release packet instead of maintaining a second
release queue here.
+2 -3
View File
@@ -1607,7 +1607,7 @@ latest turn and publishes a `thread.permissions_changed` event (`from`, `to`,
- `POST /v1/threads/{id}/undo` - fork the thread with the last N turns removed (`{"depth": N}`, default 0 = last turn only); returns the forked thread plus `original_user_text` so a GUI can pre-populate the input box. `{"turn_id": "turn_…"}` instead names the user turn to remove, together with every turn after it, and wins over `depth`; the Runtime resolves it under the thread's restore guard, so a turn another client appends meanwhile cannot move the cut. `400` when the turn is not a user turn of that thread
- `POST /v1/threads/{id}/fork-at-turn` - fork at one named user turn (`{"turn_id": "turn_…"}`, as `GET /v1/threads/{id}` reports it). The fork *keeps* that turn and every turn before it, and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is `/undo`'s (`thread`, `original_user_text`, `original_user_images`), carrying the *first dropped* user turn's prompt — what was asked next, even when a prompt-less turn such as a manual `/compact` sits between — so a client can put it back in the composer for editing. The source thread, its session document and the workspace are untouched, and there is no file rollback: a fork is a sibling conversation, and rewinding the workspace would rewind the branch left behind with it. Clients should name the turn instead of computing a `depth` — the transcript they render and the turn list this cuts are not the same list (steers, image-only prompts and injected handoffs each sit on one side only), and a client-side count that is off by one forks the wrong prefix while answering `201`. `400` when the turn is not a user turn of that thread.
- `GET /v1/threads/{id}/patch-undo?turn_id=turn_…` - validates the selected restore without changing files or publishing a fork; returns `turn_ids`, the exact dropped range, `paths` needing restoration and an optional no-change `summary`. Requires the `thread_patch_undo_turn_id` capability.
- `POST /v1/threads/{id}/patch-undo` - rolls back the files the dropped turns changed, then the same fork (`{"depth": N}` or `{"turn_id": "turn_…"}`, with `turn_id` taking precedence); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for ownership, the trust, admission and abort rules, and the `error.code` values of a refusal.
- `POST /v1/threads/{id}/patch-undo` - rolls back the files the dropped turns changed, then the same fork (`{"depth": N}` or `{"turn_id": "turn_…"}`, with `turn_id` taking precedence); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for ownership, the admission and abort rules, and the `error.code` values of a refusal.
- `POST /v1/threads/{id}/file-revert` - restore exactly one file from one restore point the thread owns (`{"path", "snapshot_id", "expected_hash"}`); never forks the conversation. See [Workspace restore endpoints](#workspace-restore-endpoints).
- `POST /v1/threads/{id}/retry` - fork with the last N turns removed and immediately start a new turn (`{"depth": N, "prompt": "..."}`; `prompt` overrides the original user text, which is re-used when omitted)
@@ -1942,7 +1942,7 @@ admission rule and one safety net, and they differ in scope and trust.
| Route | Scope | Trust | Forks the thread |
| --- | --- | --- | --- |
| `POST /v1/snapshots/{id}/restore` | whole server workspace, or the `thread_id` workspace | bearer token only (operator action) | no |
| `POST /v1/threads/{id}/patch-undo` | the files the dropped turns changed | thread `trust_mode` or `auto_approve` when files would change | yes |
| `POST /v1/threads/{id}/patch-undo` | the files the dropped turns changed | none; only files the dropped turns' own tool calls wrote, still holding the bytes the turn left, are restored | yes |
| `POST /v1/threads/{id}/file-revert` | exactly one regular file | none; the restore point must be one the thread owns and `expected_hash` must match | no |
**Admission.** A restore reserves the same admission the Runtime uses for
@@ -2043,7 +2043,6 @@ changed and no fork is published; `error.code` says why:
| `restore_point_pruned` | the restore point is no longer in the snapshot store |
| `path_not_snapshotted` | a dropped file-tool call wrote a path snapshots do not hold (ignored, built-in exclusion, or outside the workspace) |
| `workspace_changed_since_turn` | a path the turns changed was changed afterwards (or between two dropped turns), a path changed while a dropped turn ran but outside its own tool calls, or a path that is not a regular file |
| `restore_requires_trust` | there is something to restore and the thread is not in trusted mode or Full Access |
| `workspace_unavailable` | the workspace directory is not available |
For the first four a client can offer the conversation-only
+6 -4
View File
@@ -10,10 +10,12 @@ actual authority.
## Source and updates
`vendor/codewhale-design/` is the complete versioned GPUI design artifact.
Its `tokens.json` owns semantic colors, font fallbacks, spacing, control/panel
radii, focus geometry and native motion constants. Update it from the app's
design package as one folder; never edit consumer copies by hand.
`vendor/codewhale-design/` holds only the token files from the design system:
`tokens.json` and its generated outputs, `generate.py` and the inputs it reads.
`tokens.json` owns semantic colors, font fallbacks, spacing, control/panel
radii, focus geometry and native motion constants. Refresh those files from the
design system together; never edit them by hand, and do not vendor the design
system's documentation, Figma notes or whale sources into this public repository.
Run `python3 scripts/export-design-tokens.py` to regenerate
`web/app/tokens.css`. The exporter checks the portable artifact's own generated
+55 -26
View File
@@ -45,9 +45,10 @@ target/release/codewhale --version
# 1. Install. Downloads two checksum-verified binaries into ~/.local/bin (no sudo).
curl -fsSL https://codewhale.net/install.sh | sh
# 2. Make sure ~/.local/bin is on your PATH, now and in future terminals.
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc # zsh: use ~/.zshrc
export PATH="$HOME/.local/bin:$PATH"
# 2. The installer adds ~/.local/bin to your shell profile (it prints what it added and
# how to undo it; --no-modify-path skips that). Shells that are already open do not
# reread the profile, so use it in this one now, then check it.
export PATH="$HOME/.local/bin:$PATH"; hash -r
codewhale --version # -> codewhale 0.10.0 (1be1a703b975)
# 3. Give it a DeepSeek API key (from https://platform.deepseek.com/api_keys).
@@ -135,7 +136,7 @@ curl -fsSL https://codewhale.net/install.sh | sh
* 使用校验过的发布内容分别安装 `~/.local/bin/codewhale` 和
`~/.local/bin/codew`。当两个清单摘要相同时,下载暂存阶段尽量使用硬链接,
失败时使用临时副本。
* **从不使用 sudo,也从不修改你的 shell 配置文件。** 它拒绝安装到系统或包管理器目录(`/usr/bin`、`~/.cargo/bin`、Homebrew 与 MacPorts 的 bin 目录、`node_modules`、`/nix/store` 等)、你的主目录或 `/`,以及其他用户或用户组可写的目录(你自己的目录可用 `chmod go-w <目录>` 修复);也拒绝覆盖一个*不同的*已有 `codewhale`。
* **从不使用 sudo。** 当安装目录不在 `PATH` 上时,它会向你的 shell 配置文件追加一个带标记的三行块(见[把它加入 PATH](#把它加入-path)),原样打印它加了什么,并可用 `--no-modify-path` 跳过。它拒绝安装到系统或包管理器目录(`/usr/bin`、`~/.cargo/bin`、Homebrew 与 MacPorts 的 bin 目录、`node_modules`、`/nix/store` 等)、你的主目录或 `/`,以及其他用户或用户组可写的目录(你自己的目录可用 `chmod go-w <目录>` 修复);也拒绝覆盖一个*不同的*已有 `codewhale`。
预期输出:
@@ -149,14 +150,25 @@ Installed checksummed release commands:
/home/you/.local/bin/codew
…
PATH selects no codewhale command; this install is /home/you/.local/bin/codewhale
…
Put /home/you/.local/bin first on PATH in future shells (run once; this installer does not edit shell profiles):
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc
The marker lines let codewhale uninstall remove exactly this block; a bare export line is yours and is never touched.
Then run: . ~/.bashrc (or open a new terminal)
…
codewhale is not on your PATH in this shell yet.
Added to ~/.bashrc so new shells find codewhale (skip this with --no-modify-path):
# >>> codewhale PATH >>>
export PATH="$HOME/.local/bin:$PATH"
# <<< codewhale PATH <<<
Only those three lines were added; nothing else in the file was changed. Shells that are already open do not reread the profile.
To undo, run:
cw_tmp="$(mktemp)" && sed -e '/^# >>> codewhale PATH >>>$/{$!N;$!N;/\n# <<< codewhale PATH <<<$/d;}' ~/.bashrc > "$cw_tmp" && cat "$cw_tmp" > ~/.bashrc; rm -f "$cw_tmp"
PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path
Run this now, in this shell:
export PATH="$HOME/.local/bin:$PATH"; hash -r
Then check it:
codewhale --version
```
PATH 代码块始终是安装器打印的最后内容。如果你的 PATH 已经选中了这次安装,最后一行是 `Next: codewhale --version`。
### macOS 说明
在 macOS 26.1、Apple silicon(`macos-arm64`)上,用全新的 `HOME` 重新检查过:
@@ -167,22 +179,37 @@ Then run: . ~/.bashrc (or open a new terminal)
### 把它加入 PATH
如果安装器提示 `PATH selects no codewhale command`,说明 `~/.local/bin` **在当前 shell 里**不在 PATH 上。此时 `codewhale.net/install.sh` 安装器会根据你的 `$SHELL`(zsh、bash、fish 或 POSIX `sh`),打印下面这个代码块里对应的那一行;对于其他 shell,或者目录名含有引号、`$`、反引号或反斜杠的情况,它会让你自己添加该目录。它自己从不修改 shell 配置文件。发布压缩包内的 `install.sh` 只会打印针对当前 shell 的 `export` 行。在 Ubuntu 和 Debian 上,`~/.profile` 会加入 `~/.local/bin`,但前提是该目录在你*登录*时已经存在。所以:
如果安装器提示 `PATH selects no codewhale command`,说明 `~/.local/bin` **在当前 shell 里**不在 PATH 上。此时 `codewhale.net/install.sh` 以及 0.10.3 起发布压缩包内的 `install.sh` 默认会向你的 `$SHELL` 对应的配置文件追加一个带标记的三行块,打印文件名和这三行,并打印移除它们的命令:
* 新开的 SSH 会话或登录 shell 会自动生效;
* 桌面上新开的终端**窗口**(GNOME Terminal、Ghostty 等)通常不会,要等到你注销再登录。我刚装完就在 Ghostty 里遇到了 `bash: codewhale: command not found`。
| `$SHELL` | 修改的文件 |
| --- | --- |
| zsh | `~/.zshrc`(设置了 `ZDOTDIR` 时为 `$ZDOTDIR/.zshrc`) |
| Linux 上的 bash | `~/.bashrc` |
| macOS 上的 bash | `~/.bash_profile`、`~/.bash_login`、`~/.profile` 中第一个存在的(都不存在则用 `~/.bash_profile`) |
| sh、dash、ksh、ash | `~/.profile` |
一次性修复:
只会追加这三行(文件末尾没有换行时先补一个换行),不会改写或重排其他任何行。下列情况安装器不会修改任何文件:
* 你传了 `--no-modify-path`(或设置 `CODEWHALE_NO_MODIFY_PATH=1`);用网页安装器时写 `curl -fsSL https://codewhale.net/install.sh | sh -s -- --no-modify-path`。它会改为打印 `printf` 命令,由你自己运行;
* 安装目录已经在 `PATH` 上;
* 配置文件是符号链接、不是可写的普通文件,或其所在目录不存在,此时改为打印命令;
* 你的 shell 是 fish 或其他它没有对应配置文件的 shell、`$SHELL` 为空,或目录名含有引号、`$`、反引号或反斜杠。安装器会告诉你该自己添加什么(fish 用 `fish_add_path ~/.local/bin`)。
v0.10.2 发布压缩包(`releases/latest` 在 0.10.3 发布前提供的版本)不会这样做:其 `install.sh` 不接受任何选项、从不修改配置文件,只打印当前 shell 用的 `export` 行,其 `install.ps1` 则提示你自行添加目录。请自行把同样的三行(`# >>> codewhale PATH >>>`、`export PATH="$HOME/.local/bin:$PATH"`、`# <<< codewhale PATH <<<`)追加到配置文件末尾。
重复运行安装器不会再加第二个块:它看到标记行就保持文件原样。配置文件的改动不会影响已经打开的 shell,所以安装器的最后几行会给出当前 shell 用的 `export` 命令和 `codewhale --version`。
要手动撤销,请删除两个标记行之间(含标记行)的三行,或运行安装器打印的命令:
```bash
printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.bashrc # bash (macOS login bash: ~/.bash_profile, or ~/.profile if only that exists)
# printf '%s\n' '# >>> codewhale PATH >>>' 'export PATH="$HOME/.local/bin:$PATH"' '# <<< codewhale PATH <<<' >> ~/.zshrc # zsh
# fish_add_path ~/.local/bin # fish (untested on this VM)
export PATH="$HOME/.local/bin:$PATH"; hash -r
cw_tmp="$(mktemp)" && sed -e '/^# >>> codewhale PATH >>>$/{$!N;$!N;/\n# <<< codewhale PATH <<<$/d;}' ~/.bashrc > "$cw_tmp" && cat "$cw_tmp" > ~/.bashrc; rm -f "$cw_tmp" # undo
export PATH="$HOME/.local/bin:$PATH"; hash -r # this shell only
command -v codewhale codew
```
这个三行块带有标记,`codewhale uninstall` 之后可以精确地移除它。你自己加的裸 `export PATH=…`
在 Linux 桌面上,新开的终端**窗口**(GNOME Terminal、Ghostty 等)启动的是非登录 shell,读取 `~/.bashrc` 而不是 `~/.profile`,所以块写在 Linux 的 `~/.bashrc` 里。
这个三行块带有标记,`codewhale uninstall`(0.10.3 及更高版本)之后可以精确地移除它;0.10.2 请用上面的命令或自行删除这三行。你自己加的裸 `export PATH=…`
行归你自己管理:卸载从不改动它,因为 `~/.local/bin` 也被其他工具共用。
### 选项
@@ -216,8 +243,8 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
### 升级 / 卸载
* 升级:此安装会自动更新(见 §7),也可以立即运行 `codewhale update`。
* 卸载:`~/.local/bin/codewhale uninstall` 预览该安装收据所拥有的文件;加上
`--yes` 才会删除。数据的清理见 §12。
* 卸载(0.10.3 及更高版本):`~/.local/bin/codewhale uninstall` 预览该安装收据所拥有的文件;加上
`--yes` 才会删除。0.10.2 及更早版本没有这个命令(输入它会开启一次对话,并把这个词发给模型),请运行 `rm ~/.local/bin/codewhale ~/.local/bin/codew ~/.local/bin/.codewhale-install.sha256`。数据的清理见 §12。
---
@@ -268,7 +295,7 @@ cd codewhale-linux-x64 && ./install.sh # -> ~/.local/bin; PREFIX=/
(注意:校验清单是 *bundles*,不是 *artifacts*。)
压缩包里的 `install.sh` 不用 sudo,不动内容不同的已有文件,从不修改 shell 配置文件,也不提问;它只打印针对当前 shell 的 `export` 行。
压缩包里的 `install.sh` 不用 sudo,不动内容不同的已有文件,也不提问;0.10.3 起的压缩包与网页安装器一样,当目录不在 `PATH` 上时会向 shell 配置文件追加带标记的 PATH 块(`./install.sh --no-modify-path` 可跳过)。v0.10.2 压缩包不会:其 `install.sh` 不接受任何选项,只打印当前 shell 用的 `export` 行,请自行添加该块(见[把它加入 PATH](#把它加入-path))。
**升级:** `codewhale update` 对 3a 和 3b 都适用,因为它们都属于"直接二进制"安装。**卸载:** 删除那两个文件(见 §12)。
@@ -658,7 +685,7 @@ TUI 会话、恢复的 exec 会话,以及向模型提供工具的 exec 运行
| 窗口标题显示状态(`waiting on you…`、`✓ done`) | ✅ |
| 退出后终端恢复正常(回到普通屏幕、光标正常、没有鼠标上报的乱码) | ✅ |
Linux 上的 Ghostty 启动的是**非登录** shell,所以它读取 `~/.bashrc` 而不是 `~/.profile`。这就是为什么 PATH 那一行需要写进 `~/.bashrc`(§2)。
Linux 上的 Ghostty 启动的是**非登录** shell,所以它读取 `~/.bashrc` 而不是 `~/.profile`。这就是为什么 安装器把 PATH 块写进 `~/.bashrc`(§2)。
你可能会注意到,一个回合结束后,一些小圆点和淡淡的标签(例如 `other · drift`)在空白区域飘动。那是 Codewhale 装饰性的"环境生命(ambient life)"小鲸鱼,不是渲染故障。
@@ -680,7 +707,7 @@ codewhale auth clear --provider <id>
### 第 2 步:预览并删除所选安装
先运行 `codewhale doctor --installs` 查看每个副本的路径、所有者和删除方式。
先运行 `codewhale doctor --installs` 查看每个副本的路径、所有者和删除方式。`uninstall` 命令从 0.10.3 起提供;0.10.2 及更早版本请手动删除文件:`rm <目录>/codewhale <目录>/codew <目录>/.codewhale-install.sha256`。
使用要移除的程序的完整路径运行 `uninstall` 查看计划,确认后再加 `--yes`。
普通卸载保留会话、配置和文件快照。
@@ -832,7 +859,7 @@ cargo install codewhale-cli --locked # installs `codewhale`
```bash
mkdir -p "$HOME/.local"
codewhale_install_dir="$(mktemp -d "$HOME/.local/codewhale-release.XXXXXX")"
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$codewhale_install_dir" sh
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$codewhale_install_dir" sh -s -- --no-modify-path
"$codewhale_install_dir/codewhale" --version
export PATH="$codewhale_install_dir:$PATH"
hash -r
@@ -840,7 +867,7 @@ command -v codewhale codew
"$codewhale_install_dir/codewhale" update --check
```
确认版本和命令路径之后,把该目录放在 shell 配置里 PATH 的最前面。在 PowerShell 里,用 `Get-Command codewhale, codew -All` 检查解析结果;运行所选可执行文件时使用它的完整路径。一次成功的更新只会改动它自己所在的安装目录,所以 PATH 上更靠前的其他条目仍可能启动旧副本。
`--no-modify-path` 让安装器不去改动你的配置文件。确认版本和命令路径之后,把该目录放在 shell 配置里 PATH 的最前面。在 PowerShell 里,用 `Get-Command codewhale, codew -All` 检查解析结果;运行所选可执行文件时使用它的完整路径。一次成功的更新只会改动它自己所在的安装目录,所以 PATH 上更靠前的其他条目仍可能启动旧副本。
现代的、成对匹配的 `codewhale`、`codew` 以及兼容性副本,都从同一份已校验的字节更新。指向正在运行的二进制的符号链接会被保留。内容不同或不相关的同级文件会在错误里被点名并原样保留;不会仅仅为了猜测其归属而执行任何同级文件。对于带有独立 dispatcher/TUI 二进制的旧安装,请使用上面的新目录迁移方式。
@@ -1028,6 +1055,8 @@ NSIS 安装器目前包含 Windows x64 二进制。Windows ARM64 用户应通过
卸载会移除二进制、`codewhale.bat`、开始菜单快捷方式和用户 `PATH` 条目。
自 0.10.3 起,发布压缩包里的 `install.ps1` 默认把 `%USERPROFILE%\bin` 加入**用户** PATH(只写注册表值 `HKCU\Environment\Path`,不动系统 PATH),打印加了什么以及只移除该条目的 PowerShell 命令;目录已在 PATH 上时什么都不做;加 `-NoModifyPath`(或 `CODEWHALE_NO_MODIFY_PATH=1`)可跳过,此时改为打印命令;重复运行不会重复添加。已打开的窗口看不到这个改动,所以最后几行给出当前窗口用的命令和 `codewhale --version`。v0.10.2 的 `install.ps1` 不做这些,只提示你自行添加目录。该路径尚未在 Windows 主机上运行过。
**静默安装**(供 IT 管理员、SCCM、Intune 使用):
```powershell
+2 -3
View File
@@ -1171,7 +1171,7 @@ fork 还可能包含 `backtrack_depth_from_tail` 与 `dropped_turn_id`,而
- `POST /v1/threads/{id}/compact`(手动压缩)
- `POST /v1/threads/{id}/undo` - 以去掉最后 N 个回合的方式 fork 线程(`{"depth": N}`,默认 0 = 仅最后一个回合);返回 fork 出的线程以及 `original_user_text`,以便 GUI 预填输入框。改用 `{"turn_id": "turn_…"}` 时,指明要去掉的用户回合及其之后的所有回合,并优先于 `depth`;Runtime 在线程的恢复保护下解析它,因此其他客户端同时追加的回合不会移动切点。该回合不是此线程的用户回合时返回 `400`
- `POST /v1/threads/{id}/fork-at-turn` - 在一个具名用户回合处 fork(`{"turn_id": "turn_…"}`,即 `GET /v1/threads/{id}` 报告的那个)。该 fork *保留*那个回合及其之前的每个回合,丢弃其后的回合;因此指名最后一个回合会保留整个对话。回执与 `/undo` 相同(`thread`、`original_user_text`、`original_user_images`),携带*第一个被丢弃*的用户回合的提示词——即接下来被问的是什么,即使中间夹着一个无提示词回合(如一次手动 `/compact`)——这样客户端可以把它放回输入区供编辑。源线程、它的会话文档与工作区都不受影响,也没有文件回滚:fork 是一个兄弟对话,而回退工作区会把随之留下的分支一起回退。客户端应当指名回合,而不是计算 `depth`——它们渲染的转录与这里裁剪的回合列表不是同一个列表(引导、仅图片提示词与注入的交接各自只位于一侧),一个差一的客户端计数会在回答 `201` 的同时 fork 错前缀。当该回合不是该线程的用户回合时返回 `400`。
- `POST /v1/threads/{id}/patch-undo` - 回滚被丢弃回合改动过的文件,随后做同样的 fork(`{"depth": N}`);除 fork 出的线程外还返回 `patch_result`(`files_restored`、`summary`、`snapshot_label`)。所有权、信任、准入与中止规则,以及拒绝时的 `error.code` 取值,参见 [工作区恢复端点](#工作区恢复端点)。
- `POST /v1/threads/{id}/patch-undo` - 回滚被丢弃回合改动过的文件,随后做同样的 fork(`{"depth": N}`);除 fork 出的线程外还返回 `patch_result`(`files_restored`、`summary`、`snapshot_label`)。所有权、准入与中止规则,以及拒绝时的 `error.code` 取值,参见 [工作区恢复端点](#工作区恢复端点)。
- `POST /v1/threads/{id}/file-revert` - 从线程拥有的一个恢复点中恢复恰好一个文件(`{"path", "snapshot_id", "expected_hash"}`);永不 fork 对话。参见 [工作区恢复端点](#工作区恢复端点)。
- `POST /v1/threads/{id}/retry` - 以去掉最后 N 个回合的方式 fork,并立即启动一个新回合(`{"depth": N, "prompt": "..."}`;`prompt` 覆盖原始用户文本,省略时复用后者)
@@ -1450,7 +1450,7 @@ HTTP `202 Accepted` 与 `tool_call.resolved` 共享“持久接受”这层含
| 路由 | 范围 | 信任 | 是否 fork 线程 |
| --- | --- | --- | --- |
| `POST /v1/snapshots/{id}/restore` | 整个服务器工作区 | 仅需 bearer 令牌(操作者动作) | 否 |
| `POST /v1/threads/{id}/patch-undo` | 被丢弃回合改动过的文件 | 当文件将被改动时需要线程 `trust_mode` 或 `auto_approve` | 是 |
| `POST /v1/threads/{id}/patch-undo` | 被丢弃回合改动过的文件 | 无;只恢复被丢弃回合自己的工具调用写入、且仍保持该回合所留内容的文件 | 是 |
| `POST /v1/threads/{id}/file-revert` | 恰好一个常规文件 | 无;恢复点必须属于该线程,且 `expected_hash` 必须匹配 | 否 |
**准入。** 一次恢复会预定 Runtime 用于配置重载与会话检查点的同一个准入,
@@ -1531,7 +1531,6 @@ shell 命令不声明路径:它在被排除路径下写入的东西(构建
| `restore_point_pruned` | 恢复点已不在快照存储中 |
| `path_not_snapshotted` | 某个被丢弃的文件工具调用写入了快照不保存的路径(被忽略、内置排除,或在工作区之外) |
| `workspace_changed_since_turn` | 这些回合改动过的某个路径之后又被改动(或在两个被丢弃回合之间被改动)、某个路径在被丢弃回合运行期间但在其自身工具调用之外被改动,或某个路径不是常规文件 |
| `restore_requires_trust` | 有东西要恢复,而线程不处于受信任模式或 Full Access |
| `workspace_unavailable` | 工作区目录不可用 |
对前四种情况,客户端可以改为提供只作用于对话的 `POST /v1/threads/{id}/undo`,
+1 -1
View File
@@ -85,6 +85,6 @@ for(let i=0;i<pose.points.length;i++){const q=pose.points[i],p=previous?.points[
let color=m.slice(0,3),alpha=m[3];if(local){if(!a.eventColors)color=a.particle;else if(light){const peak=Math.max(...color,1);color=color.map(v=>Math.round(v*Math.min(1,115/peak)))}alpha=Math.min(1,alpha*a.brightness/(preview?1:f.appearance?.brightness||1))}if(pixel&&pixelWhale.complete&&pixelWhale.naturalWidth)alpha*=morph;if(hollow)alpha=Math.min(alpha,.48);if(stale)alpha*=.55;
const material='rgb('+color.map(Math.round).join(',')+')',depth=.8+.2*Math.min(1,alpha);ctx.fillStyle=ctx.strokeStyle=material;ctx.globalAlpha=alpha;ctx.lineWidth=Math.max(.7,radius*.6);ctx.beginPath();ctx.arc(x,y,radius*depth,0,Math.PI*2);hollow?ctx.stroke():ctx.fill();if(!hollow&&!light&&a.glow>0&&i%5===0){ctx.globalAlpha=alpha*.035*a.glow;ctx.beginPath();ctx.arc(x,y,radius*(1+a.glow*4),0,Math.PI*2);ctx.fill()}}ctx.globalAlpha=1;}
function draw(now){requestAnimationFrame(draw);if(document.hidden)return;const dt=lastDraw?Math.min(.1,(now-lastDraw)/1000):0;lastDraw=now;if(preview&&pet){accumulator+=dt;let count=0;while(accumulator>=1/30&&count++<3){advancePreview();accumulator-=1/30}}if(document.body.classList.contains('gallery-mode')){if(++galleryTick%3===0)Array.from($('gallery').querySelectorAll('canvas')).forEach((c,i)=>paint(c,current,presets[i],now,true))}else paint(canvas,current,appearance,now);if(current){const p=$('still').checked?current.still:current,activity=current.activity,c=cue(activity),known=c&&(preview||now-lastTickAt<800),title=demoDone?'Settling back into the habitat.':known?c.label:'A moment between tasks.',detail=demoDone?'Preview complete':known?((preview?'Simulated action':'Observed action')+(c.actionId?' · '+c.actionId:'')):'Activity unobserved',evidence=preview?'Simulated preview':known?'Observed in Codewhale':'Local companion';for(const [id,text] of [['activity-title',title],['activity-detail',detail],['activity-evidence',evidence]])if($(id).textContent!==text)$(id).textContent=text;const phrase=$('activity-phrase');phrase.hidden=!known||!current.displayPhrase;phrase.lang=current.displayLanguage||'en';if(phrase.textContent!==(current.displayPhrase||''))phrase.textContent=current.displayPhrase||'';$('activity-parallel').textContent=known&&activity.parallelAgentCount?activity.parallelAgentCount+' parallel agent'+(activity.parallelAgentCount===1?'':'s')+' active':'';$('caption').textContent=(preview?'Preview · ':'')+'Codewhale'+(current.state.observed<.92?' · unobserved':'')+(demoDone?' · complete':'');$('clock').textContent=Math.floor(current.timeMs/60000)+':'+String(Math.floor(current.timeMs/1000)%60).padStart(2,'0');canvas.setAttribute('aria-label',title+'; '+detail+'; '+$('caption').textContent+'; '+(current.displayPhrase?current.displayPhrase+'; '+current.displayLanguage+'; ':'')+(selected==='Custom'?'custom':selected)+' appearance')}}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {Authorization:'Bearer '+token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
async function connect(){if(preview){await makePreview();return}const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);if(token){if(!/^[a-f0-9]{64}$/i.test(token))throw Error('Invalid local connection');await request('/v1/attach',{}, {'X-Pet-Code':token})}$('join').hidden=true;$('message').textContent='One live pet. Appearance changes are shared; preview work stays isolated.';await poll()}
requestAnimationFrame(draw);connect().catch(e=>{$('message').textContent=e.message});
</script></html>
+3
View File
@@ -6,6 +6,9 @@
"crates/account-cli/src/work.rs": {
"std_fs": 1
},
"crates/app-server-protocol/src/bin/contract.rs": {
"std_fs": 5
},
"crates/app-server-protocol/src/bin/export.rs": {
"std_fs": 3
},
+6 -2
View File
@@ -2,14 +2,18 @@
"_comment": "Ceiling for dead-code suppressions across crates/, counting both `#[allow(dead_code)]` and `#[expect(dead_code)]`. `total` is the sum and is the ratcheted figure. It may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
"_issue": "https://github.com/codewhale-hq/CodeWhale/issues/4785",
"_expect_blind_spot": "Until #6241 this gate counted only the `allow` spelling, so rewriting an allow as an expect lowered the number without removing any dead code. The ceiling was re-based to the true combined count when that was fixed; it is not a regression.",
"total": 119,
"allow_total": 87,
"total": 121,
"allow_total": 89,
"expect_total": 32,
"per_crate": {
"app-server": {
"allow": 1,
"expect": 3
},
"app-server-protocol": {
"allow": 2,
"expect": 0
},
"cli": {
"allow": 0,
"expect": 1
@@ -0,0 +1,617 @@
#!/usr/bin/env python3
"""Pinned DSH plugin acceptance driver and receipt validator (CW-7051).
run drive one pinned row through lifecycle L against an installed Engine
validate check a receipt against the pins; the verdict is recomputed here
matrix combine validated receipts into the row x OS table
selftest prove the validator rejects a missing step and never infers PASS
The driver only uses the Engine's own /v1/apps/plugins routes. It never loads
plugin code itself and never calls a model provider unless --allow-provider is
given. Verdicts: PASS, FAIL, UNAVAILABLE (a named platform, provider, desktop
or artifact is missing), BLOCKED (admission refused as pinned), INVALID.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import platform
import shutil
import socket
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
PINS_PATH = HERE.parents[1] / "crates/engine/tests/fixtures/dsh-plugin-acceptance/pins.json"
RECEIPT_SCHEMA = "codewhale.dsh-acceptance-receipt/1"
MATRIX_SCHEMA = "codewhale.dsh-acceptance-matrix/1"
OSES = ["macos", "linux", "windows"]
STATUSES = {"PASS", "FAIL", "UNAVAILABLE", "SKIPPED"}
UNAVAILABLE_KINDS = {"platform", "provider", "desktop", "artifact"}
LEVELS = {"source", "local", "installed", "provider", "desktop"}
STEP_LEVEL = {
"pin.tarball_hash": "source",
"package.extract": "local",
"engine.identity": "installed",
"platform.identity": "installed",
"admission.preview": "installed",
"admission.refused": "installed",
"lifecycle.install": "installed",
"lifecycle.trust": "installed",
"lifecycle.enable": "installed",
"lifecycle.restart_retained": "installed",
"journey.core": "provider",
"journey.controls": "provider",
"client.seats_rendered": "desktop",
"lifecycle.revoke": "installed",
"lifecycle.uninstall": "installed",
"security.no_key_leak": "installed",
}
COMMON = ["pin.tarball_hash", "package.extract", "engine.identity", "platform.identity"]
ADMITTED_ROW = [
"admission.preview", "lifecycle.install", "lifecycle.trust", "lifecycle.enable",
"journey.core", "journey.controls", "lifecycle.restart_retained",
"lifecycle.revoke", "lifecycle.uninstall", "security.no_key_leak",
]
REFUSED_ROW = ["admission.refused"]
def os_name() -> str:
return {"darwin": "macos", "linux": "linux", "win32": "windows"}.get(sys.platform, sys.platform)
def load_pins(path: Path = PINS_PATH) -> dict:
pins = json.loads(path.read_text())
if pins.get("schema") != "codewhale.dsh-acceptance-pins/1" or len(pins["rows"]) != 33:
raise SystemExit("pins file is not the ratified 33-row table")
return pins
def pin_for(pins: dict, row: int) -> dict:
for entry in pins["rows"]:
if entry["row"] == row:
return entry
raise SystemExit(f"row {row} is not in the pinned list")
def required_steps(pin: dict) -> list[str]:
if pin["admission"] != "admit":
return COMMON + REFUSED_ROW
steps = COMMON + ADMITTED_ROW
if pin["declares_client"]:
steps.insert(steps.index("lifecycle.restart_retained") + 1, "client.seats_rendered")
return steps
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_json(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def refusal_needle(pin: dict) -> str:
refusal = pin["refusal"]
return f"{refusal['code']}: `{refusal['field']}` requires DSH `{refusal['range']}`"
def verdict_of(pin: dict, steps: list[dict]) -> str:
by_id = {step["id"]: step for step in steps}
statuses = [by_id[name]["status"] for name in required_steps(pin)]
if "FAIL" in statuses:
return "FAIL"
if all(status == "PASS" for status in statuses):
return "PASS" if pin["admission"] == "admit" else "BLOCKED"
if "UNAVAILABLE" in statuses:
return "UNAVAILABLE"
return "FAIL"
def validate_receipt(receipt: dict, pins: dict, allow_synthetic: bool = False) -> tuple[str, list[str]]:
problems: list[str] = []
if receipt.get("schema") != RECEIPT_SCHEMA:
return "INVALID", ["schema is not " + RECEIPT_SCHEMA]
if receipt.get("synthetic") and not allow_synthetic:
return "INVALID", ["synthetic receipts cannot be accepted"]
try:
pin = pin_for(pins, receipt.get("row"))
except SystemExit as error:
return "INVALID", [str(error)]
for field in ("package", "version", "tarball_sha256"):
if receipt.get(field) != pin[field]:
problems.append(f"{field} does not match the pin")
if receipt.get("os") not in OSES:
problems.append("os is not one of " + ",".join(OSES))
steps = receipt.get("steps")
if not isinstance(steps, list) or not steps:
return "INVALID", problems + ["no steps recorded"]
seen: dict[str, dict] = {}
for step in steps:
name = step.get("id")
if name not in STEP_LEVEL:
problems.append(f"unknown step {name}")
continue
if name in seen:
problems.append(f"duplicate step {name}")
continue
seen[name] = step
if step.get("status") not in STATUSES:
problems.append(f"{name}: bad status")
if step.get("level") != STEP_LEVEL[name]:
problems.append(f"{name}: level must be {STEP_LEVEL[name]}")
if step.get("status") == "PASS":
if not step.get("evidence"):
problems.append(f"{name}: PASS without evidence")
if step.get("count", 0) < 1:
problems.append(f"{name}: PASS with zero observed events")
if step.get("status") == "UNAVAILABLE" and step.get("unavailable") not in UNAVAILABLE_KINDS:
problems.append(f"{name}: UNAVAILABLE must name platform, provider, desktop or artifact")
for name in required_steps(pin):
if name not in seen:
problems.append(f"required step missing: {name}")
if pin["admission"] != "admit" and seen.get("admission.refused", {}).get("status") == "PASS":
message = (seen["admission.refused"].get("evidence") or {}).get("message", "")
if refusal_needle(pin) not in message:
problems.append("admission.refused: refusal reason does not match the pinned basis")
engine = receipt.get("engine") or {}
if not engine.get("sha256") or not engine.get("version"):
problems.append("engine identity is incomplete")
if problems:
return "INVALID", problems
verdict = verdict_of(pin, steps)
if verdict in ("PASS", "BLOCKED") and engine.get("kind") != "installed":
problems.append("an accepted row needs the installed Engine artifact, not a source build")
if verdict in ("PASS", "BLOCKED") and "(dev)" in str(engine.get("version")):
problems.append("an accepted row cannot come from a dev build")
if receipt.get("verdict") != verdict:
problems.append(f"claimed verdict {receipt.get('verdict')} differs from computed {verdict}")
if problems:
return "INVALID", problems
return verdict, []
def step(name: str, status: str, detail: str, evidence=None, count: int = 0, unavailable: str | None = None) -> dict:
record = {"id": name, "level": STEP_LEVEL[name], "status": status, "detail": detail, "count": count,
"evidence": evidence or {}, "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
if unavailable:
record["unavailable"] = unavailable
return record
class Engine:
def __init__(self, binary: Path, home: Path, workspace: Path, flags: list[str], token: str):
self.binary, self.home, self.workspace, self.flags, self.token = binary, home, workspace, flags, token
self.port = 0
self.proc: subprocess.Popen | None = None
self.log = home.parent / "engine.log"
def env(self) -> dict:
env = dict(os.environ)
env.update({"CODEWHALE_HOME": str(self.home), "HOME": str(self.home), "USERPROFILE": str(self.home),
"CODEWHALE_RUNTIME_TOKEN": self.token})
return env
def start(self) -> None:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
self.port = probe.getsockname()[1]
out = self.log.open("ab")
command = [str(self.binary), "app-server", "--http", "--port", str(self.port)]
for flag in self.flags:
command += ["--enable", flag]
self.proc = subprocess.Popen(command, cwd=self.workspace, env=self.env(), stdout=out, stderr=subprocess.STDOUT)
deadline = time.time() + 90
while time.time() < deadline:
if self.proc.poll() is not None:
raise RuntimeError(f"Engine exited with {self.proc.returncode} before listening")
if self.request("GET", "/v1/apps/plugins")[0] == 200:
return
time.sleep(0.5)
raise RuntimeError("Engine did not answer /v1/apps/plugins within 90 s")
def stop(self) -> None:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(20)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
def request(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=data, method=method)
req.add_header("Authorization", "Bearer " + self.token)
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as response:
raw = response.read()
return response.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as error:
raw = error.read()
try:
return error.code, json.loads(raw)
except ValueError:
return error.code, {"raw": raw.decode("utf-8", "replace")[:500]}
except OSError:
return 0, {}
def extract_tarball(tarball: Path, target: Path) -> Path:
root = target.resolve()
with tarfile.open(tarball) as archive:
members = archive.getmembers()
for member in members:
destination = (root / member.name).resolve()
if not (destination == root or root in destination.parents) or member.issym() or member.islnk():
raise ValueError(f"unsafe archive member {member.name}")
archive.extractall(root, members=members)
candidates = sorted(path.parent for path in root.glob("*/package.json"))
if len(candidates) != 1:
raise ValueError(f"expected one package directory, found {len(candidates)}")
return candidates[0]
def view(plugin: dict) -> dict:
keys = ("state", "enabled", "trust_status", "active", "compatibility", "content_hash", "capability_hash")
return {key: plugin.get(key) for key in keys}
def find_tarball(pin: dict, tarball_dir: Path) -> Path:
path = tarball_dir / pin["tarball"]
if not path.is_file():
raise FileNotFoundError(f"pinned tarball not found: {path}")
return path
def run_row(args, pins: dict) -> dict:
pin = pin_for(pins, args.row)
binary = Path(args.engine).expanduser().resolve()
steps: list[dict] = []
receipt = {"schema": RECEIPT_SCHEMA, "row": pin["row"], "package": pin["package"], "version": pin["version"],
"tarball_sha256": pin["tarball_sha256"], "os": os_name(), "arch": platform.machine().lower(),
"flags": list(args.enable), "steps": steps,
"provider_calls_allowed": bool(args.allow_provider)}
version = subprocess.run([str(binary), "--version"], capture_output=True, text=True, timeout=60).stdout.strip()
receipt["engine"] = {"path": str(binary), "sha256": sha256_file(binary), "version": version, "kind": args.engine_kind}
steps.append(step("engine.identity", "PASS", version, {"sha256": receipt["engine"]["sha256"], "kind": args.engine_kind}, 1))
steps.append(step("platform.identity", "PASS" if os_name() in OSES else "UNAVAILABLE", os_name(),
{"os": os_name(), "arch": receipt["arch"]}, 1, None if os_name() in OSES else "platform"))
failed: list[str] = []
def record(item: dict) -> bool:
steps.append(item)
if item["status"] == "FAIL":
failed.append(item["id"])
return item["status"] == "PASS"
work = Path(tempfile.mkdtemp(prefix="dsh-acceptance-", dir=args.work_dir))
engine = None
try:
try:
tarball = find_tarball(pin, Path(args.tarballs))
actual = sha256_file(tarball)
ok = actual == pin["tarball_sha256"]
record(step("pin.tarball_hash", "PASS" if ok else "FAIL", f"sha256 {actual}", {"sha256": actual, "pinned": pin["tarball_sha256"]}, 1))
if not ok:
raise ValueError("tarball hash differs from the pin")
package = extract_tarball(tarball, work / "pkg")
files = sum(1 for path in package.rglob("*") if path.is_file())
record(step("package.extract", "PASS" if files else "FAIL", f"{files} files", {"package_dir": package.name, "files": files}, files))
except Exception as error:
if "pin.tarball_hash" not in [s["id"] for s in steps]:
record(step("pin.tarball_hash", "FAIL", str(error)))
if "package.extract" not in [s["id"] for s in steps]:
record(step("package.extract", "SKIPPED", "no verified package"))
raise
home, workspace = work / "home", work / "workspace"
home.mkdir()
workspace.mkdir()
engine = Engine(binary, home, workspace, list(args.enable), "dsh-acc-" + hashlib.sha256(os.urandom(16)).hexdigest())
subprocess.run([str(binary), "config", "set", "telemetry", "false"], env=engine.env(), capture_output=True, timeout=60)
engine.start()
code, preview = engine.request("POST", "/v1/apps/plugins/import/dsh/preview", {"path": str(package)})
if pin["admission"] != "admit":
message = str(preview.get("error", {}).get("message", ""))
refused = code == 400 and refusal_needle(pin) in message
record(step("admission.refused", "PASS" if refused else "FAIL", f"HTTP {code}",
{"http": code, "message": message[:600], "pinned_verdict": pin["admission"], "pinned_refusal": refusal_needle(pin)}, 1))
return receipt
ok = code == 200 and preview.get("content_hash")
record(step("admission.preview", "PASS" if ok else "FAIL", f"HTTP {code}",
{"http": code, "review_hash": preview.get("content_hash"), "error": preview.get("error", {}).get("message", "")[:300],
"outcomes": preview.get("conversion", {}).get("outcomes", [])}, 1 if ok else 0))
if not ok:
raise RuntimeError("Engine did not admit the pinned package")
name = preview["conversion"]["plugin_name"]
code, installed = engine.request("POST", "/v1/apps/plugins/install",
{"source": preview["install_source"], "expected_content_hash": preview["content_hash"]})
plugin = installed.get("plugin", {})
ok = code == 201 and plugin.get("state") == "disabled" and plugin.get("trust_status") == "not-reviewed"
record(step("lifecycle.install", "PASS" if ok else "FAIL", f"HTTP {code}, installed disabled and untrusted: {ok}",
{"http": code, "plugin": view(plugin)}, 1 if ok else 0))
if not ok:
raise RuntimeError("install failed")
events = trust_and_enable(engine, name)
record(events[0])
record(events[1])
if failed:
raise RuntimeError("enable failed")
provider_unavailable = "provider calls are off; run with --allow-provider and a configured provider"
for journey in ("journey.core", "journey.controls"):
if args.allow_provider:
record(step(journey, "UNAVAILABLE", "no per-row scripted journey exists yet for this row", {}, 0, "artifact"))
else:
record(step(journey, "UNAVAILABLE", provider_unavailable, {}, 0, "provider"))
engine.stop()
engine.start()
code, after = engine.request("GET", f"/v1/apps/plugins/{name}")
ok = code == 200 and after.get("enabled") is True and after.get("trust_status") == "trusted" and after.get("content_hash") == plugin.get("content_hash")
record(step("lifecycle.restart_retained", "PASS" if ok else "FAIL", f"HTTP {code} after Engine restart",
{"http": code, "plugin": view(after), "installed": view(plugin)}, 1 if ok else 0))
if pin["declares_client"]:
record(step("client.seats_rendered", "UNAVAILABLE", "the desktop slot-observation contract is not available to this driver; screenshots are not accepted",
{"declared_slot_keys": pin["declared_dsh_slot_keys"]}, 0, "desktop"))
code, _ = engine.request("POST", f"/v1/apps/plugins/{name}/disable")
code2, revoked = engine.request("POST", f"/v1/apps/plugins/{name}/revoke")
_, state = engine.request("GET", f"/v1/apps/plugins/{name}")
ok = code == 200 and code2 == 200 and state.get("active") is False and state.get("trust_status") != "trusted"
record(step("lifecycle.revoke", "PASS" if ok else "FAIL", f"disable HTTP {code}, revoke HTTP {code2}",
{"disable": code, "revoke": code2, "plugin": view(state)}, 1 if ok else 0))
code, _ = engine.request("DELETE", f"/v1/apps/plugins/{name}")
code2, _ = engine.request("GET", f"/v1/apps/plugins/{name}")
record(step("lifecycle.uninstall", "PASS" if code == 200 and code2 == 404 else "FAIL", f"delete HTTP {code}, then GET HTTP {code2}",
{"delete": code, "get_after": code2}, 1 if code == 200 and code2 == 404 else 0))
engine.stop()
hits, scanned = scan_for_secret(engine.token, [engine.log, home])
record(step("security.no_key_leak", "PASS" if scanned and not hits else "FAIL", f"scanned {scanned} files, {len(hits)} hits",
{"files_scanned": scanned, "hits": hits[:5], "sentinel": "runtime bearer token"}, scanned))
except Exception as error:
if "lifecycle.install" not in [s["id"] for s in steps] and pin["admission"] == "admit" and "admission.preview" in [s["id"] for s in steps]:
steps.append(step("lifecycle.install", "FAIL", f"driver stopped: {error}"))
receipt["driver_error"] = str(error)
finally:
if engine:
engine.stop()
have = {s["id"] for s in steps}
for name_ in required_steps(pin):
if name_ not in have:
steps.append(step(name_, "SKIPPED", "an earlier step failed or the driver stopped"))
shutil.rmtree(work, ignore_errors=True)
return receipt
def trust_and_enable(engine: Engine, name: str) -> list[dict]:
base = f"/v1/apps/plugins/{name}"
trusted = None
rounds = []
for _ in range(3):
code, detail = engine.request("GET", base)
token = (detail.get("review") or {}).get("token")
if code != 200 or not token:
return [step("lifecycle.trust", "FAIL", f"no review token, HTTP {code}", {"http": code}),
step("lifecycle.enable", "SKIPPED", "trust did not complete")]
code, answer = engine.request("POST", base + "/trust", {"token": token})
rounds.append({"trust_http": code})
if code != 200:
return [step("lifecycle.trust", "FAIL", f"trust HTTP {code}", {"http": code, "answer": answer}),
step("lifecycle.enable", "SKIPPED", "trust did not complete")]
trusted = step("lifecycle.trust", "PASS", "review token accepted", {"rounds": rounds}, len(rounds))
code, answer = engine.request("POST", base + "/enable")
rounds[-1]["enable_http"] = code
if code == 409 and "capability review" in json.dumps(answer):
continue
_, state = engine.request("GET", base)
ok = code == 200 and state.get("active") is True
trusted["evidence"]["rounds"] = rounds
return [trusted, step("lifecycle.enable", "PASS" if ok else "FAIL", f"enable HTTP {code}, active={state.get('active')}",
{"http": code, "plugin": view(state), "answer": answer if code != 200 else {}}, 1 if ok else 0)]
return [trusted or step("lifecycle.trust", "FAIL", "review kept changing"),
step("lifecycle.enable", "FAIL", "capability review did not settle after three rounds", {"rounds": rounds})]
def scan_for_secret(secret: str, roots: list[Path]) -> tuple[list[str], int]:
needle = secret.encode()
hits, scanned = [], 0
for root in roots:
paths = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()]
for path in paths:
try:
if path.stat().st_size > 64 * 1024 * 1024:
continue
scanned += 1
if needle in path.read_bytes():
hits.append(str(path))
except OSError:
continue
return hits, scanned
def synthetic_receipt(pins: dict, row: int, with_pass_everywhere: bool = True) -> dict:
pin = pin_for(pins, row)
steps = []
for name in required_steps(pin):
evidence = {"synthetic": True}
if name == "admission.refused":
evidence["message"] = "DSH import refused: " + refusal_needle(pin)
steps.append(step(name, "PASS", "synthetic", evidence, 1))
return {"schema": RECEIPT_SCHEMA, "synthetic": True, "row": pin["row"], "package": pin["package"], "version": pin["version"],
"tarball_sha256": pin["tarball_sha256"], "os": "macos", "arch": "arm64",
"engine": {"path": "/x", "sha256": "0" * 64, "version": "synthetic", "kind": "installed"},
"steps": steps, "verdict": "BLOCKED" if pin["admission"] != "admit" else "PASS"}
def selftest(pins: dict) -> int:
results = []
def check(label: str, expected: str, receipt: dict, fix_claim: bool = True, synthetic_ok: bool = True) -> None:
if fix_claim and receipt.get("steps"):
pin = pin_for(pins, receipt["row"])
ids = {s["id"] for s in receipt["steps"]}
if all(n in ids for n in required_steps(pin)):
receipt["verdict"] = verdict_of(pin, receipt["steps"])
verdict, problems = validate_receipt(receipt, pins, allow_synthetic=synthetic_ok)
results.append((label, expected, verdict, problems[:2], verdict == expected))
base = synthetic_receipt(pins, 1)
check("all required steps pass", "PASS", copy.deepcopy(base))
missing = copy.deepcopy(base)
missing["steps"] = [s for s in missing["steps"] if s["id"] != "lifecycle.revoke"]
check("one required event missing (lifecycle.revoke)", "INVALID", missing)
empty = copy.deepcopy(base)
empty["steps"][4]["count"] = 0
check("PASS with zero observed events (empty selector)", "INVALID", empty)
bare = copy.deepcopy(base)
bare["steps"][4]["evidence"] = {}
check("PASS without evidence", "INVALID", bare)
provider = copy.deepcopy(base)
for item in provider["steps"]:
if item["id"] == "journey.core":
item.update(status="UNAVAILABLE", unavailable="provider", count=0, evidence={})
check("provider unavailable is not PASS", "UNAVAILABLE", provider)
lying = copy.deepcopy(provider)
lying["verdict"] = "PASS"
check("claimed PASS over an unavailable step", "INVALID", lying, fix_claim=False)
failed = copy.deepcopy(base)
failed["steps"][5].update(status="FAIL")
check("a failed step is FAIL", "FAIL", failed)
unnamed = copy.deepcopy(provider)
for item in unnamed["steps"]:
item.pop("unavailable", None)
check("UNAVAILABLE must name what is missing", "INVALID", unnamed)
wrong = copy.deepcopy(base)
wrong["tarball_sha256"] = "f" * 64
check("tarball hash differs from the pin", "INVALID", wrong)
source = copy.deepcopy(base)
source["engine"]["kind"] = "source-build"
check("source build cannot be accepted as installed", "INVALID", source)
check("synthetic receipt refused in real validation", "INVALID", copy.deepcopy(base), synthetic_ok=False)
check("refused row passes only as BLOCKED", "BLOCKED", synthetic_receipt(pins, 14))
reason = synthetic_receipt(pins, 14)
for item in reason["steps"]:
if item["id"] == "admission.refused":
item["evidence"]["message"] = "DSH import refused: hidden file `.gitignore` is not allowed"
check("refusal for the wrong reason is not BLOCKED", "INVALID", reason)
dev = copy.deepcopy(base)
dev["engine"]["version"] = "codewhale 0.10.3 (dev)"
check("dev build cannot be accepted", "INVALID", dev)
client = synthetic_receipt(pins, 2)
client["steps"] = [s for s in client["steps"] if s["id"] != "client.seats_rendered"]
check("declared client seats cannot be skipped", "INVALID", client)
width = max(len(r[0]) for r in results)
bad = 0
for label, expected, got, problems, ok in results:
bad += not ok
print(f"{'ok ' if ok else 'FAIL'} {label.ljust(width)} expected={expected} got={got} {problems if got == 'INVALID' else ''}")
print(f"selftest: {len(results) - bad} passed, {bad} failed, {len(results)} total")
return 1 if bad else 0
def cmd_validate(args, pins: dict) -> int:
receipt = json.loads(Path(args.receipt).read_text())
verdict, problems = validate_receipt(receipt, pins)
print(json.dumps({"verdict": verdict, "problems": problems, "row": receipt.get("row"), "os": receipt.get("os")}, indent=1))
return 0 if verdict in ("PASS", "BLOCKED") else 1
def cmd_matrix(args, pins: dict) -> int:
table: dict = {}
invalid = []
for path in sorted(Path(args.receipts).glob("**/*.json")):
try:
receipt = json.loads(path.read_text())
except ValueError:
continue
if not isinstance(receipt, dict) or receipt.get("schema") != RECEIPT_SCHEMA:
continue
verdict, problems = validate_receipt(receipt, pins)
if verdict == "INVALID":
invalid.append({"receipt": str(path), "problems": problems})
continue
slot = table.setdefault(receipt["row"], {})
previous = slot.get(receipt["os"])
if previous is None or receipt.get("steps", [{}])[0].get("at", "") >= previous["at"]:
slot[receipt["os"]] = {"verdict": verdict, "receipt": str(path), "at": receipt["steps"][0].get("at", ""),
"engine": receipt["engine"]["sha256"]}
rows = []
for pin in pins["rows"]:
per_os = {}
for name in OSES:
entry = table.get(pin["row"], {}).get(name)
per_os[name] = entry["verdict"] if entry else "UNAVAILABLE(no receipt)"
values = set(per_os.values())
overall = "FAIL" if "FAIL" in values else "PASS" if values == {"PASS"} else "BLOCKED" if values == {"BLOCKED"} else "UNAVAILABLE"
rows.append({"row": pin["row"], "package": pin["package"], "verdict": overall, "os": per_os})
summary = {}
for item in rows:
summary[item["verdict"]] = summary.get(item["verdict"], 0) + 1
out = {"schema": MATRIX_SCHEMA, "rows": rows, "summary": summary, "invalid_receipts": invalid}
print(json.dumps(out, indent=1))
return 0 if summary.get("PASS", 0) + summary.get("BLOCKED", 0) == len(rows) else 1
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="drive one pinned row against an installed Engine")
run.add_argument("--row", type=int, required=True)
run.add_argument("--engine", required=True, help="path to the installed codewhale binary")
run.add_argument("--engine-kind", choices=["installed", "source-build"], required=True)
run.add_argument("--tarballs", required=True, help="directory holding the pinned tarballs")
run.add_argument("--enable", action="append", default=[], help="Engine feature to enable (repeatable), e.g. extension_host")
run.add_argument("--allow-provider", action="store_true", help="permit model-provider calls (spend); off by default")
run.add_argument("--work-dir", default=None, help="scratch parent directory")
run.add_argument("--out", required=True, help="receipt path")
val = sub.add_parser("validate", help="validate one receipt")
val.add_argument("receipt")
mat = sub.add_parser("matrix", help="combine receipts into the row x OS table")
mat.add_argument("receipts")
sub.add_parser("selftest", help="negative controls for the validator")
sub.add_parser("pins", help="verify tarball hashes in a directory")
pins_cmd = sub.choices["pins"]
pins_cmd.add_argument("--tarballs", required=True)
args = parser.parse_args()
pins = load_pins()
if args.command == "selftest":
return selftest(pins)
if args.command == "validate":
return cmd_validate(args, pins)
if args.command == "matrix":
return cmd_matrix(args, pins)
if args.command == "pins":
bad = 0
for pin in pins["rows"]:
path = Path(args.tarballs) / pin["tarball"]
ok = path.is_file() and sha256_file(path) == pin["tarball_sha256"]
bad += not ok
print(f"row {pin['row']:>2} {'ok ' if ok else 'FAIL'} {pin['package']}@{pin['version']}")
print(f"pins: {len(pins['rows']) - bad} ok, {bad} failed")
return 1 if bad else 0
receipt = run_row(args, pins)
receipt["verdict"] = verdict_of(pin_for(pins, args.row), receipt["steps"])
Path(args.out).write_text(json.dumps(receipt, indent=1))
verdict, problems = validate_receipt(receipt, pins)
print(f"row {args.row} {receipt['package']}@{receipt['version']} on {receipt['os']}: driver verdict {receipt['verdict']}, validator {verdict} {problems}")
for item in receipt["steps"]:
print(f" {item['status']:<11} {item['id']:<28} {item['detail'][:110]}")
return 0 if verdict in ("PASS", "BLOCKED") else 1
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -139,6 +139,9 @@ def gpui_geometry(data, digest):
f"{e[kind + '_blur_px']}px rgb(0 0 0 / {e[kind + '_alpha_' + mode]:g});")
for name, value in data["breakpoints"].items():
lines.append(f" --gpui-breakpoint-{name.removesuffix('_px')}: {value}px;")
for mode, stops in data["water"].items():
ramp = ", ".join(f"#{stop['color']} {stop['at']}%" for stop in stops)
lines.append(f" --gpui-water-{mode}: linear-gradient(180deg, {ramp});")
rise, deep = data["ombre"]["rise"], data["ombre"]["deep"]
lines.append(f" --gpui-ombre: linear-gradient(135deg, #{rise}, #{deep});")
fallbacks = ", ".join(json.dumps(v) for v in data["typography"]["fallbacks"])
+194 -4
View File
@@ -11,7 +11,7 @@ const path = require("node:path");
const test = require("node:test");
const repo = path.resolve(__dirname, "../..");
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\necho "codewhale 0.9.11"\n';
const bytes = '#!/bin/sh\necho "executed $*" >> "$INSTALL_TEST_EXECUTED"\ncase "$1" in --help) printf \'%s\\n\' "${INSTALL_TEST_HELP:-}" ;; *) echo "codewhale 0.9.11" ;; esac\n';
function executable(file, body) {
fs.writeFileSync(file, body, { mode: 0o755 });
@@ -58,14 +58,14 @@ cp "$INSTALL_TEST_ASSETS/$asset" "$output"
INSTALL_TEST_EXECUTED: path.join(root, "executed"),
INSTALL_TEST_DOWNLOADS: path.join(root, "downloads") };
for (const key of ["CODEWHALE_RELEASE_BASE_URL", "DEEPSEEK_TUI_RELEASE_BASE_URL", "CODEWHALE_SKIP_GLIBC_CHECK", "DEEPSEEK_TUI_SKIP_GLIBC_CHECK", "DEEPSEEK_SKIP_GLIBC_CHECK", "TERMUX_VERSION", "CODEWHALE_HOME", "CODEWHALE_FORCE_REINSTALL", "CODEWHALE_INSTALL_COMPILED_HOST", "SHELL", "ZDOTDIR"]) delete env[key];
function run() {
function run(args = []) {
const script = kind === "website" ? path.join(repo, "web/public/install.sh") : path.join(archive, "install.sh");
return spawnSync(kind === "website" ? "sh" : "bash", [script], { env, encoding: "utf8", timeout: 10000 });
return spawnSync(kind === "website" ? "sh" : "bash", [script, ...args], { env, encoding: "utf8", timeout: 10000 });
}
function prepare() { fs.mkdirSync(env.CODEWHALE_INSTALL_DIR, { recursive: true }); }
function untouched() {
const runs = fs.existsSync(env.INSTALL_TEST_EXECUTED) ? fs.readFileSync(env.INSTALL_TEST_EXECUTED, "utf8").trim().split("\n") : [];
assert.ok(runs.every(line => line === "executed doctor --installs --brief"), `installers must not execute existing files or sudo: ${runs.join(", ")}`);
assert.ok(runs.every(line => line === "executed doctor --installs --brief" || line === "executed --help"), `installers must not execute existing files or sudo: ${runs.join(", ")}`);
}
return { root, home, bin, archive, assets, destination, env, run, prepare, untouched };
}
@@ -97,6 +97,34 @@ ${hash} codew
f.untouched();
});
test(`${kind}: the output ends with the PATH command and the verify command`, t => {
const f = fixture(t, kind);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const lines = result.stdout.split("\n").map(line => line.trim()).filter(Boolean);
assert.equal(lines.at(-1), "codewhale --version", result.stdout);
assert.equal(lines.at(-2), "Then check it:", result.stdout);
assert.ok(lines.at(-3).startsWith("export PATH="), result.stdout);
assert.ok(lines.at(-3).endsWith("hash -r"), result.stdout);
f.untouched();
});
test(`${kind}: on macOS without Node the Computer Use notice precedes the PATH block`, t => {
const noNode = ["/usr/bin", "/bin"].every(dir => !fs.existsSync(path.join(dir, "node")));
if (!noNode) return t.skip("node is installed in a system directory");
const f = fixture(t, kind);
f.env.PATH = `${f.bin}:/usr/bin:/bin`;
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const out = result.stdout;
const notice = out.indexOf("Computer Use is included and needs Node.js 20");
assert.ok(notice >= 0, out);
assert.ok(notice < out.indexOf("PATH selects"), out);
assert.ok(notice < out.indexOf("Run this now, in this shell:"), out);
assert.equal(out.trim().split("\n").at(-1).trim(), "codewhale --version", out);
f.untouched();
});
test(`${kind}: rerunning an identical installation preserves its files`, t => {
const f = fixture(t, kind);
assert.equal(f.run().status, 0);
@@ -238,6 +266,168 @@ exec /bin/ln "$@"
}
}
const blockOf = dir => `# >>> codewhale PATH >>>\nexport PATH="${dir}:$PATH"\n# <<< codewhale PATH <<<\n`;
const shownDir = f => (fs.realpathSync(f.destination) === fs.realpathSync(path.join(f.home, ".local", "bin")) ? "$HOME/.local/bin" : fs.realpathSync(f.destination));
for (const kind of ["website", "archive"]) {
for (const [shell, profile] of [["/bin/zsh", ".zshrc"], ["/bin/bash", ".bash_profile"], ["/bin/sh", ".profile"]]) {
test(`${kind}: ${shell} gets the marked PATH block in ~/${profile} by default and the output says so`, t => {
const f = fixture(t, kind);
f.env.SHELL = shell;
fs.writeFileSync(path.join(f.home, profile), "# mine\nalias a=b");
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(f.home, profile), "utf8"), `# mine\nalias a=b\n${blockOf(shownDir(f))}`);
assert.ok(result.stdout.includes(`Added to ~/${profile}`), result.stdout);
assert.ok(result.stdout.includes("--no-modify-path"), result.stdout);
const lines = result.stdout.split("\n").map(line => line.trim()).filter(Boolean);
assert.equal(lines.at(-1), "codewhale --version", result.stdout);
assert.ok(lines.at(-3).startsWith("export PATH=") && lines.at(-3).endsWith("hash -r"), result.stdout);
f.untouched();
});
}
test(`${kind}: a fresh profile is created with only the block`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
assert.equal(f.run().status, 0);
assert.equal(fs.readFileSync(path.join(f.home, ".zshrc"), "utf8"), blockOf(shownDir(f)));
});
test(`${kind}: the printed undo command removes exactly the block`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
const before = "# top\nexport A=1\n";
const after = "export B=2\n";
fs.writeFileSync(path.join(f.home, ".zshrc"), before + after);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const undo = result.stdout.split("\n").map(line => line.trim()).find(line => line.startsWith("cw_tmp="));
assert.ok(undo, result.stdout);
const profile = path.join(f.home, ".zshrc");
fs.writeFileSync(profile, before + blockOf(shownDir(f)) + after);
const ran = spawnSync("sh", ["-c", undo], { env: { ...f.env }, encoding: "utf8" });
assert.equal(ran.status, 0, ran.stderr);
assert.equal(fs.readFileSync(profile, "utf8"), before + after);
});
for (const tail of ["a\n# >>> codewhale PATH >>>\nlast\n", "a\n# >>> codewhale PATH >>>\n", "a\n# >>> codewhale PATH >>>"]) {
test(`${kind}: the printed undo command leaves a lone start marker near end of file untouched (${JSON.stringify(tail)})`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const undo = result.stdout.split("\n").map(line => line.trim()).find(line => line.startsWith("cw_tmp="));
const profile = path.join(f.home, ".zshrc");
fs.writeFileSync(profile, tail);
const ran = spawnSync("sh", ["-c", undo], { env: { ...f.env }, encoding: "utf8" });
assert.equal(ran.status, 0, ran.stderr);
assert.equal(fs.readFileSync(profile, "utf8"), tail);
});
}
test(`${kind}: --no-modify-path and CODEWHALE_NO_MODIFY_PATH leave every profile alone and print the command`, t => {
for (const mode of ["flag", "env"]) {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
if (mode === "env") f.env.CODEWHALE_NO_MODIFY_PATH = "1";
const result = f.run(mode === "flag" ? ["--no-modify-path"] : []);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(path.join(f.home, ".zshrc")), false);
assert.ok(result.stdout.includes("Not editing your shell profile"), result.stdout);
assert.ok(result.stdout.includes(">> ~/.zshrc"), result.stdout);
assert.equal(result.stdout.trim().split("\n").at(-1).trim(), "codewhale --version");
}
});
test(`${kind}: an unknown option fails before installing`, t => {
const f = fixture(t, kind);
const result = f.run(["--no-modify-pth"]);
assert.equal(result.status, 2);
assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
});
test(`${kind}: no edit when the install directory is already on PATH`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
const dir = fs.realpathSync(f.destination);
f.env.PATH = `${dir}:${f.env.PATH}`;
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(path.join(f.home, ".zshrc")), false);
assert.ok(!result.stdout.includes("Added to"), result.stdout);
});
test(`${kind}: reinstalling never duplicates the block`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
assert.equal(f.run().status, 0);
const first = fs.readFileSync(path.join(f.home, ".zshrc"), "utf8");
const again = f.run();
assert.equal(again.status, 0, again.stderr);
assert.equal(fs.readFileSync(path.join(f.home, ".zshrc"), "utf8"), first);
assert.ok(again.stdout.includes("already has the codewhale PATH block"), again.stdout);
});
test(`${kind}: a symlinked profile is refused, left alone, and the command is printed`, t => {
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
const target = path.join(f.root, "dotfiles-zshrc");
fs.writeFileSync(target, "# managed\n");
fs.symlinkSync(target, path.join(f.home, ".zshrc"));
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(target, "utf8"), "# managed\n");
assert.ok(result.stdout.includes("is a symlink"), result.stdout);
assert.ok(result.stdout.includes(">> ~/.zshrc"), result.stdout);
assert.equal(result.stdout.trim().split("\n").at(-1).trim(), "codewhale --version");
});
test(`${kind}: an unwritable profile is refused and left alone`, t => {
if (process.getuid && process.getuid() === 0) return t.skip("root can write read-only files");
const f = fixture(t, kind);
f.env.SHELL = "/bin/zsh";
const profile = path.join(f.home, ".zshrc");
fs.writeFileSync(profile, "# ro\n", { mode: 0o444 });
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(profile, "utf8"), "# ro\n");
assert.ok(result.stdout.includes("not a writable regular file"), result.stdout);
});
test(`${kind}: no profile is chosen or edited when the shell cannot be determined`, t => {
for (const shell of ["", "/usr/bin/fish", "/bin/tcsh"]) {
const f = fixture(t, kind);
f.env.SHELL = shell;
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(fs.readdirSync(f.home).filter(name => name !== ".local"), [], `${shell}: ${result.stdout}`);
assert.ok(!result.stdout.includes("Added to"), result.stdout);
}
});
}
const helpWithout = "Usage: codewhale [OPTIONS] [PROMPT]...\n\nCommands:\n update Update\n doctor Diagnose";
const helpWith = `${helpWithout}\n uninstall Remove this receipt-owned release install`;
for (const kind of ["website", "archive"]) {
for (const [label, help, supported] of [["without", helpWithout, false], ["with", helpWith, true]]) {
test(`${kind}: a binary ${label} an uninstall command ${supported ? "gets" : "never gets"} the uninstall and update-mode lines`, t => {
const f = fixture(t, kind);
f.env.INSTALL_TEST_HELP = help;
f.env.SHELL = "/bin/bash";
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.includes("uninstall --yes"), supported, result.stdout);
assert.equal(result.stdout.includes("update.mode"), supported, result.stdout);
assert.equal(/codewhale uninstall/.test(result.stdout), supported, result.stdout);
assert.equal(result.stdout.includes("this release has no uninstall command"), !supported, result.stdout);
if (!supported) {
assert.ok(result.stdout.includes(`rm "${fs.realpathSync(f.destination)}/codewhale" "${fs.realpathSync(f.destination)}/codew" "${fs.realpathSync(f.destination)}/.codewhale-install.sha256"`), result.stdout);
}
f.untouched();
});
}
}
test("website: a checksum mismatch stops before any installation", t => {
const f = fixture(t, "website");
fs.writeFileSync(path.join(f.assets, "codewhale-macos-arm64"), "tampered");
+45 -1
View File
@@ -1,4 +1,5 @@
# Archive installation only. Runtime authority remains with Codewhale Core.
param([switch]$NoModifyPath)
$ErrorActionPreference = 'Stop'
$destination = Join-Path $env:USERPROFILE 'bin'
$names = @('codewhale.exe', 'codew.exe', 'codewhale.bat')
@@ -7,6 +8,38 @@ $optional = $env:CODEWHALE_INSTALL_COMPILED_HOST -eq '1'
if ($optional) {
$names += @('codewhale-extension-host.exe', 'codewhale-extension-host.LICENSES.txt', 'codewhale-extension-host.relink-source.tar.gz', 'codewhale-extension-host.release.json')
}
function Path-Key([string]$entry) {
return [Environment]::ExpandEnvironmentVariables($entry.Trim()).TrimEnd('\', '/').ToLowerInvariant()
}
function Ensure-UserPath([string]$directory) {
$key = Path-Key $directory
$quoted = $directory.Replace("'", "''")
$registry = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
try {
$current = [string]$registry.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if (@($current -split ';' | Where-Object { $_ -and (Path-Key $_) -eq $key }).Count -gt 0) {
Write-Host "$directory is already in your user PATH; it was left as is."
return
}
if (@($env:Path -split ';' | Where-Object { $_ -and (Path-Key $_) -eq $key }).Count -gt 0) {
Write-Host "$directory is already on PATH; your user PATH was not changed."
return
}
if ($NoModifyPath -or $env:CODEWHALE_NO_MODIFY_PATH -in @('1', 'true', 'yes')) {
Write-Host "Not changing your user PATH (-NoModifyPath). To add it yourself, run once:"
Write-Host " [Environment]::SetEnvironmentVariable('Path', ([Environment]::GetEnvironmentVariable('Path', 'User') + ';$quoted'), 'User')"
return
}
$updated = if ($current -and !$current.EndsWith(';')) { "$current;$directory" } else { "$current$directory" }
$registry.SetValue('Path', $updated, [Microsoft.Win32.RegistryValueKind]::ExpandString)
try { [Environment]::SetEnvironmentVariable('CODEWHALE_PATH_NOTIFY', $null, 'User') } catch { }
Write-Host "Added $directory to your user PATH so new terminals find codewhale (skip this with -NoModifyPath). Only that one entry was added."
Write-Host "To undo, run:"
Write-Host " `$p = (Get-Item 'HKCU:\Environment').GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); Set-ItemProperty -Path 'HKCU:\Environment' -Name Path -Type ExpandString -Value (@(`$p -split ';' | Where-Object { `$_ -and `$_.TrimEnd('\') -ine '$quoted' }) -join ';')"
} finally {
$registry.Close()
}
}
function File-Hash([string]$file) {
if (!(Test-Path -LiteralPath $file)) { return $null }
$item = Get-Item -LiteralPath $file -Force
@@ -114,7 +147,7 @@ try {
Move-Item -LiteralPath $row.Staged -Destination $row.Target -Force:([bool]$row.OldHash)
$published += $row
}
Write-Host "Installed canonical Codewhale commands to $destination. Add this directory to your user PATH. Node remains the default host. Remove later with: `"$destination\codewhale.exe`" uninstall"
Write-Host "Installed canonical Codewhale commands to $destination. Node remains the default host. Remove later with: `"$destination\codewhale.exe`" uninstall"
$kept = @($published | Where-Object { $_.OldHash -and $_.OldHash -ne $_.NewHash -and (Test-Path -LiteralPath $_.Backup) })
if ($kept.Count -gt 0) {
$dataRoot = if ($env:CODEWHALE_HOME -and [IO.Path]::IsPathRooted($env:CODEWHALE_HOME)) { $env:CODEWHALE_HOME } else { Join-Path $env:USERPROFILE '.codewhale' }
@@ -133,6 +166,17 @@ try {
}
try { & (Join-Path $destination 'codewhale.exe') doctor --installs --brief 2>$null } catch { }
Write-Host "Updates install automatically; turn off with: codewhale config set update.mode notify"
$onPath = @($env:Path -split ';' | Where-Object { $_ -and (Path-Key $_) -eq (Path-Key $destination) }).Count -gt 0
try { Ensure-UserPath $destination } catch { Write-Warning "Could not update your user PATH: $($_.Exception.Message). Add $destination to it yourself." }
Write-Host ''
if ($onPath) {
Write-Host 'Next: codewhale --version'
} else {
Write-Host 'Use it in this window now (a PATH change does not reach windows that are already open):'
Write-Host " `$env:Path = `"$destination;`$env:Path`""
Write-Host 'Then check it:'
Write-Host ' codewhale --version'
}
} catch {
$original = $_
[array]::Reverse($published)
+217 -10
View File
@@ -3,6 +3,23 @@ set -euo pipefail
# CodeWhale Unix installer
# Copies codewhale and codew to ~/.local/bin (or $PREFIX/bin)
modify_path=1
case "${CODEWHALE_NO_MODIFY_PATH:-}" in
1|true|yes) modify_path=0 ;;
esac
for arg in "$@"; do
case "$arg" in
-h|--help)
echo "Usage: ./install.sh [--no-modify-path]"
echo " Installs codewhale and codew to \$PREFIX/bin (default ~/.local/bin)."
echo " --no-modify-path Do not add that directory to your shell profile."
exit 0
;;
--no-modify-path) modify_path=0 ;;
*) echo "codewhale install: unknown option: $arg (see --help)" >&2; exit 2 ;;
esac
done
PREFIX="${PREFIX:-$HOME/.local}"
BIN_DIR="${PREFIX}/bin"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -310,25 +327,215 @@ done
echo ""
install_binary "$receipt_tmp/$receipt_name" "$BIN_DIR/$receipt_name" 0644
echo "Done. Commands installed to $BIN_DIR."
echo "Uninstall this receipted copy: \"$BIN_DIR/codewhale\" uninstall --yes"
supports_uninstall=0
installed_help="$("$BIN_DIR/codewhale" --help </dev/null 2>/dev/null || true)"
if printf '%s\n' "$installed_help" | grep -E '^[[:space:]]+uninstall([[:space:]]|$)' >/dev/null; then
supports_uninstall=1
fi
if [[ "$supports_uninstall" == 1 ]]; then
echo "Uninstall this receipted copy: \"$BIN_DIR/codewhale\" uninstall --yes"
else
echo "Uninstall: this release has no uninstall command. Remove the files: rm \"$BIN_DIR/codewhale\" \"$BIN_DIR/codew\" \"$BIN_DIR/.codewhale-install.sha256\""
echo "Your data in ~/.codewhale is separate; delete it only if you want it gone."
fi
if [[ -n "$recovery_dir" ]]; then
echo "Previous files are kept in $recovery_dir for 30 days. Undo: \"$BIN_DIR/codewhale\" doctor --restore $(basename "$recovery_dir")"
fi
"$BIN_DIR/codewhale" doctor --installs --brief 2>/dev/null || true
echo "Future updates: \"$BIN_DIR/codewhale\" update"
echo "Updates install automatically; turn off with: codewhale config set update.mode notify"
if [[ "$supports_uninstall" == 1 ]]; then
echo "Updates install automatically; turn off with: codewhale config set update.mode notify"
fi
if [[ "$(uname -s)" == Darwin ]] && ! command -v node >/dev/null 2>&1; then
echo ""
echo "Computer Use is included and needs Node.js 20 or newer on PATH."
echo "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use."
fi
path_selected=1
for bin in codewhale codew; do
resolved="$(command -v "$bin" || true)"
if [[ "$resolved" != "$BIN_DIR/$bin" ]]; then
echo "PATH selects ${resolved:-no $bin command}; this install is $BIN_DIR/$bin"
path_selected=0
fi
done
echo "To select this installation in the current shell:"
echo " export PATH=\"$BIN_DIR:\$PATH\""
echo " hash -r"
echo " command -v codewhale codew"
echo "Keep the directory first in your shell profile after verifying it."
if ! command -v node >/dev/null 2>&1; then
echo "Computer Use is included and needs Node.js 20 or newer on PATH."
echo "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use."
say() { printf '%s\n' "$*"; }
install_dir="$BIN_DIR"
case "$(uname -s)" in Darwin) target=macos-host ;; *) target=other ;; esac
path_marker_start="# >>> codewhale PATH >>>"
path_marker_end="# <<< codewhale PATH <<<"
install_dir_on_path() {
case ":${PATH:-}:" in
*":$install_dir:"*) return 0 ;;
esac
return 1
}
pick_profile() {
profile_file=""
profile_shown=""
[ -n "${HOME:-}" ] || return 0
case "$1" in
zsh)
if [ -n "${ZDOTDIR:-}" ]; then
case "$ZDOTDIR" in
/*) ;;
*) return 0 ;;
esac
case "$ZDOTDIR" in
*[!A-Za-z0-9_./+-]*) return 0 ;;
esac
profile_file="$ZDOTDIR/.zshrc"
profile_shown="\"$profile_file\""
return 0
fi
profile=".zshrc"
;;
bash)
case "$target" in
macos-*)
profile=".bash_profile"
for candidate in .bash_profile .bash_login .profile; do
if [ -e "$HOME/$candidate" ]; then
profile="$candidate"
break
fi
done
;;
*) profile=".bashrc" ;;
esac
;;
*) profile=".profile" ;;
esac
profile_file="$HOME/$profile"
profile_shown="~/$profile"
}
add_path_block() {
path_note=""
path_state=added
if [ -L "$profile_file" ]; then
path_note="$profile_shown is a symlink"
return 1
fi
lead_newline=0
if [ -e "$profile_file" ]; then
if [ ! -f "$profile_file" ] || [ ! -w "$profile_file" ]; then
path_note="$profile_shown is not a writable regular file"
return 1
fi
if grep -qxF "$path_marker_start" "$profile_file" 2>/dev/null; then
path_state=present
return 0
fi
if [ -s "$profile_file" ] && [ -n "$(tail -c 1 "$profile_file" 2>/dev/null)" ]; then
lead_newline=1
fi
else
profile_parent="$(dirname "$profile_file")"
if [ ! -d "$profile_parent" ] || [ ! -w "$profile_parent" ]; then
path_note="the folder for $profile_shown is missing or not writable"
return 1
fi
fi
if ! {
if [ "$lead_newline" -eq 1 ]; then printf '\n'; fi
printf '%s\n' "$path_marker_start" "export PATH=\"$path_dir:\$PATH\"" "$path_marker_end"
} >> "$profile_file" 2>/dev/null; then
path_note="could not write to $profile_shown"
return 1
fi
return 0
}
undo_path_block_command() {
printf '%s' "cw_tmp=\"\$(mktemp)\" && sed -e '/^# >>> codewhale PATH >>>\$/{\$!N;\$!N;/\\n# <<< codewhale PATH <<<\$/d;}' $profile_shown > \"\$cw_tmp\" && cat \"\$cw_tmp\" > $profile_shown; rm -f \"\$cw_tmp\""
}
if [ "$path_selected" -eq 1 ]; then
say ""
say "Next: codewhale --version"
else
path_dir="$install_dir"
if [ -n "${HOME:-}" ] && [ "$install_dir" = "$(cd -P "$HOME/.local/bin" 2>/dev/null && pwd)" ]; then
path_dir="\$HOME/.local/bin"
fi
shell_name="${SHELL:-}"
shell_name="${shell_name##*/}"
case "$path_dir" in
*[\'\"\`\\\$]*|*"
"*)
if [ "$path_dir" != "\$HOME/.local/bin" ]; then
shell_name="unsafe-path"
fi
;;
esac
say ""
if [ -z "$(command -v codewhale 2>/dev/null || true)" ]; then
say "codewhale is not on your PATH in this shell yet."
else
say "A different codewhale comes first on your PATH in this shell."
fi
case "$shell_name" in
fish)
say "This installer does not edit fish's configuration. Put $install_dir first on PATH in future shells (run once):"
say " fish_add_path \"$path_dir\""
say "It takes effect in this fish shell and in new ones (fish 3.2 or newer)."
say "PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path"
say ""
say "Then check it:"
say " codewhale --version"
;;
zsh|bash|sh|dash|ksh|mksh|ash)
pick_profile "$shell_name"
persist_line="printf '%s\\n' '$path_marker_start' 'export PATH=\"$path_dir:\$PATH\"' '$path_marker_end' >> $profile_shown"
if [ -z "$profile_file" ]; then
say "This installer could not pick a profile file for $shell_name, so it changed nothing. To keep it in future shells (run once):"
say " Add $install_dir first to PATH in your shell's startup file."
elif [ "$modify_path" -eq 0 ]; then
say "Not editing your shell profile (--no-modify-path). To keep it in future shells, run once:"
say " $persist_line"
elif install_dir_on_path; then
say "$install_dir is on PATH but not first, so your shell profile was not edited. To put it first in future shells, run once:"
say " $persist_line"
elif add_path_block; then
if [ "$path_state" = present ]; then
say "$profile_shown already has the codewhale PATH block; it was left as is."
else
say "Added to $profile_shown so new shells find codewhale (skip this with --no-modify-path):"
say " $path_marker_start"
say " export PATH=\"$path_dir:\$PATH\""
say " $path_marker_end"
say "Only those three lines were added; nothing else in the file was changed. Shells that are already open do not reread the profile."
fi
say "To undo, run:"
say " $(undo_path_block_command)"
if [ "$supports_uninstall" -eq 1 ]; then
say "The marker lines let codewhale uninstall remove exactly this block; a bare export line is yours and is never touched."
fi
else
say "Did not edit your shell profile: $path_note. To keep it in future shells, run once:"
say " $persist_line"
say "To undo that later, delete the three lines between and including the marker lines."
fi
say "PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path"
say ""
say "Run this now, in this shell:"
say " export PATH=\"$path_dir:\$PATH\"; hash -r"
say "Then check it:"
say " codewhale --version"
;;
unsafe-path)
say "Add $install_dir first to PATH in your shell's startup file; its name contains shell-special characters, so no command line is printed for it and nothing was edited."
say "PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path"
;;
*)
say "Add $install_dir first to PATH in your shell's startup file; this installer has no PATH line for ${shell_name:-an unknown shell} and edited nothing."
say "PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path"
say ""
say "Until then, run it by its full path:"
say " \"$install_dir/codewhale\" --version"
;;
esac
fi
-76
View File
@@ -1,76 +0,0 @@
# codewhale-design
Keep every Codewhale surface on one set of design tokens.
[SYSTEM.md](SYSTEM.md) is the design system: the product model, how tokens are used, components per
medium, patterns and the current audit. [DIRECTION.md](DIRECTION.md) is how it should feel.
This repository is the source of truth for Codewhale's colours, type, spacing,
radii and motion constants. The desktop app, the website, the terminal UI
(`codewhale-ratatui`) and the mobile app each vendor a copy and verify it at
build time, so no consumer needs the network or a sibling checkout.
Update tokens: edit `tokens.json`, bump its version, run `python3 generate.py`,
then run `scripts/sync-to.sh <consumer-repo>` for each consumer.
## Details
`tokens.json` is the versioned semantic design authority. Desktop consumes
`tokens.rs`; GPUI mobile vendors this folder and consumes the same Rust data;
web-next vendors this folder and imports `tokens.css`. No network access or
sibling checkout is required to build a consumer.
Change the JSON here, increment its version, run `python3 generate.py`,
and use `scripts/sync-to.sh` to copy the runtime token files into each consumer's
`vendor/codewhale-design`. Design studies and visual proofs stay in this repository.
Run the generator with `--check` in each repository. Generated files include the
source digest; consumer tests reject drift. Layout remains native to each
surface. Focus and reduced-motion settings must remain accessible on each host.
Typography and palette come from the shipping desktop theme. `primary` fills
actions and switches in neutral ink; `accent` is the one quiet blue, for focus
rings, links, text selection and the caret; the logo ombre stays on the whale
and the sign-in page. Icons retain the
24-unit, 1.7-pixel rounded stroke family; semantic names and accessible labels
stay with the host controls. Motion is shared too: durations (state, arrive,
exit, panel, linger), the arrive rise, the two loop periods (`beat` for
something that needs you, `breathe` for work in progress), the loop frame cap,
three easing curves (`out`, `in`, `panel`), state glows, the logo ombre and the
popover elevation reach Rust as constants and CSS as `--motion-*`, `--glow-*`,
`--ombre-*` and `--elevation-popover`. Desktop spring constants and the
reduced-motion poll cadence stay Rust-only. Under reduced motion every host
runs durations at zero, stops loops and keeps glows static.
`stream` paces how received reply text is uncovered, the same way on every
client: it follows the stream's arrival rate averaged over `rate_window_ms`,
uncovers the first receipt within `reveal_first_ms`, never lets hidden text
trail the received text by more than `reveal_lag_ms`, shows a receipt of at
most `instant_graphemes` at once when nothing is waiting, and shows a backlog
over `max_pending_bytes` (UTF-16 units on the web) at once. Rust reads them as
`STREAM_*`; the web reads `tokens.json`. Completion, corrections and reduced
motion always show the exact received text immediately.
`icons/` holds the sea glyphs DIRECTION.md names, on the same 24-unit grid at a
1.7-pixel round stroke, each with its word as `aria-label`: sonar (searching,
thinking), tide (real progress, waking), shell (receipt) and kelp (divider).
A consumer copies the file it uses beside its own icons; a glyph never stands
without its word on screen.
CSS consumers use `font-family: var(--font-family), var(--font-fallbacks), sans-serif`
to keep the shared family and CJK fallbacks together. The generator rejects
selection and primary-hover opacity values outside the inclusive 0–1 range,
and any palette that misses WCAG AA contrast in either mode: every text role
at 4.5:1 (`muted_foreground` at 5.5:1) and `border_strong` at 3:1 on
`background`, `surface`, `sidebar`, `hover`, `selected`, `ocean_depth` and the diff rows `diff_add` and `diff_remove`, `primary_foreground` on `primary` and on `accent` at 4.5:1, and dark
`foreground` on the ombre's deep stop at 4.5:1 (its rising stop is never a text ground). It also rejects
glow and elevation alphas outside 0–1 and any easing curve whose control points leave [0, 1], so nothing
overshoots. A new
colour, such as a named blue, enters the tokens only once it passes.
## Design studies and implementation handoffs
[Claude desktop → Codewhale, October 9, 2026](studies/claude-parity-20261009/README.md)
contains the editable Figma screen map, reusable component inventory, visual
proofs and frame-specific implementation packets. Its observed reference
behavior and proposed states are recorded separately. The study uses the
existing tokens; it does not introduce another palette or change shipped defaults.
File diff suppressed because it is too large. Load diff
+78
View File
@@ -0,0 +1,78 @@
const DRY = typeof DRY_RUN !== "undefined" && DRY_RUN;
const rgba = (v) => {
const n = parseInt(v.hex, 16);
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: v.alpha };
};
const same = (a, b) =>
typeof a === "number" || typeof b === "number"
? a === b
: !!a && !!b && ["r", "g", "b", "a"].every((k) => Math.abs((a[k] ?? 1) - (b[k] ?? 1)) < 0.002);
const report = { version: PAYLOAD.version, dry: DRY, created: [], updated: [], unchanged: 0, notInTokens: [], styles: { created: [], updated: [], unchanged: 0 } };
const collections = await figma.variables.getLocalVariableCollectionsAsync();
const variables = await figma.variables.getLocalVariablesAsync();
for (const wanted of PAYLOAD.collections) {
let collection = collections.find((c) => c.name === wanted.name);
if (!collection) {
if (DRY) {
report.created.push(...wanted.variables.map((v) => `${wanted.name}: ${v.name}`));
continue;
}
collection = figma.variables.createVariableCollection(wanted.name);
collection.renameMode(collection.modes[0].modeId, wanted.modes[0]);
for (const mode of wanted.modes.slice(1)) collection.addMode(mode);
}
const modeId = Object.fromEntries(collection.modes.map((m) => [m.name, m.modeId]));
const missingMode = wanted.modes.find((m) => !modeId[m]);
if (missingMode) throw new Error(`${wanted.name} has no mode named ${missingMode}`);
const existing = variables.filter((v) => v.variableCollectionId === collection.id);
const names = new Set(wanted.variables.map((v) => v.name));
report.notInTokens.push(...existing.filter((v) => !names.has(v.name)).map((v) => `${wanted.name}: ${v.name}`));
for (const token of wanted.variables) {
let variable = existing.find((v) => v.name === token.name);
if (!variable) {
report.created.push(`${wanted.name}: ${token.name}`);
if (DRY) continue;
variable = figma.variables.createVariable(token.name, collection, token.type);
variable.scopes = token.scopes;
variable.setVariableCodeSyntax("WEB", token.web);
} else if (!DRY && (variable.codeSyntax.WEB || "") !== token.web) {
variable.setVariableCodeSyntax("WEB", token.web);
}
let changed = false;
for (const mode of wanted.modes) {
const next = token.type === "COLOR" ? rgba(token.values[mode]) : token.values[mode];
if (same(variable.valuesByMode[modeId[mode]], next)) continue;
changed = true;
if (!DRY) variable.setValueForMode(modeId[mode], next);
}
if (changed && !report.created.includes(`${wanted.name}: ${token.name}`)) report.updated.push(`${wanted.name}: ${token.name}`);
if (!changed) report.unchanged += 1;
}
}
const textStyles = await figma.getLocalTextStylesAsync();
for (const wanted of PAYLOAD.textStyles) {
const font = { family: wanted.family, style: wanted.style };
let style = textStyles.find((s) => s.name === wanted.name);
const matches =
style &&
style.fontName.family === font.family &&
style.fontName.style === font.style &&
style.fontSize === wanted.size &&
style.lineHeight.unit === "PIXELS" &&
style.lineHeight.value === wanted.line;
if (matches) {
report.styles.unchanged += 1;
continue;
}
report.styles[style ? "updated" : "created"].push(wanted.name);
if (DRY) continue;
await figma.loadFontAsync(font);
if (!style) {
style = figma.createTextStyle();
style.name = wanted.name;
}
style.fontName = font;
style.fontSize = wanted.size;
style.lineHeight = { unit: "PIXELS", value: wanted.line };
}
return report;
File diff suppressed because it is too large. Load diff
+236 -19
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Generate the portable GPUI design artifact. No sibling checkout is needed.
"""Generate the portable Codewhale design artifact. No sibling checkout is needed.
Edit tokens.json only in the codewhale-design repository. Consumers vendor the whole folder.
"""
import argparse
@@ -8,6 +8,7 @@ import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
HERE = ROOT
# WCAG 2.x contrast every palette must hold, in both modes, before anything is
# generated: text roles at 4.5:1 and control edges at 3:1 on each ground a
@@ -15,15 +16,43 @@ ROOT = Path(__file__).resolve().parent
# of the ocean field behind the working area, and the added and removed rows
# of a diff, which carry code and their +/− marks.
GROUNDS = ["background", "surface", "sidebar", "hover", "selected", "ocean_depth", "diff_add", "diff_remove"]
TEXT = ["foreground", "muted_foreground", "primary", "accent", "live", "attention", "danger"]
# `primary` is a fill only; its text is `primary_foreground`, checked in FILLED.
TEXT = ["foreground", "muted_foreground", "accent", "live", "attention", "danger"]
# Secondary text carries labels, metadata and status words people must read at
# small sizes, so it holds 5.5:1 on every ground, above the AA floor.
TEXT_FLOORS = {"muted_foreground": 5.5}
EDGES = ["border_strong"]
FILLED = [("primary_foreground", "primary"), ("primary_foreground", "accent")]
# The ombre's deep stop can carry body text on the dark ground; its rising
# stop is a brand colour only and never a text ground.
# Disabled labels and icons stay readable while reading as off: a solid ink
# at the 3:1 floor of a user-interface component, never an opacity.
QUIET = ["disabled"]
FILLED = [("primary_foreground", "primary")]
# The ombre's deep stop can carry body text on the dark ground; both stops
# carry `primary_foreground` on the primary action in either mode.
OMBRE_TEXT = [("foreground", "deep", 4.5)]
LEVELS = ["clear", "frosted", "solid"]
HEX = "0123456789abcdef"
def color(value, name):
"""A token colour is a solid hex string or {"hex", "alpha"}; returns (hex, alpha)."""
hex6, alpha = (value, 1) if isinstance(value, str) else (value.get("hex"), value.get("alpha"))
if not (isinstance(hex6, str) and len(hex6) == 6 and all(c in HEX for c in hex6)):
raise ValueError(f"{name} must be six lowercase hex digits")
if isinstance(alpha, bool) or not isinstance(alpha, (int, float)) or not 0 <= alpha <= 1:
raise ValueError(f"{name} alpha must be a number between 0 and 1")
return hex6, alpha
def over(value, ground, name="colour"):
hex6, alpha = color(value, name)
top = [int(hex6[i:i + 2], 16) for i in (0, 2, 4)]
under = [int(ground[i:i + 2], 16) for i in (0, 2, 4)]
return "".join(f"{round(alpha * t + (1 - alpha) * u):02x}" for t, u in zip(top, under))
def css_color(value, name):
hex6, alpha = color(value, name)
if alpha == 1:
return f"#{hex6}"
r, g, b = (int(hex6[i:i + 2], 16) for i in (0, 2, 4))
return f"rgb({r} {g} {b} / {alpha})"
def luminance(hex6):
channels = [int(hex6[i:i + 2], 16) / 255 for i in (0, 2, 4)]
@@ -34,9 +63,26 @@ def contrast(a, b):
hi, lo = sorted([luminance(a), luminance(b)], reverse=True)
return (hi + 0.05) / (lo + 0.05)
def contrast_violations(colors, ombre):
def composited_grounds(data, mode):
"""Every glass and sheet fill over each water stop and the surface-light peak,
and every card fill over the sheet and each Glass level: the real grounds text sits on."""
water = {f"water {s['at']}%": color(s["color"], f"water.{mode}")[0] for s in data["water"][mode]}
first = data["water"][mode][0]["color"]
water["surface-light peak"] = over(data["surface_light"][mode]["stops"][0]["color"], first, f"surface_light.{mode}")
grounds = {}
for under_name, under in water.items():
for level in LEVELS:
grounds[f"{level} glass over {under_name}"] = over(data["glass"][mode][level]["fill"], under, f"glass.{mode}.{level}")
grounds[f"sheet over {under_name}"] = over(data["sheet"][mode]["fill"], under, f"sheet.{mode}")
for name, ground in list(grounds.items()):
grounds[f"card on {name}"] = over(data["card"][mode]["fill"], ground, f"card.{mode}")
return grounds
def contrast_violations(data):
colors, ombre = data["colors"], data["ombre"]
pairs = [(fg, bg, TEXT_FLOORS.get(fg, 4.5)) for fg in TEXT for bg in GROUNDS]
pairs += [(edge, bg, 3.0) for edge in EDGES for bg in GROUNDS]
pairs += [(quiet, bg, 3.0) for quiet in QUIET for bg in GROUNDS]
pairs += [(fg, bg, 4.5) for fg, bg in FILLED]
found = []
for mode, palette in colors.items():
@@ -44,17 +90,130 @@ def contrast_violations(colors, ombre):
ratio = contrast(palette[fg], palette[bg])
if ratio < floor:
found.append(f"{mode} {fg} on {bg} is {ratio:.2f}:1, needs {floor}:1")
for stop, value in ombre.items():
ratio = contrast(palette["primary_foreground"], value)
if ratio < 4.5:
found.append(f"{mode} primary_foreground on ombre {stop} is {ratio:.2f}:1, needs 4.5:1")
for ground_name, ground in composited_grounds(data, mode).items():
for fg in TEXT + QUIET:
floor = 3.0 if fg in QUIET else TEXT_FLOORS.get(fg, 4.5)
ratio = contrast(palette[fg], ground)
if ratio < floor:
found.append(f"{mode} {fg} on {ground_name} is {ratio:.2f}:1, needs {floor}:1")
for fg, stop, floor in OMBRE_TEXT:
ratio = contrast(colors["dark"][fg], ombre[stop])
if ratio < floor:
found.append(f"dark {fg} on ombre {stop} is {ratio:.2f}:1, needs {floor}:1")
return found
def check_solid_roles(data):
"""colors.* keep solid hex: each translucent v5 value is stored as its result over `background`."""
for mode, palette in data["colors"].items():
ground = palette["background"]
if ground != data["water"][mode][-1]["color"]:
raise ValueError(f"colors.{mode}.background must be the bottom water stop")
for role, value in [("surface", data["sheet"][mode]["fill"]), ("sidebar", data["glass"][mode]["frosted"]["fill"])]:
if palette[role] != over(value, ground):
raise ValueError(f"colors.{mode}.{role} must be {over(value, ground)}, its fill over background")
FILL_, EDGE_ = ["FRAME_FILL", "SHAPE_FILL"], ["STROKE_COLOR"]
TINT_SOURCES = {"accent_fill": "var(--accent)", "accent_border": "var(--accent)", "count_fill": "var(--text)",
"danger_border": "var(--danger)", "scrim": "var(--surface)", "on_primary_border": "currentColor"}
TINT_FIGMA = {"accent_fill": ("effort/fill", "accent", FILL_), "accent_border": ("chip/accent-border", "accent", EDGE_),
"count_fill": ("count/fill", "foreground", FILL_), "danger_border": ("tint/danger-border", "danger", EDGE_),
"scrim": ("tint/scrim", "background", FILL_), "on_primary_border": ("kbd/on-primary-border", "primary_foreground", EDGE_)}
WEIGHT_STYLE = {400: "Regular", 500: "Medium", 550: "SemiBold", 600: "SemiBold", 700: "Bold"}
FILL, INK, EDGE = ["FRAME_FILL", "SHAPE_FILL"], ["SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"], ["STROKE_COLOR"]
FIGMA_SCOPES = {"text": INK, "muted": INK, "disabled": INK, "accent": FILL + ["TEXT_FILL", "STROKE_COLOR"], "button-text": INK,
"live": INK, "attention": INK, "danger": INK, "line": FILL + EDGE, "border-strong": EDGE}
def figma_payload(data, aliases):
modes = list(data["colors"])
def both(get):
return {mode.capitalize(): dict(zip(["hex", "alpha"], color(get(mode), "figma"))) for mode in modes}
colors = [{"name": alias, "type": "COLOR", "scopes": FIGMA_SCOPES.get(alias, FILL), "web": f"var(--{alias})",
"values": both(lambda mode, key=key: data["colors"][mode][key])} for alias, key in aliases.items()]
for group in ["card", "sheet"]:
for part in ["fill", "border"]:
colors.append({"name": f"{group}/{part}", "type": "COLOR", "scopes": FILL if part == "fill" else EDGE,
"web": f"var(--{group}-{part})", "values": both(lambda mode, g=group, p=part: data[g][mode][p])})
for level in LEVELS:
colors.append({"name": f"glass/{level}-fill", "type": "COLOR", "scopes": FILL, "web": f"var(--glass-{level}-fill)",
"values": both(lambda mode, l=level: data["glass"][mode][l]["fill"])})
for part in ["border", "highlight"]:
colors.append({"name": f"glass/{part}", "type": "COLOR", "scopes": EDGE, "web": f"var(--glass-{part})",
"values": both(lambda mode, p=part: data["glass"][mode][p])})
for name, value in data["ombre"].items():
colors.append({"name": f"ombre/{name}", "type": "COLOR", "scopes": FILL, "web": f"var(--ombre-{name})",
"values": both(lambda mode, v=value: v)})
for index, stop in enumerate(["top", "upper", "lower", "bottom"]):
if all(len(data["water"][mode]) == 4 for mode in modes):
colors.append({"name": f"water/{stop}", "type": "COLOR", "scopes": ["FRAME_FILL"], "web": f"var(--water) · {stop} stop",
"values": both(lambda mode, i=index: data["water"][mode][i]["color"])})
for index, stop in enumerate(["peak", "mid", "edge"]):
if all(len(data["surface_light"][mode]["stops"]) == 3 for mode in modes):
colors.append({"name": f"surface-light/{stop}", "type": "COLOR", "scopes": ["FRAME_FILL"], "web": f"var(--surface-light) · {stop} stop",
"values": both(lambda mode, i=index: data["surface_light"][mode]["stops"][i]["color"])})
for layer in ["popover", "dialog"]:
colors.append({"name": f"shadow/{layer}", "type": "COLOR", "scopes": ["EFFECT_COLOR"], "web": f"var(--elevation-{layer})",
"values": both(lambda mode, l=layer: {"hex": "000000", "alpha": data["elevation"][f"{l}_alpha_{mode}"]})})
for name, (figma_name, role, scopes) in TINT_FIGMA.items():
web = f"var(--tint-{name.replace('_', '-')})"
colors.append({"name": figma_name, "type": "COLOR", "scopes": scopes, "web": web,
"values": both(lambda mode, r=role, n=name: {"hex": data["colors"][mode][r], "alpha": data["tint"][n]})})
whale = json.loads((HERE / "whale" / "source" / "spec.json").read_text())["palette"]
for figma_name, pick in [("whale/rise", lambda p: p["body"][0]), ("whale/deep", lambda p: p["body"][1]),
("whale/pouch", lambda p: p["throat"]), ("whale/eye", lambda p: p["eye"])]:
colors.append({"name": figma_name, "type": "COLOR", "scopes": FILL, "web": "whale/source/spec.json palette",
"values": both(lambda mode, f=pick: f(whale[mode])[1:])})
metric = lambda name, value, scopes, web: {"name": name, "type": "FLOAT", "scopes": scopes, "web": web, "values": {"Value": value}}
metrics = [metric(f"spacing/{name}", value, ["GAP"], f"var(--spacing-{name})") for name, value in data["spacing"].items()]
metrics += [metric(f"radius/{name}", value, ["CORNER_RADIUS"], f"var(--radius-{name})") for name, value in data["radius"].items()]
metrics += [metric(f"size/{name[:-3].replace('_', '-')}", value, ["WIDTH_HEIGHT"], f"var(--size-{name[:-3].replace('_', '-')})")
for name, value in data["size"].items()]
metrics += [metric(f"focus/{name}", value, ["STROKE_FLOAT"] if name == "width" else ["GAP"], f"var(--focus-{name})")
for name, value in data["focus"].items()]
styles = []
for name, role in data["type_roles"].items():
if role["weight"] not in WEIGHT_STYLE:
raise ValueError(f"type_roles.{name} weight {role['weight']} has no Figma font style")
styles.append({"name": f"CW 5/{name.capitalize()}", "family": "Geist Mono" if name == "code" else "Geist",
"style": WEIGHT_STYLE[role["weight"]], "size": role["size_px"], "line": role["line_px"]})
return {"version": data["version"], "textStyles": styles, "collections": [
{"name": "CW 5 · Color", "modes": [mode.capitalize() for mode in modes], "variables": colors},
{"name": "CW 5 · Metrics", "modes": ["Value"], "variables": metrics}]}
def dtcg(data):
def hexa(value):
hex6, alpha = color(value, "dtcg")
return f"#{hex6}" if alpha == 1 else f"#{hex6}{round(alpha * 255):02x}"
px = lambda group: {name: {"$value": f"{value}px"} for name, value in data[group].items()}
out = {"$description": f"Codewhale design {data['version']}. Generated from tokens.json; do not edit.",
"color": {mode: {"$type": "color", **{role: {"$value": f"#{value}"} for role, value in palette.items()}}
for mode, palette in data["colors"].items()},
"ombre": {"$type": "color", **{name: {"$value": f"#{value}"} for name, value in data["ombre"].items()}},
"spacing": {"$type": "dimension", **px("spacing")}, "radius": {"$type": "dimension", **px("radius")},
"size": {"$type": "dimension", **{name[:-3]: {"$value": f"{value}px"} for name, value in data["size"].items()}},
"duration": {"$type": "duration", **{name[9:-3]: {"$value": f"{value}ms"} for name, value in data["motion"].items()
if name.startswith("duration_")}},
"easing": {"$type": "cubicBezier", **{name: {"$value": curve} for name, curve in data["easing"].items()}},
"type": {"$type": "typography", **{name: {"$value": {
"fontFamily": data["typography"]["mono_family" if name == "code" else "family"], "fontSize": f"{role['size_px']}px",
"lineHeight": round(role["line_px"] / role["size_px"], 4), "fontWeight": role["weight"]}}
for name, role in data["type_roles"].items()}}}
for group in ["glass", "sheet", "card"]:
out[group] = {"$type": "color"}
for mode, parts in data[group].items():
out[group][mode] = {part: {"$value": hexa(value["fill"] if isinstance(value, dict) and "fill" in value else value)}
for part, value in parts.items()}
return out
def render():
source = (ROOT / "tokens.json").read_bytes()
data = json.loads(source)
unit = {name: data[name] for name in ["selection_opacity", "primary_hover_opacity"]}
unit |= {f"glow.{name}": v for name, v in data["glow"].items() if name.endswith("_alpha")}
unit |= {f"tint.{name}": v for name, v in data["tint"].items()}
unit |= {f"elevation.{name}": v for name, v in data["elevation"].items() if "_alpha" in name}
for name, value in unit.items():
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1:
@@ -67,14 +226,30 @@ def render():
if not all(isinstance(v, int) and v > 0 for v in stream.values()) or stream["reveal_first_ms"] > stream["reveal_lag_ms"]:
raise ValueError("stream values must be positive integers, with reveal_first_ms no longer than reveal_lag_ms")
digest = hashlib.sha256(source).hexdigest()
header = f"Generated from Codewhale GPUI design {data['version']}; sha256 {digest}. Do not edit."
header = f"Generated from Codewhale design {data['version']}; sha256 {digest}. Do not edit."
keys = list(data["colors"]["dark"])
assert set(keys) == set(data["colors"]["light"])
for palette in [*data["colors"].values(), data["ombre"]]:
assert all(len(v) == 6 and all(c in "0123456789abcdef" for c in v) for v in palette.values())
violations = contrast_violations(data["colors"], data["ombre"])
assert all(len(v) == 6 and all(c in HEX for c in v) for v in palette.values())
modes = list(data["colors"])
for group in ["water", "surface_light", "glass", "sheet", "card"]:
if set(data[group]) != set(modes):
raise ValueError(f"{group} needs exactly the modes {', '.join(modes)}")
for mode in modes:
stops = data["water"][mode]
if not stops or [s["at"] for s in stops] != sorted(s["at"] for s in stops):
raise ValueError(f"water.{mode} needs stops in order")
if set(data["glass"][mode]) != {"border", "highlight", *LEVELS}:
raise ValueError(f"glass.{mode} needs border, highlight, {', '.join(LEVELS)}")
check_solid_roles(data)
violations = contrast_violations(data)
if violations:
raise ValueError("Contrast below WCAG AA:\n " + "\n ".join(violations))
def rust_color(prefix, value, name):
hex6, alpha = color(value, name)
return [f"pub const {prefix}: u32 = 0x{hex6};", f"pub const {prefix}_ALPHA: f32 = {float(alpha)};"]
rust = [f"// {header}", "#![allow(dead_code)]", "", f'pub const VERSION: &str = "{data["version"]}";',
"#[derive(Clone, Copy, Debug)]", "pub struct Colors {"]
rust += [f" pub {k}: u32," for k in keys]
@@ -84,7 +259,21 @@ def render():
rust += [f" {k}: 0x{v}," for k, v in palette.items()]
rust += ["};"]
rust += ["pub fn colors(dark: bool) -> Colors {", " if dark { DARK } else { LIGHT }", "}"]
for mode in modes:
m = mode.upper()
for stop in data["water"][mode]:
rust += [f"pub const WATER_{m}_{stop['at']}: u32 = 0x{color(stop['color'], 'water')[0]};"]
for level in LEVELS:
glass = data["glass"][mode][level]
rust += rust_color(f"GLASS_{m}_{level.upper()}_FILL", glass["fill"], "glass")
rust += [f"pub const GLASS_{m}_{level.upper()}_BLUR_PX: f32 = {float(glass['blur_px'])};"]
for part in ["border", "highlight"]:
rust += rust_color(f"GLASS_{m}_{part.upper()}", data["glass"][mode][part], "glass")
for group in ["sheet", "card"]:
for part, value in data[group][mode].items():
rust += rust_color(f"{group.upper()}_{m}_{part.upper()}", value, group)
rust += [f'pub const FONT_FAMILY: &str = "{data["typography"]["family"]}";',
f'pub const FONT_MONO_FAMILY: &str = "{data["typography"]["mono_family"]}";',
"pub const FONT_FALLBACKS: &[&str] = &["]
rust += [f" {json.dumps(v)}," for v in data["typography"]["fallbacks"]]
rust += ["];"]
@@ -114,7 +303,7 @@ def render():
rust += [f'pub const TYPE_{name.upper()}: f32 = {float(data["typography"][name])};']
# Existing web names remain aliases, so every current component migrates together.
aliases = {"outside":"sidebar","surface":"background","panel":"surface","text":"foreground",
"muted":"muted_foreground","line":"border","accent":"accent","button":"primary",
"muted":"muted_foreground","disabled":"disabled","line":"border","accent":"accent","button":"primary",
"button-text":"primary_foreground","attention":"attention","hover":"hover","selected":"selected",
"live":"live","danger":"danger","border-strong":"border_strong","ocean-depth":"ocean_depth",
"diff-add":"diff_add","diff-remove":"diff_remove"}
@@ -125,8 +314,31 @@ def render():
elevation = data["elevation"]
css += [f' --selection: #{palette["accent"]}{round(data["selection_opacity"] * 255):02x};',
f' --elevation-popover: 0 {elevation["popover_y_px"]}px {elevation["popover_blur_px"]}px rgb(0 0 0 / {elevation[f"popover_alpha_{mode}"]});',
f' --elevation-dialog: 0 {elevation["dialog_y_px"]}px {elevation["dialog_blur_px"]}px rgb(0 0 0 / {elevation[f"dialog_alpha_{mode}"]});',
f" color-scheme: {mode};", "}"]
f' --elevation-dialog: 0 {elevation["dialog_y_px"]}px {elevation["dialog_blur_px"]}px rgb(0 0 0 / {elevation[f"dialog_alpha_{mode}"]});']
water = ", ".join(f"#{color(s['color'], 'water')[0]} {s['at']}%" for s in data["water"][mode])
light = data["surface_light"][mode]
glow = ", ".join(f"{css_color(s['color'], 'surface_light')} {s['at']}%" for s in light["stops"])
css += [f" --water: linear-gradient(180deg, {water});",
f" --surface-light: radial-gradient(ellipse at {light['x']}% {light['y']}%, {glow});"]
css += [f" --glass-{level}-fill: {css_color(data['glass'][mode][level]['fill'], 'glass')};" for level in LEVELS]
css += [f" --glass-{part}: {css_color(data['glass'][mode][part], 'glass')};" for part in ["border", "highlight"]]
for group in ["sheet", "card"]:
css += [f" --{group}-{part}: {css_color(value, group)};" for part, value in data[group][mode].items()]
css += [f" color-scheme: {mode};", "}"]
dark_glass = data["glass"][modes[0]]
for index, level in enumerate(["frosted", "clear", "solid"]):
selector = ":root, [data-theme], [data-glass]" if index == 0 else f'[data-glass="{level}"], [data-glass="{level}"] [data-theme]'
css += [f"{selector} {{", f" --glass-fill: var(--glass-{level}-fill);",
f" --glass-blur: {dark_glass[level]['blur_px']}px;", "}"]
css += [":root, [data-theme] {"]
glow = data["glow"]
css_alias = {"primary": "accent", "live": "live", "attention": "attention"}
css += [f' --glow-{name[:-6]}: 0 0 {glow["blur_px"]}px color-mix(in srgb, var(--{css_alias[name[:-6]]}) {round(value * 100)}%, transparent);'
for name, value in glow.items() if name.endswith("_alpha")]
css += [f' --tint-{name.replace("_", "-")}: color-mix(in srgb, {source} {round(data["tint"][name] * 100)}%, transparent);'
for name, source in TINT_SOURCES.items()]
css += [" --focus-ring: var(--focus-width) solid var(--accent);", " --glass-edge: inset 0 1px 0 var(--glass-highlight);"]
css += ["}"]
css += [":root {"]
for section in ["spacing", "radius", "focus"]:
css += [f" --{section}-{name}: {value}px;" for name, value in data[section].items()]
@@ -138,13 +350,11 @@ def render():
css += [f' --motion-arrive-rise: {motion["arrive_rise_px"]}px;']
css += [f" --motion-ease-{name}: cubic-bezier({', '.join(str(v) for v in curve)});"
for name, curve in data["easing"].items()]
glow = data["glow"]
css_alias = {"primary": "accent", "live": "live", "attention": "attention"}
css += [f' --glow-{name[:-6]}: 0 0 {glow["blur_px"]}px color-mix(in srgb, var(--{css_alias[name[:-6]]}) {round(value * 100)}%, transparent);'
for name, value in glow.items() if name.endswith("_alpha")]
css += [f" --ombre-{name}: #{value};" for name, value in data["ombre"].items()]
css += [f' --ombre: linear-gradient(135deg, #{data["ombre"]["rise"]}, #{data["ombre"]["deep"]});']
css += [f' --icon-stroke: {data["icons"]["stroke"]};',
f' --font-family: {json.dumps(data["typography"]["family"], ensure_ascii=False)};',
f' --font-mono-family: {json.dumps(data["typography"]["mono_family"], ensure_ascii=False)};',
f' --font-fallbacks: {", ".join(json.dumps(v, ensure_ascii=False) for v in data["typography"]["fallbacks"])};',
f' --font-body-size: {data["typography"]["body_px"]}px;',
f' --font-code-size: {data["typography"]["mono_px"]}px;']
@@ -157,7 +367,13 @@ def render():
css += [f' --size-{name[:-3].replace("_", "-")}: {value}px;' for name, value in data["size"].items()]
css += [f' --breakpoint-{name[:-3]}: {value}px;' for name, value in data["breakpoints"].items()]
css += ["}"]
return {"tokens.rs":"\n".join(rust) + "\n", "tokens.css":"\n".join(css) + "\n"}
ts = [f"// {header}", f"export const tokens = {json.dumps(data, indent=2, ensure_ascii=False)} as const;",
"export type Tokens = typeof tokens;"]
figma = figma_payload(data, aliases)
sync = f"// {header}\nconst PAYLOAD = {json.dumps(figma, indent=1)};\n" + (HERE / "figma" / "sync.template.js").read_text()
return {"tokens.rs": "\n".join(rust) + "\n", "tokens.css": "\n".join(css) + "\n", "tokens.ts": "\n".join(ts) + "\n",
"tokens.dtcg.json": json.dumps(dtcg(data), indent=2) + "\n",
"figma/variables.json": json.dumps(figma, indent=2) + "\n", "figma/sync.js": sync}
def main():
parser = argparse.ArgumentParser(description=__doc__)
@@ -170,10 +386,11 @@ def main():
if not path.exists() or path.read_text() != content:
stale.append(name)
else:
path.parent.mkdir(exist_ok=True)
path.write_text(content)
if stale:
raise SystemExit("Stale GPUI design artifact: " + ", ".join(stale))
print("GPUI design artifact verified" if args.check else "GPUI design artifact generated")
raise SystemExit("Stale design artifact: " + ", ".join(stale))
print("Design artifact verified" if args.check else "Design artifact generated")
if __name__ == "__main__":
main()
+109 -48
View File
@@ -1,62 +1,120 @@
/* Generated from Codewhale GPUI design 2.3.0; sha256 9e74fb8f8fbfebb6b90a9740b324c56405277886e61273e2a0cb682797a8a6f9. Do not edit. */
/* Generated from Codewhale design 5.3.0; sha256 0d9acc771562a7b50fc9473e23ebccbff7cbc56962639277943a0cb0131ea83e. Do not edit. */
:root {
--outside: #191a1c;
--surface: #202123;
--panel: #2a2b2e;
--text: #efeeeb;
--muted: #b8b8b4;
--outside: #0d121b;
--surface: #030508;
--panel: #04070c;
--text: #e8edf2;
--muted: #a8b6c3;
--disabled: #848583;
--line: #3b3c3f;
--accent: #a9c0de;
--button: #e6e4df;
--button-text: #202123;
--attention: #e8b077;
--accent: #8fbdff;
--button: #0b48bb;
--button-text: #ffffff;
--attention: #eab982;
--hover: #303134;
--selected: #37393d;
--live: #9ec7b2;
--danger: #e39a90;
--selected: #17191c;
--live: #9ecbbd;
--danger: #eea59d;
--border-strong: #828386;
--ocean-depth: #202123;
--diff-add: #22392f;
--diff-remove: #45302f;
--selection: #a9c0de47;
--selection: #8fbdff47;
--elevation-popover: 0 4px 8px rgb(0 0 0 / 0.4);
--elevation-dialog: 0 12px 32px rgb(0 0 0 / 0.5);
--water: linear-gradient(180deg, #0b1e3a 0%, #071326 35%, #04090f 75%, #030508 100%);
--surface-light: radial-gradient(ellipse at 55% 0%, rgb(40 150 230 / 0.32) 0%, rgb(11 72 187 / 0.1) 45%, rgb(11 72 187 / 0) 75%);
--glass-clear-fill: rgb(20 28 40 / 0.4);
--glass-frosted-fill: rgb(20 28 40 / 0.58);
--glass-solid-fill: #141c28;
--glass-border: rgb(255 255 255 / 0.09);
--glass-highlight: rgb(255 255 255 / 0.14);
--sheet-fill: rgb(5 8 13 / 0.74);
--sheet-border: rgb(255 255 255 / 0.05);
--card-fill: rgb(255 255 255 / 0.035);
--card-border: rgb(255 255 255 / 0.08);
color-scheme: dark;
}
[data-theme="light"] {
--outside: #f0ede8;
--surface: #faf8f5;
--panel: #ffffff;
--text: #28292b;
--muted: #4f504d;
--line: #d9d5cf;
--accent: #425c82;
--button: #2e2f31;
--button-text: #faf8f5;
--attention: #86520d;
--hover: #e8e5e0;
--selected: #dfdcd6;
--live: #3a6352;
--outside: #f9fafc;
--surface: #f1f5f9;
--panel: #fbfcfd;
--text: #1b2a35;
--muted: #4a545d;
--disabled: #6f7780;
--line: #d5dce3;
--accent: #245bc7;
--button: #0b48bb;
--button-text: #ffffff;
--attention: #84530d;
--hover: #e3e9ef;
--selected: #d8e0e8;
--live: #396554;
--danger: #9e3f36;
--border-strong: #807c76;
--ocean-depth: #faf8f5;
--border-strong: #737d88;
--ocean-depth: #f6f9fc;
--diff-add: #d2eadd;
--diff-remove: #f8e1dd;
--selection: #425c8247;
--selection: #245bc747;
--elevation-popover: 0 4px 8px rgb(0 0 0 / 0.16);
--elevation-dialog: 0 12px 32px rgb(0 0 0 / 0.2);
--water: linear-gradient(180deg, #bfe1f2 0%, #dceef3 30%, #e7f0f6 62%, #f1f5f9 100%);
--surface-light: radial-gradient(ellipse at 55% 0%, rgb(255 255 255 / 0.85) 0%, rgb(255 255 255 / 0.25) 40%, rgb(255 255 255 / 0) 72%);
--glass-clear-fill: rgb(255 255 255 / 0.4);
--glass-frosted-fill: rgb(255 255 255 / 0.55);
--glass-solid-fill: #ffffff;
--glass-border: rgb(255 255 255 / 0.75);
--glass-highlight: rgb(255 255 255 / 0.75);
--sheet-fill: rgb(252 253 254 / 0.88);
--sheet-border: rgb(31 51 64 / 0.1);
--card-fill: #ffffff;
--card-border: rgb(31 51 64 / 0.1);
color-scheme: light;
}
:root, [data-theme], [data-glass] {
--glass-fill: var(--glass-frosted-fill);
--glass-blur: 24px;
}
[data-glass="clear"], [data-glass="clear"] [data-theme] {
--glass-fill: var(--glass-clear-fill);
--glass-blur: 12px;
}
[data-glass="solid"], [data-glass="solid"] [data-theme] {
--glass-fill: var(--glass-solid-fill);
--glass-blur: 0px;
}
:root, [data-theme] {
--glow-primary: 0 0 6px color-mix(in srgb, var(--accent) 12%, transparent);
--glow-live: 0 0 6px color-mix(in srgb, var(--live) 35%, transparent);
--glow-attention: 0 0 6px color-mix(in srgb, var(--attention) 40%, transparent);
--tint-accent-fill: color-mix(in srgb, var(--accent) 16%, transparent);
--tint-accent-border: color-mix(in srgb, var(--accent) 35%, transparent);
--tint-count-fill: color-mix(in srgb, var(--text) 10%, transparent);
--tint-danger-border: color-mix(in srgb, var(--danger) 45%, transparent);
--tint-scrim: color-mix(in srgb, var(--surface) 45%, transparent);
--tint-on-primary-border: color-mix(in srgb, currentColor 45%, transparent);
--focus-ring: var(--focus-width) solid var(--accent);
--glass-edge: inset 0 1px 0 var(--glass-highlight);
}
:root {
--spacing-hairline: 2px;
--spacing-compact: 4px;
--spacing-tight: 6px;
--spacing-control: 8px;
--spacing-snug: 10px;
--spacing-group: 12px;
--spacing-section: 16px;
--spacing-large: 24px;
--spacing-page: 32px;
--radius-control: 6px;
--radius-panel: 10px;
--radius-sheet: 14px;
--radius-small: 6px;
--radius-field: 8px;
--radius-control: 10px;
--radius-panel: 18px;
--radius-sheet: 18px;
--radius-window: 14px;
--radius-card: 14px;
--radius-composer: 24px;
--radius-chip: 999px;
--focus-width: 2px;
--focus-offset: 3px;
--motion-duration-state: 120ms;
@@ -66,37 +124,40 @@
--motion-duration-linger: 1200ms;
--motion-loop-beat: 1200ms;
--motion-loop-breathe: 2400ms;
--motion-loop-spin: 1000ms;
--motion-arrive-rise: 4px;
--motion-ease-out: cubic-bezier(0, 0, 0.2, 1);
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
--motion-ease-panel: cubic-bezier(0.2, 0.1, 0.16, 1);
--glow-primary: 0 0 6px color-mix(in srgb, var(--accent) 12%, transparent);
--glow-live: 0 0 6px color-mix(in srgb, var(--live) 35%, transparent);
--glow-attention: 0 0 6px color-mix(in srgb, var(--attention) 40%, transparent);
--ombre-rise: #1e8fd8;
--ombre-rise: #1775c9;
--ombre-deep: #0b48bb;
--ombre: linear-gradient(135deg, #1775c9, #0b48bb);
--icon-stroke: 1.7;
--font-family: "Shannon Sans";
--font-family: "Geist Sans";
--font-mono-family: "Geist Mono";
--font-fallbacks: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC";
--font-body-size: 14px;
--font-code-size: 13px;
--font-caption-size: 12px;
--font-prose-size: 17px;
--font-heading-size: 17px;
--font-caption-size: 11.5px;
--font-prose-size: 16px;
--font-heading-size: 15px;
--font-title-size: 22px;
--type-caption-size: 12px;
--type-caption-size: 11.5px;
--type-caption-line: 16px;
--type-caption-weight: 400;
--type-label-size: 13px;
--type-label-size: 12.5px;
--type-label-line: 18px;
--type-label-weight: 400;
--type-control-size: 13px;
--type-control-line: 18px;
--type-control-weight: 400;
--type-body-size: 14px;
--type-body-line: 22px;
--type-body-weight: 400;
--type-prose-size: 16px;
--type-prose-line: 26px;
--type-prose-weight: 400;
--type-heading-size: 17px;
--type-heading-size: 15px;
--type-heading-line: 24px;
--type-heading-weight: 550;
--type-title-size: 22px;
@@ -113,9 +174,9 @@
--size-touch-target: 44px;
--size-icon: 16px;
--size-icon-large: 20px;
--size-sidebar: 248px;
--size-panel: 360px;
--size-sidebar: 272px;
--size-panel: 300px;
--breakpoint-compact: 720px;
--breakpoint-medium: 1024px;
--breakpoint-wide: 1440px;
--breakpoint-medium: 900px;
--breakpoint-wide: 1200px;
}
+413
View File
@@ -0,0 +1,413 @@
{
"$description": "Codewhale design 5.3.0. Generated from tokens.json; do not edit.",
"color": {
"dark": {
"$type": "color",
"background": {
"$value": "#030508"
},
"foreground": {
"$value": "#e8edf2"
},
"surface": {
"$value": "#04070c"
},
"muted_foreground": {
"$value": "#a8b6c3"
},
"disabled": {
"$value": "#848583"
},
"border": {
"$value": "#3b3c3f"
},
"sidebar": {
"$value": "#0d121b"
},
"primary": {
"$value": "#0b48bb"
},
"primary_foreground": {
"$value": "#ffffff"
},
"accent": {
"$value": "#8fbdff"
},
"hover": {
"$value": "#303134"
},
"selected": {
"$value": "#17191c"
},
"attention": {
"$value": "#eab982"
},
"live": {
"$value": "#9ecbbd"
},
"danger": {
"$value": "#eea59d"
},
"border_strong": {
"$value": "#828386"
},
"ocean_depth": {
"$value": "#202123"
},
"diff_add": {
"$value": "#22392f"
},
"diff_remove": {
"$value": "#45302f"
}
},
"light": {
"$type": "color",
"background": {
"$value": "#f1f5f9"
},
"foreground": {
"$value": "#1b2a35"
},
"surface": {
"$value": "#fbfcfd"
},
"muted_foreground": {
"$value": "#4a545d"
},
"disabled": {
"$value": "#6f7780"
},
"border": {
"$value": "#d5dce3"
},
"sidebar": {
"$value": "#f9fafc"
},
"primary": {
"$value": "#0b48bb"
},
"primary_foreground": {
"$value": "#ffffff"
},
"accent": {
"$value": "#245bc7"
},
"hover": {
"$value": "#e3e9ef"
},
"selected": {
"$value": "#d8e0e8"
},
"attention": {
"$value": "#84530d"
},
"live": {
"$value": "#396554"
},
"danger": {
"$value": "#9e3f36"
},
"border_strong": {
"$value": "#737d88"
},
"ocean_depth": {
"$value": "#f6f9fc"
},
"diff_add": {
"$value": "#d2eadd"
},
"diff_remove": {
"$value": "#f8e1dd"
}
}
},
"ombre": {
"$type": "color",
"rise": {
"$value": "#1775c9"
},
"deep": {
"$value": "#0b48bb"
}
},
"spacing": {
"$type": "dimension",
"hairline": {
"$value": "2px"
},
"compact": {
"$value": "4px"
},
"tight": {
"$value": "6px"
},
"control": {
"$value": "8px"
},
"snug": {
"$value": "10px"
},
"group": {
"$value": "12px"
},
"section": {
"$value": "16px"
},
"large": {
"$value": "24px"
},
"page": {
"$value": "32px"
}
},
"radius": {
"$type": "dimension",
"small": {
"$value": "6px"
},
"field": {
"$value": "8px"
},
"control": {
"$value": "10px"
},
"panel": {
"$value": "18px"
},
"sheet": {
"$value": "18px"
},
"window": {
"$value": "14px"
},
"card": {
"$value": "14px"
},
"composer": {
"$value": "24px"
},
"chip": {
"$value": "999px"
}
},
"size": {
"$type": "dimension",
"control": {
"$value": "28px"
},
"row": {
"$value": "30px"
},
"touch_target": {
"$value": "44px"
},
"icon": {
"$value": "16px"
},
"icon_large": {
"$value": "20px"
},
"sidebar": {
"$value": "272px"
},
"panel": {
"$value": "300px"
}
},
"duration": {
"$type": "duration",
"state": {
"$value": "120ms"
},
"arrive": {
"$value": "180ms"
},
"exit": {
"$value": "120ms"
},
"panel": {
"$value": "340ms"
},
"linger": {
"$value": "1200ms"
}
},
"easing": {
"$type": "cubicBezier",
"out": {
"$value": [
0,
0,
0.2,
1
]
},
"in": {
"$value": [
0.4,
0,
1,
1
]
},
"panel": {
"$value": [
0.2,
0.1,
0.16,
1
]
}
},
"type": {
"$type": "typography",
"caption": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "11.5px",
"lineHeight": 1.3913,
"fontWeight": 400
}
},
"label": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "12.5px",
"lineHeight": 1.44,
"fontWeight": 400
}
},
"control": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "13px",
"lineHeight": 1.3846,
"fontWeight": 400
}
},
"body": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "14px",
"lineHeight": 1.5714,
"fontWeight": 400
}
},
"prose": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "16px",
"lineHeight": 1.625,
"fontWeight": 400
}
},
"heading": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "15px",
"lineHeight": 1.6,
"fontWeight": 550
}
},
"title": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "22px",
"lineHeight": 1.2727,
"fontWeight": 550
}
},
"display": {
"$value": {
"fontFamily": "Geist Sans",
"fontSize": "28px",
"lineHeight": 1.2143,
"fontWeight": 550
}
},
"code": {
"$value": {
"fontFamily": "Geist Mono",
"fontSize": "13px",
"lineHeight": 1.5385,
"fontWeight": 400
}
}
},
"glass": {
"$type": "color",
"dark": {
"border": {
"$value": "#ffffff17"
},
"highlight": {
"$value": "#ffffff24"
},
"clear": {
"$value": "#141c2866"
},
"frosted": {
"$value": "#141c2894"
},
"solid": {
"$value": "#141c28"
}
},
"light": {
"border": {
"$value": "#ffffffbf"
},
"highlight": {
"$value": "#ffffffbf"
},
"clear": {
"$value": "#ffffff66"
},
"frosted": {
"$value": "#ffffff8c"
},
"solid": {
"$value": "#ffffff"
}
}
},
"sheet": {
"$type": "color",
"dark": {
"fill": {
"$value": "#05080dbd"
},
"border": {
"$value": "#ffffff0d"
}
},
"light": {
"fill": {
"$value": "#fcfdfee0"
},
"border": {
"$value": "#1f33401a"
}
}
},
"card": {
"$type": "color",
"dark": {
"fill": {
"$value": "#ffffff09"
},
"border": {
"$value": "#ffffff14"
}
},
"light": {
"fill": {
"$value": "#ffffff"
},
"border": {
"$value": "#1f33401a"
}
}
}
}
+270 -42
View File
@@ -1,49 +1,254 @@
{
"version": "2.3.0",
"version": "5.3.0",
"colors": {
"dark": {
"background": "202123",
"foreground": "efeeeb",
"surface": "2a2b2e",
"muted_foreground": "b8b8b4",
"background": "030508",
"foreground": "e8edf2",
"surface": "04070c",
"muted_foreground": "a8b6c3",
"disabled": "848583",
"border": "3b3c3f",
"sidebar": "191a1c",
"primary": "e6e4df",
"primary_foreground": "202123",
"accent": "a9c0de",
"sidebar": "0d121b",
"primary": "0b48bb",
"primary_foreground": "ffffff",
"accent": "8fbdff",
"hover": "303134",
"selected": "37393d",
"attention": "e8b077",
"live": "9ec7b2",
"danger": "e39a90",
"selected": "17191c",
"attention": "eab982",
"live": "9ecbbd",
"danger": "eea59d",
"border_strong": "828386",
"ocean_depth": "202123",
"diff_add": "22392f",
"diff_remove": "45302f"
},
"light": {
"background": "faf8f5",
"foreground": "28292b",
"surface": "ffffff",
"muted_foreground": "4f504d",
"border": "d9d5cf",
"sidebar": "f0ede8",
"primary": "2e2f31",
"primary_foreground": "faf8f5",
"accent": "425c82",
"hover": "e8e5e0",
"selected": "dfdcd6",
"attention": "86520d",
"live": "3a6352",
"background": "f1f5f9",
"foreground": "1b2a35",
"surface": "fbfcfd",
"muted_foreground": "4a545d",
"disabled": "6f7780",
"border": "d5dce3",
"sidebar": "f9fafc",
"primary": "0b48bb",
"primary_foreground": "ffffff",
"accent": "245bc7",
"hover": "e3e9ef",
"selected": "d8e0e8",
"attention": "84530d",
"live": "396554",
"danger": "9e3f36",
"border_strong": "807c76",
"ocean_depth": "faf8f5",
"border_strong": "737d88",
"ocean_depth": "f6f9fc",
"diff_add": "d2eadd",
"diff_remove": "f8e1dd"
}
},
"water": {
"dark": [
{
"color": "0b1e3a",
"at": 0
},
{
"color": "071326",
"at": 35
},
{
"color": "04090f",
"at": 75
},
{
"color": "030508",
"at": 100
}
],
"light": [
{
"color": "bfe1f2",
"at": 0
},
{
"color": "dceef3",
"at": 30
},
{
"color": "e7f0f6",
"at": 62
},
{
"color": "f1f5f9",
"at": 100
}
]
},
"surface_light": {
"dark": {
"x": 55,
"y": 0,
"stops": [
{
"color": {
"hex": "2896e6",
"alpha": 0.32
},
"at": 0
},
{
"color": {
"hex": "0b48bb",
"alpha": 0.1
},
"at": 45
},
{
"color": {
"hex": "0b48bb",
"alpha": 0
},
"at": 75
}
]
},
"light": {
"x": 55,
"y": 0,
"stops": [
{
"color": {
"hex": "ffffff",
"alpha": 0.85
},
"at": 0
},
{
"color": {
"hex": "ffffff",
"alpha": 0.25
},
"at": 40
},
{
"color": {
"hex": "ffffff",
"alpha": 0
},
"at": 72
}
]
}
},
"glass": {
"dark": {
"border": {
"hex": "ffffff",
"alpha": 0.09
},
"highlight": {
"hex": "ffffff",
"alpha": 0.14
},
"clear": {
"fill": {
"hex": "141c28",
"alpha": 0.4
},
"blur_px": 12
},
"frosted": {
"fill": {
"hex": "141c28",
"alpha": 0.58
},
"blur_px": 24
},
"solid": {
"fill": {
"hex": "141c28",
"alpha": 1
},
"blur_px": 0
}
},
"light": {
"border": {
"hex": "ffffff",
"alpha": 0.75
},
"highlight": {
"hex": "ffffff",
"alpha": 0.75
},
"clear": {
"fill": {
"hex": "ffffff",
"alpha": 0.4
},
"blur_px": 12
},
"frosted": {
"fill": {
"hex": "ffffff",
"alpha": 0.55
},
"blur_px": 24
},
"solid": {
"fill": {
"hex": "ffffff",
"alpha": 1
},
"blur_px": 0
}
}
},
"sheet": {
"dark": {
"fill": {
"hex": "05080d",
"alpha": 0.74
},
"border": {
"hex": "ffffff",
"alpha": 0.05
}
},
"light": {
"fill": {
"hex": "fcfdfe",
"alpha": 0.88
},
"border": {
"hex": "1f3340",
"alpha": 0.1
}
}
},
"card": {
"dark": {
"fill": {
"hex": "ffffff",
"alpha": 0.035
},
"border": {
"hex": "ffffff",
"alpha": 0.08
}
},
"light": {
"fill": {
"hex": "ffffff",
"alpha": 1
},
"border": {
"hex": "1f3340",
"alpha": 0.1
}
}
},
"typography": {
"family": "Shannon Sans",
"family": "Geist Sans",
"mono_family": "Geist Mono",
"fallbacks": [
"PingFang SC",
"Hiragino Sans GB",
@@ -55,23 +260,32 @@
],
"body_px": 14,
"mono_px": 13,
"caption_px": 12,
"prose_px": 17,
"heading_px": 17,
"caption_px": 11.5,
"prose_px": 16,
"heading_px": 15,
"title_px": 22
},
"spacing": {
"hairline": 2,
"compact": 4,
"tight": 6,
"control": 8,
"snug": 10,
"group": 12,
"section": 16,
"large": 24,
"page": 32
},
"radius": {
"control": 6,
"panel": 10,
"sheet": 14
"small": 6,
"field": 8,
"control": 10,
"panel": 18,
"sheet": 18,
"window": 14,
"card": 14,
"composer": 24,
"chip": 999
},
"focus": {
"width": 2,
@@ -95,6 +309,7 @@
"arrive_rise_px": 4,
"loop_beat_ms": 1200,
"loop_breathe_ms": 2400,
"loop_spin_ms": 1000,
"loop_fps_cap": 15
},
"easing": {
@@ -131,7 +346,7 @@
"attention_alpha": 0.4
},
"ombre": {
"rise": "1e8fd8",
"rise": "1775c9",
"deep": "0b48bb"
},
"elevation": {
@@ -144,15 +359,28 @@
"dialog_alpha_dark": 0.5,
"dialog_alpha_light": 0.2
},
"tint": {
"accent_fill": 0.16,
"accent_border": 0.35,
"count_fill": 0.1,
"danger_border": 0.45,
"scrim": 0.45,
"on_primary_border": 0.45
},
"selection_opacity": 0.28,
"primary_hover_opacity": 0.9,
"type_roles": {
"caption": {
"size_px": 12,
"size_px": 11.5,
"line_px": 16,
"weight": 400
},
"label": {
"size_px": 12.5,
"line_px": 18,
"weight": 400
},
"control": {
"size_px": 13,
"line_px": 18,
"weight": 400
@@ -168,7 +396,7 @@
"weight": 400
},
"heading": {
"size_px": 17,
"size_px": 15,
"line_px": 24,
"weight": 550
},
@@ -194,12 +422,12 @@
"touch_target_px": 44,
"icon_px": 16,
"icon_large_px": 20,
"sidebar_px": 248,
"panel_px": 360
"sidebar_px": 272,
"panel_px": 300
},
"breakpoints": {
"compact_px": 720,
"medium_px": 1024,
"wide_px": 1440
"medium_px": 900,
"wide_px": 1200
}
}
+111 -44
View File
@@ -1,13 +1,14 @@
// Generated from Codewhale GPUI design 2.3.0; sha256 9e74fb8f8fbfebb6b90a9740b324c56405277886e61273e2a0cb682797a8a6f9. Do not edit.
// Generated from Codewhale design 5.3.0; sha256 0d9acc771562a7b50fc9473e23ebccbff7cbc56962639277943a0cb0131ea83e. Do not edit.
#![allow(dead_code)]
pub const VERSION: &str = "2.3.0";
pub const VERSION: &str = "5.3.0";
#[derive(Clone, Copy, Debug)]
pub struct Colors {
pub background: u32,
pub foreground: u32,
pub surface: u32,
pub muted_foreground: u32,
pub disabled: u32,
pub border: u32,
pub sidebar: u32,
pub primary: u32,
@@ -24,49 +25,102 @@ pub struct Colors {
pub diff_remove: u32,
}
pub const DARK: Colors = Colors {
background: 0x202123,
foreground: 0xefeeeb,
surface: 0x2a2b2e,
muted_foreground: 0xb8b8b4,
background: 0x030508,
foreground: 0xe8edf2,
surface: 0x04070c,
muted_foreground: 0xa8b6c3,
disabled: 0x848583,
border: 0x3b3c3f,
sidebar: 0x191a1c,
primary: 0xe6e4df,
primary_foreground: 0x202123,
accent: 0xa9c0de,
sidebar: 0x0d121b,
primary: 0x0b48bb,
primary_foreground: 0xffffff,
accent: 0x8fbdff,
hover: 0x303134,
selected: 0x37393d,
attention: 0xe8b077,
live: 0x9ec7b2,
danger: 0xe39a90,
selected: 0x17191c,
attention: 0xeab982,
live: 0x9ecbbd,
danger: 0xeea59d,
border_strong: 0x828386,
ocean_depth: 0x202123,
diff_add: 0x22392f,
diff_remove: 0x45302f,
};
pub const LIGHT: Colors = Colors {
background: 0xfaf8f5,
foreground: 0x28292b,
surface: 0xffffff,
muted_foreground: 0x4f504d,
border: 0xd9d5cf,
sidebar: 0xf0ede8,
primary: 0x2e2f31,
primary_foreground: 0xfaf8f5,
accent: 0x425c82,
hover: 0xe8e5e0,
selected: 0xdfdcd6,
attention: 0x86520d,
live: 0x3a6352,
background: 0xf1f5f9,
foreground: 0x1b2a35,
surface: 0xfbfcfd,
muted_foreground: 0x4a545d,
disabled: 0x6f7780,
border: 0xd5dce3,
sidebar: 0xf9fafc,
primary: 0x0b48bb,
primary_foreground: 0xffffff,
accent: 0x245bc7,
hover: 0xe3e9ef,
selected: 0xd8e0e8,
attention: 0x84530d,
live: 0x396554,
danger: 0x9e3f36,
border_strong: 0x807c76,
ocean_depth: 0xfaf8f5,
border_strong: 0x737d88,
ocean_depth: 0xf6f9fc,
diff_add: 0xd2eadd,
diff_remove: 0xf8e1dd,
};
pub fn colors(dark: bool) -> Colors {
if dark { DARK } else { LIGHT }
}
pub const FONT_FAMILY: &str = "Shannon Sans";
pub const WATER_DARK_0: u32 = 0x0b1e3a;
pub const WATER_DARK_35: u32 = 0x071326;
pub const WATER_DARK_75: u32 = 0x04090f;
pub const WATER_DARK_100: u32 = 0x030508;
pub const GLASS_DARK_CLEAR_FILL: u32 = 0x141c28;
pub const GLASS_DARK_CLEAR_FILL_ALPHA: f32 = 0.4;
pub const GLASS_DARK_CLEAR_BLUR_PX: f32 = 12.0;
pub const GLASS_DARK_FROSTED_FILL: u32 = 0x141c28;
pub const GLASS_DARK_FROSTED_FILL_ALPHA: f32 = 0.58;
pub const GLASS_DARK_FROSTED_BLUR_PX: f32 = 24.0;
pub const GLASS_DARK_SOLID_FILL: u32 = 0x141c28;
pub const GLASS_DARK_SOLID_FILL_ALPHA: f32 = 1.0;
pub const GLASS_DARK_SOLID_BLUR_PX: f32 = 0.0;
pub const GLASS_DARK_BORDER: u32 = 0xffffff;
pub const GLASS_DARK_BORDER_ALPHA: f32 = 0.09;
pub const GLASS_DARK_HIGHLIGHT: u32 = 0xffffff;
pub const GLASS_DARK_HIGHLIGHT_ALPHA: f32 = 0.14;
pub const SHEET_DARK_FILL: u32 = 0x05080d;
pub const SHEET_DARK_FILL_ALPHA: f32 = 0.74;
pub const SHEET_DARK_BORDER: u32 = 0xffffff;
pub const SHEET_DARK_BORDER_ALPHA: f32 = 0.05;
pub const CARD_DARK_FILL: u32 = 0xffffff;
pub const CARD_DARK_FILL_ALPHA: f32 = 0.035;
pub const CARD_DARK_BORDER: u32 = 0xffffff;
pub const CARD_DARK_BORDER_ALPHA: f32 = 0.08;
pub const WATER_LIGHT_0: u32 = 0xbfe1f2;
pub const WATER_LIGHT_30: u32 = 0xdceef3;
pub const WATER_LIGHT_62: u32 = 0xe7f0f6;
pub const WATER_LIGHT_100: u32 = 0xf1f5f9;
pub const GLASS_LIGHT_CLEAR_FILL: u32 = 0xffffff;
pub const GLASS_LIGHT_CLEAR_FILL_ALPHA: f32 = 0.4;
pub const GLASS_LIGHT_CLEAR_BLUR_PX: f32 = 12.0;
pub const GLASS_LIGHT_FROSTED_FILL: u32 = 0xffffff;
pub const GLASS_LIGHT_FROSTED_FILL_ALPHA: f32 = 0.55;
pub const GLASS_LIGHT_FROSTED_BLUR_PX: f32 = 24.0;
pub const GLASS_LIGHT_SOLID_FILL: u32 = 0xffffff;
pub const GLASS_LIGHT_SOLID_FILL_ALPHA: f32 = 1.0;
pub const GLASS_LIGHT_SOLID_BLUR_PX: f32 = 0.0;
pub const GLASS_LIGHT_BORDER: u32 = 0xffffff;
pub const GLASS_LIGHT_BORDER_ALPHA: f32 = 0.75;
pub const GLASS_LIGHT_HIGHLIGHT: u32 = 0xffffff;
pub const GLASS_LIGHT_HIGHLIGHT_ALPHA: f32 = 0.75;
pub const SHEET_LIGHT_FILL: u32 = 0xfcfdfe;
pub const SHEET_LIGHT_FILL_ALPHA: f32 = 0.88;
pub const SHEET_LIGHT_BORDER: u32 = 0x1f3340;
pub const SHEET_LIGHT_BORDER_ALPHA: f32 = 0.1;
pub const CARD_LIGHT_FILL: u32 = 0xffffff;
pub const CARD_LIGHT_FILL_ALPHA: f32 = 1.0;
pub const CARD_LIGHT_BORDER: u32 = 0x1f3340;
pub const CARD_LIGHT_BORDER_ALPHA: f32 = 0.1;
pub const FONT_FAMILY: &str = "Geist Sans";
pub const FONT_MONO_FAMILY: &str = "Geist Mono";
pub const FONT_FALLBACKS: &[&str] = &[
"PingFang SC",
"Hiragino Sans GB",
@@ -76,15 +130,24 @@ pub const FONT_FALLBACKS: &[&str] = &[
"Noto Sans SC",
"Source Han Sans SC",
];
pub const SPACING_HAIRLINE: f32 = 2.0;
pub const SPACING_COMPACT: f32 = 4.0;
pub const SPACING_TIGHT: f32 = 6.0;
pub const SPACING_CONTROL: f32 = 8.0;
pub const SPACING_SNUG: f32 = 10.0;
pub const SPACING_GROUP: f32 = 12.0;
pub const SPACING_SECTION: f32 = 16.0;
pub const SPACING_LARGE: f32 = 24.0;
pub const SPACING_PAGE: f32 = 32.0;
pub const RADIUS_CONTROL: f32 = 6.0;
pub const RADIUS_PANEL: f32 = 10.0;
pub const RADIUS_SHEET: f32 = 14.0;
pub const RADIUS_SMALL: f32 = 6.0;
pub const RADIUS_FIELD: f32 = 8.0;
pub const RADIUS_CONTROL: f32 = 10.0;
pub const RADIUS_PANEL: f32 = 18.0;
pub const RADIUS_SHEET: f32 = 18.0;
pub const RADIUS_WINDOW: f32 = 14.0;
pub const RADIUS_CARD: f32 = 14.0;
pub const RADIUS_COMPOSER: f32 = 24.0;
pub const RADIUS_CHIP: f32 = 999.0;
pub const FOCUS_WIDTH: f32 = 2.0;
pub const FOCUS_OFFSET: f32 = 3.0;
pub const ICONS_GRID: f32 = 24.0;
@@ -102,6 +165,7 @@ pub const MOTION_DURATION_LINGER_MS: f32 = 1200.0;
pub const MOTION_ARRIVE_RISE_PX: f32 = 4.0;
pub const MOTION_LOOP_BEAT_MS: f32 = 1200.0;
pub const MOTION_LOOP_BREATHE_MS: f32 = 2400.0;
pub const MOTION_LOOP_SPIN_MS: f32 = 1000.0;
pub const MOTION_LOOP_FPS_CAP: f32 = 15.0;
pub const STREAM_REVEAL_FIRST_MS: f32 = 100.0;
pub const STREAM_REVEAL_LAG_MS: f32 = 500.0;
@@ -123,24 +187,27 @@ pub const ELEVATION_DIALOG_ALPHA_LIGHT: f32 = 0.2;
pub const EASING_OUT: [f32; 4] = [0.0, 0.0, 0.2, 1.0];
pub const EASING_IN: [f32; 4] = [0.4, 0.0, 1.0, 1.0];
pub const EASING_PANEL: [f32; 4] = [0.2, 0.1, 0.16, 1.0];
pub const OMBRE_RISE: u32 = 0x1e8fd8;
pub const OMBRE_RISE: u32 = 0x1775c9;
pub const OMBRE_DEEP: u32 = 0x0b48bb;
pub const SELECTION_OPACITY: f32 = 0.28;
pub const PRIMARY_HOVER_OPACITY: f32 = 0.9;
pub const MONO_PX: f32 = 13.0;
pub const TYPE_ROLE_CAPTION_SIZE_PX: f32 = 12.0;
pub const TYPE_ROLE_CAPTION_SIZE_PX: f32 = 11.5;
pub const TYPE_ROLE_CAPTION_LINE_PX: f32 = 16.0;
pub const TYPE_ROLE_CAPTION_WEIGHT: f32 = 400.0;
pub const TYPE_ROLE_LABEL_SIZE_PX: f32 = 13.0;
pub const TYPE_ROLE_LABEL_SIZE_PX: f32 = 12.5;
pub const TYPE_ROLE_LABEL_LINE_PX: f32 = 18.0;
pub const TYPE_ROLE_LABEL_WEIGHT: f32 = 400.0;
pub const TYPE_ROLE_CONTROL_SIZE_PX: f32 = 13.0;
pub const TYPE_ROLE_CONTROL_LINE_PX: f32 = 18.0;
pub const TYPE_ROLE_CONTROL_WEIGHT: f32 = 400.0;
pub const TYPE_ROLE_BODY_SIZE_PX: f32 = 14.0;
pub const TYPE_ROLE_BODY_LINE_PX: f32 = 22.0;
pub const TYPE_ROLE_BODY_WEIGHT: f32 = 400.0;
pub const TYPE_ROLE_PROSE_SIZE_PX: f32 = 16.0;
pub const TYPE_ROLE_PROSE_LINE_PX: f32 = 26.0;
pub const TYPE_ROLE_PROSE_WEIGHT: f32 = 400.0;
pub const TYPE_ROLE_HEADING_SIZE_PX: f32 = 17.0;
pub const TYPE_ROLE_HEADING_SIZE_PX: f32 = 15.0;
pub const TYPE_ROLE_HEADING_LINE_PX: f32 = 24.0;
pub const TYPE_ROLE_HEADING_WEIGHT: f32 = 550.0;
pub const TYPE_ROLE_TITLE_SIZE_PX: f32 = 22.0;
@@ -157,13 +224,13 @@ pub const SIZE_ROW_PX: f32 = 30.0;
pub const SIZE_TOUCH_TARGET_PX: f32 = 44.0;
pub const SIZE_ICON_PX: f32 = 16.0;
pub const SIZE_ICON_LARGE_PX: f32 = 20.0;
pub const SIZE_SIDEBAR_PX: f32 = 248.0;
pub const SIZE_PANEL_PX: f32 = 360.0;
pub const SIZE_SIDEBAR_PX: f32 = 272.0;
pub const SIZE_PANEL_PX: f32 = 300.0;
pub const BREAKPOINTS_COMPACT_PX: f32 = 720.0;
pub const BREAKPOINTS_MEDIUM_PX: f32 = 1024.0;
pub const BREAKPOINTS_WIDE_PX: f32 = 1440.0;
pub const BREAKPOINTS_MEDIUM_PX: f32 = 900.0;
pub const BREAKPOINTS_WIDE_PX: f32 = 1200.0;
pub const TYPE_BODY_PX: f32 = 14.0;
pub const TYPE_CAPTION_PX: f32 = 12.0;
pub const TYPE_PROSE_PX: f32 = 17.0;
pub const TYPE_HEADING_PX: f32 = 17.0;
pub const TYPE_CAPTION_PX: f32 = 11.5;
pub const TYPE_PROSE_PX: f32 = 16.0;
pub const TYPE_HEADING_PX: f32 = 15.0;
pub const TYPE_TITLE_PX: f32 = 22.0;
+435
View File
@@ -0,0 +1,435 @@
// Generated from Codewhale design 5.3.0; sha256 0d9acc771562a7b50fc9473e23ebccbff7cbc56962639277943a0cb0131ea83e. Do not edit.
export const tokens = {
"version": "5.3.0",
"colors": {
"dark": {
"background": "030508",
"foreground": "e8edf2",
"surface": "04070c",
"muted_foreground": "a8b6c3",
"disabled": "848583",
"border": "3b3c3f",
"sidebar": "0d121b",
"primary": "0b48bb",
"primary_foreground": "ffffff",
"accent": "8fbdff",
"hover": "303134",
"selected": "17191c",
"attention": "eab982",
"live": "9ecbbd",
"danger": "eea59d",
"border_strong": "828386",
"ocean_depth": "202123",
"diff_add": "22392f",
"diff_remove": "45302f"
},
"light": {
"background": "f1f5f9",
"foreground": "1b2a35",
"surface": "fbfcfd",
"muted_foreground": "4a545d",
"disabled": "6f7780",
"border": "d5dce3",
"sidebar": "f9fafc",
"primary": "0b48bb",
"primary_foreground": "ffffff",
"accent": "245bc7",
"hover": "e3e9ef",
"selected": "d8e0e8",
"attention": "84530d",
"live": "396554",
"danger": "9e3f36",
"border_strong": "737d88",
"ocean_depth": "f6f9fc",
"diff_add": "d2eadd",
"diff_remove": "f8e1dd"
}
},
"water": {
"dark": [
{
"color": "0b1e3a",
"at": 0
},
{
"color": "071326",
"at": 35
},
{
"color": "04090f",
"at": 75
},
{
"color": "030508",
"at": 100
}
],
"light": [
{
"color": "bfe1f2",
"at": 0
},
{
"color": "dceef3",
"at": 30
},
{
"color": "e7f0f6",
"at": 62
},
{
"color": "f1f5f9",
"at": 100
}
]
},
"surface_light": {
"dark": {
"x": 55,
"y": 0,
"stops": [
{
"color": {
"hex": "2896e6",
"alpha": 0.32
},
"at": 0
},
{
"color": {
"hex": "0b48bb",
"alpha": 0.1
},
"at": 45
},
{
"color": {
"hex": "0b48bb",
"alpha": 0
},
"at": 75
}
]
},
"light": {
"x": 55,
"y": 0,
"stops": [
{
"color": {
"hex": "ffffff",
"alpha": 0.85
},
"at": 0
},
{
"color": {
"hex": "ffffff",
"alpha": 0.25
},
"at": 40
},
{
"color": {
"hex": "ffffff",
"alpha": 0
},
"at": 72
}
]
}
},
"glass": {
"dark": {
"border": {
"hex": "ffffff",
"alpha": 0.09
},
"highlight": {
"hex": "ffffff",
"alpha": 0.14
},
"clear": {
"fill": {
"hex": "141c28",
"alpha": 0.4
},
"blur_px": 12
},
"frosted": {
"fill": {
"hex": "141c28",
"alpha": 0.58
},
"blur_px": 24
},
"solid": {
"fill": {
"hex": "141c28",
"alpha": 1
},
"blur_px": 0
}
},
"light": {
"border": {
"hex": "ffffff",
"alpha": 0.75
},
"highlight": {
"hex": "ffffff",
"alpha": 0.75
},
"clear": {
"fill": {
"hex": "ffffff",
"alpha": 0.4
},
"blur_px": 12
},
"frosted": {
"fill": {
"hex": "ffffff",
"alpha": 0.55
},
"blur_px": 24
},
"solid": {
"fill": {
"hex": "ffffff",
"alpha": 1
},
"blur_px": 0
}
}
},
"sheet": {
"dark": {
"fill": {
"hex": "05080d",
"alpha": 0.74
},
"border": {
"hex": "ffffff",
"alpha": 0.05
}
},
"light": {
"fill": {
"hex": "fcfdfe",
"alpha": 0.88
},
"border": {
"hex": "1f3340",
"alpha": 0.1
}
}
},
"card": {
"dark": {
"fill": {
"hex": "ffffff",
"alpha": 0.035
},
"border": {
"hex": "ffffff",
"alpha": 0.08
}
},
"light": {
"fill": {
"hex": "ffffff",
"alpha": 1
},
"border": {
"hex": "1f3340",
"alpha": 0.1
}
}
},
"typography": {
"family": "Geist Sans",
"mono_family": "Geist Mono",
"fallbacks": [
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei UI",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Noto Sans SC",
"Source Han Sans SC"
],
"body_px": 14,
"mono_px": 13,
"caption_px": 11.5,
"prose_px": 16,
"heading_px": 15,
"title_px": 22
},
"spacing": {
"hairline": 2,
"compact": 4,
"tight": 6,
"control": 8,
"snug": 10,
"group": 12,
"section": 16,
"large": 24,
"page": 32
},
"radius": {
"small": 6,
"field": 8,
"control": 10,
"panel": 18,
"sheet": 18,
"window": 14,
"card": 14,
"composer": 24,
"chip": 999
},
"focus": {
"width": 2,
"offset": 3
},
"icons": {
"grid": 24,
"stroke": 1.7
},
"motion": {
"spring_stiffness": 420,
"spring_damping": 42,
"spring_mass": 1,
"pet_poll_ms": 30,
"reduced_poll_ms": 500,
"duration_state_ms": 120,
"duration_arrive_ms": 180,
"duration_exit_ms": 120,
"duration_panel_ms": 340,
"duration_linger_ms": 1200,
"arrive_rise_px": 4,
"loop_beat_ms": 1200,
"loop_breathe_ms": 2400,
"loop_spin_ms": 1000,
"loop_fps_cap": 15
},
"easing": {
"out": [
0,
0,
0.2,
1
],
"in": [
0.4,
0,
1,
1
],
"panel": [
0.2,
0.1,
0.16,
1
]
},
"stream": {
"reveal_first_ms": 100,
"reveal_lag_ms": 500,
"rate_window_ms": 1000,
"instant_graphemes": 8,
"max_pending_bytes": 16384
},
"glow": {
"blur_px": 6,
"primary_alpha": 0.12,
"live_alpha": 0.35,
"attention_alpha": 0.4
},
"ombre": {
"rise": "1775c9",
"deep": "0b48bb"
},
"elevation": {
"popover_y_px": 4,
"popover_blur_px": 8,
"popover_alpha_dark": 0.4,
"popover_alpha_light": 0.16,
"dialog_y_px": 12,
"dialog_blur_px": 32,
"dialog_alpha_dark": 0.5,
"dialog_alpha_light": 0.2
},
"tint": {
"accent_fill": 0.16,
"accent_border": 0.35,
"count_fill": 0.1,
"danger_border": 0.45,
"scrim": 0.45,
"on_primary_border": 0.45
},
"selection_opacity": 0.28,
"primary_hover_opacity": 0.9,
"type_roles": {
"caption": {
"size_px": 11.5,
"line_px": 16,
"weight": 400
},
"label": {
"size_px": 12.5,
"line_px": 18,
"weight": 400
},
"control": {
"size_px": 13,
"line_px": 18,
"weight": 400
},
"body": {
"size_px": 14,
"line_px": 22,
"weight": 400
},
"prose": {
"size_px": 16,
"line_px": 26,
"weight": 400
},
"heading": {
"size_px": 15,
"line_px": 24,
"weight": 550
},
"title": {
"size_px": 22,
"line_px": 28,
"weight": 550
},
"display": {
"size_px": 28,
"line_px": 34,
"weight": 550
},
"code": {
"size_px": 13,
"line_px": 20,
"weight": 400
}
},
"size": {
"control_px": 28,
"row_px": 30,
"touch_target_px": 44,
"icon_px": 16,
"icon_large_px": 20,
"sidebar_px": 272,
"panel_px": 300
},
"breakpoints": {
"compact_px": 720,
"medium_px": 900,
"wide_px": 1200
}
} as const;
export type Tokens = typeof tokens;
+159
View File
@@ -0,0 +1,159 @@
{
"schema": 1,
"steps": 240,
"mark": {
"spine": [[47, -1.5], [34, 8], [20, 15], [0, 21], [-20, 23], [-33, 18], [-40, 6], [-40, -8], [-35, -21], [-26, -30], [-14, -34.5], [-4, -35.5]],
"box": [-62, -65, 124, 130]
},
"whale": {
"length": 124,
"anchor": [60, -12],
"heading": 3.041592653589793,
"arch": {
"bend": 1.5,
"from": 0.3,
"to": 0.92
},
"nose": {
"at": 0.017,
"reach": 0.015,
"chin": 1.25
},
"cap": 0.01,
"blade": [0.38, 0.31],
"dorsal": [[0, 0.005], [0.02, 0.015], [0.06, 0.029], [0.1, 0.041], [0.15, 0.054], [0.2, 0.065], [0.27, 0.077], [0.35, 0.085], [0.43, 0.087], [0.52, 0.083], [0.6, 0.076], [0.7, 0.065], [0.8, 0.052], [0.88, 0.042], [0.95, 0.032], [1, 0.026]],
"ventral": [[0, 0.009], [0.02, 0.032], [0.06, 0.058], [0.12, 0.082], [0.2, 0.1], [0.28, 0.11], [0.36, 0.11], [0.45, 0.101], [0.55, 0.087], [0.65, 0.071], [0.75, 0.056], [0.85, 0.042], [0.92, 0.032], [1, 0.023]],
"gape": {
"at": 0.235,
"from": 0.005,
"to": 0.024,
"bend": 1.5
},
"throat": {
"end": 0.6,
"drop": 1.45,
"inset": 0.011
},
"pleats": [[0.05, 0.5, 0.64, 0.0042], [0.08, 0.4, 0.36, 0.0036]],
"eye": {
"at": 0.238,
"v": -0.004,
"r": 0.0125
},
"fin": {
"at": 0.31,
"v": 0.05,
"angle": 30,
"length": 0.165,
"width": 0.034,
"lead": 0.45
},
"dorsalFin": {
"at": 0.71,
"base": 0.068,
"height": 0.024,
"sweep": 0.014
},
"flukes": {
"roll": 26,
"pitch": -7,
"near": 0.13,
"inset": 0.022,
"scale": 1.16,
"lead": [[0, 0.018], [0.012, 0.04], [0.03, 0.072], [0.054, 0.1], [0.078, 0.121], [0.1, 0.132], [0.112, 0.135]],
"trail": [[0.106, 0.123], [0.097, 0.1], [0.09, 0.073], [0.085, 0.046], [0.08, 0.021], [0.072, 0]]
},
"sections": {
"body": [47, 53, 80, 124, 125],
"pouch": [24, 36],
"pleat-1": [16, 40],
"pleat-2": [5, 11],
"flipper": [4, 7],
"flukes": [12, 27, 31, 34, 35]
},
"beatSpan": [0.5, 0.95]
},
"drones": {
"radius": 0.32,
"coverage": 0.45,
"launch": {
"form": "mark",
"densities": [16, 20, 24, 28, 34, 40, 48, 56, 64, 80, 96]
},
"whale": {
"form": "whale",
"densities": [24, 32, 40, 52, 64]
},
"sidebar": {
"form": "mark",
"densities": [10, 12, 14, 16, 20, 24, 28, 34, 40]
},
"indicator": {
"form": "whale",
"densities": [14, 18, 22, 30, 44],
"frames": 12,
"amplitude": 9,
"lanternDrones": 7
},
"terminalLaunch": {
"form": "mark",
"cols": 64
},
"terminalIndicator": {
"form": "whale",
"cols": 12,
"coverage": 0.3,
"beats": [0, 24, 0, -24]
},
"signs": {
"on": "sidebar",
"waiting": ["..#####..", ".#######.", ".##...##.", ".....###.", "....###..", "...###...", ".........", "...##....", "...##...."],
"blocked": ["##.....##", "###...###", ".###.###.", "..#####..", "...###...", "..#####..", ".###.###.", "###...###", "##.....##"],
"complete": [".......##", "......###", ".....###.", "....###..", "##.###...", "#####....", ".###.....", "..#......", "........."]
},
"minPitchPx": 3
},
"poses": {
"stroke": 7,
"blink": 0.12,
"signRest": 0.18
},
"palette": {
"light": {
"body": ["#1e8fd8", "#0b48bb"],
"throat": "#dcebf7",
"dronesThroat": "#9ccbf0",
"pleat": "#0b48bb",
"eye": "#0b1424",
"lantern": "#d68a12",
"blocked": "#9e3f36",
"ink": "#142352"
},
"dark": {
"body": ["#3594d8", "#2b70d5"],
"throat": "#dcecf8",
"dronesBody": ["#7cc0f2", "#3d86e0"],
"dronesThroat": "#eef7ff",
"pleat": "#2b70d5",
"eye": "#0b1424",
"lantern": "#f2b544",
"blocked": "#eea59d",
"ink": "#faf8f5"
}
},
"icon": {
"tile": "#211F23",
"mark": "#F2ECE5",
"markScale": 1.2,
"sheen": 0.07,
"dark": {
"tile": "#18171a",
"mark": "#F2ECE5"
},
"tinted": {
"tile": "#2b2c30",
"mark": "#e8e8ea"
},
"source": "codewhale/brand/mark-reversed.svg"
}
}
+7 -13
View File
@@ -1,22 +1,17 @@
# Community Assistant Agent
The community assistant is a set of Cloudflare Cron Triggers that call `deepseek-flash` to draft triage comments, PR reviews, stale-issue nudges, duplicate suggestions, and weekly digests. **It never posts to GitHub directly.** Every output is a draft staged in Workers KV for maintainer review.
The community assistant is a small set of Cloudflare Cron Triggers: the weekly digest drafter (`deepseek-flash`), the facts-drift refresher and the content watchers. Triage, PR-review, stale, duplicate and Today's Dispatch tasks were retired (§132.4); scheduled website upkeep now runs as pull requests from `.github/workflows/web-maintainer.yml` (see `maintainer/README.md`). **The Worker never posts to GitHub directly.** Every drafted output is staged in Workers KV for maintainer review.
## Architecture
```
Cloudflare Cron Triggers
└─ worker.ts scheduled() handler
├─ */30 min → triage (new issues) + pr-review (new PRs)
├─ daily → stale (30d inactive) + dupes (embed-similarity scan)
├─ daily → linkcheck + semantic-drift watchers
├─ weekly → digest (Mon 09:00 UTC)
└─ 6h → curate (Today's Dispatch — pre-existing)
└─ 6h → facts-drift
Drafts stored in Workers KV (keys always derived via `draftStorageKey` in `lib/community-agent.ts`):
draft:triage:<issue-number>
draft:pr-review:<pr-number>
draft:stale:<issue-number>
draft:dupes:<issue-number>
draft:digest:<year>-W<week>
draft:linkcheck:<slug>-<sha256-16> (identity: full broken URL)
draft:semantic-drift:<slug>-<sha256-16> (identity: page+claim+evidence+replacement)
@@ -47,9 +42,8 @@ Usage logged to (one record per model call; sum the day's prefix):
| Expression | Frequency | Tasks |
|---|---|---|
| `0 */6 * * *` | Every 6 hours | Today's Dispatch (curate) |
| `*/30 * * * *` | Every 30 min | Issue triage + PR review |
| `0 0 * * *` | Daily 00:00 UTC | Stale issue nudges + duplicate detection |
| `0 */6 * * *` | Every 6 hours | Facts drift |
| `0 0 * * *` | Daily 00:00 UTC | Link check + semantic drift |
| `0 9 * * 1` | Monday 09:00 UTC | Weekly digest |
## Voice constraints
@@ -132,10 +126,10 @@ One-time setup before the first `npm run deploy`:
To disable the community agent entirely:
1. Remove all cron triggers from `cloudflare.config.ts` except the original `0 */6 * * *` (curate).
1. Remove all cron triggers from `cloudflare.config.ts` except the `0 */6 * * *` facts-drift cron.
2. Redeploy: `npm run deploy`.
The curate cron (Today's Dispatch) continues working independently. Individual tasks remain callable manually for testing through `/api/cron?task=triage`, `/api/cron?task=pr-review`, etc.
Facts drift continues working independently. Individual tasks remain callable manually for testing through `/api/cron?task=digest`, `/api/cron?task=linkcheck`, etc.
To disable a specific cron task, remove its cron expression from `cloudflare.config.ts` and redeploy.
+5 -5
View File
@@ -2,7 +2,7 @@
Documentation and community site for [Codewhale](https://github.com/codewhale-hq/CodeWhale) — lives at **codewhale.net**.
Next.js 16 (App Router) + Tailwind, deployed to Cloudflare Workers via [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare). Curated "Today's Dispatch" content is regenerated every 6 hours by a Cloudflare Cron Trigger that calls `deepseek-flash` to summarise recent repo activity, and stored in Workers KV.
Next.js 16 (App Router) + Tailwind, deployed to Cloudflare Workers via [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare). Scheduled upkeep runs as pull requests from `.github/workflows/web-maintainer.yml` (see `maintainer/README.md`).
## Local dev
@@ -27,7 +27,7 @@ Env (mirrors `.env.example`):
| `MAINTAINER_GITHUB_PAT` | PAT with `issues:write`, for posting comments via `/admin` | only for `/admin` posting |
| `NEXT_PUBLIC_GITEE_ENABLED` | Set to `1` once the Gitee mirror exists; blank hides Gitee links | optional |
The site renders fine without any of them — `Today's Dispatch` falls back to a static editorial; the GitHub feed shows "feed not yet loaded".
The site renders fine without any of them — the GitHub feed shows "feed not yet loaded".
## Deploy to Cloudflare
@@ -112,7 +112,7 @@ web/
│ │ ├── contribute/page.tsx how to PR + house rules + dev loop
│ │ └── admin/ maintainer panel (page.tsx + admin-client.tsx)
│ └── api/
│ ├── cron/route.ts cron tasks: curate, triage, facts-drift, …
│ ├── cron/route.ts cron tasks: digest, facts-drift, linkcheck, …
│ ├── facts/route.ts public source/deployment receipt
│ ├── github/feed/route.ts cached JSON endpoint
│ └── admin/ login, logout, post (MAINTAINER_TOKEN-gated)
@@ -131,13 +131,13 @@ web/
│ ├── types.ts shared types
│ ├── i18n/ locale config, en/zh dictionaries
│ ├── github.ts REST client + relative-time formatter
│ ├── deepseek.ts v4-flash chat client + curate() prompt
│ ├── deepseek.ts v4-flash chat client
│ ├── facts.ts getFacts(): KV value, else build-time FACTS
│ ├── facts.generated.ts GENERATED — do not edit by hand
│ ├── changelog.generated.ts GENERATED at build/test time, untracked
│ ├── install-guide.generated.ts GENERATED at build/test time, untracked
│ ├── facts-drift.ts runtime re-derivation for the drift cron
│ ├── community-agent.ts triage / pr-review / digest cron tasks
│ ├── community-agent.ts digest cron task and draft store
│ └── kv.ts Cloudflare KV access via OpenNext bindings
├── scripts/
│ ├── derive-facts.mjs prebuild: repo sources → lib/facts.generated.ts
-4
View File
@@ -18,10 +18,6 @@ export const metadata: Metadata = {
};
const TYPE_LABELS: Record<string, { en: string; zh: string }> = {
triage: { en: "Issue Triage", zh: "议题分类" },
"pr-review": { en: "PR Review", zh: "PR 审阅" },
stale: { en: "Stale Nudge", zh: "过期提醒" },
dupes: { en: "Duplicate", zh: "重复检测" },
digest: { en: "Weekly Digest", zh: "每周摘要" },
linkcheck: { en: "Broken Link", zh: "失效链接" },
"semantic-drift": { en: "Content Drift", zh: "内容漂移" },
+16 -8
View File
@@ -3,9 +3,11 @@ import Link from "next/link";
import { Icon } from "@/components/icon";
import { PageHeader, Section } from "@/components/page-header";
import { Status } from "@/components/status-badge";
import { AVAILABILITY_ROWS } from "@/lib/content/availability";
import { DesktopDownloadBlock } from "@/components/desktop-download";
import { AVAILABILITY_ROWS, describeRow } from "@/lib/content/availability";
import { APP_COPY } from "@/lib/content/app-page";
import { pickText } from "@/lib/i18n/dictionaries";
import { stateLabel, STATE_TONE, surfaceState } from "@/lib/surface-states";
import { buildPageMetadata } from "@/lib/page-meta";
export const revalidate = 300;
@@ -30,6 +32,8 @@ export default async function AppPage({ params }: { params: Promise<{ locale: st
const { locale } = await params;
const t = (text: { en: string; zh: string }) => pickText(text, locale);
const rows = AVAILABILITY_ROWS.filter((row) => row.pages.includes("app"));
const desktopState = surfaceState("desktop");
const hostedState = surfaceState("hosted-web");
return (
<>
@@ -39,8 +43,8 @@ export default async function AppPage({ params }: { params: Promise<{ locale: st
pose="think"
meta={
<p className="app-status">
<Status tone="attention">{t(APP_COPY.status.desktop)}</Status>
<Status tone="idle">{t(APP_COPY.status.browser)}</Status>
<Status tone={STATE_TONE[desktopState]}>{t(APP_COPY.surfaces.desktop)}: {stateLabel(desktopState, locale)}</Status>
<Status tone={STATE_TONE[hostedState]}>{t(APP_COPY.surfaces.browser)}: {stateLabel(hostedState, locale)}</Status>
</p>
}
actions={
@@ -124,15 +128,18 @@ export default async function AppPage({ params }: { params: Promise<{ locale: st
</tr>
</thead>
<tbody>
{rows.map((row) => (
{rows.map((row) => {
const view = describeRow(row, locale);
return (
<tr key={row.id}>
<th scope="row">{t(row.surface)}</th>
<td>
<Status tone={row.tone}>{t(row.status)}</Status>
<Status tone={view.tone}>{view.status}</Status>
</td>
<td>
{t(row.detail)}
{row.href && row.linkLabel ? (
{view.detail}
{view.download ? <DesktopDownloadBlock download={view.download} locale={locale} /> : null}
{view.link && row.href && row.linkLabel ? (
<>
{" "}
<Link href={`/${locale}${row.href}`} className="link">
@@ -142,7 +149,8 @@ export default async function AppPage({ params }: { params: Promise<{ locale: st
) : null}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { Icon } from "@/components/icon";
import { PageHeader, Section } from "@/components/page-header";
import { GitHubStar, FaceWall, MergedList } from "@/components/project-pulse";
import { GitHubStar, FaceWall, MergedList, statsAsOf } from "@/components/project-pulse";
import { getFacts } from "@/lib/facts";
import { fetchLabelled, type OpenIssue } from "@/lib/github";
import { getEnv } from "@/lib/kv";
@@ -53,7 +53,7 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
{t.fileIssue}
<Icon name="external" className="icon" />
</Link>
<GitHubStar stars={pulse.stats?.stars} locale={locale} />
<GitHubStar stars={pulse.stats?.stars ?? undefined} locale={locale} asOf={statsAsOf(pulse.stats, locale)} />
<Link href={`${REPO_URL}/blob/main/CONTRIBUTING.md`} className="btn btn-secondary btn-lg">
{t.fullGuide}
<Icon name="external" className="icon" />
+12 -10
View File
@@ -1,8 +1,10 @@
import { PageHeader } from "@/components/page-header";
import { Status, type StatusTone } from "@/components/status-badge";
import { BUILD_FACTS } from "@/lib/facts";
import { FEATURE_CATALOGUE, compareVersions, featureDocsHref, type FeatureStatus, type SurfaceStatus } from "@/lib/features";
import { fill, getFeatures, getHome } from "@/lib/i18n/dictionaries";
import { capabilityViews, runtimeCapabilityContext } from "@/lib/capabilities";
import { featureDocsHref, type FeatureStatus, type SurfaceStatus } from "@/lib/features";
import { fill, getFeatures } from "@/lib/i18n/dictionaries";
import { stateLabel } from "@/lib/surface-states";
import { buildPageMetadata } from "@/lib/page-meta";
const TONES: Record<FeatureStatus | SurfaceStatus, StatusTone> = {
@@ -18,24 +20,24 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
export default async function FeaturesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = getFeatures(locale);
const views = capabilityViews(runtimeCapabilityContext(BUILD_FACTS));
return <>
<PageHeader title={t.title} lede={t.description} actions={<a href="/features.json" className="btn btn-secondary">{t.exportJson}</a>} />
<div className="page-body">
<p className="muted">{fill(t.notice, { count: FEATURE_CATALOGUE.features.length, version: BUILD_FACTS.version ?? "?" })}</p>
<p className="muted">{fill(t.notice, { count: views.length, version: BUILD_FACTS.version ?? "?" })}</p>
<div className="feature-catalogue">
{FEATURE_CATALOGUE.features.map((feature) => <article key={feature.id} id={feature.id} className="feature-entry">
{views.map(({ feature, availability, name, summary }) => <article key={feature.id} id={feature.id} className="feature-entry">
<div lang="en" dir="ltr">
<h2><a href={`#${feature.id}`}>{feature.name}</a></h2>
<p>{feature.summary}</p>
<h2><a href={`#${feature.id}`}>{name}</a></h2>
<p>{summary}</p>
</div>
<div className="feature-states">
<Status tone={TONES[feature.status]}>{t[feature.status]}</Status>
{Object.entries(feature.surfaces).map(([surface, state]) => <Status key={surface} tone={TONES[state]}><bdi>{surface.toUpperCase()}</bdi>: {t[state]}</Status>)}
{feature.flag && <code dir="ltr">{feature.flag}</code>}
{feature.since === "unreleased" && <Status tone="idle">{getHome(locale).sourceCandidate}</Status>}
{feature.since && feature.since !== "unreleased" && (BUILD_FACTS.latestPublishedRelease && (compareVersions(feature.since, BUILD_FACTS.latestPublishedRelease.version) ?? 0) > 0
? <Status tone="idle">{getHome(locale).sourceCandidate} <bdi>v{feature.since}</bdi></Status>
: <code dir="ltr">v{feature.since}</code>)}
{availability === "in_source"
? <Status tone="idle">{stateLabel("in_source", locale)}</Status>
: feature.since && <code dir="ltr">v{feature.since}</code>}
</div>
<a className="section-link" href={featureDocsHref(feature)}>{feature.docs ? t.docs : t.source}</a>
</article>)}
+7 -30
View File
@@ -13,44 +13,21 @@ import { buildSiteJsonLd } from "@/lib/site-schema";
import { metadata as notFoundMetadata } from "./not-found";
import "../globals.css";
// Shannon Sans is the one face, as in the GPUI app (`set_theme`). The pinned
// variable font is split by scripts/subset-shannon-sans.py into a Latin face,
// the only font preloaded, and an extended face (latin-ext, Greek, Cyrillic,
// Devanagari) the browser fetches only when a page contains one of its
// glyphs. The unicode ranges must match what that script prints. Code uses
// the system monospace stack (tokens-roles.css), so no mono face loads.
const sansLatin = localFont({
src: "../../public/brand/fonts/ShannonSans-Variable-latin.woff2",
const sans = localFont({
src: "../../public/brand/fonts/Geist-Variable.woff2",
weight: "100 900",
style: "normal",
variable: "--font-shannon-latin",
variable: "--font-geist-sans",
display: "swap",
// The extended face's metric-matched fallback sits last in the stack; a
// fallback here would claim every non-Latin glyph before the extended face.
adjustFontFallback: false,
declarations: [
{
prop: "unicode-range",
value:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2190-2199, U+2212-2215, U+FEFF, U+FFFD",
},
],
});
const sansExt = localFont({
src: "../../public/brand/fonts/ShannonSans-Variable-ext.woff2",
const mono = localFont({
src: "../../public/brand/fonts/GeistMono-Variable.woff2",
weight: "100 900",
style: "normal",
variable: "--font-shannon-ext",
variable: "--font-geist-mono",
display: "swap",
preload: false,
declarations: [
{
prop: "unicode-range",
value:
"U+0100-02BA, U+02BD-02C5, U+02C7-02D9, U+02DB, U+02DD-0303, U+0305-0307, U+0309-0328, U+032A-052F, U+0900-097F, U+10FB, U+1AB0-1ACE, U+1C80-1C88, U+1D00-1FFF, U+2070-209C, U+20A0-20AB, U+20AD-20C0, U+20F0, U+2100-2121, U+2123-218F, U+25CC, U+2C60-2C7F, U+2DE0-2E5D, U+A640-A69F, U+A700-A7FF, U+A8FF, U+A92E, U+AB30-AB6B, U+FB00-FB06, U+FE00-FE2F, U+FFFC, U+10780-107BA, U+1DF00-1DF1E",
},
],
});
export function generateStaticParams() {
@@ -95,7 +72,7 @@ export default async function LocaleLayout({
<html
lang={locale}
dir={dir}
className={`${sansLatin.variable} ${sansExt.variable}`}
className={`${sans.variable} ${mono.variable}`}
suppressHydrationWarning
>
<body>
+13 -9
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { releaseView } from "@/lib/capabilities";
import { getFacts } from "@/lib/facts";
import { buildPageMetadata } from "@/lib/page-meta";
import {
@@ -32,17 +33,18 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
export default async function ModelsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const facts = await getFacts();
const released = releaseView(facts);
const t = (copy: LocalizedText) => fill(pickText(copy, locale), {
version: facts.version ?? "—", model: facts.defaultModel ?? "—", count: facts.models.length,
version: facts.latestPublishedRelease?.version ?? "—", model: facts.defaultModel ?? "—", count: released.models.length,
});
const dateLocale = getChrome(locale).dateLocale;
const latest = latestFirstPartyModels(facts.models);
const providers = sortProvidersFirstPartyFirst(facts.providers);
const latest = latestFirstPartyModels(released.models);
const providers = sortProvidersFirstPartyFirst(released.providers);
const routeEnv = ROUTE_PROVIDERS.flatMap((route) => {
const provider = facts.providers.find((p) => p.id === route.id);
const provider = released.providers.find((p) => p.id === route.id);
return provider ? [{ ...route, env: provider.env }] : [];
});
const deepseekEnv = facts.providers.find((p) => p.id === "deepseek")?.env;
const deepseekEnv = released.providers.find((p) => p.id === "deepseek")?.env;
return (
<>
@@ -120,14 +122,16 @@ export default async function ModelsPage({ params }: { params: Promise<{ locale:
</div>
</Section>
<Section id="models-title" title={t(MODELS_COPY.modelsTitle)} scope={t(MODELS_COPY.modelsLead)}>
<ModelsCatalog models={facts.models} locale={locale} />
</Section>
{released.counts && (
<Section id="models-title" title={t(MODELS_COPY.modelsTitle)} scope={t(MODELS_COPY.modelsLead)}>
<ModelsCatalog models={released.models} locale={locale} />
</Section>
)}
<Section id="providers-title" title={t(MODELS_COPY.listTitle)}>
<details className="group-card disclosure-row">
<summary>
<span>{fill(pickText(MODELS_COPY.providerCount, locale), { count: providers.length })}</span>
<span>{released.counts ? fill(pickText(MODELS_COPY.providerCount, locale), { count: released.counts.providerRoutes }) : t(MODELS_COPY.listTitle)}</span>
<Icon name="chevron-down" className="disclosure-chevron" />
</summary>
<div className="disclosure-body">
+11 -9
View File
@@ -7,14 +7,16 @@ import { Section } from "@/components/page-header";
import { Strata } from "@/components/strata";
import { NativeTerminalGallery } from "@/components/native-terminal-gallery";
import { LivingHeroWhale } from "@/components/living-hero-whale";
import { releaseView } from "@/lib/capabilities";
import { getFacts } from "@/lib/facts";
import { GitHubStar, FaceWall, MergedList, PulseStats } from "@/components/project-pulse";
import { GitHubStar, FaceWall, MergedList, PulseStats, statsAsOf } from "@/components/project-pulse";
import { LiveStarLink } from "@/components/github-live";
import { loadProjectPulse } from "@/lib/project-pulse";
import { PROJECT_COPY } from "@/lib/content/project";
import { HOME_OVERVIEW } from "@/lib/content/home-overview";
import { PRODUCT_COPY } from "@/lib/content/product";
import { PARTNER_PROMO } from "@/lib/content/partner-promo";
import { daysLeft, isActiveAt } from "@/lib/expiry";
import { fill, getChrome, getComputerUse, getHome, pickText, splitToken } from "@/lib/i18n/dictionaries";
import { REPO_ISSUES_URL, REPO_URL } from "@/lib/i18n/links";
import { serializeJsonLd } from "@/lib/json-ld";
@@ -33,19 +35,19 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
const facts = await getFacts();
const updates = await getReleaseUpdates(facts.latestPublishedRelease);
const pulse = await loadProjectPulse(5, 48);
const released = releaseView(facts);
const sourceVersion = facts.version ?? "unknown";
const publishedRelease = updates.terminal.release;
const sourceIsPublished = publishedRelease?.version === sourceVersion;
const promoLeft = Date.parse(PARTNER_PROMO.endsAt) - Date.now();
const promoDays = Math.max(1, Math.ceil(promoLeft / 86_400_000));
const showPromo = promoLeft > 0 && PARTNER_PROMO.locales.includes(locale);
const promoDays = daysLeft(PARTNER_PROMO.endsAt);
const showPromo = isActiveAt(PARTNER_PROMO.endsAt) && PARTNER_PROMO.locales.includes(locale);
const jsonLd = buildSoftwareApplicationJsonLd(publishedRelease);
const ledeParts = splitToken(d.heroIntro, "brand");
const gateways = new Set(["OpenRouter", "Together", "OpenAI Codex"]);
const makers = [...new Set(facts.models.map((model) => model.provider).filter((name): name is string => !!name && !gateways.has(name)))]
const makers = [...new Set(released.models.map((model) => model.provider).filter((name): name is string => !!name && !gateways.has(name)))]
.sort((a, b) => a.localeCompare(b));
const firstParty = new Set(makers);
const newest = facts.models
const newest = released.models
.filter((model) => model.addedAt && model.provider && firstParty.has(model.provider))
.sort((a, b) => (b.addedAt ?? "").localeCompare(a.addedAt ?? ""))
.slice(0, 6);
@@ -79,7 +81,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
<Link href={`/${locale}/install`} className="btn btn-primary btn-lg">
{d.getCodewhale}
</Link>
<GitHubStar stars={pulse.stats?.stars} locale={locale} />
<GitHubStar stars={pulse.stats?.stars ?? undefined} locale={locale} asOf={statsAsOf(pulse.stats, locale)} />
<Link href={`/${locale}/contribute`} className="btn btn-ghost btn-lg">
{d.contribute}
<Icon name="arrow-right" className="icon icon-flip" />
@@ -117,7 +119,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
) : (
<Link href={`/${locale}/changelog`} className="section-link">{chrome.footerChangelog}</Link>
)}
<LiveStarLink initial={pulse.stats?.stars} label={pickText(PROJECT_COPY.statStars, locale)} href={REPO_URL} numberLocale={chrome.dateLocale} />
<LiveStarLink initial={pulse.stats?.stars ?? undefined} label={pickText(PROJECT_COPY.statStars, locale)} href={REPO_URL} numberLocale={chrome.dateLocale} asOf={statsAsOf(pulse.stats, locale)} />
<Link href={`/${locale}/contribute`} className="section-link">{fill(d.proofContributors, { count: facts.contributorCount })}</Link>
<span>{facts.license ?? "MIT"}</span>
<Link href={`/${locale}/roadmap`} className="section-link">{chrome.footerRoadmap}</Link>
@@ -131,7 +133,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
id="home-models"
className="home-section"
title={d.modelsHeading}
scope={fill(pickText(HOME_OVERVIEW.modelsCount, locale), { providers: facts.providers.length, models: facts.models.length })}
scope={released.counts && publishedRelease ? fill(pickText(HOME_OVERVIEW.modelsCount, locale), { version: `v${publishedRelease.version}`, routes: released.counts.providerRoutes, models: released.counts.models }) : undefined}
link={<Link href={`/${locale}/models`} className="section-link">{d.modelsLink}<Icon name="arrow-right" className="icon icon-flip" /></Link>}
>
<ul className="home-makers" role="list">
+45 -34
View File
@@ -2,11 +2,16 @@ import Link from "next/link";
import { PageHeader, Section } from "@/components/page-header";
import { Status } from "@/components/status-badge";
import { NativeTerminalGallery } from "@/components/native-terminal-gallery";
import { DesktopDownloadBlock } from "@/components/desktop-download";
import { claimedItems, runtimeCapabilityContext } from "@/lib/capabilities";
import { describeRow } from "@/lib/content/availability";
import { PRODUCT_COPY } from "@/lib/content/product";
import { getFacts } from "@/lib/facts";
import { fill, getChrome, getHome, pickText } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
import { getNativeTerminalCopy } from "@/lib/content/native-terminal";
import { TERMINAL_SCREENSHOT } from "@/lib/media-manifest";
import { stateLabel } from "@/lib/surface-states";
export const revalidate = 300;
@@ -29,6 +34,8 @@ export default async function ProductPage({ params }: { params: Promise<{ locale
const t = (text: { en: string; zh: string }) => pickText(text, locale);
const home = getHome(locale);
const terminal = getNativeTerminalCopy(locale);
const capabilityContext = runtimeCapabilityContext(await getFacts());
const inSource = <Status tone="idle">{stateLabel("in_source", locale)}</Status>;
return (
<>
@@ -63,10 +70,10 @@ export default async function ProductPage({ params }: { params: Promise<{ locale
<Section id="product-workflows" layout="split" title={t(PRODUCT_COPY.workHeading)}>
<dl className="ruled-list">
{PRODUCT_COPY.workPatterns.map((pattern) => (
{claimedItems(PRODUCT_COPY.workPatterns, capabilityContext).map(({ item: pattern, availability }) => (
<div key={pattern.href}>
<dt>{t(pattern.title)}</dt>
<dd>{t(pattern.body)} <Link className="section-link" href={pattern.href.startsWith("/") ? `/${locale}${pattern.href}` : pattern.href}><code>{pattern.command}</code></Link></dd>
<dd>{availability === "in_source" ? inSource : null}{t(pattern.body)} <Link className="section-link" href={pattern.href.startsWith("/") ? `/${locale}${pattern.href}` : pattern.href}><code>{pattern.command}</code></Link></dd>
</div>
))}
</dl>
@@ -79,18 +86,18 @@ export default async function ProductPage({ params }: { params: Promise<{ locale
>
<div className="stack">
<dl className="ruled-list">
{PRODUCT_COPY.modes.map((row) => (
{claimedItems(PRODUCT_COPY.modes, capabilityContext).map(({ item: row, availability }) => (
<div key={row.title.en}>
<dt>{t(row.title)}</dt>
<dd>{t(row.body)}</dd>
<dd>{availability === "in_source" ? inSource : null}{t(row.body)}</dd>
</div>
))}
</dl>
<dl className="ruled-list">
{PRODUCT_COPY.permissions.map((row) => (
{claimedItems(PRODUCT_COPY.permissions, capabilityContext).map(({ item: row, availability }) => (
<div key={row.title.en}>
<dt>{t(row.title)}</dt>
<dd>{t(row.body)}</dd>
<dd>{availability === "in_source" ? inSource : null}{t(row.body)}</dd>
</div>
))}
</dl>
@@ -103,34 +110,38 @@ export default async function ProductPage({ params }: { params: Promise<{ locale
title={t(PRODUCT_COPY.availabilityHeading)}
>
<dl className="ruled-list">
{PRODUCT_COPY.availability.map((row) => (
<div key={row.id}>
<dt>{t(row.surface)}</dt>
<dd>
<Status tone={row.tone}>{t(row.status)}</Status>
{t(row.detail)}
{row.href && row.linkLabel && (
<>
{" "}
{row.href.startsWith("http") ? (
<a
href={row.href}
className="link"
target="_blank"
rel="noopener noreferrer"
>
{t(row.linkLabel)}
</a>
) : (
<Link href={`/${locale}${row.href}`} className="link">
{t(row.linkLabel)}
</Link>
)}
</>
)}
</dd>
</div>
))}
{PRODUCT_COPY.availability.map((row) => {
const view = describeRow(row, locale);
return (
<div key={row.id}>
<dt>{t(row.surface)}</dt>
<dd>
<Status tone={view.tone}>{view.status}</Status>
{view.detail}
{view.download ? <DesktopDownloadBlock download={view.download} locale={locale} /> : null}
{view.link && row.href && row.linkLabel && (
<>
{" "}
{row.href.startsWith("http") ? (
<a
href={row.href}
className="link"
target="_blank"
rel="noopener noreferrer"
>
{t(row.linkLabel)}
</a>
) : (
<Link href={`/${locale}${row.href}`} className="link">
{t(row.linkLabel)}
</Link>
)}
</>
)}
</dd>
</div>
);
})}
</dl>
</Section>
</div>
+8 -4
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Icon } from "@/components/icon";
import { PageHeader, Section } from "@/components/page-header";
import { Status } from "@/components/status-badge";
import { releaseView } from "@/lib/capabilities";
import { getFacts } from "@/lib/facts";
import { getChrome, getRuntime, pickText } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
@@ -25,6 +26,7 @@ export default async function RuntimePage({ params }: { params: Promise<{ locale
const chrome = getChrome(locale);
const t = getRuntime(locale);
const facts = await getFacts();
const releasedTools = releaseView(facts).counts?.tools ?? null;
return (
<>
@@ -83,10 +85,12 @@ export default async function RuntimePage({ params }: { params: Promise<{ locale
<dt>{t.version}</dt>
<dd className="tabular">{facts.version ?? "—"}</dd>
</div>
<div>
<dt>{t.toolCount}</dt>
<dd className="tabular">{facts.toolCount ?? "—"}</dd>
</div>
{releasedTools !== null && (
<div>
<dt>{t.toolCount}</dt>
<dd className="tabular">{releasedTools}</dd>
</div>
)}
<div>
<dt>{t.sandboxBackends}</dt>
<dd>{facts.sandboxBackends.length ? facts.sandboxBackends.join(" · ") : "—"}</dd>
+2 -22
View File
@@ -1,11 +1,6 @@
import { NextResponse } from "next/server";
import { getEnv } from "@/lib/kv";
import {
runCurate,
runTriage,
runPrReview,
runStale,
runDupes,
runDigest,
type AgentEnv,
} from "@/lib/community-agent-tasks";
@@ -15,14 +10,14 @@ import { safeEqual } from "@/lib/community-agent";
export const dynamic = "force-dynamic";
const TASKS = ["curate", "triage", "pr-review", "stale", "dupes", "digest", "facts-drift", "linkcheck", "semantic-drift"] as const;
const TASKS = ["digest", "facts-drift", "linkcheck", "semantic-drift"] as const;
type Task = (typeof TASKS)[number];
/**
* Manual trigger surface for community-agent tasks.
*
* Usage:
* GET /api/cron?task=curate
* GET /api/cron?task=digest
* Header: x-cron-secret: <CRON_SECRET>
*
* Real cron scheduling is handled by worker.ts's scheduled() handler.
@@ -71,21 +66,6 @@ export async function GET(req: Request) {
try {
let result: Record<string, unknown>;
switch (task) {
case "curate":
result = await runCurate(agentEnv);
break;
case "triage":
result = await runTriage(agentEnv);
break;
case "pr-review":
result = await runPrReview(agentEnv);
break;
case "stale":
result = await runStale(agentEnv);
break;
case "dupes":
result = await runDupes(agentEnv);
break;
case "digest":
result = await runDigest(agentEnv);
break;
+3
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { releaseView } from "@/lib/capabilities";
import { BUILD_FACTS, getFactsWithProvenance, type RepoFacts } from "@/lib/facts";
import { cloudFactsSummary } from "@/lib/cloud-facts";
import { getEnv } from "@/lib/kv";
@@ -8,6 +9,7 @@ export const revalidate = 0;
function summary(facts: RepoFacts) {
return {
generatedAt: facts.generatedAt,
sourceRevision: facts.sourceRevision,
sourceCommittedAt: facts.sourceCommittedAt,
version: facts.version,
@@ -35,6 +37,7 @@ export async function GET() {
reason: resolution.reason,
},
latestPublishedRelease: resolution.facts.latestPublishedRelease,
releaseCounts: releaseView(resolution.facts).counts,
cloudFacts,
},
{
+8 -1
View File
@@ -1,9 +1,16 @@
import { capabilityViews, runtimeCapabilityContext } from "@/lib/capabilities";
import { BUILD_FACTS } from "@/lib/facts";
import { FEATURE_CATALOGUE } from "@/lib/features";
export const dynamic = "force-static";
export function GET() {
return Response.json(FEATURE_CATALOGUE, {
const context = runtimeCapabilityContext(BUILD_FACTS);
return Response.json({
...FEATURE_CATALOGUE,
publishedRelease: context.release?.tag ?? null,
features: capabilityViews(context).map(({ feature, availability, name, summary }) => ({ ...feature, name, summary, availability })),
}, {
headers: { "cache-control": "public, max-age=3600" },
});
}
+4 -4
View File
@@ -14,8 +14,8 @@ html {
}
body {
/* The ground: paper under a pale sky in light; the navy ocean rising into
the logo's blue at the top of the page in dark. */
/* The ground: the 5.x water gradient over the floor colour in both
appearances. */
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -35,8 +35,8 @@ main { flex: 1 0 auto; }
main, header, footer, nav { position: relative; z-index: 1; }
/* ---------- type ---------- */
/* One face: Shannon Sans for headings, body and labels, a little negative
tracking on the big headings. Code uses the system monospace stack. No
/* One face: Geist for headings, body and labels, a little negative
tracking on the big headings. Code uses Geist Mono. No
serif, no all-caps, no wide tracking. */
.font-display { font-family: var(--font-display); font-weight: 600; }
.font-cjk { font-family: var(--font-cjk); }
+1 -1
View File
@@ -32,7 +32,7 @@
--strata-2: rgb(11 72 187 / 0.18);
--strata-3: rgb(11 72 187 / 0.28);
--strata-4: var(--ocean-sky);
--strata-line: var(--kit-bioluminescence);
--strata-line: var(--gpui-dark-accent);
}
/* ---------- paper: the hero ---------- */
+1 -1
View File
@@ -4,7 +4,7 @@
/* ---------- docs: the reading sheet ---------- */
/* Read mode: comprehension first. The article gets a book measure, the
Shannon Sans heading voice, generous leading, and a contents rail that
Geist heading voice, generous leading, and a contents rail that
stays put while the sheet scrolls. */
.docs-content {
max-width: 46rem;
+84 -103
View File
@@ -2,15 +2,15 @@
/* Tokens and roles — the whale-road */
/* ------------------------------------------------------------------ */
/*
* Direction: codewhale-design/DIRECTION.md. The website ocean palette is
* restored from the Codewhale brand kit; mapping: docs/BRAND_COLOR_MAPPING.md.
* Shared type, spacing, radius, motion and elevation come from tokens.json,
* exported to ../tokens.css (`--gpui-*`) by scripts/export-design-tokens.py.
* Pages paint with these roles rather than importing desktop colors.
* Direction: codewhale-design/DIRECTION.md. The site's colour roles are the
* codewhale-design 5.x light and dark roles (the desktop app's), type is
* Geist, and shared spacing, radius, motion and elevation come from
* tokens.json, exported to ../tokens.css (`--gpui-*`) by
* scripts/export-design-tokens.py. Pages paint with these roles rather than
* importing desktop colors.
*
* Grounds are warm paper (light) and navy ocean (dark). Filled actions
* use blue; links, focus and selection carry the matching accent.
* The sea below the horizon stays navy in both appearances.
* Filled actions use the primary blue; links, focus and selection carry
* the accent. The sea below the horizon stays navy in both appearances.
*
* Depth is elevation. --panel is raised (nearer the surface, lighter);
* --bg is the floor; --surface is recessed (code, rails, fields).
@@ -18,8 +18,10 @@
* State is a mark and a word: --live (working, done, ready), --attention
* (needs you), --danger (failed), --muted (idle). Never colour alone.
*
* NAMING TRAP: tokens.json `surface` is the RAISED colour (web --panel);
* tokens.json `sidebar` is the RECESSED one (web --surface).
* NAMING TRAP: tokens.json `background` is the floor (web --bg); in dark,
* tokens.json `sidebar` is the raised colour (web --panel) and `surface` the
* recessed one (web --surface); in light, `surface` is raised and `sidebar`
* the recessed one.
*/
/* ---------- scheme-independent tokens ---------- */
@@ -32,55 +34,34 @@
--kit-ocean-depth: #173b50;
--kit-night-surface: #20374e;
--kit-night-border: #31495e;
--kit-foam: #e6eef7;
--kit-mist: #bbc9d7;
--kit-bioluminescence: #8fbdff;
--kit-warm-paper: #faf9f5;
--kit-white: #ffffff;
--kit-seaglass: #edf1ef;
--kit-shallows: #dcefeb;
--kit-tideline: #d2dedd;
--kit-ink: #263844;
--kit-slate: #495a63;
--kit-signal-blue: #245bc7;
--kit-kelp: #9ecbbd;
--kit-kelp-deep: #396554;
--kit-lantern: #eab982;
--kit-amber: #84530d;
--kit-coral: #eea59d;
--kit-rust: #9e3f36;
--ocean-abyss: var(--kit-abyss);
--ocean-floor: var(--kit-deep-navy);
--ocean-raised: var(--kit-ocean-depth);
--ocean-hover: var(--kit-night-surface);
--ocean-selected: var(--kit-night-border);
--ocean-line: var(--kit-night-border);
--ocean-line-strong: var(--kit-mist);
--ocean-line-strong: var(--gpui-dark-border-strong);
--ocean-sky: var(--kit-mark-navy);
--brand-light: var(--kit-current-blue);
--brand-deep: var(--kit-whale-blue);
--brand-mid: #1877c9;
--brand-fill: linear-gradient(135deg, var(--brand-mid), var(--brand-deep));
--brand-fill-hover: linear-gradient(135deg, var(--brand-deep), var(--kit-mark-navy));
--on-brand: var(--kit-white);
--on-brand: var(--gpui-dark-on-primary);
--sea-surface: var(--brand-deep);
--sea-shallow: #0f3991;
--sea-mid: var(--kit-mark-navy);
--sea: linear-gradient(180deg, var(--sea-shallow) 0, var(--sea-mid) 5rem, var(--ocean-floor) 22rem);
--sea-band: linear-gradient(180deg, var(--sea-shallow) 0, var(--sea-mid) 40%, var(--ocean-floor) 100%);
--tide-aqua: var(--kit-shallows);
--tide: linear-gradient(180deg, var(--kit-warm-paper) 0, var(--tide-aqua) 2rem, var(--brand-light) 4.5rem, var(--brand-deep) 7rem, var(--sea-mid) 11rem, var(--ocean-floor) 26rem);
--horizon-light: rgb(143 189 255 / 0.85);
--horizon-light: color-mix(in srgb, var(--gpui-dark-accent) 85%, transparent);
--horizon-glow: var(--horizon-light);
--ombre: var(--gpui-ombre);
/* Type: Shannon Sans for every role, the system monospace stack for
things a person could type or paste. No serif, no third family. */
--font-body: var(--font-shannon-latin), var(--font-shannon-ext), var(--gpui-font-fallbacks),
ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-body: var(--font-geist-sans), var(--gpui-font-fallbacks), ui-sans-serif, system-ui,
-apple-system, "Segoe UI", sans-serif;
--font-display: var(--font-body);
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco,
Consolas, "Liberation Mono", monospace;
--font-cjk: var(--gpui-font-fallbacks), sans-serif;
/* Type roles from tokens.json. The editorial section and display sizes
@@ -150,67 +131,67 @@
}
}
/* ---------- light — warm paper ---------- */
/* ---------- light ---------- */
:root {
--bg: #faf8f5;
--surface: #f0ede8;
--panel: var(--kit-white);
--text: var(--kit-ink);
--muted: var(--kit-slate);
--line: #d9d5cf;
--line-strong: var(--kit-slate);
--accent: var(--kit-whale-blue);
--accent-hover: rgb(11 72 187 / var(--gpui-primary-hover-opacity));
--on-accent: var(--kit-white);
--bg: var(--gpui-light-bg);
--surface: var(--gpui-light-sidebar);
--panel: var(--gpui-light-panel);
--text: var(--gpui-light-text);
--muted: var(--gpui-light-text-muted);
--line: var(--gpui-light-border);
--line-strong: var(--gpui-light-border-strong);
--accent: var(--gpui-light-primary);
--accent-hover: color-mix(in srgb, var(--gpui-light-primary) calc(var(--gpui-primary-hover-opacity) * 100%), transparent);
--on-accent: var(--gpui-light-on-primary);
--hover: #f0ede8;
--selected: var(--kit-shallows);
--selection: rgb(11 72 187 / var(--gpui-selection-opacity));
--ring: var(--kit-whale-blue);
--live: var(--kit-kelp-deep);
--success: var(--kit-kelp-deep);
--attention: var(--kit-amber);
--danger: var(--kit-rust);
--mark-ink: var(--kit-ink);
--hover: var(--gpui-light-hover);
--selected: var(--gpui-light-list-active);
--selection: color-mix(in srgb, var(--gpui-light-accent) calc(var(--gpui-selection-opacity) * 100%), transparent);
--ring: var(--gpui-light-accent);
--live: var(--gpui-light-live);
--success: var(--gpui-light-live);
--attention: var(--gpui-light-attention);
--danger: var(--gpui-light-danger);
--mark-ink: var(--gpui-light-text);
--ombre-ink: linear-gradient(135deg, var(--brand-light), var(--brand-deep));
--diff-add: rgb(57 101 84 / 0.14);
--diff-del: rgb(158 63 54 / 0.11);
--ground: var(--bg);
--diff-add: var(--gpui-light-diff-add);
--diff-del: var(--gpui-light-diff-remove);
--ground: var(--gpui-water-light) top center / 100% 38rem no-repeat, var(--bg);
--glow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent);
--glow-focus: 0 0 0 4px rgb(11 72 187 / 0.16);
--glow-focus: 0 0 0 4px color-mix(in srgb, var(--gpui-light-accent) 16%, transparent);
--shadow-popover: var(--gpui-elevation-popover-light);
--shadow-dialog: var(--gpui-elevation-dialog-light);
color-scheme: light;
}
/* ---------- dark — navy ocean, only when pinned ---------- */
/* ---------- dark — only when pinned ---------- */
:root[data-theme="dark"] {
--bg: var(--ocean-floor);
--surface: var(--ocean-abyss);
--panel: var(--ocean-raised);
--text: var(--kit-foam);
--muted: var(--kit-mist);
--line: var(--ocean-line);
--line-strong: var(--ocean-line-strong);
--accent: var(--kit-bioluminescence);
--accent-hover: rgb(143 189 255 / var(--gpui-primary-hover-opacity));
--on-accent: var(--kit-abyss);
--bg: var(--gpui-dark-bg);
--surface: var(--gpui-dark-panel);
--panel: var(--gpui-dark-sidebar);
--text: var(--gpui-dark-text);
--muted: var(--gpui-dark-text-muted);
--line: var(--gpui-dark-border);
--line-strong: var(--gpui-dark-border-strong);
--accent: var(--gpui-dark-accent);
--accent-hover: color-mix(in srgb, var(--gpui-dark-accent) calc(var(--gpui-primary-hover-opacity) * 100%), transparent);
--on-accent: var(--gpui-dark-bg);
--hover: var(--ocean-hover);
--selected: var(--ocean-selected);
--selection: rgb(143 189 255 / var(--gpui-selection-opacity));
--ring: var(--kit-bioluminescence);
--live: var(--kit-kelp);
--success: var(--kit-kelp);
--attention: var(--kit-lantern);
--danger: var(--kit-coral);
--mark-ink: var(--kit-foam);
--ombre-ink: linear-gradient(135deg, var(--kit-bioluminescence), var(--brand-light));
--diff-add: rgb(158 203 189 / 0.16);
--diff-del: rgb(238 165 157 / 0.12);
--ground: radial-gradient(90rem 38rem at 50% -14rem, rgb(30 143 216 / 0.3), transparent 70%), linear-gradient(180deg, var(--ocean-sky) 0, var(--ocean-floor) 34rem);
--hover: var(--gpui-dark-hover);
--selected: var(--gpui-dark-list-active);
--selection: color-mix(in srgb, var(--gpui-dark-accent) calc(var(--gpui-selection-opacity) * 100%), transparent);
--ring: var(--gpui-dark-accent);
--live: var(--gpui-dark-live);
--success: var(--gpui-dark-live);
--attention: var(--gpui-dark-attention);
--danger: var(--gpui-dark-danger);
--mark-ink: var(--gpui-dark-text);
--ombre-ink: linear-gradient(135deg, var(--gpui-dark-accent), var(--brand-light));
--diff-add: var(--gpui-dark-diff-add);
--diff-del: var(--gpui-dark-diff-remove);
--ground: var(--gpui-water-dark) top center / 100% 38rem no-repeat, var(--bg);
--glow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent), 0 0 12px 1px color-mix(in srgb, currentColor 45%, transparent);
--glow-focus: 0 0 0 4px rgb(143 189 255 / 0.3);
--glow-focus: 0 0 0 4px color-mix(in srgb, var(--gpui-dark-accent) 30%, transparent);
--shadow-popover: var(--gpui-elevation-popover-dark);
--shadow-dialog: var(--gpui-elevation-dialog-dark);
color-scheme: dark;
@@ -225,28 +206,28 @@
--bg: var(--ocean-floor);
--surface: var(--ocean-abyss);
--panel: var(--ocean-raised);
--text: var(--kit-foam);
--muted: var(--kit-mist);
--text: var(--gpui-dark-text);
--muted: var(--gpui-dark-text-muted);
--line: var(--ocean-line);
--line-strong: var(--ocean-line-strong);
--accent: var(--kit-bioluminescence);
--accent-hover: rgb(143 189 255 / var(--gpui-primary-hover-opacity));
--on-accent: var(--kit-abyss);
--accent: var(--gpui-dark-accent);
--accent-hover: color-mix(in srgb, var(--gpui-dark-accent) calc(var(--gpui-primary-hover-opacity) * 100%), transparent);
--on-accent: var(--gpui-dark-bg);
--hover: var(--ocean-hover);
--selected: var(--ocean-selected);
--selection: rgb(143 189 255 / var(--gpui-selection-opacity));
--ring: var(--kit-bioluminescence);
--live: var(--kit-kelp);
--success: var(--kit-kelp);
--attention: var(--kit-lantern);
--danger: var(--kit-coral);
--mark-ink: var(--kit-foam);
--ombre-ink: linear-gradient(135deg, var(--kit-bioluminescence), var(--brand-light));
--diff-add: rgb(158 203 189 / 0.16);
--diff-del: rgb(238 165 157 / 0.12);
--selection: color-mix(in srgb, var(--gpui-dark-accent) calc(var(--gpui-selection-opacity) * 100%), transparent);
--ring: var(--gpui-dark-accent);
--live: var(--gpui-dark-live);
--success: var(--gpui-dark-live);
--attention: var(--gpui-dark-attention);
--danger: var(--gpui-dark-danger);
--mark-ink: var(--gpui-dark-text);
--ombre-ink: linear-gradient(135deg, var(--gpui-dark-accent), var(--brand-light));
--diff-add: var(--gpui-dark-diff-add);
--diff-del: var(--gpui-dark-diff-remove);
--glow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent), 0 0 12px 1px color-mix(in srgb, currentColor 45%, transparent);
--glow-focus: 0 0 0 4px rgb(143 189 255 / 0.3);
--glow-focus: 0 0 0 4px color-mix(in srgb, var(--gpui-dark-accent) 30%, transparent);
--shadow-popover: var(--gpui-elevation-popover-dark);
--paper: var(--bg);
+82 -67
View File
@@ -245,34 +245,34 @@
--shoreline-light-diff-deleted-fg-rgb: 168 40 80;
--shoreline-light-diff-deleted-bg: #fbe6ec;
--shoreline-light-diff-deleted-bg-rgb: 251 230 236;
--gpui-dark-bg: #202123;
--gpui-dark-bg-rgb: 32 33 35;
--gpui-dark-text: #efeeeb;
--gpui-dark-text-rgb: 239 238 235;
--gpui-dark-panel: #2a2b2e;
--gpui-dark-panel-rgb: 42 43 46;
--gpui-dark-text-muted: #b8b8b4;
--gpui-dark-text-muted-rgb: 184 184 180;
--gpui-dark-bg: #030508;
--gpui-dark-bg-rgb: 3 5 8;
--gpui-dark-text: #e8edf2;
--gpui-dark-text-rgb: 232 237 242;
--gpui-dark-panel: #04070c;
--gpui-dark-panel-rgb: 4 7 12;
--gpui-dark-text-muted: #a8b6c3;
--gpui-dark-text-muted-rgb: 168 182 195;
--gpui-dark-border: #3b3c3f;
--gpui-dark-border-rgb: 59 60 63;
--gpui-dark-sidebar: #191a1c;
--gpui-dark-sidebar-rgb: 25 26 28;
--gpui-dark-primary: #e6e4df;
--gpui-dark-primary-rgb: 230 228 223;
--gpui-dark-on-primary: #202123;
--gpui-dark-on-primary-rgb: 32 33 35;
--gpui-dark-accent: #a9c0de;
--gpui-dark-accent-rgb: 169 192 222;
--gpui-dark-sidebar: #0d121b;
--gpui-dark-sidebar-rgb: 13 18 27;
--gpui-dark-primary: #0b48bb;
--gpui-dark-primary-rgb: 11 72 187;
--gpui-dark-on-primary: #ffffff;
--gpui-dark-on-primary-rgb: 255 255 255;
--gpui-dark-accent: #8fbdff;
--gpui-dark-accent-rgb: 143 189 255;
--gpui-dark-hover: #303134;
--gpui-dark-hover-rgb: 48 49 52;
--gpui-dark-list-active: #37393d;
--gpui-dark-list-active-rgb: 55 57 61;
--gpui-dark-attention: #e8b077;
--gpui-dark-attention-rgb: 232 176 119;
--gpui-dark-live: #9ec7b2;
--gpui-dark-live-rgb: 158 199 178;
--gpui-dark-danger: #e39a90;
--gpui-dark-danger-rgb: 227 154 144;
--gpui-dark-list-active: #17191c;
--gpui-dark-list-active-rgb: 23 25 28;
--gpui-dark-attention: #eab982;
--gpui-dark-attention-rgb: 234 185 130;
--gpui-dark-live: #9ecbbd;
--gpui-dark-live-rgb: 158 203 189;
--gpui-dark-danger: #eea59d;
--gpui-dark-danger-rgb: 238 165 157;
--gpui-dark-border-strong: #828386;
--gpui-dark-border-strong-rgb: 130 131 134;
--gpui-dark-ocean-depth: #202123;
@@ -281,72 +281,84 @@
--gpui-dark-diff-add-rgb: 34 57 47;
--gpui-dark-diff-remove: #45302f;
--gpui-dark-diff-remove-rgb: 69 48 47;
--gpui-light-bg: #faf8f5;
--gpui-light-bg-rgb: 250 248 245;
--gpui-light-text: #28292b;
--gpui-light-text-rgb: 40 41 43;
--gpui-light-panel: #ffffff;
--gpui-light-panel-rgb: 255 255 255;
--gpui-light-text-muted: #4f504d;
--gpui-light-text-muted-rgb: 79 80 77;
--gpui-light-border: #d9d5cf;
--gpui-light-border-rgb: 217 213 207;
--gpui-light-sidebar: #f0ede8;
--gpui-light-sidebar-rgb: 240 237 232;
--gpui-light-primary: #2e2f31;
--gpui-light-primary-rgb: 46 47 49;
--gpui-light-on-primary: #faf8f5;
--gpui-light-on-primary-rgb: 250 248 245;
--gpui-light-accent: #425c82;
--gpui-light-accent-rgb: 66 92 130;
--gpui-light-hover: #e8e5e0;
--gpui-light-hover-rgb: 232 229 224;
--gpui-light-list-active: #dfdcd6;
--gpui-light-list-active-rgb: 223 220 214;
--gpui-light-attention: #86520d;
--gpui-light-attention-rgb: 134 82 13;
--gpui-light-live: #3a6352;
--gpui-light-live-rgb: 58 99 82;
--gpui-light-bg: #f1f5f9;
--gpui-light-bg-rgb: 241 245 249;
--gpui-light-text: #1b2a35;
--gpui-light-text-rgb: 27 42 53;
--gpui-light-panel: #fbfcfd;
--gpui-light-panel-rgb: 251 252 253;
--gpui-light-text-muted: #4a545d;
--gpui-light-text-muted-rgb: 74 84 93;
--gpui-light-border: #d5dce3;
--gpui-light-border-rgb: 213 220 227;
--gpui-light-sidebar: #f9fafc;
--gpui-light-sidebar-rgb: 249 250 252;
--gpui-light-primary: #0b48bb;
--gpui-light-primary-rgb: 11 72 187;
--gpui-light-on-primary: #ffffff;
--gpui-light-on-primary-rgb: 255 255 255;
--gpui-light-accent: #245bc7;
--gpui-light-accent-rgb: 36 91 199;
--gpui-light-hover: #e3e9ef;
--gpui-light-hover-rgb: 227 233 239;
--gpui-light-list-active: #d8e0e8;
--gpui-light-list-active-rgb: 216 224 232;
--gpui-light-attention: #84530d;
--gpui-light-attention-rgb: 132 83 13;
--gpui-light-live: #396554;
--gpui-light-live-rgb: 57 101 84;
--gpui-light-danger: #9e3f36;
--gpui-light-danger-rgb: 158 63 54;
--gpui-light-border-strong: #807c76;
--gpui-light-border-strong-rgb: 128 124 118;
--gpui-light-ocean-depth: #faf8f5;
--gpui-light-ocean-depth-rgb: 250 248 245;
--gpui-light-border-strong: #737d88;
--gpui-light-border-strong-rgb: 115 125 136;
--gpui-light-ocean-depth: #f6f9fc;
--gpui-light-ocean-depth-rgb: 246 249 252;
--gpui-light-diff-add: #d2eadd;
--gpui-light-diff-add-rgb: 210 234 221;
--gpui-light-diff-remove: #f8e1dd;
--gpui-light-diff-remove-rgb: 248 225 221;
}
/* GPUI design 2.3.0; sha256 9e74fb8f8fbfebb6b90a9740b324c56405277886e61273e2a0cb682797a8a6f9. */
/* GPUI design 5.3.0; sha256 0d9acc771562a7b50fc9473e23ebccbff7cbc56962639277943a0cb0131ea83e. */
:root {
--gpui-radius-control: 6px;
--gpui-radius-panel: 10px;
--gpui-radius-sheet: 14px;
--gpui-radius-small: 6px;
--gpui-radius-field: 8px;
--gpui-radius-control: 10px;
--gpui-radius-panel: 18px;
--gpui-radius-sheet: 18px;
--gpui-radius-window: 14px;
--gpui-radius-card: 14px;
--gpui-radius-composer: 24px;
--gpui-radius-chip: 999px;
--gpui-focus-width: 2px;
--gpui-focus-offset: 3px;
--gpui-spacing-hairline: 2px;
--gpui-spacing-compact: 4px;
--gpui-spacing-tight: 6px;
--gpui-spacing-control: 8px;
--gpui-spacing-snug: 10px;
--gpui-spacing-group: 12px;
--gpui-spacing-section: 16px;
--gpui-spacing-large: 24px;
--gpui-spacing-page: 32px;
--gpui-selection-opacity: 0.28;
--gpui-primary-hover-opacity: 0.9;
--gpui-type-caption-size: 0.75rem;
--gpui-type-caption-line: 1.333;
--gpui-type-caption-size: 0.71875rem;
--gpui-type-caption-line: 1.391;
--gpui-type-caption-weight: 400;
--gpui-type-label-size: 0.8125rem;
--gpui-type-label-line: 1.385;
--gpui-type-label-size: 0.78125rem;
--gpui-type-label-line: 1.44;
--gpui-type-label-weight: 400;
--gpui-type-control-size: 0.8125rem;
--gpui-type-control-line: 1.385;
--gpui-type-control-weight: 400;
--gpui-type-body-size: 0.875rem;
--gpui-type-body-line: 1.571;
--gpui-type-body-weight: 400;
--gpui-type-prose-size: 1rem;
--gpui-type-prose-line: 1.625;
--gpui-type-prose-weight: 400;
--gpui-type-heading-size: 1.0625rem;
--gpui-type-heading-line: 1.412;
--gpui-type-heading-size: 0.9375rem;
--gpui-type-heading-line: 1.6;
--gpui-type-heading-weight: 550;
--gpui-type-title-size: 1.375rem;
--gpui-type-title-line: 1.273;
@@ -364,6 +376,7 @@
--gpui-motion-duration-linger: 1200ms;
--gpui-motion-loop-beat: 1200ms;
--gpui-motion-loop-breathe: 2400ms;
--gpui-motion-loop-spin: 1000ms;
--gpui-motion-arrive-rise: 4px;
--gpui-easing-out: cubic-bezier(0, 0, 0.2, 1);
--gpui-easing-in: cubic-bezier(0.4, 0, 1, 1);
@@ -373,9 +386,11 @@
--gpui-elevation-dialog-light: 0 12px 32px rgb(0 0 0 / 0.2);
--gpui-elevation-dialog-dark: 0 12px 32px rgb(0 0 0 / 0.5);
--gpui-breakpoint-compact: 720px;
--gpui-breakpoint-medium: 1024px;
--gpui-breakpoint-wide: 1440px;
--gpui-ombre: linear-gradient(135deg, #1e8fd8, #0b48bb);
--gpui-breakpoint-medium: 900px;
--gpui-breakpoint-wide: 1200px;
--gpui-water-dark: linear-gradient(180deg, #0b1e3a 0%, #071326 35%, #04090f 75%, #030508 100%);
--gpui-water-light: linear-gradient(180deg, #bfe1f2 0%, #dceef3 30%, #e7f0f6 62%, #f1f5f9 100%);
--gpui-ombre: linear-gradient(135deg, #1775c9, #0b48bb);
--gpui-font-fallbacks: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC";
--gpui-mono-size: 0.8125rem;
}
-3
View File
@@ -20,9 +20,6 @@ export default defineConfig({
triggers.scheduled({
schedule: "0 */6 * * *",
}),
triggers.scheduled({
schedule: "*/30 * * * *",
}),
triggers.scheduled({
schedule: "0 0 * * *",
}),
+22
View File
@@ -0,0 +1,22 @@
import type { DesktopDownload } from "@/lib/desktop-release";
import { fill, getChrome } from "@/lib/i18n/dictionaries";
export function DesktopDownloadBlock({ download, locale }: { download: DesktopDownload; locale: string }) {
const chrome = getChrome(locale);
const size = `${new Intl.NumberFormat(chrome.dateLocale, { maximumFractionDigits: 1 }).format(download.size / 1_000_000)} MB`;
return (
<div className="desktop-download">
<p>
<a href={download.url} className="btn btn-primary" rel="noopener">
{chrome.downloadMacos}
</a>
</p>
<p>
{fill(chrome.downloadVersion, { version: download.version })} · {fill(chrome.downloadSize, { size })} · {chrome.downloadSigned}
</p>
<p>
{chrome.downloadChecksum}: <code>{download.sha256}</code>
</p>
</div>
);
}
+30 -12
View File
@@ -46,9 +46,9 @@ async function fetchLive(): Promise<LiveRepo | null> {
user: { login: string; avatar_url: string; type?: string };
}>>,
]);
const stars = repo.stargazers_count ?? 0;
if (stars <= 0) return null;
const openPulls = prs.total_count ?? 0;
const stars = repo.stargazers_count;
const openPulls = prs.total_count;
if (typeof stars !== "number" || stars <= 0 || typeof openPulls !== "number" || typeof repo.open_issues_count !== "number") return null;
const merged = pulls
.filter((pr) => pr.merged_at && pr.user.type !== "Bot" && !pr.user.login.endsWith("[bot]"))
.sort((a, b) => +new Date(b.merged_at!) - +new Date(a.merged_at!))
@@ -61,7 +61,7 @@ async function fetchLive(): Promise<LiveRepo | null> {
mergedAt: pr.merged_at!,
firstTime: pr.author_association === "FIRST_TIME_CONTRIBUTOR",
}));
const data = { stars, openPulls, openIssues: Math.max(0, (repo.open_issues_count ?? 0) - openPulls), merged };
const data = { stars, openPulls, openIssues: Math.max(0, repo.open_issues_count - openPulls), merged };
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), data })); } catch {}
return data;
} catch {
@@ -81,27 +81,36 @@ export function useLiveRepo(enabled: boolean): LiveRepo | null {
return data;
}
export function LiveStarCount({ initial, numberLocale }: { initial?: number; numberLocale: string }) {
export function LiveStarCount({ initial, numberLocale, asOf }: { initial?: number; numberLocale: string; asOf?: string }) {
const live = useLiveRepo(initial === undefined);
const stars = initial ?? live?.stars;
if (stars === undefined) return null;
const dated = initial !== undefined ? asOf : undefined;
const formatted = new Intl.NumberFormat(numberLocale).format(stars);
return (
<span className="github-star-count tabular" data-live={initial === undefined ? "client" : "server"}>
{new Intl.NumberFormat(numberLocale).format(stars)}
<span
className="github-star-count tabular"
data-live={initial === undefined ? "client" : "server"}
title={dated}
aria-label={dated ? `${formatted}, ${dated}` : undefined}
>
{formatted}
</span>
);
}
export interface PulseRow { key: "stars" | "contributors" | "pulls" | "issues"; label: string; href: string; value?: string }
export interface PulseRow { key: "stars" | "contributors" | "pulls" | "issues"; label: string; href: string; value?: string; asOf?: string }
export function LiveStarLink({ initial, label, href, numberLocale }: { initial?: number; label: string; href: string; numberLocale: string }) {
export function LiveStarLink({ initial, label, href, numberLocale, asOf }: { initial?: number; label: string; href: string; numberLocale: string; asOf?: string }) {
const live = useLiveRepo(initial === undefined);
const stars = initial ?? live?.stars;
if (stars === undefined) return null;
const dated = initial !== undefined ? asOf : undefined;
const formatted = new Intl.NumberFormat(numberLocale).format(stars);
return (
<a href={href} className="section-link home-proof-stars">
<a href={href} className="section-link home-proof-stars" title={dated} aria-label={dated ? `${formatted} ${label}, ${dated}` : undefined}>
<span aria-hidden="true">★</span>
<span className="tabular">{new Intl.NumberFormat(numberLocale).format(stars)}</span>
<span className="tabular">{formatted}</span>
<span>{label}</span>
</a>
);
@@ -126,7 +135,16 @@ export function LivePulseStats({ rows, numberLocale }: { rows: PulseRow[]; numbe
return (
<div key={row.key}>
<dt>{row.label}</dt>
<dd><a href={row.href} className="tabular">{value}</a></dd>
<dd>
<a
href={row.href}
className="tabular"
title={row.value !== undefined ? row.asOf : undefined}
aria-label={row.value !== undefined && row.asOf ? `${value} ${row.label}, ${row.asOf}` : undefined}
>
{value}
</a>
</dd>
</div>
);
})}
+12 -5
View File
@@ -14,12 +14,19 @@ function age(iso: string, locale: string): string {
return new Intl.RelativeTimeFormat(getChrome(locale).dateLocale, { numeric: "auto" }).format(value, unit);
}
export function statsAsOf(stats: RepoStats | null, locale: string): string | undefined {
if (!stats?.fetchedAt || !Number.isFinite(Date.parse(stats.fetchedAt))) return undefined;
const date = new Intl.DateTimeFormat(getChrome(locale).dateLocale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(stats.fetchedAt));
return fill(getChrome(locale).numbersAsOf, { date });
}
export function PulseStats({ stats, contributors, locale }: { stats: RepoStats | null; contributors: number; locale: string }) {
const asOf = statsAsOf(stats, locale);
const rows: PulseRow[] = [
{ key: "stars", label: pickText(PROJECT_COPY.statStars, locale), href: REPO_URL, value: stats ? formatStars(stats.stars) : undefined },
{ key: "stars", label: pickText(PROJECT_COPY.statStars, locale), href: REPO_URL, value: stats?.stars != null ? formatStars(stats.stars) : undefined, asOf },
{ key: "contributors", label: pickText(PROJECT_COPY.statContributors, locale), href: `${REPO_URL}/blob/main/docs/CONTRIBUTORS.md`, value: String(contributors) },
{ key: "pulls", label: pickText(PROJECT_COPY.statPulls, locale), href: `${REPO_URL}/pulls`, value: stats ? String(stats.openPulls) : undefined },
{ key: "issues", label: pickText(PROJECT_COPY.statIssues, locale), href: `${REPO_URL}/issues`, value: stats ? String(stats.openIssues) : undefined },
{ key: "pulls", label: pickText(PROJECT_COPY.statPulls, locale), href: `${REPO_URL}/pulls`, value: stats?.openPulls != null ? String(stats.openPulls) : undefined, asOf },
{ key: "issues", label: pickText(PROJECT_COPY.statIssues, locale), href: `${REPO_URL}/issues`, value: stats?.openIssues != null ? String(stats.openIssues) : undefined, asOf },
];
return <LivePulseStats rows={rows} numberLocale={getChrome(locale).dateLocale} />;
}
@@ -80,13 +87,13 @@ export function FaceWall({ faces, total, locale }: { faces: Face[]; total: numbe
);
}
export function GitHubStar({ stars, locale }: { stars?: number; locale: string }) {
export function GitHubStar({ stars, locale, asOf }: { stars?: number; locale: string; asOf?: string }) {
const label = pickText(GITHUB_STAR_COPY, locale);
return (
<a href={REPO_URL} className="btn btn-secondary github-star" data-funnel="star_exit">
<span aria-hidden="true">☆</span>
<span>{label}</span>
<LiveStarCount initial={stars} numberLocale={getChrome(locale).dateLocale} />
<LiveStarCount initial={stars} numberLocale={getChrome(locale).dateLocale} asOf={asOf} />
</a>
);
}
+18 -18
View File
@@ -5,8 +5,8 @@
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "ff16c4034aa9724b3c9671cdf7c47e89eccd469d91200b92b136fa580f12da32",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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.\nprintf '%s\\n' '# >>> codewhale PATH >>>' 'export PATH=\"$HOME/.local/bin:$PATH\"' '# <<< codewhale 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 / builLine truncated
"sha256": "bd2b42a54c06c787bcd5cd702216bc9a71b7af0b387e51097444cab831d2f585",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\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](#4Line truncated
}
]
},
@@ -21,8 +21,8 @@
},
{
"path": "docs/INSTALL.md",
"sha256": "ff16c4034aa9724b3c9671cdf7c47e89eccd469d91200b92b136fa580f12da32",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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.\nprintf '%s\\n' '# >>> codewhale PATH >>>' 'export PATH=\"$HOME/.local/bin:$PATH\"' '# <<< codewhale 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 / builLine truncated
"sha256": "bd2b42a54c06c787bcd5cd702216bc9a71b7af0b387e51097444cab831d2f585",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\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](#4Line truncated
},
{
"path": "docs/KEYBINDINGS.md",
@@ -37,8 +37,8 @@
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "ff16c4034aa9724b3c9671cdf7c47e89eccd469d91200b92b136fa580f12da32",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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.\nprintf '%s\\n' '# >>> codewhale PATH >>>' 'export PATH=\"$HOME/.local/bin:$PATH\"' '# <<< codewhale 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 / builLine truncated
"sha256": "bd2b42a54c06c787bcd5cd702216bc9a71b7af0b387e51097444cab831d2f585",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\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](#4Line truncated
},
{
"path": "docs/PROVIDERS.md",
@@ -323,8 +323,8 @@
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "18cabae8aa269665302b6443087b385bcf1f1de89a2be317213a67a82bcad6de",
"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": "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
}
]
},
@@ -334,8 +334,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "0775b9d4a6d73ac49173a622cedb4a953fbf7c510fcbe042d41fb3e97fdf96d6",
"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": "93e0df60097e0e63b304984f4ca99d7944682b5719be99241e445ecac4216f67",
"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
}
]
},
@@ -356,8 +356,8 @@
"sources": [
{
"path": "docs/INSTALL.md",
"sha256": "ff16c4034aa9724b3c9671cdf7c47e89eccd469d91200b92b136fa580f12da32",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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.\nprintf '%s\\n' '# >>> codewhale PATH >>>' 'export PATH=\"$HOME/.local/bin:$PATH\"' '# <<< codewhale 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 / builLine truncated
"sha256": "bd2b42a54c06c787bcd5cd702216bc9a71b7af0b387e51097444cab831d2f585",
"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 **Accept Edits** posture it\napplies file edits inside the repository immediately and shows you the diff,\nand asks before each shell command, so commit or stash anything you care about\nfirst. **Ask** (`Shift+Tab` round the dial, or `/config`) asks before every\nedit as well. It works with many model\nproviders. **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.3 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. The installer adds ~/.local/bin to your shell profile (it prints what it added and\n# how to undo it; --no-modify-path skips that). Shells that are already open do not\n# reread the profile, so use it in this one now, then check it.\nexport PATH=\"$HOME/.local/bin:$PATH\"; hash -r\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](#4Line truncated
},
{
"path": "docs/OPERATIONS_RUNBOOK.md",
@@ -387,13 +387,13 @@
},
{
"path": "docs/CONTRIBUTORS.md",
"sha256": "a5ef8d75e4dd80deaae66b49b33dd12922db3273ca3e0247f70581c622fdb0bb",
"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) · [DingYong4223Line truncated
"sha256": "5ab3b08305a8484d6a55ee6439edce471dc7960e68be9c91eaeb5bfd1239a9d8",
"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) · [DingYong4223Line truncated
},
{
"path": "docs/RELEASE_CHECKLIST.md",
"sha256": "31de19e7ea014bb21f431322667619e5e5f88c4cdfb5907451c63a95b6c92f4e",
"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- [ ] Uncommitted work found in the checkout is yours (founder, 2026-10-22):\n read it, prove it, and commit it by explicit path. A file changed in the\n last 15 minutes or held by a live session is mid-edit: ask that writer\n to commit first. Check status before staging; never `git add -A`.\n 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/reLine truncated
"sha256": "9ae3c83392c47e170c6529025d1fbbe12765617e63d40a356e454c1f61e9f0be",
"content": "# Release Checklist\n\nFollow this sequence from the canonical `/Volumes/VIXinSSD/CW/codewhale`\ncheckout on `main`. Agent-owned preparation is built in a short-lived agent\nworktree and lands as verified slices on `main` (CURRENT_DECISIONS §93); it\nneeds no release branch, nested clone or preparation PR. Freezing, tagging and\npublication run from the clean canonical `main`. Contributor PRs still land as\nPRs. Treat any unchecked required gate as a release blocker.\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- [ ] Uncommitted work found in the checkout is yours (founder, 2026-10-22):\n read it, prove it, and commit it by explicit path. A file changed in the\n last 15 minutes or held by a live session is mid-edit: ask that writer\n to commit first. Check status before staging; never `git add -A`.\n 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-verLine truncated
}
]
},
@@ -403,8 +403,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "0775b9d4a6d73ac49173a622cedb4a953fbf7c510fcbe042d41fb3e97fdf96d6",
"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": "93e0df60097e0e63b304984f4ca99d7944682b5719be99241e445ecac4216f67",
"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",
+1
View File
@@ -0,0 +1 @@
null
+174 -1
View File
@@ -2,5 +2,178 @@
"tag": "v0.10.2",
"version": "0.10.2",
"publishedAt": "2026-10-24T17:31:14Z",
"url": "https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.2"
"url": "https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.2",
"readAt": "2026-11-11T00:13:00.295Z",
"assets": [
{
"name": "codew-android-arm64",
"size": 93244040,
"sha256": "f97a5a9592298648960ea88a688f4207eab4c8feefbf3522308b58ce24311905"
},
{
"name": "codew-linux-arm64",
"size": 89491616,
"sha256": "b7c55e36f7ef30c8fae4b8741bfaf9e0177220a9d4d6d3e26650609b4d9ffe90"
},
{
"name": "codew-linux-x64",
"size": 110250368,
"sha256": "28ac984a2f145bf99b29933498a3f951a591283c22782933ce933861dda6c70b"
},
{
"name": "codew-macos-arm64",
"size": 90300464,
"sha256": "da0229610647f1cc2dd14c4227b333a92ea276a49f5d4d79160fa19e515e590d"
},
{
"name": "codew-macos-x64",
"size": 101942008,
"sha256": "ac135d8961525b4092101a15874016d42f71ef821c9c34db3b8f936aad9cb988"
},
{
"name": "codew-windows-arm64.exe",
"size": 109250560,
"sha256": "cbb56b79e071089e98c09e3325e9e237b38b787dcba29c13fd06701d3528aa63"
},
{
"name": "codew-windows-x64.exe",
"size": 134734336,
"sha256": "578280bec8d6e0ee9cfe97ee29de817535689f4078d8afd47384ff1941b1ee6c"
},
{
"name": "codewhale-android-arm64",
"size": 93244040,
"sha256": "f97a5a9592298648960ea88a688f4207eab4c8feefbf3522308b58ce24311905"
},
{
"name": "codewhale-android-arm64.tar.gz",
"size": 85081129,
"sha256": "c7107d1dc6eb944e98775835677544c7d020e532c1b9232f913f7594e181f3a7"
},
{
"name": "codewhale-artifacts-sha256.txt",
"size": 2995,
"sha256": "efa8fb6b55e1691b2467b1888f26b95b4112cba97aa91a4d8bec1210ac046f1b"
},
{
"name": "codewhale-bundles-sha256.txt",
"size": 863,
"sha256": "f57ad13ae6d7d8f5df7b077f0fe9e69148f158cea1d7f7d64c3db9d8569fe167"
},
{
"name": "codewhale-linux-arm64",
"size": 89491616,
"sha256": "b7c55e36f7ef30c8fae4b8741bfaf9e0177220a9d4d6d3e26650609b4d9ffe90"
},
{
"name": "codewhale-linux-arm64.tar.gz",
"size": 84879881,
"sha256": "d737fc57e2e0abb106c5a84fb33733316541857601a6237449c207bb06993387"
},
{
"name": "codewhale-linux-x64",
"size": 110250368,
"sha256": "28ac984a2f145bf99b29933498a3f951a591283c22782933ce933861dda6c70b"
},
{
"name": "codewhale-linux-x64.tar.gz",
"size": 92854522,
"sha256": "ed666471d845549c922bb808c01e7d5553c3e97972d77ca411398002510f70d9"
},
{
"name": "codewhale-macos-arm64",
"size": 90300464,
"sha256": "da0229610647f1cc2dd14c4227b333a92ea276a49f5d4d79160fa19e515e590d"
},
{
"name": "codewhale-macos-arm64.tar.gz",
"size": 83416151,
"sha256": "0462cc6332ea45cf8afdcc238b5b8493011efe06ca64a6738fa38d223690aa15"
},
{
"name": "codewhale-macos-x64",
"size": 101942008,
"sha256": "ac135d8961525b4092101a15874016d42f71ef821c9c34db3b8f936aad9cb988"
},
{
"name": "codewhale-macos-x64.tar.gz",
"size": 89796859,
"sha256": "a3cefe3d0cc8880cb6284443e1045ba7642fd30d390ba1314783dde69a7eb61d"
},
{
"name": "codewhale-tui-android-arm64",
"size": 93244040,
"sha256": "f97a5a9592298648960ea88a688f4207eab4c8feefbf3522308b58ce24311905"
},
{
"name": "codewhale-tui-linux-arm64",
"size": 89491616,
"sha256": "b7c55e36f7ef30c8fae4b8741bfaf9e0177220a9d4d6d3e26650609b4d9ffe90"
},
{
"name": "codewhale-tui-linux-x64",
"size": 110250368,
"sha256": "28ac984a2f145bf99b29933498a3f951a591283c22782933ce933861dda6c70b"
},
{
"name": "codewhale-tui-macos-arm64",
"size": 90300464,
"sha256": "da0229610647f1cc2dd14c4227b333a92ea276a49f5d4d79160fa19e515e590d"
},
{
"name": "codewhale-tui-macos-x64",
"size": 101942008,
"sha256": "ac135d8961525b4092101a15874016d42f71ef821c9c34db3b8f936aad9cb988"
},
{
"name": "codewhale-tui-windows-arm64.exe",
"size": 109250560,
"sha256": "cbb56b79e071089e98c09e3325e9e237b38b787dcba29c13fd06701d3528aa63"
},
{
"name": "codewhale-tui-windows-x64.exe",
"size": 134734336,
"sha256": "578280bec8d6e0ee9cfe97ee29de817535689f4078d8afd47384ff1941b1ee6c"
},
{
"name": "codewhale-windows-arm64-portable.zip",
"size": 90465462,
"sha256": "aa0bdd12c796668ee8923ff0afbd8b56104c06ea42b0eb703d24171665a2b23e"
},
{
"name": "codewhale-windows-arm64.exe",
"size": 109250560,
"sha256": "cbb56b79e071089e98c09e3325e9e237b38b787dcba29c13fd06701d3528aa63"
},
{
"name": "codewhale-windows-arm64.zip",
"size": 90467997,
"sha256": "5c771d7b8fa9ed417a188f09c92e76a230714fc4d6f282beaec97b886b62c292"
},
{
"name": "codewhale-windows-x64-portable.zip",
"size": 96696202,
"sha256": "5c2e07b8887d9aa4f344ccd47677ba215fd9f8a67c4ec347096fc7e1cd6c7410"
},
{
"name": "codewhale-windows-x64.exe",
"size": 134734336,
"sha256": "578280bec8d6e0ee9cfe97ee29de817535689f4078d8afd47384ff1941b1ee6c"
},
{
"name": "codewhale-windows-x64.zip",
"size": 96698729,
"sha256": "21b27337a24d2f555ca3b4a1c5c47f5808a4939a363be14076655c4521ab272c"
},
{
"name": "codewhale.bat",
"size": 196,
"sha256": "1c330fc3ca5530438d6d787951faf00893af4e5fa4b5326f0c167ee974790d16"
},
{
"name": "CodeWhaleSetup.exe",
"size": 48074206,
"sha256": "b94d4a9f9d464ef8640b37dba664d7b574da6161b5d1ecf2fa6b551f24e9930a"
}
]
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
{
"stars": 41173,
"forks": 3570,
"openIssues": 67,
"openPulls": 5,
"readAt": "2026-11-11T00:12:45.683Z"
}
+17 -2
View File
@@ -300,7 +300,22 @@
"footerLegal": "Legal",
"switcherLabel": "Language",
"switcherSwitchTo": "Switch to {label}",
"partialBadge": "(partial)"
"partialBadge": "(partial)",
"stateReleased": "Released",
"stateAvailable": "Available",
"stateBeta": "Beta",
"stateDev": "Development build",
"stateOpen": "Open",
"stateInviteOnly": "Invite-only",
"stateNotOpen": "Not open yet",
"stateNotPublic": "Not public",
"stateInSource": "In source, not yet released",
"numbersAsOf": "As of {date}",
"downloadMacos": "Download for macOS",
"downloadVersion": "Version {version}",
"downloadSize": "Size {size}",
"downloadChecksum": "SHA-256 checksum",
"downloadSigned": "Signed and notarized"
},
"home": {
"metaTitle": "Codewhale · Use any model through an agent you can read",
@@ -1989,7 +2004,7 @@
"title": "Codewhale accounts",
"blocks": [
{
"p": "A provider key and a Codewhale account are different things. Codewhale never needs an account, and new accounts are not open. Provider keys stay on your machine with `codewhale auth set`."
"p": "A provider key and a Codewhale account are different things. Local use never needs an account; anyone can sign in, and cloud computers are invite-only. Provider keys stay on your machine with `codewhale auth set`."
},
{
"rows": [
+17 -2
View File
@@ -300,7 +300,22 @@
"footerLegal": "法律",
"switcherLabel": "语言",
"switcherSwitchTo": "切换到 {label}",
"partialBadge": "(部分)"
"partialBadge": "(部分)",
"stateReleased": "已发布",
"stateAvailable": "可用",
"stateBeta": "测试版",
"stateDev": "开发版本",
"stateOpen": "已开放",
"stateInviteOnly": "仅限受邀",
"stateNotOpen": "暂未开放",
"stateNotPublic": "未公开",
"stateInSource": "已在源码中,尚未发布",
"numbersAsOf": "截至 {date}",
"downloadMacos": "下载 macOS 版",
"downloadVersion": "版本 {version}",
"downloadSize": "大小 {size}",
"downloadChecksum": "SHA-256 校验和",
"downloadSigned": "已签名并经过公证"
},
"home": {
"metaTitle": "Codewhale · 通过一个你读得懂的智能体使用任意模型",
@@ -1989,7 +2004,7 @@
"title": "Codewhale 账户",
"blocks": [
{
"p": "提供商密钥和 Codewhale 账户是两回事。Codewhale 从不需要账户,新账户也暂未开放。提供商密钥通过 `codewhale auth set` 保存在你的机器上。"
"p": "提供商密钥和 Codewhale 账户是两回事。本地使用从不需要账户;任何人都可以登录,云端计算机仅限受邀使用。提供商密钥通过 `codewhale auth set` 保存在你的机器上。"
},
{
"rows": [
+2 -4
View File
@@ -1,7 +1,7 @@
import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchBoundedText } from "./bounded-body";
import { runDigest, runDupes, runPrReview, runStale, runTriage } from "./community-agent-tasks";
import { runDigest } from "./community-agent-tasks";
import { runLinkCheck } from "./content-watch";
import { deriveFactsFromRemote } from "./facts-drift";
@@ -34,9 +34,7 @@ describe("bounded outbound reads", () => {
await deriveFactsFromRemote();
await runLinkCheck({ CURATED_KV: { get: async () => null, put: async () => undefined } } as never);
const kv = { get: async () => null, put: async () => undefined, delete: async () => undefined, list: async () => ({ keys: [], list_complete: true }) };
for (const task of [runTriage, runPrReview, runStale, runDupes, runDigest]) {
await task({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" } as never).catch(() => undefined);
}
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" } as never).catch(() => undefined);
expect(seen.mock.calls.length).toBeGreaterThan(1);
const unbounded = seen.mock.calls
.filter((call) => !((call as unknown[])[1] as RequestInit | undefined)?.signal)
+286
View File
@@ -0,0 +1,286 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
RELEASE_BASELINE,
baselineFor,
capabilityAvailability,
capabilityViews,
claimAvailability,
claimedItems,
isFounderHeld,
parseReleaseBaseline,
releaseView,
releasedFeatureNames,
runtimeCapabilityContext,
type CapabilityContext,
type ReleaseBaseline,
} from "./capabilities";
import { FACTS } from "./facts.generated";
import { FEATURE_CATALOGUE, type Feature } from "./features";
import { FOUNDER_SWITCHES, FOUNDER_SWITCH_VALUES } from "./founder-switches";
import { PRODUCT_COPY } from "./content/product";
import { stateLabel } from "./surface-states";
import ModelsPage from "../app/[locale]/models/page";
import ProductPage from "../app/[locale]/product/page";
import HomePage from "../app/[locale]/page";
import { buildLlmsTxt } from "./llms-txt";
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn(), usePathname: () => "/en", useRouter: () => ({}) }));
const WEB = new URL("..", import.meta.url).pathname;
const row = (id: string, since: string | null, extra: Partial<Feature> = {}): Feature => ({
id, name: `Name ${id}`, summary: `Summary ${id}`, status: "stable", surfaces: { tui: "stable" }, since, flag: null, docs: null, registryLine: 1, ...extra,
});
const baseline = (tag: string, ids: string[], summaries: Record<string, string> = {}): ReleaseBaseline => ({
format: "codewhale.release-baseline.v1",
tag,
version: tag.slice(1),
features: ids.map((id) => ({ id, name: `Tagged ${id}`, summary: summaries[id] ?? `Summary ${id}`, status: "stable", since: null })),
providers: [{ id: "alpha", label: "Alpha", env: "ALPHA_KEY" }],
models: [{ id: "alpha-1", provider: "Alpha", contextWindow: 1000, maxOutput: 100, reasoning: false, addedAt: "2026-01-01" }],
toolCount: 7,
});
const at = (tag: string, ids: string[] = [], summaries: Record<string, string> = {}): CapabilityContext => ({
release: { tag, version: tag.slice(1) },
baseline: baseline("v1.2.0", ids, summaries),
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("a registry row is available only at or below the published release", () => {
const fixture = row("fixture-capability", "1.3.0");
it("never renders as available while its since version is above the published release", () => {
expect(capabilityAvailability(fixture, at("v1.2.0"))).toBe("in_source");
expect(capabilityViews(at("v1.2.0"), [fixture]).map((view) => view.availability)).toEqual(["in_source"]);
expect(claimedItems([{ capability: { feature: fixture.id } }], at("v1.2.0"), [fixture])[0].availability).toBe("in_source");
});
it("renders as available at or below it", () => {
expect(capabilityAvailability(row("older", "1.1.0"), at("v1.2.0"))).toBe("released");
expect(capabilityAvailability(row("same", "1.2.0"), at("v1.2.0"))).toBe("released");
});
it("turns on when only the published release is raised", () => {
const before = capabilityAvailability(fixture, at("v1.2.9"));
const after = capabilityAvailability(fixture, at("v1.3.0"));
expect([before, after]).toEqual(["in_source", "released"]);
});
it("treats unreleased as source only and an unknown release or malformed version as hidden", () => {
expect(capabilityAvailability(row("pending", "unreleased"), at("v9.9.9"))).toBe("in_source");
expect(capabilityAvailability(fixture, { release: null, baseline: null })).toBe("hidden");
expect(capabilityAvailability(row("odd", "soon"), at("v9.9.9"))).toBe("hidden");
});
it("uses the registry at the published tag for rows with no recorded since", () => {
const unrecorded = row("unrecorded", null);
expect(capabilityAvailability(unrecorded, at("v1.2.0", ["unrecorded"]))).toBe("released");
expect(capabilityAvailability(unrecorded, at("v1.2.0", ["other"]))).toBe("in_source");
expect(capabilityAvailability(unrecorded, at("v1.3.0", ["unrecorded"]))).toBe("hidden");
expect(capabilityAvailability(unrecorded, { release: { tag: "v1.2.0", version: "1.2.0" }, baseline: null })).toBe("hidden");
});
it("shows the wording the published release had, not the newer source wording", () => {
const changed = row("changed", null, { summary: "Newer wording" });
const context = at("v1.2.0", ["changed"], { changed: "Wording at the tag" });
const [view] = capabilityViews(context, [changed]);
expect(view.summary).toBe("Wording at the tag");
expect(view.name).toBe("Tagged changed");
});
it("holds back a claim that describes the current row when the row changed since the release", () => {
const changed = row("changed", null, { summary: "Newer wording" });
const claim = { feature: "changed", describesCurrentRow: true };
expect(claimAvailability(claim, at("v1.2.0", ["changed"], { changed: "Older wording" }), [changed])).toBe("in_source");
expect(claimAvailability(claim, at("v1.2.0", ["changed"], { changed: "Newer wording" }), [changed])).toBe("released");
expect(claimAvailability({ feature: "changed" }, at("v1.2.0", ["changed"], { changed: "Older wording" }), [changed])).toBe("released");
expect(claimAvailability({ feature: "missing-row" }, at("v1.2.0"), [changed])).toBe("hidden");
});
});
describe("founder holds stay hidden after a tag", () => {
const far = at("v9.9.9");
it("hides cloud computer rows and vendor agents over ACP, whatever the registry says", () => {
const cloud = row("cloud-computer-dispatch", "0.1.0");
const acp = row("acp-agents", "0.1.0", { summary: "Drive Claude Code and Codex as agents over ACP." });
const acpReverse = row("acp-other", "0.1.0", { summary: "ACP lets Codewhale drive other agents such as Codex." });
for (const feature of [cloud, acp, acpReverse]) {
expect(isFounderHeld(feature), feature.id).toBe(true);
expect(capabilityAvailability(feature, far), feature.id).toBe("hidden");
expect(capabilityViews(far, [feature])).toEqual([]);
}
expect(capabilityAvailability(row("acp-client", "0.1.0", { summary: "ACP stdio connects compatible editors." }), far)).toBe("released");
});
it("hides rows about Codewhale plans or pricing until plans are published", () => {
const plans = row("plan-row", "0.1.0", { summary: "Codewhale plans include a monthly allowance." });
expect(capabilityAvailability(plans, far)).toBe("hidden");
expect(capabilityAvailability(plans, { ...far, switches: { ...FOUNDER_SWITCH_VALUES, plansPublished: true } })).toBe("released");
expect(capabilityAvailability(row("plan-mode", "0.1.0", { summary: "Plan and Work modes." }), far)).toBe("released");
});
it("opens only when the committed switch is turned on", () => {
const acp = row("acp-agents", "0.1.0", { summary: "Drive Claude Code over ACP." });
expect(capabilityAvailability(acp, { ...far, switches: { ...FOUNDER_SWITCH_VALUES, namedVendorAgentsOverAcp: true } })).toBe("released");
});
it("are committed off with a decision reference and not read from the environment", () => {
for (const key of ["cloudComputersBeyondInvite", "namedVendorAgentsOverAcp", "plansPublished"] as const) {
expect(FOUNDER_SWITCHES[key].value, key).toBe(false);
expect(FOUNDER_SWITCHES[key].decision, key).toMatch(/§\d/);
}
});
it("hold every real registry row about cloud computers", () => {
const context = runtimeCapabilityContext(FACTS);
const listed = new Set(capabilityViews(context).map((view) => view.feature.id));
expect(listed.has("cloud-computer-dispatch")).toBe(false);
expect(listed.has("account-computer-files")).toBe(false);
});
});
describe("the committed registry and baseline", () => {
const context = runtimeCapabilityContext(FACTS);
it("has a baseline for exactly the published release", () => {
expect(RELEASE_BASELINE).not.toBeNull();
expect(baselineFor(context)?.tag).toBe(FACTS.latestPublishedRelease?.tag);
});
it("never lists a row newer than the published release as available", () => {
const published = context.release!.version;
const tagged = new Set(RELEASE_BASELINE!.features.map((feature) => feature.id));
for (const view of capabilityViews(context, FEATURE_CATALOGUE.features)) {
if (view.availability !== "released") continue;
const { since, id } = view.feature;
expect(since === "unreleased", id).toBe(false);
if (since) {
const [a, b, c] = since.split(".").map(Number);
const [x, y, z] = published.split(".").map(Number);
expect(a * 1e6 + b * 1e3 + c, id).toBeLessThanOrEqual(x * 1e6 + y * 1e3 + z);
} else {
expect(tagged.has(id), id).toBe(true);
}
}
});
it("keeps main-only rows out of the structured-data feature list", () => {
const names = releasedFeatureNames(context.release!.version);
for (const view of capabilityViews(context)) {
if (view.availability === "in_source") expect(names).not.toContain(view.name);
}
});
it("rejects a baseline that is malformed or names another format", () => {
expect(parseReleaseBaseline(null)).toBeNull();
expect(parseReleaseBaseline({ ...RELEASE_BASELINE, format: "other" })).toBeNull();
expect(parseReleaseBaseline({ ...RELEASE_BASELINE, tag: "v0.0.1" })).toBeNull();
expect(parseReleaseBaseline({ ...RELEASE_BASELINE, models: [] })).toBeNull();
});
it("every capability key in the product copy resolves to a registry row", () => {
const ids = new Set(FEATURE_CATALOGUE.features.map((feature) => feature.id));
const copy = [...PRODUCT_COPY.workPatterns, ...PRODUCT_COPY.modes, ...PRODUCT_COPY.permissions];
for (const item of copy) expect(ids.has(item.capability!.feature), item.capability!.feature).toBe(true);
});
});
describe("counts are the counts at the published release tag", () => {
const view = releaseView(FACTS);
it("come from the tagged source and name what they count", () => {
expect(view.counts).toEqual({
providerRoutes: RELEASE_BASELINE!.providers.length,
models: RELEASE_BASELINE!.models.length,
tools: RELEASE_BASELINE!.toolCount,
});
expect(view.providers).toHaveLength(view.counts!.providerRoutes);
});
it("are unknown, not guessed, when the baseline is for a different release", () => {
const other = { latestPublishedRelease: { ...FACTS.latestPublishedRelease!, tag: "v9.9.9", version: "9.9.9" } };
expect(releaseView(other)).toEqual({ providers: [], models: [], counts: null });
expect(releaseView({ latestPublishedRelease: null })).toEqual({ providers: [], models: [], counts: null });
});
it("leave main-only models out of the models page and the home page", async () => {
const tagged = new Set(RELEASE_BASELINE!.models.map((model) => model.id));
const mainOnly = FACTS.models.filter((model) => !tagged.has(model.id));
expect(mainOnly.length).toBeGreaterThan(0);
const props = { params: Promise.resolve({ locale: "en" }), searchParams: Promise.resolve({}) } as never;
const models = renderToStaticMarkup((await ModelsPage(props)) as never);
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); }));
vi.spyOn(console, "error").mockImplementation(() => {});
const home = renderToStaticMarkup((await HomePage(props)) as never);
for (const model of mainOnly) {
expect(models, model.id).not.toContain(`>${model.id}<`);
expect(home, model.id).not.toContain(`>${model.id}<`);
}
expect(models).toContain(`Show all ${view.counts!.providerRoutes} provider routes`);
expect(models).not.toMatch(/Show all \d+ providers/);
expect(home).toContain(`${view.counts!.providerRoutes} provider routes and ${view.counts!.models} catalog models`);
expect(home).not.toMatch(/\d+ providers and/);
});
it("print provider routes in llms.txt at the tag", () => {
const text = buildLlmsTxt();
expect(text).toContain(`Built-in provider routes in ${FACTS.latestPublishedRelease!.tag} (${view.counts!.providerRoutes})`);
});
it("omit every counted sentence when the count is unknown", async () => {
const other = { ...FACTS, latestPublishedRelease: { ...FACTS.latestPublishedRelease!, tag: "v9.9.9", version: "9.9.9" } };
expect(releaseView(other).counts).toBeNull();
});
});
describe("the product page labels a source-only permission", () => {
it("shows the in-source label beside a claim the published release does not describe", async () => {
const context = runtimeCapabilityContext(FACTS);
const accept = PRODUCT_COPY.permissions.find((item) => item.title.en === "Accept Edits")!;
const availability = claimAvailability(accept.capability!, context);
const props = { params: Promise.resolve({ locale: "en" }) } as never;
const html = renderToStaticMarkup((await ProductPage(props)) as never);
if (availability === "in_source") {
expect(html).toContain(stateLabel("in_source", "en"));
expect(html).toContain("Accept Edits");
} else {
expect(html).not.toContain(stateLabel("in_source", "en"));
}
});
});
describe("no literal count in copy", () => {
const COUNT_WORDS = "(?:\\d[\\d,]*\\+?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred)";
const NOUNS = "(?:built-in )?(?:provider routes|providers|models|tools|native tools|locales|languages|routes)";
const PATTERN = new RegExp(`\\b${COUNT_WORDS} ${NOUNS}\\b`, "i");
const ZH = /\d+\s*(?:个|条|种)\s*(?:提供商|模型|工具|语言|路由)/;
const ALLOWED: Record<string, string> = {
"lib/content/tools.ts": "fourteen native tools",
"lib/i18n/dictionaries/en/docs-mcp.ts": "listed those two tools",
};
function sources(dir: string): string[] {
return readdirSync(join(WEB, dir)).flatMap((name) => {
const path = join(dir, name);
if (statSync(join(WEB, path)).isDirectory()) return sources(path);
return /\.(ts|tsx)$/.test(name) && !/\.(test|generated)\./.test(name) ? [path] : [];
});
}
it.each([...sources("lib/content"), ...sources("lib/i18n/dictionaries"), ...sources("app"), ...sources("components")])("%s", (path) => {
const text = readFileSync(join(WEB, path), "utf8");
const hit = text.split("\n").find((line) => !/^\s*(?:\*|\/\/|\/\*)/.test(line)
&& !(ALLOWED[path] && line.includes(ALLOWED[path])) && (PATTERN.test(line) || ZH.test(line)));
expect(hit, hit).toBeUndefined();
});
});
+175
View File
@@ -0,0 +1,175 @@
import baselineJson from "@/data/release-baseline.json";
import { FEATURE_CATALOGUE, compareVersions, type Feature } from "./features";
import { FOUNDER_SWITCH_VALUES, type FounderSwitchValues } from "./founder-switches";
import type { ModelFact, ProviderFact, RepoFacts } from "./facts";
export interface BaselineFeature {
id: string;
name: string;
summary: string;
status: string;
since: string | null;
}
export interface ReleaseBaseline {
format: string;
tag: string;
version: string;
features: BaselineFeature[];
providers: ProviderFact[];
models: ModelFact[];
toolCount: number | null;
}
export type CapabilityAvailability = "released" | "in_source" | "hidden";
export interface CapabilityContext {
release: { tag: string; version: string } | null;
baseline: ReleaseBaseline | null;
switches?: FounderSwitchValues;
}
export interface ReleaseCounts {
providerRoutes: number;
models: number;
tools: number | null;
}
export interface ReleaseView {
providers: ProviderFact[];
models: ModelFact[];
counts: ReleaseCounts | null;
}
const BASELINE_FORMAT = "codewhale.release-baseline.v1";
const isString = (value: unknown): value is string => typeof value === "string" && value.length > 0;
export function parseReleaseBaseline(value: unknown): ReleaseBaseline | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
if (raw.format !== BASELINE_FORMAT || !isString(raw.tag) || !isString(raw.version) || raw.tag !== `v${raw.version}`
|| !Array.isArray(raw.features) || !Array.isArray(raw.providers) || !Array.isArray(raw.models)
|| !(raw.toolCount === null || (Number.isSafeInteger(raw.toolCount) && (raw.toolCount as number) > 0))) return null;
const features = raw.features.every((row) => row && isString(row.id) && isString(row.name) && isString(row.summary) && isString(row.status)
&& (row.since === null || typeof row.since === "string"));
const providers = raw.providers.every((row) => row && isString(row.id) && isString(row.label) && typeof row.env === "string");
const models = raw.models.every((row) => row && isString(row.id));
if (!features || !providers || !models || raw.providers.length === 0 || raw.models.length === 0) return null;
return raw as unknown as ReleaseBaseline;
}
export const RELEASE_BASELINE: ReleaseBaseline | null = parseReleaseBaseline(baselineJson);
const VENDOR_AGENT_ACP = /(?:\b(?:Claude Code|Codex|Antigravity|Gemini CLI)\b[^.]*\bACP\b|\bACP\b[^.]*\b(?:Claude Code|Codex|Antigravity|Gemini CLI)\b)/i;
const CODEWHALE_PLANS = /\bcode ?whale (?:plans?|tiers?|pricing|subscriptions?|allowances?)\b/i;
const HELD_BY_SWITCH: Record<string, keyof FounderSwitchValues> = {
"cloud-computer-dispatch": "cloudComputersBeyondInvite",
"account-computer-files": "cloudComputersBeyondInvite",
};
export function isFounderHeld(feature: Pick<Feature, "id" | "name" | "summary">, switches: FounderSwitchValues = FOUNDER_SWITCH_VALUES): boolean {
const key = HELD_BY_SWITCH[feature.id];
if (key) return !switches[key];
if (VENDOR_AGENT_ACP.test(`${feature.name}. ${feature.summary}`)) return !switches.namedVendorAgentsOverAcp;
if (CODEWHALE_PLANS.test(`${feature.name}. ${feature.summary}`)) return !switches.plansPublished;
return false;
}
export function baselineFor(context: CapabilityContext): ReleaseBaseline | null {
const { baseline, release } = context;
return baseline && release && baseline.tag === release.tag ? baseline : null;
}
export function capabilityAvailability(
feature: Pick<Feature, "id" | "name" | "summary" | "since">,
context: CapabilityContext,
): CapabilityAvailability {
if (isFounderHeld(feature, context.switches)) return "hidden";
const { release } = context;
if (!release) return "hidden";
const { since } = feature;
if (since === "unreleased") return "in_source";
if (since) {
const comparison = compareVersions(since, release.version);
if (comparison === null) return "hidden";
return comparison <= 0 ? "released" : "in_source";
}
const baseline = baselineFor(context);
if (!baseline) return "hidden";
return baseline.features.some((row) => row.id === feature.id) ? "released" : "in_source";
}
export interface CapabilityView {
feature: Feature;
availability: Exclude<CapabilityAvailability, "hidden">;
name: string;
summary: string;
}
export function capabilityView(feature: Feature, context: CapabilityContext): CapabilityView | null {
const availability = capabilityAvailability(feature, context);
if (availability === "hidden") return null;
const tagged = availability === "released" ? baselineFor(context)?.features.find((row) => row.id === feature.id) : undefined;
return { feature, availability, name: tagged?.name ?? feature.name, summary: tagged?.summary ?? feature.summary };
}
export function capabilityViews(context: CapabilityContext, features: readonly Feature[] = FEATURE_CATALOGUE.features): CapabilityView[] {
return features.flatMap((feature) => {
const view = capabilityView(feature, context);
return view ? [view] : [];
});
}
export interface CapabilityClaim {
feature: string;
describesCurrentRow?: boolean;
}
export function claimAvailability(
claim: CapabilityClaim,
context: CapabilityContext,
features: readonly Feature[] = FEATURE_CATALOGUE.features,
): CapabilityAvailability {
const feature = features.find((row) => row.id === claim.feature);
if (!feature) return "hidden";
const availability = capabilityAvailability(feature, context);
if (availability !== "released" || !claim.describesCurrentRow) return availability;
const tagged = baselineFor(context)?.features.find((row) => row.id === feature.id);
return tagged && tagged.summary === feature.summary ? "released" : "in_source";
}
export function runtimeCapabilityContext(facts: Pick<RepoFacts, "latestPublishedRelease">): CapabilityContext {
const release = facts.latestPublishedRelease;
return { release: release ? { tag: release.tag, version: release.version } : null, baseline: RELEASE_BASELINE };
}
export function releaseView(facts: Pick<RepoFacts, "latestPublishedRelease">, baseline: ReleaseBaseline | null = RELEASE_BASELINE): ReleaseView {
const matched = baselineFor({ release: facts.latestPublishedRelease, baseline });
if (!matched) return { providers: [], models: [], counts: null };
return {
providers: matched.providers,
models: matched.models,
counts: { providerRoutes: matched.providers.length, models: matched.models.length, tools: matched.toolCount },
};
}
export function releasedFeatureNames(version: string): string[] {
const context: CapabilityContext = { release: { tag: `v${version}`, version }, baseline: RELEASE_BASELINE };
return capabilityViews(context)
.filter((view) => view.availability === "released" && view.feature.status === "stable" && view.feature.since)
.map((view) => view.name);
}
export function claimedItems<T extends { capability?: CapabilityClaim }>(
items: readonly T[],
context: CapabilityContext,
features: readonly Feature[] = FEATURE_CATALOGUE.features,
): { item: T; availability: "released" | "in_source" }[] {
return items.flatMap((item) => {
const availability = item.capability ? claimAvailability(item.capability, context, features) : "released";
return availability === "hidden" ? [] : [{ item, availability }];
});
}
+67 -154
View File
@@ -31,7 +31,7 @@ import {
saveDraft,
type AgentDraft,
} from "./community-agent";
import { runDigest, runDupes, runTriage } from "./community-agent-tasks";
import { runDigest } from "./community-agent-tasks";
import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
/** In-memory KV that pages like Cloudflare KV (max 1000 keys per list call). */
@@ -86,7 +86,7 @@ class FakeKv {
function draft(overrides: Partial<AgentDraft> = {}): AgentDraft {
return {
id: "42",
type: "triage",
type: "linkcheck",
targetNumber: 42,
bodyEn: "English body",
bodyZh: "中文正文",
@@ -405,99 +405,14 @@ describe("weekly digest publication requires maintainer approval", () => {
});
});
describe("resolved drafts are not resurrected by the cron", () => {
function stubIssues(updatedAt: string) {
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
const url = inputUrl(input);
if (!url.includes("/issues?")) throw new Error(`unexpected URL: ${url}`);
return jsonResponse([{
number: 42,
title: "Issue",
body: "body",
updated_at: updatedAt,
html_url: "https://github.com/codewhale-hq/CodeWhale/issues/42",
labels: [],
}]);
}));
}
beforeEach(() => {
mocks.agentChat.mockResolvedValue({
content: JSON.stringify({ bodyEn: "review", bodyZh: "审阅" }),
usage: { input: 1, output: 1 },
});
});
it("does not redraft a discarded triage item", async () => {
const kv = new FakeKv();
await saveDraft(kv.draftStores, draft());
stubAdminEnv(kv);
const res = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
expect(res.status).toBe(200);
stubIssues("2020-01-01T00:00:00.000Z");
await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 0, skipped: 1 });
expect(mocks.agentChat).not.toHaveBeenCalled();
expect(kv.values.has("draft:triage:42")).toBe(false);
});
it("does not turn a posted triage draft back into a pending one after the issue updates", async () => {
const kv = new FakeKv();
await saveDraft(kv.draftStores, draft());
stubAdminEnv(kv);
stubGitHub();
const res = await act(kv, { action: "post", draftKey: "draft:triage:42", lang: "en" });
expect(res.status).toBe(200);
// Our own comment bumps updated_at past the draft's generatedAt.
stubIssues(new Date().toISOString());
await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
expect(mocks.agentChat).not.toHaveBeenCalled();
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: true });
});
it("drafts again when the item has new activity well after the maintainer's decision", async () => {
const kv = new FakeKv();
await saveDraft(kv.draftStores, draft());
stubAdminEnv(kv);
stubGitHub();
expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
// New commits or a reply a day later.
stubIssues(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 1 });
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: false, bodyEn: "review" });
// The fresh draft can be posted.
const posts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
expect(posts).toHaveLength(1);
});
it("does not let the dupes run overwrite a posted dupes draft", async () => {
const kv = new FakeKv();
kv.values.set("draft:dupes:7", JSON.stringify(draft({ id: "7", type: "dupes", targetNumber: 7, posted: true })));
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(
[1, 2, 7].map((n) => ({ number: n, title: `t${n}`, updated_at: "2020-01-01T00:00:00.000Z", html_url: `u${n}` }))
)));
mocks.agentChat.mockResolvedValue({
content: JSON.stringify({ suggestions: [{ targetNumber: 1, duplicateNumber: 7, reason: "r", bodyEn: "dup", bodyZh: "重复" }] }),
usage: { input: 1, output: 1 },
});
await expect(runDupes({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true, processed: 0 });
expect(JSON.parse(kv.values.get("draft:dupes:7")!)).toMatchObject({ posted: true });
});
});
describe("admin post action is idempotent and bounded", () => {
it("refuses to post a draft that is already posted", async () => {
const kv = new FakeKv();
kv.values.set("draft:triage:42", JSON.stringify(draft({ posted: true })));
kv.values.set("draft:linkcheck:42", JSON.stringify(draft({ posted: true })));
stubAdminEnv(kv);
const posts = stubGitHub();
const res = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const res = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(res.status).toBe(409);
expect(posts).toEqual([]);
});
@@ -507,13 +422,13 @@ describe("admin post action is idempotent and bounded", () => {
await saveDraft(kv.draftStores, draft());
stubAdminEnv(kv);
const posts = stubGitHub();
kv.failPutsMatching = /^draft:triage:42$/;
kv.failPutsMatching = /^draft:linkcheck:42$/;
const first = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const first = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(first.status).toBe(200);
await expect(first.json()).resolves.toMatchObject({ ok: true, action: "posted", warning: expect.any(String) });
const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const retry = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(retry.status).toBe(409);
expect(posts).toHaveLength(1);
});
@@ -524,11 +439,11 @@ describe("admin post action is idempotent and bounded", () => {
stubAdminEnv(kv);
stubGitHub(422);
const failed = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const failed = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(failed.status).toBe(502);
const posts = stubGitHub();
const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const retry = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(retry.status).toBe(200);
expect(posts).toHaveLength(1);
});
@@ -541,12 +456,12 @@ describe("admin post action is idempotent and bounded", () => {
stubAdminEnv(kv);
const posts = stubGitHub(status);
const failed = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const failed = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(failed.status).toBe(502);
await expect(failed.json()).resolves.toMatchObject({ error: expect.stringContaining("check GitHub before retrying") });
stubGitHub();
const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const retry = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(retry.status).toBe(409);
expect(posts).toHaveLength(1);
}
@@ -560,8 +475,8 @@ describe("admin post action is idempotent and bounded", () => {
const posts = stubGitHub();
const results = await Promise.all([
act(kv, { action: "post", draftKey: "draft:triage:42" }),
act(kv, { action: "post", draftKey: "draft:triage:42" }),
act(kv, { action: "post", draftKey: "draft:linkcheck:42" }),
act(kv, { action: "post", draftKey: "draft:linkcheck:42" }),
]);
const statuses = results.map((r) => r.status);
// This fake KV makes every write visible at once, so the claim is exact
@@ -572,10 +487,10 @@ describe("admin post action is idempotent and bounded", () => {
// Refused claims are released, so the maintainer's retry posts exactly once.
if (posts.length === 0) {
expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(200);
expect((await act(kv, { action: "post", draftKey: "draft:linkcheck:42" })).status).toBe(200);
}
expect(posts).toHaveLength(1);
expect((await act(kv, { action: "post", draftKey: "draft:triage:42" })).status).toBe(409);
expect((await act(kv, { action: "post", draftKey: "draft:linkcheck:42" })).status).toBe(409);
expect(posts).toHaveLength(1);
});
@@ -630,8 +545,8 @@ describe("admin post action is idempotent and bounded", () => {
const posts = raceAfterAbsenceChecks(kv);
const results = await Promise.all([
act(kv, { action: "post", draftKey: "draft:triage:42" }),
act(kv, { action: "post", draftKey: "draft:triage:42" }),
act(kv, { action: "post", draftKey: "draft:linkcheck:42" }),
act(kv, { action: "post", draftKey: "draft:linkcheck:42" }),
]);
expect(results.map((r) => r.status).sort()).toEqual([200, 409]);
@@ -646,15 +561,15 @@ describe("admin post action is idempotent and bounded", () => {
const posts = raceAfterAbsenceChecks(kv);
const [post, discard] = await Promise.all([
act(kv, { action: "post", draftKey: "draft:triage:42" }),
act(kv, { action: "discard", draftKey: "draft:triage:42" }),
act(kv, { action: "post", draftKey: "draft:linkcheck:42" }),
act(kv, { action: "discard", draftKey: "draft:linkcheck:42" }),
]);
expect(post.status).toBe(200);
expect(discard.status).toBe(409);
expect(posts).toHaveLength(1);
expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "posted" });
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: true });
expect(JSON.parse(kv.values.get("draft-resolved:linkcheck:42")!)).toMatchObject({ state: "posted" });
expect(JSON.parse(kv.values.get("draft:linkcheck:42")!)).toMatchObject({ posted: true });
});
it("claims without KV list, whose results can lag writes by up to a minute", async () => {
@@ -669,7 +584,7 @@ describe("admin post action is idempotent and bounded", () => {
return realList(options);
};
const res = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const res = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(res.status).toBe(200);
expect(posts).toHaveLength(1);
expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:"))).toEqual([]);
@@ -688,12 +603,12 @@ describe("admin post action is idempotent and bounded", () => {
return realGet(key);
};
const first = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const first = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(first.status).toBe(503);
expect(posts).toEqual([]);
expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:") || k.startsWith("draft-resolved:"))).toEqual([]);
const retry = await act(kv, { action: "post", draftKey: "draft:triage:42" });
const retry = await act(kv, { action: "post", draftKey: "draft:linkcheck:42" });
expect(retry.status).toBe(200);
expect(posts).toHaveLength(1);
});
@@ -703,14 +618,14 @@ describe("admin post action is idempotent and bounded", () => {
await saveDraft(kv.draftStores, draft());
stubAdminEnv(kv);
const posts = stubGitHub();
kv.values.set("draft-claim:triage:42", "other-request");
kv.values.set("draft-claim:linkcheck:42", "other-request");
for (const action of ["post", "discard"]) {
const res = await act(kv, { action, draftKey: "draft:triage:42" });
const res = await act(kv, { action, draftKey: "draft:linkcheck:42" });
expect(res.status).toBe(409);
}
expect(posts).toEqual([]);
expect(kv.values.get("draft-claim:triage:42")).toBe("other-request");
expect(kv.values.get("draft-claim:linkcheck:42")).toBe("other-request");
});
it("treats a repeated discard as a no-op, including one that sees the marker only at claim time", async () => {
@@ -719,27 +634,27 @@ describe("admin post action is idempotent and bounded", () => {
stubAdminEnv(kv);
stubGitHub();
const first = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
const first = await act(kv, { action: "discard", draftKey: "draft:linkcheck:42" });
expect(first.status).toBe(200);
// A double click whose request still reads the draft (its delete not yet
// visible), first past the marker, then with the marker missed by the
// route's own check and seen only by the claim.
const staleDraft = JSON.stringify(draft());
kv.values.set("draft:triage:42", staleDraft);
const again = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
kv.values.set("draft:linkcheck:42", staleDraft);
const again = await act(kv, { action: "discard", draftKey: "draft:linkcheck:42" });
expect(again.status).toBe(200);
await expect(again.json()).resolves.toEqual({ ok: true, action: "discarded" });
kv.values.set("draft:triage:42", staleDraft);
kv.values.set("draft:linkcheck:42", staleDraft);
const realGet = kv.get.bind(kv);
let markerReads = 0;
kv.get = async (key: string) => {
if (key.startsWith("draft-resolved:") && ++markerReads === 1) return null;
return realGet(key);
};
const racing = await act(kv, { action: "discard", draftKey: "draft:triage:42" });
const racing = await act(kv, { action: "discard", draftKey: "draft:linkcheck:42" });
expect(racing.status).toBe(200);
expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "discarded" });
expect(JSON.parse(kv.values.get("draft-resolved:linkcheck:42")!)).toMatchObject({ state: "discarded" });
expect([...kv.values.keys()].filter((k) => k.startsWith("draft-claim:"))).toEqual([]);
});
@@ -753,12 +668,12 @@ describe("admin post action is idempotent and bounded", () => {
const posts = stubGitHub();
for (const action of ["post", "discard"]) {
const res = await act(kv, { action, draftKey: "draft:triage:42", lang: "en", reviewedSha256: shownA });
const res = await act(kv, { action, draftKey: "draft:linkcheck:42", lang: "en", reviewedSha256: shownA });
expect(res.status).toBe(409);
await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining("changed") });
}
expect(posts).toEqual([]);
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ bodyEn: "unseen B", posted: false });
expect(JSON.parse(kv.values.get("draft:linkcheck:42")!)).toMatchObject({ bodyEn: "unseen B", posted: false });
expect([...kv.values.keys()].filter((k) => k.startsWith("draft-resolved:") || k.startsWith("draft-claim:"))).toEqual([]);
});
@@ -768,7 +683,7 @@ describe("admin post action is idempotent and bounded", () => {
stubAdminEnv(kv);
const posts = stubGitHub();
const res = await adminPost(adminRequest(postBody({ action: "post", draftKey: "draft:triage:42" })));
const res = await adminPost(adminRequest(postBody({ action: "post", draftKey: "draft:linkcheck:42" })));
expect(res.status).toBe(400);
expect(posts).toEqual([]);
});
@@ -800,7 +715,7 @@ describe("admin post action is idempotent and bounded", () => {
});
describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
const KEY = "draft:triage:42";
const KEY = "draft:linkcheck:42";
async function lockedAdmin(status = 201) {
const kv = new FakeKv();
@@ -819,7 +734,7 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
/** What a location whose KV has not yet seen the first request's writes reads. */
function forgetDecision(kv: FakeKv) {
kv.values.set(KEY, JSON.stringify(draft()));
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
}
it("lets exactly one of several concurrent posts through, without KV claim keys", async () => {
@@ -857,7 +772,7 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
stubGitHub();
// Even without the KV "posting" marker, the lease refuses a retry...
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(409);
expect(posts).toHaveLength(1);
@@ -872,7 +787,7 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
const { kv, lock } = await lockedAdmin(502);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
// GitHub did create it: the late retry records it and posts nothing.
const posts = stubGitHub(201, [{ id: 7, body: "English body" }]);
@@ -880,15 +795,15 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
expect(retry.status).toBe(200);
await expect(retry.json()).resolves.toMatchObject({ ok: true, action: "posted", warning: expect.stringContaining("not posted again") });
expect(posts).toEqual([]);
expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!).state).toBe("posted");
expect(kv.values.has("draft-post-unknown:triage:42")).toBe(false);
expect(JSON.parse(kv.values.get("draft-resolved:linkcheck:42")!).state).toBe("posted");
expect(kv.values.has("draft-post-unknown:linkcheck:42")).toBe(false);
});
it("posts nothing when the lookup for an earlier attempt fails, and frees the draft", async () => {
const { kv, lock } = await lockedAdmin(502);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
const posts: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
@@ -924,7 +839,7 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
]);
expect(both.map((r) => r.status)).toEqual([200, 200]);
for (const res of both) await expect(res.json()).resolves.toEqual({ ok: true, action: "discarded" });
expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!)).toMatchObject({ state: "discarded" });
expect(JSON.parse(kv.values.get("draft-resolved:linkcheck:42")!)).toMatchObject({ state: "discarded" });
// A third click served from stale KV hits the post-discard hold.
forgetDecision(kv);
@@ -957,14 +872,14 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
const res = await act(kv, { action: "post", draftKey: KEY });
expect(res.status).toBe(500);
expect(posts).toEqual([]);
expect(kv.values.get("draft-resolved:triage:42")).toBeUndefined();
expect(kv.values.get("draft-resolved:linkcheck:42")).toBeUndefined();
});
});
describe("admin draft queue", () => {
function fill(kv: FakeKv, count: number) {
for (let i = 1; i <= count; i++) {
kv.values.set(`draft:triage:${i}`, JSON.stringify(draft({ id: String(i), targetNumber: i })));
kv.values.set(`draft:linkcheck:${i}`, JSON.stringify(draft({ id: String(i), targetNumber: i })));
}
}
@@ -997,7 +912,7 @@ describe("admin draft queue", () => {
});
describe("post retry durability and generation admission", () => {
const KEY = "draft:triage:42";
const KEY = "draft:linkcheck:42";
async function prepared() {
const kv = new FakeKv();
await saveDraft(kv.draftStores, draft());
@@ -1025,8 +940,8 @@ describe("post retry durability and generation admission", () => {
stubGitHub(504);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-post-unknown:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
kv.values.delete("draft-post-unknown:linkcheck:42");
const posts: string[] = [], pages: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = inputUrl(input);
@@ -1043,7 +958,7 @@ describe("post retry durability and generation admission", () => {
stubGitHub(504);
await act(kv, { action: "post", draftKey: KEY });
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
const posts: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (init?.method === "POST") posts.push(inputUrl(input));
@@ -1057,33 +972,31 @@ describe("post retry durability and generation admission", () => {
stubGitHub(504);
await act(kv, { action: "post", draftKey: KEY });
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-resolved:linkcheck:42");
const posts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: KEY, editedBody: "different body" })).status).toBe(409);
expect(posts).toEqual([]);
});
it("admits one overlapping generation batch and holds completed KV propagation", async () => {
const kv = new FakeKv(), lock = new FakeDraftClaimLock();
const env = { CURATED_KV: kv, DRAFT_CLAIM_LOCK: lock, DEEPSEEK_API_KEY: "k" };
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse([{ number: 42, title: "issue", body: "body", updated_at: "2020-01-01T00:00:00.000Z", html_url: "https://github.com/codewhale-hq/CodeWhale/issues/42", labels: [] }])));
let finish!: (value: unknown) => void;
mocks.agentChat.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ content: JSON.stringify({ bodyEn: "other", bodyZh: "另一个" }), usage: { input: 1, output: 1 } });
const first = runTriage(env);
await vi.waitFor(() => expect(mocks.agentChat).toHaveBeenCalledOnce());
const second = await runTriage(env);
expect(second).toMatchObject({ processed: 0, skipped: 1 });
finish({ content: JSON.stringify({ bodyEn: "generated", bodyZh: "生成" }), usage: { input: 1, output: 1 } });
expect(await first).toMatchObject({ processed: 1 });
kv.values.delete(KEY); // another location's stale KV must not spend again.
expect(await runTriage(env)).toMatchObject({ processed: 0, skipped: 1 });
expect(mocks.agentChat).toHaveBeenCalledOnce();
});
it("missing or failed durable generation admission starts no provider call", async () => {
const kv = new FakeKv();
const reads = vi.fn(); vi.stubGlobal("fetch", reads);
expect(await runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
expect(await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
const broken = { idFromName: () => ({ toString: () => "x" }), get: () => ({ act: async () => { throw new Error("down"); } }) };
expect(await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: broken, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
expect(await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: broken, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
expect(reads).not.toHaveBeenCalled(); expect(mocks.agentChat).not.toHaveBeenCalled();
});
it("admits one overlapping digest generation and spends once", async () => {
const kv = new FakeKv();
const env = { CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" };
stubDigestSources();
const output = { content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } };
let finish!: (value: unknown) => void;
mocks.agentChat.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue(output);
const first = runDigest(env);
await vi.waitFor(() => expect(mocks.agentChat).toHaveBeenCalledOnce());
expect(await runDigest(env)).toMatchObject({ processed: 0, skipped: 1 });
finish(output);
await first;
expect(mocks.agentChat).toHaveBeenCalledOnce();
});
});
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more