mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
Compare commits
50
Commits
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
let cwd = params
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.default_cwd.clone());
|
||||
let cwd = absolute_cwd(¶ms, "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(¶ms)?;
|
||||
absolute_cwd(¶ms, "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
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1872,55 +1872,6 @@
|
||||
},
|
||||
"qwen3.6-35b-a3b": {
|
||||
"source": "preserved public presentation facts, not route availability"
|
||||
},
|
||||
"mistral-large-4": {
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"mistral-large-4-0-8": {
|
||||
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"minimax-m3.1-flash": {
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1000000,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "MiniMax"
|
||||
},
|
||||
"kimi-k3.1-pro": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
},
|
||||
"kimi-k3.1-pro-highspeed": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
},
|
||||
"gpt-6.1-terra": {
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1050000,
|
||||
"max_output": 128000,
|
||||
"reasoning": true,
|
||||
"family": "OpenAI"
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "Meta"
|
||||
}
|
||||
},
|
||||
"official_deepseek_aliases": {
|
||||
@@ -4570,62 +4521,6 @@
|
||||
"aliases": [
|
||||
"deepseek-reasoner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4-0-8",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4-0-8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M3.1-Flash",
|
||||
"label": "MiniMax",
|
||||
"added_at": "2026-10-19",
|
||||
"aliases": [
|
||||
"MiniMax-M3.1-Flash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kimi-k3.1-pro",
|
||||
"label": "Moonshot/Kimi",
|
||||
"added_at": "2026-10-23",
|
||||
"aliases": [
|
||||
"kimi-k3.1-pro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kimi-k3.1-pro-highspeed",
|
||||
"label": "Moonshot/Kimi",
|
||||
"added_at": "2026-10-23",
|
||||
"aliases": [
|
||||
"kimi-k3.1-pro-highspeed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gpt-6.1-terra",
|
||||
"label": "OpenAI",
|
||||
"added_at": "2026-10-26",
|
||||
"aliases": [
|
||||
"gpt-6.1-terra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "muse-ember-1.0",
|
||||
"label": "Meta",
|
||||
"added_at": "2026-11-09",
|
||||
"aliases": [
|
||||
"muse-ember-1.0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"constants": {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
|
||||
"pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.",
|
||||
"generated_by": "python3 scripts/catalog_models_dev.py seed render from scripts/catalog/models_dev_seed.toml and scripts/catalog/models_dev_seed.lock.json. Do not edit this file by hand; see docs/CATALOG_REFRESH.md.",
|
||||
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T22:14:03Z sha256 2d85d334089340e30ae6d3c6bbbcd8c7591e27b954e8089494348bb59d1bd7af",
|
||||
"coverage": "23 providers, 137 provider model rows, 4 canonical model entries."
|
||||
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T21:14:00Z sha256 019e248c87ca384d63f16bfd2fde60f8e26b2c6fa1126927a1dac25fe88c9740",
|
||||
"coverage": "23 providers, 136 provider model rows, 4 canonical model entries."
|
||||
},
|
||||
"models": {
|
||||
"deepseek/deepseek-v4-pro-0813": {
|
||||
@@ -3348,71 +3348,6 @@
|
||||
"output": 20,
|
||||
"cache_read": 0.2
|
||||
}
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"id": "muse-ember-1.0",
|
||||
"canonical_model_id": "meta/muse-ember-1.0",
|
||||
"name": "Muse Ember",
|
||||
"description": "Muse Ember is Meta's smallest and most efficient reasoning model, built for the work agents repeat all day: tool calls, coding subtasks, routing, extraction, and long-document parsing. It pairs with Muse Wonder or Muse Spark, which plan while Muse Ember handles the volume.",
|
||||
"family": "muse",
|
||||
"release_date": "2026-11-09",
|
||||
"last_updated": "2026-11-09",
|
||||
"attachment": true,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{
|
||||
"type": "effort",
|
||||
"values": [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_call": true,
|
||||
"structured_output": true,
|
||||
"temperature": true,
|
||||
"open_weights": false,
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"pdf",
|
||||
"audio"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 524288
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.5,
|
||||
"cache_read": 0.004,
|
||||
"tiers": [
|
||||
{
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012,
|
||||
"tier": {
|
||||
"type": "context",
|
||||
"size": 200000
|
||||
}
|
||||
}
|
||||
],
|
||||
"context_over_200k": {
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5177,9 +5112,9 @@
|
||||
"output": 384000
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.894012,
|
||||
"output": 1.788024,
|
||||
"cache_read": 0.074501
|
||||
"input": 0.95526,
|
||||
"output": 1.91052,
|
||||
"cache_read": 0.079605
|
||||
}
|
||||
},
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
@@ -9561,55 +9496,6 @@
|
||||
},
|
||||
"qwen3.6-35b-a3b": {
|
||||
"source": "preserved public presentation facts, not route availability"
|
||||
},
|
||||
"mistral-large-4": {
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"mistral-large-4-0-8": {
|
||||
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"minimax-m3.1-flash": {
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1000000,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "MiniMax"
|
||||
},
|
||||
"kimi-k3.1-pro": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
},
|
||||
"kimi-k3.1-pro-highspeed": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
},
|
||||
"gpt-6.1-terra": {
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1050000,
|
||||
"max_output": 128000,
|
||||
"reasoning": true,
|
||||
"family": "OpenAI"
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "Meta"
|
||||
}
|
||||
},
|
||||
"official_deepseek_aliases": {
|
||||
@@ -12259,62 +12145,6 @@
|
||||
"aliases": [
|
||||
"deepseek-reasoner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4-0-8",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4-0-8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M3.1-Flash",
|
||||
"label": "MiniMax",
|
||||
"added_at": "2026-10-19",
|
||||
"aliases": [
|
||||
"MiniMax-M3.1-Flash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kimi-k3.1-pro",
|
||||
"label": "Moonshot/Kimi",
|
||||
"added_at": "2026-10-23",
|
||||
"aliases": [
|
||||
"kimi-k3.1-pro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kimi-k3.1-pro-highspeed",
|
||||
"label": "Moonshot/Kimi",
|
||||
"added_at": "2026-10-23",
|
||||
"aliases": [
|
||||
"kimi-k3.1-pro-highspeed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gpt-6.1-terra",
|
||||
"label": "OpenAI",
|
||||
"added_at": "2026-10-26",
|
||||
"aliases": [
|
||||
"gpt-6.1-terra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "muse-ember-1.0",
|
||||
"label": "Meta",
|
||||
"added_at": "2026-11-09",
|
||||
"aliases": [
|
||||
"muse-ember-1.0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"constants": {
|
||||
|
||||
+20
-1
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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 || {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<_>>(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"source": {
|
||||
"url": "https://models.dev/catalog.json",
|
||||
"fetched_at": "2026-11-10T22:14:03Z",
|
||||
"sha256": "2d85d334089340e30ae6d3c6bbbcd8c7591e27b954e8089494348bb59d1bd7af"
|
||||
"fetched_at": "2026-11-10T21:14:00Z",
|
||||
"sha256": "019e248c87ca384d63f16bfd2fde60f8e26b2c6fa1126927a1dac25fe88c9740"
|
||||
},
|
||||
"models": {
|
||||
"deepseek/deepseek-v4-pro-0813": {
|
||||
@@ -2588,71 +2588,6 @@
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"muse-ember-1.0": {
|
||||
"id": "muse-ember-1.0",
|
||||
"canonical_model_id": "meta/muse-ember-1.0",
|
||||
"name": "Muse Ember",
|
||||
"description": "Muse Ember is Meta's smallest and most efficient reasoning model, built for the work agents repeat all day: tool calls, coding subtasks, routing, extraction, and long-document parsing. It pairs with Muse Wonder or Muse Spark, which plan while Muse Ember handles the volume.",
|
||||
"family": "muse",
|
||||
"release_date": "2026-11-09",
|
||||
"last_updated": "2026-11-09",
|
||||
"attachment": true,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{
|
||||
"type": "effort",
|
||||
"values": [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_call": true,
|
||||
"structured_output": true,
|
||||
"temperature": true,
|
||||
"open_weights": false,
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"pdf",
|
||||
"audio"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 524288
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.5,
|
||||
"cache_read": 0.004,
|
||||
"tiers": [
|
||||
{
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012,
|
||||
"tier": {
|
||||
"type": "context",
|
||||
"size": 200000
|
||||
}
|
||||
}
|
||||
],
|
||||
"context_over_200k": {
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012
|
||||
}
|
||||
}
|
||||
},
|
||||
"muse-spark-1.1": {
|
||||
"id": "muse-spark-1.1",
|
||||
"canonical_model_id": "meta/muse-spark-1.1",
|
||||
@@ -4589,9 +4524,9 @@
|
||||
"output": 384000
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.894012,
|
||||
"output": 1.788024,
|
||||
"cache_read": 0.074501
|
||||
"input": 0.95526,
|
||||
"output": 1.91052,
|
||||
"cache_read": 0.079605
|
||||
}
|
||||
},
|
||||
"dots-studio/dots-3-note-preview:free": {
|
||||
|
||||
@@ -210,7 +210,6 @@ models = [
|
||||
"muse-spark-1.3",
|
||||
"muse-spark-1.3-contributor",
|
||||
"muse-wonder-1.0",
|
||||
"muse-ember-1.0",
|
||||
]
|
||||
|
||||
[[providers]]
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
@@ -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"])
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
Vendored
-76
@@ -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.
|
||||
+1387
File diff suppressed because it is too large.
Load diff
+78
@@ -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;
|
||||
+1308
File diff suppressed because it is too large.
Load diff
Vendored
+236
-19
@@ -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()
|
||||
Vendored
+109
-48
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+270
-42
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+111
-44
@@ -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;
|
||||
Vendored
+435
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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: "内容漂移" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 ---------- */
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -20,9 +20,6 @@ export default defineConfig({
|
||||
triggers.scheduled({
|
||||
schedule: "0 */6 * * *",
|
||||
}),
|
||||
triggers.scheduled({
|
||||
schedule: "*/30 * * * *",
|
||||
}),
|
||||
triggers.scheduled({
|
||||
schedule: "0 0 * * *",
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[](#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[](#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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stars": 41173,
|
||||
"forks": 3570,
|
||||
"openIssues": 67,
|
||||
"openPulls": 5,
|
||||
"readAt": "2026-11-11T00:12:45.683Z"
|
||||
}
|
||||
+17
-2
@@ -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
@@ -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": [
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user