Compare commits

..
Author SHA1 Message Date
esengine 5207d6f89b docs: draft code signing policy page (not in force) 2026-09-30 01:46:32 -07:00
1469 changed files with 5459 additions and 95561 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,6 @@
# User-facing guides and references.
/docs/ @SivanCola @esengine
/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola
/sdk/ @SivanCola @esengine
/README.md @SivanCola @esengine
/README.zh-CN.md @SivanCola @esengine
@@ -23,6 +22,7 @@
/docs/*_CONTRACT.md @esengine @SivanCola
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
/docs/CHECKPOINTS.md @esengine @SivanCola
/docs/EXTENSION_*.md @esengine @SivanCola
/docs/SUBAGENT_*.md @esengine @SivanCola
-6
View File
@@ -6,9 +6,3 @@ contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
- name: Discord
url: https://discord.gg/XF78rEME2D
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
url: https://qm.qq.com/q/i59b0z2R8s
about: Chinese-language community chat for setup help and everyday questions.
-16
View File
@@ -8,22 +8,6 @@ only, on `main-v2`.
-
## Cause
-
## Blast radius
-
## Neighbouring behaviours tested
-
## Why this layer
-
## Issues
<!--
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

+10 -14
View File
@@ -62,7 +62,7 @@ jobs:
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
@@ -80,11 +80,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -179,12 +175,12 @@ jobs:
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 12m, not 3m: across four consecutive runs of the same
# That timeout is 8m, not 3m: across four consecutive runs of the same
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. The boot package
# has since grown to 422s and then ran past 480s on two consecutive runs of the
# same code, so 8m no longer leaves room. A hang still trips the job budget.
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
# step failed on slow runners without a code change; a hang still trips
# the 15 minute job budget above.
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
@@ -195,11 +191,11 @@ jobs:
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 20
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -218,7 +214,7 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 12m package budget as Windows smoke:
# Full push suite still uses the same 8m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
@@ -234,7 +230,7 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./...
run: go test -count=1 -p 4 -timeout=8m ./...
race:
needs: changes
-44
View File
@@ -1,44 +0,0 @@
name: Deploy ops events relay
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-ops-events
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/ops-events/package-lock.json
- name: Validate
working-directory: workers/ops-events
run: |
npm ci
npm run typecheck
npm test
- name: Deploy
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler deploy
- name: Sync secrets
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
run: |
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
run_install: false
-77
View File
@@ -1,77 +0,0 @@
name: Sync in-app feedback
# Turns feedback submitted from Reasonix Studio into issues and reports issue
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
# and mirrors the reporter's replies onto their issue as bot comments.
# Text from the public reaches the script only through the worker's JSON and
# the GitHub API, never through a shell command line or an expression.
on:
schedule:
- cron: '*/10 * * * *'
- cron: '5 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Print what would happen without creating issues or writing back
required: false
default: false
type: boolean
full:
description: Re-read every open entry instead of only recently changed issues
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
concurrency:
group: feedback-sync
cancel-in-progress: false
jobs:
sync:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- name: Check secret
id: gate
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
run: |
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.gate.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# git tag --contains needs full history; the tag API would cost a request per entry.
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Sync
if: steps.gate.outputs.run == 'true'
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
FULL: ${{ github.event.inputs.full || 'false' }}
SCHEDULE: ${{ github.event.schedule }}
run: |
set -euo pipefail
args=()
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
# The daily schedule sweeps everything the change window could have missed.
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
node scripts/feedback-sync.mjs "${args[@]}"
+35 -170
View File
@@ -757,18 +757,14 @@ jobs:
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
mkdir -p dist
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
pkg=reasonix/internal/tools/productdocs
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"; arch="${target#*/}"; ext=""
[ "$os" = windows ] && ext=".exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
@@ -1111,125 +1107,50 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
name: require the 1.x line to be frozen
needs: resolve
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require CLI_PUBLISH_FROZEN
env:
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
run: |
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
exit 1
fi
# The v<version> tag the update pointer needs is created here, by the release
# identity the 1.x line also uses, because only that identity may create v*
# tags. This job checks out nothing and runs no repository code; the one step
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
# tag or branch, through the studio-release environment's ref policy.
cli-tag:
name: create the CLI tag
needs: [resolve, publish, cli-gate]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
environment: studio-release
permissions:
contents: read
steps:
- name: Create v<version> on the studio commit
env:
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
READ_TOKEN: ${{ github.token }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
repo=esengine/DeepSeek-Reasonix
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
tag="v${CLI_VERSION#v}"
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
echo "$tag publishes no CLI pointer; no tag is created"
exit 0
fi
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
case "$GITHUB_REF" in
refs/tags/studio-v* | refs/heads/studio) ;;
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
esac
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
actor="$(write_api user)"
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
behind | identical) ;;
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
esac
# Prints the commit the tag points at; status 1 only when the tag does not exist.
tag_commit() {
local ref kind sha
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
cat "$RUNNER_TEMP/tag.error" >&2
exit 1
}
read -r kind sha <<<"$ref"
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
echo "$sha"
}
if existing="$(tag_commit)"; then
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
# A concurrent run may have created the same tag in the meantime.
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
cat "$RUNNER_TEMP/create.error" >&2
exit 1
fi
echo "created $tag at $APPROVED_SHA"
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
# line owns them until this one takes the CLI over; the repository variable
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
# created the CLI release, because the cask points at its archives. A candidate
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
# channel, so a candidate cask would become the default install.
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
# exists, because the cask points at its archives. A candidate goes to npm's
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
# candidate cask would become the default install.
cli-channels:
name: publish the CLI to npm and Homebrew
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
needs: [resolve, publish]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# Owner approval before anything reaches npm or the tap: the switch alone
# would otherwise publish on every studio tag.
environment: studio-cli-channels
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
steps:
# A job naming a missing environment gets an unprotected one, so the
# gate is verified rather than assumed; the tokens below exist only in it.
- name: Require the approval gate on studio-cli-channels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
case "$status" in
200) ;;
404)
echo "::error::environment studio-cli-channels does not exist"
exit 1 ;;
*)
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
exit 1 ;;
esac
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
@@ -1243,7 +1164,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -1254,24 +1175,12 @@ jobs:
- name: Update the Homebrew cask
if: needs.resolve.outputs.candidate != 'true'
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
case "$decision" in
write) ;;
skip)
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
exit 0
;;
*)
echo "::error::unexpected cask guard decision: '$decision'"
exit 1
;;
esac
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
cd tap
git add Casks/reasonix.rb
@@ -1281,47 +1190,3 @@ jobs:
fi
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
git push origin HEAD:main
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
# sit under that tag, so the archives go to a v<version> release. Only a
# -preview.N version moves preview; other prereleases publish nothing.
cli-pointer:
name: publish the CLI update pointer
needs: [resolve, publish, cli-gate, cli-tag]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
concurrency:
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
cancel-in-progress: false
permissions:
contents: write # create the CLI release; cli-tag has already made its tag
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-archives
path: cli-archives
- name: Publish the release, its record and the pointer
env:
GH_TOKEN: ${{ github.token }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
REPOSITORY: ${{ github.repository }}
ARCHIVES_DIR: cli-archives
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured; the CLI pointer was not written"
exit 1
fi
bash scripts/publish-cli-pointer.sh
-1
View File
@@ -15,7 +15,6 @@
/runtime-resume
/extension-protocol-gen
/reasonix-plugin-example
/mcp-line-counter-kit
/reasonix-studio-host
/signpath-contract
/studio-delta
+2 -10
View File
@@ -18,8 +18,6 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -33,7 +31,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -44,16 +41,12 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
<h3 align="center">A coding agent you can leave running.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
>
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
<br/>
@@ -272,9 +265,8 @@ coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付 / 支付宝(扫码)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
</p>
+2 -10
View File
@@ -18,8 +18,6 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -33,7 +31,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -44,16 +41,12 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
>
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## 版本
@@ -252,10 +245,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付 / 支付宝(扫下方二维码)
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
</p>
-25
View File
@@ -320,31 +320,6 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Change discipline
**Assess before editing.** For every change, humans and agents list callers,
consumers of the same state, sibling features, reload/replay/restart paths,
error and edge states, TUI/CLI and desktop equivalents, and wire mirrors.
A fix that breaks a neighbouring behaviour is a failed fix.
**Test the neighbourhood.** UI changes add or extend interaction tests for
adjacent functions of the same component or flow (composer: submit, clear,
queue, withdraw, restore, steer, failure, replay after reload) and run the whole
affected suite.
Write tests first; they must fail without the change.
**Fix at the owning layer.** Use the existing unified pattern: no special-case
flags, duplicated paths, or workarounds at the symptom site.
If the design is duplicated or wrong, make the smallest unifying change:
one path instead of two, deleting what becomes dead. Use current language and
framework idioms; do not add a new pattern where one exists.
**PR evidence.** Every PR description carries: Cause, Blast radius,
Neighbouring behaviours tested, and Why this layer.
## Reasonix host checks
The paths below decide what the agent is allowed to do — a wrong change here
+11 -82
View File
@@ -3,12 +3,12 @@
Two questions about long-horizon memory, and the harness that makes their
answers trustworthy.
-mode=preflight build all 18 tasks under every arm and check the contract
-mode=preflight build all 12 tasks under every arm and check the contract
-mode=snippet does a rank-1 hit hand over the whole answer?
-mode=adversarial send a model after the answer through every host surface
-mode=run-search Experiment R: is searchable canonical a real capability?
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
-mode=calibrate find each index task's PlantAfterGen for its cue tier
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
Existing evidence is not sufficient to tune the shipped 1% budget in either
direction. Reopening this wants a new corpus, not more samples: that substrate
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
direction. Reopening this wants a new corpus, not more samples: see
"Index corpus v2" below.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -74,83 +74,11 @@ scanned for answer literals before the first provider request and again after
the turn. Any answer reaching the model through a tool that is not `recall`
marks the run contaminated and takes it out of every statistic.
## Index corpus v2
## Index corpus v2, if this is reopened
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
`i47-tenantlease` and `i112-lagquota` (default).
They came out of five batches of candidates, each screened the same way: one
`-mode=run-index` pass over the batch, then two more only for the candidates that
were clean on both boundary arms, and only the candidates that held on both arms
in all three passes stayed. A candidate that does not hold is dropped, not
promoted. The numbers are candidates, per tier:
| batch | quarter | half | default |
| --- | --- | --- | --- |
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
That table counts candidates that held on both arms in all three passes, which is
not the size of the substrate. 89 candidates were authored, 77 were screened (the
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
the other 19 survivors are spares rather than corpus members. Candidates from the
earlier round are not counted here.
| task | tier | cue present | cue absent |
| --- | --- | ---: | ---: |
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
| `i118-fencelag` | quarter | 3/3 | 3/3 |
| `i106-budgetseal` | half | 3/3 | 3/3 |
| `i70-ingestfloor` | half | 3/3 | 3/3 |
| `i47-tenantlease` | default | 3/3 | 3/3 |
| `i112-lagquota` | default | 3/3 | 3/3 |
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
`NeverSufficient`.
Four of the first six were flagged in review for sharing answer values, probe
queries and prompt wording within a tier. Separating them and re-screening those
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
were replaced - `i90-recoverylag` was tried first and did not hold either, and
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
cosmetic: the shared wording had been carrying two of the six.
Nothing in the task shape separates the survivors from the rest: within the half
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
second, and codename answers survived 8 of 16 in the third. An earlier claim here
that the answer kind decides whether a candidate holds does not survive this
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
failures - the model re-checking a value it already had - but which tasks hit it
was not predictable from their shape at this sample.
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
once each with `-mode=run-index -task <id>`, per task and per arm:
| task | tier | cue present | cue absent |
| --- | --- | --- | --- |
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
and `i01` is clean on both. The cue-present arm is clean for four of them
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
stopping side rather than being retitled. All six are excluded from `run-index`
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
task added to the corpus declares a role, so it cannot arrive without one.
A task qualifies only if it is clean on both of its boundary arms - the
cue-present run and the cue-absent run - in every run of a screening batch. Clean
means a `stopping_class` other than `PostSufficientRetrieval` or
The present index tasks are not a fit substrate: two of six carry stopping
behaviour that dominates the signal. A task should qualify only if a run-index
run reports a `stopping_class` other than `PostSufficientRetrieval` or
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
all qualify, and a defensive read is deliberately not counted as waste. The
class comes from the three counts in that report — `searches_after_sufficient`,
@@ -158,7 +86,8 @@ class comes from the three counts in that report — `searches_after_sufficient`
sufficiency was reached and how many rounds followed it. The remaining
requirements still apply — successful tool call, answer in the output rather
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
nothing in the workspace.
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
efficiency one.
`-mode=calibrate` is not a screening tool: it builds and reads back the
fixtures, and reads nothing else, so its cue-visibility profile is the same for
+7 -132
View File
@@ -26,12 +26,6 @@ const (
tierDefault = "default" // visible at default only
)
// role places an index task in the efficiency substrate or the stopping corpus.
const (
roleEfficiency = "efficiency"
roleStopping = "stopping"
)
// plantKind is how a task's target enters the transcript.
const (
plantAssistant = "assistant" // the model's own account: never index-addressed
@@ -51,8 +45,6 @@ type contextTask struct {
AnswerVars []string
CueVar string
CueTier string
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
Role string
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
// so what follows a cue decides which budget addresses it. Frozen, because
// a run that searched for it would let the tested code move its goalposts.
@@ -129,20 +121,6 @@ func num(name string, lo, hi int) varSpec {
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
}
// efficiencyTasks is the substrate a study measures on. Every task in it
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
// because a task carrying post-sufficient stopping behaviour swamps the effect
// an efficiency batch exists to measure.
func efficiencyTasks() []contextTask {
var out []contextTask
for _, t := range indexTasks() {
if t.Role == roleEfficiency {
out = append(out, t)
}
}
return out
}
func searchTasks() []contextTask {
return []contextTask{
{
@@ -214,104 +192,10 @@ func searchTasks() []contextTask {
}
}
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
func efficiencyIndexTasks() []contextTask {
out := indexQuarterTasks()
out = append(out, indexHalfTasks()...)
out = append(out, indexDefaultTasks()...)
return out
}
// indexQuarterTasks holds the quarter-tier literals.
func indexQuarterTasks() []contextTask {
func indexTasks() []contextTask {
return []contextTask{
{
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
ProbeQuery: "dispatchlag dispatch lag epoch",
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "p37", CallTool: "bash",
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
PlantBody: "dispatch lag epoch = {{epoch}}",
},
{
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
ProbeQuery: "fencelag fence lag",
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "x118", CallTool: "bash",
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
PlantBody: "fence lag = {{v}}",
},
}
}
// indexHalfTasks holds the half-tier literals.
func indexHalfTasks() []contextTask {
return []contextTask{
{
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
ProbeQuery: "ingestfloor quota",
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "q70", CallTool: "bash",
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
{
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
ProbeQuery: "budgetseal route quota",
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "h106", CallTool: "bash",
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
}
}
// indexDefaultTasks holds the default-tier literals.
func indexDefaultTasks() []contextTask {
return []contextTask{
{
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
ProbeQuery: "tenantlease tenant lease",
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "p47", CallTool: "bash",
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
PlantBody: "tenant lease = {{lease}}",
},
{
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
ProbeQuery: "lagquota lagquota lease",
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "d112", CallTool: "bash",
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
PlantBody: "lagquota lease = {{lease}}",
},
}
}
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
// batches and measurable on their own with -task <id>.
func stoppingIndexTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
ProbeQuery: "transport fallback probe selector",
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
@@ -322,7 +206,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "fallback selector = {{selector}}",
},
{
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
ProbeQuery: "scheduler handoff grace marker",
Vars: []varSpec{
@@ -336,7 +220,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
},
{
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
ProbeQuery: "coalescing policy completion window",
Vars: []varSpec{
@@ -350,7 +234,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
},
{
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
ProbeQuery: "recovery fence epoch check",
Vars: []varSpec{
@@ -364,7 +248,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
},
{
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
ProbeQuery: "cache probe prefix salt scope",
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
@@ -375,7 +259,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
},
{
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
ProbeQuery: "dispatch handoff probe mode",
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
@@ -388,12 +272,6 @@ func stoppingIndexTasks() []contextTask {
}
}
// indexTasks is the whole index corpus, the efficiency substrate first.
func indexTasks() []contextTask {
out := efficiencyIndexTasks()
return append(out, stoppingIndexTasks()...)
}
func allTasks() []contextTask {
return append(searchTasks(), indexTasks()...)
}
@@ -454,9 +332,6 @@ func validateCorpus() error {
if t.CueVar == "" {
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
}
if t.Role != roleEfficiency && t.Role != roleStopping {
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
}
if v, _ := tierScales(t.CueTier); v == nil {
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
}
+3 -14
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"fmt"
"hash/fnv"
"io"
mathrand "math/rand"
"strings"
)
@@ -91,25 +90,15 @@ func seededRand(parts ...string) *mathrand.Rand {
// liveRand is the generator a paid run uses: every run gets values that existed
// nowhere before it started.
func liveRand() *mathrand.Rand {
rng, err := liveRandFrom(rand.Reader)
if err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
return rng
}
// liveRandFrom seeds a generator from an entropy source, so what a run's values
// are unpredictable from is the source and nothing else.
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
var seed [8]byte
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
return nil, err
if _, err := rand.Read(seed[:]); err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
var n int64
for _, b := range seed {
n = n<<8 | int64(b)
}
return mathrand.New(mathrand.NewSource(n)), nil
return mathrand.New(mathrand.NewSource(n))
}
// instantiate substitutes {{name}} and refuses anything left over. The grammar
+1 -8
View File
@@ -62,14 +62,7 @@ func main() {
case "adversarial":
os.Exit(runAdversarial(root))
case "run-index":
batch := tasks
if *only == "" {
// A batch measures the efficiency substrate only. Naming one task
// with -task runs that task, role included, so the stopping corpus
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
+15 -67
View File
@@ -1,10 +1,7 @@
package main
import (
"bytes"
"fmt"
"maps"
mathrand "math/rand"
"os/exec"
"slices"
"strings"
@@ -22,24 +19,16 @@ func TestCorpusIsWellFormed(t *testing.T) {
if got := len(searchTasks()); got != 6 {
t.Errorf("search experiment has %d tasks, want 6", got)
}
if got := len(indexTasks()); got != 12 {
t.Errorf("index experiment has %d tasks, want 12", got)
if got := len(indexTasks()); got != 6 {
t.Errorf("index experiment has %d tasks, want 6", got)
}
stopping := map[string]bool{}
tiers := map[string]int{}
for _, task := range indexTasks() {
if task.Role == roleStopping {
stopping[task.ID] = true
continue
}
tiers[task.CueTier]++
}
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
}
// Two per tier, over the efficiency substrate, is what makes the staircase
// readable: the same task can be compared across the boundary its cue sits
// on, instead of two different questions being compared across two arms.
// Two per tier is what makes the staircase readable: the same task can be
// compared across the boundary its cue sits on, instead of two different
// questions being compared across two arms.
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
if tiers[tier] != 2 {
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
@@ -47,21 +36,6 @@ func TestCorpusIsWellFormed(t *testing.T) {
}
}
// The role decides which batch a task enters; the literal lists are only where
// it is declared, so the two must not drift.
func TestIndexListsMatchTheirRoles(t *testing.T) {
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
if task.Role != roleEfficiency {
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
}
}
for _, task := range stoppingIndexTasks() {
if task.Role != roleStopping {
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
}
}
}
// The invariant the whole instantiation layer exists for: no literal a scorer
// needs may be readable in this repository before a run starts.
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
@@ -85,46 +59,20 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
}
}
// A run's values are unpredictable from a previous run only if the live
// generator is seeded from entropy. Comparing answers across two instances
// cannot assert that: a task whose answer is a bounded number collides by
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
func TestLiveRandDrawsFromEntropy(t *testing.T) {
a, b := liveRand(), liveRand()
for range 4 {
if a.Int63() != b.Int63() {
return
}
}
t.Fatal("two live generators produced the same stream: they are seeded alike")
}
// The source alone decides an instance: the same entropy gives the same
// answers, different entropy gives different values.
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
same := func() *mathrand.Rand {
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
// Two runs of the same task must not share an answer, or one leaked run leaks
// every future one.
func TestInstancesDifferBetweenRuns(t *testing.T) {
for _, task := range allTasks() {
a, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
}
return rng
}
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
if err != nil {
t.Fatal(err)
}
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
t.Fatal("an empty entropy source was accepted")
}
for _, task := range allTasks() {
a, _ := instantiateTask(task, same())
b, _ := instantiateTask(task, same())
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
t.Errorf("%s: the same entropy gave different instances", task.ID)
b, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
}
c, _ := instantiateTask(task, other)
if maps.Equal(a.Vars, c.Vars) {
t.Errorf("%s: different entropy gave the same values", task.ID)
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
}
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
var all []contextMetrics
var cueSide, noCueSide []contextMetrics
for _, t := range efficiencyTasks() {
for _, t := range indexTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
@@ -1,8 +0,0 @@
def wire_fee(amount):
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
A zero or negative amount has no wire fee, so it returns 0.0.
"""
if amount <= 0:
return 0.0
return max(15.0, amount * 0.001)
@@ -1,13 +0,0 @@
def convert(amount, rate):
"""Convert `amount` at `rate` and return the product.
Both units belong to the caller. A negative amount converts unchanged.
"""
return amount * rate
def spread(bid, ask):
"""Return `ask - bid`, the spread between the two quoted prices.
A negative result means the bid is above the ask.
"""
return ask - bid
@@ -1,4 +0,0 @@
EARTH_RADIUS_KM = 6371.0
def dist(left, right):
return abs(left - right) * EARTH_RADIUS_KM
@@ -1,4 +0,0 @@
import geo
def near_by(point, other):
return geo.dist(point, other) < 10
@@ -1,16 +0,0 @@
import json
import sys
def upgrade(row):
parts = row["fullname"].rsplit(" ", 1)
out = {k: v for k, v in row.items() if k != "fullname"}
out["first"] = parts[0]
out["last"] = parts[1] if len(parts) > 1 else ""
return out
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
print(json.dumps(upgrade(json.loads(line))))
@@ -1,7 +0,0 @@
import logging
log = logging.getLogger("orders")
def place(order, request_id):
log.info("placing order %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -1,7 +0,0 @@
import logging
log = logging.getLogger("shipping")
def dispatch(order, request_id):
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -1,11 +0,0 @@
import math
def p95(sorted_values):
if not sorted_values:
return 0
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
def rate(events, seconds):
if seconds <= 0:
return 0.0
return len(events) / seconds
@@ -1,9 +0,0 @@
import core
_cache = None
def list_records_cached():
global _cache
if _cache is None:
_cache = core.list_records()
return _cache
@@ -1,5 +0,0 @@
import cache
def main():
for k, v in sorted(cache.list_records_cached().items()):
print(f"{k}={v}")
@@ -1,4 +0,0 @@
DATA = {"a": 1, "b": 2}
def list_records():
return dict(DATA)
@@ -1 +0,0 @@
DEFAULTS = {"host": "localhost", "port": 8080}
@@ -1,6 +0,0 @@
import json
import defaults
def load(text):
data = json.loads(text) if text.strip() else {}
return dict(defaults.DEFAULTS, **data)
@@ -1,4 +0,0 @@
import json
def dump(cfg):
return json.dumps(cfg, sort_keys=True)
@@ -1,2 +0,0 @@
STATES = ("new", "paid", "delivered", "cancelled")
TERMINAL = ("delivered", "cancelled")
@@ -1,11 +0,0 @@
import states
LEGAL = {
"new": ("paid", "cancelled"),
"paid": ("delivered", "cancelled"),
"delivered": (),
"cancelled": (),
}
def can_move(a, b):
return b in LEGAL[a]
@@ -1,5 +0,0 @@
import schema, serializer
def display_name(line):
rec = serializer.load(line)
return rec["handle"] or rec["email"]
@@ -1,4 +0,0 @@
FIELDS = ("handle", "email")
def blank():
return {f: "" for f in FIELDS}
@@ -1,4 +0,0 @@
import timings
def is_slow(entry):
return entry["elapsed_s"] > timings.SLOW_SECONDS
@@ -1,4 +0,0 @@
import timings
def within_budget(entries):
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
@@ -1,5 +0,0 @@
SLOW_SECONDS = 2.0
BUDGET_SECONDS = 60.0
def record(seconds):
return {"elapsed_s": seconds}
+3 -3
View File
@@ -250,10 +250,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
if dir == trainCorpusDir || dir == verificationStressDir {
t.Fatal("no solution/: a corpus task must prove its grader can pass")
if dir == trainCorpusDir {
t.Fatal("no solution/: a generated task must prove its grader can pass")
}
t.Skip("no solution/: the e2e suite does not commit reference solutions")
t.Skip("no solution/ to check this grader against")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
-28
View File
@@ -1,28 +0,0 @@
package main
import (
"testing"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
)
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
for _, tt := range []struct{ setting, env, want string }{
{"auto", "", "zh"},
{"en", "", "en"},
{"auto", "en", "en"},
{"zh-TW", "en", "zh-TW"},
} {
t.Setenv("REASONIX_LANG", tt.env)
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
}
}
}
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/frontend/serve"
)
// launchWorkspace is the folder the first pane opens. The working directory
// counts only when someone chose it: a Start-menu or Finder launch inherits the
// install directory or the filesystem root, and taking that as a project puts
// the app's own folder in the sidebar again after every removal.
func launchWorkspace(appExe string) string {
home, _ := os.UserHomeDir()
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
if userChoseDir(cwd, appExe) {
return cwd
}
for _, dir := range remembered {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
}
if home != "" {
return home
}
return cwd
}
func userChoseDir(cwd, appExe string) bool {
if cwd == "" || filepath.Dir(cwd) == cwd {
return false
}
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
if err != nil {
return true
}
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
return !inside
}
@@ -1,92 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
cases := []struct {
name, cwd, exe string
want bool
}{
{"install directory", app, exe, false},
{"below the install directory", filepath.Join(app, "resources"), exe, false},
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
{"a sibling sharing the name prefix", app + "-notes", exe, true},
{"unpackaged run keeps the directory", app, "", true},
}
for _, c := range cases {
if got := userChoseDir(c.cwd, c.exe); got != c.want {
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
}
}
}
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
mk := func(name string) string {
d := filepath.Join(base, name)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
return d
}
a, b, home := mk("a"), mk("b"), mk("home")
gone := filepath.Join(base, "gone")
proj := mk("proj")
cases := []struct {
name string
cwd string
remembered []string
want string
}{
{"chosen directory wins over the list", proj, []string{a}, proj},
{"list head", app, []string{a, b}, a},
{"stale head skipped", app, []string{gone, b}, b},
{"all stale falls to home", app, []string{gone}, home},
{"empty list falls to home", app, nil, home},
}
for _, c := range cases {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "state")
app := filepath.Join(base, "app")
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
for _, dir := range []string{home, app, a, b} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("REASONIX_HOME", home)
t.Chdir(app)
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
t.Fatal(err)
}
exe := filepath.Join(app, "Reasonix Studio")
if got := launchWorkspace(exe); got != a {
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
}
if err := os.Remove(a); err != nil {
t.Fatal(err)
}
if got := launchWorkspace(exe); got != b {
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
}
}
+11 -35
View File
@@ -19,7 +19,6 @@ import (
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
@@ -35,7 +34,6 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
"reasonix/internal/platform/notify"
"reasonix/internal/platform/remotecloud"
@@ -149,7 +147,6 @@ func main() {
// because that pipe is the only channel pointing that way; every log goes to
// logs, which is why no line there can be mistaken for one of these.
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
phases := newStartupPhases(logs, time.Now)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if lease != nil {
@@ -162,7 +159,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
}
served, err := serve.FindPage(page)
phases.mark(startupPage)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -171,7 +167,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
}
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
hub, err := assemble(ctx, logs, handshakeTo, shell, served)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -181,7 +177,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
defer hub.Shutdown()
bound, err := bind(hub.Handler())
phases.mark(startupBind)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -189,10 +184,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
// The credential-writing setup surface opens only on a loopback address,
// and this is the first host that has one to show it.
hub.EnableProviderSetupForListener(bound.listener.Addr().String())
phases.mark(startupSetup)
err = announce(handshakeTo, bound)
phases.mark(startupAnnounce)
if err != nil {
if err := announce(handshakeTo, bound); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
}
@@ -289,15 +281,13 @@ func resolveKernelLanguage(cfg *config.Config) string {
// assemble builds the hub this host serves: one pane on the workspace it was
// launched in, carrying the capabilities a local window may exercise.
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
// The one-time upgrades belong to whichever entry point starts first; a
// person who only ever opens the window would otherwise never get them.
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
}
phases.mark(startupConfigUpgrade)
cfg, err := config.Load()
phases.mark(startupConfig)
if err != nil {
return nil, err
}
@@ -321,24 +311,15 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
paneSink = reporter.Wrap(paneSink)
}
}
phases.mark(startupSinks)
root := launchWorkspace(shell.exe)
phases.mark(startupWorkspace)
root := boot.ResolveWorkspaceRoot("")
built, err := boot.BuildRuntime(ctx, boot.Options{
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
StatsSource: surface.Desktop,
})
phases.mark(startupRuntime)
if err != nil {
return nil, err
}
@@ -353,7 +334,6 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// Shut until the person at the window opens it; the context ending closes
// it with the rest of the kernel, unpairing every device.
share := serve.NewDeviceShare(page)
share.RestorePort(cfg.SharePort())
go func() {
<-ctx.Done()
share.Close()
@@ -378,16 +358,12 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
srv := serve.New(built.Controller, bc, hubCfg)
srv.SetPaneSink(paneSink)
srv.AdoptRuntime(built)
phases.mark(startupHub)
_, err = hub.Adopt(srv, bc)
phases.mark(startupAdopt)
if err != nil {
if _, err := hub.Adopt(srv, bc); err != nil {
hub.Shutdown()
return nil, err
}
hub.StartRecoveryGC(ctx)
startCloudRemote(ctx, cfg, logs, hub)
phases.mark(startupBackground)
return hub, nil
}
+1 -1
View File
@@ -487,7 +487,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatal(err)
}
t.Chdir(testenv.TempDir(t))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
-50
View File
@@ -1,50 +0,0 @@
package main
import (
"fmt"
"io"
"time"
"reasonix/internal/assembly/boot"
)
type startupPhase string
const (
startupPage startupPhase = "page"
startupConfigUpgrade startupPhase = "config-upgrade"
startupConfig startupPhase = "config"
startupSinks startupPhase = "sinks"
startupWorkspace startupPhase = "workspace"
startupRuntime startupPhase = "runtime"
startupHub startupPhase = "hub"
startupAdopt startupPhase = "adopt"
startupBackground startupPhase = "background"
startupBind startupPhase = "bind"
startupSetup startupPhase = "provider-setup"
startupAnnounce startupPhase = "announce"
)
type startupPhases struct {
logs io.Writer
now func() time.Time
last time.Time
}
func newStartupPhases(logs io.Writer, now func() time.Time) *startupPhases {
return &startupPhases{logs: logs, now: now, last: now()}
}
func (p *startupPhases) mark(phase startupPhase) {
now := p.now()
p.write(phase, now.Sub(p.last))
p.last = now
}
func (p *startupPhases) write(phase startupPhase, elapsed time.Duration) {
fmt.Fprintf(p.logs, "reasonix-studio-host: startup phase=%s elapsed_ms=%d\n", phase, elapsed.Milliseconds())
}
func (p *startupPhases) boot(phase boot.Phase) {
p.write(startupPhase("boot."+phase.Name), phase.D)
}
-26
View File
@@ -1,26 +0,0 @@
package main
import (
"bytes"
"testing"
"time"
"reasonix/internal/assembly/boot"
)
func TestStartupPhasesUseElapsedMilliseconds(t *testing.T) {
var logs bytes.Buffer
now := time.Unix(100, 0)
p := newStartupPhases(&logs, func() time.Time { return now })
now = now.Add(1250 * time.Millisecond)
p.mark(startupPage)
now = now.Add(7 * time.Millisecond)
p.boot(boot.Phase{Name: "sessions", D: 3 * time.Millisecond})
p.mark(startupRuntime)
want := "reasonix-studio-host: startup phase=page elapsed_ms=1250\n" +
"reasonix-studio-host: startup phase=boot.sessions elapsed_ms=3\n" +
"reasonix-studio-host: startup phase=runtime elapsed_ms=7\n"
if got := logs.String(); got != want {
t.Fatalf("phase log = %q, want %q", got, want)
}
}
@@ -1,100 +0,0 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"reasonix/internal/base/i18n"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
)
func TestAssembledTrayLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
t.Chdir(testenv.TempDir(t))
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
t.Setenv("REASONIX_LANG", tt.env)
t.Setenv("LANG", tt.locale)
cfg := config.Default()
disabled := false
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
t.Fatal(err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
if err != nil {
t.Fatalf("assemble: %v", err)
}
defer hub.Shutdown()
b, err := bind(hub.Handler())
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- b.serve(ctx) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(30 * time.Second):
t.Error("host did not drain")
}
}()
check := func(want string) {
t.Helper()
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
if status != http.StatusOK {
t.Fatalf("tray status = %d", status)
}
var state serve.TrayState
if err := json.Unmarshal([]byte(body), &state); err != nil {
t.Fatal(err)
}
say := i18n.Catalog(want)
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
if state.Labels != labels || state.Line != say.TrayIdle {
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
}
}
check(tt.want)
for _, lang := range []string{"en", "zh"} {
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
})
if status != http.StatusOK {
t.Fatalf("appearance status = %d", status)
}
check(lang)
}
})
}
}
-37
View File
@@ -71,40 +71,3 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
}
}
}
// Sweeping a session directory costs a handful of file operations per
// transcript, and the window cannot open until the handshake. A build that
// sweeps inline makes the first launch after the cache went cold wait on
// history it never displays.
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
found := false
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Options" {
return true
}
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
found = true
}
}
}
return true
})
if !found {
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
}
}
+99 -23
View File
@@ -2,6 +2,7 @@ package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -10,7 +11,10 @@ import (
"gopkg.in/yaml.v3"
)
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
// The 1.x line reads these repository secrets; studio must never touch them.
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
t.Helper()
@@ -90,8 +94,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
if job == nil {
t.Fatal("cli-channels job is missing")
}
if mappingValue(job, "environment") != nil {
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
@@ -109,11 +113,18 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
}
}
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
}
assertGateBeforePublishing(t, job)
for _, name := range cliChannelSecrets {
if !secretRefPattern(name).MatchString(scalarString(job)) {
t.Fatalf("cli-channels does not read secrets.%s", name)
}
}
if name, found := refersTo(job, legacyRegistrySecrets); found {
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
}
}
func scalarString(node *yaml.Node) string {
@@ -122,20 +133,6 @@ func scalarString(node *yaml.Node) string {
return strings.Join(scalars, "\n")
}
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
t.Error("release-studio.yml grants write-all")
}
jobs := jobsOf(t, root)
for i := 0; i+1 < len(jobs.Content); i += 2 {
name, job := jobs.Content[i].Value, jobs.Content[i+1]
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
t.Errorf("release-studio.yml job %s grants write-all", name)
}
}
}
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
files, err := filepath.Glob("../../.github/workflows/*.yml")
if err != nil || len(files) == 0 {
@@ -176,7 +173,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
if err != nil || len(files) == 0 {
t.Fatalf("no workflows found: %v", err)
}
guarded := cliChannelSecrets
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
for _, file := range files {
root := parseWorkflowFile(t, file)
jobs := mappingValue(root, "jobs")
@@ -199,18 +196,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
continue
}
if name, found := refersTo(root, cliChannelSecrets); found {
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
}
}
}
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
for _, text := range []string{
"${{ secrets.NPM_TOKEN }}",
"${{ secrets['NPM_TOKEN'] }}",
`${{ secrets["NPM_TOKEN"] }}`,
"${{ secrets.STUDIO_NPM_TOKEN }}",
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
"${{ toJSON(secrets) }}",
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
} {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
@@ -228,3 +225,82 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
t.Error("an unrelated secret was reported")
}
}
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
t.Helper()
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
t.Fatal("cli-channels has no steps")
}
gate := -1
for i, step := range steps.Content {
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
gate = i
break
}
}
if gate < 0 {
t.Fatal("cli-channels has no environment check step")
}
for i, step := range steps.Content[:gate] {
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
t.Errorf("step %d runs or gets credentials before the environment check", i)
}
}
for i, step := range steps.Content {
if i > gate {
continue
}
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
if nodeContains(step, name) {
t.Errorf("step %d references %s before or at the environment check", i, name)
}
}
}
}
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not available")
}
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-channels")
var script string
for _, step := range mappingValue(job, "steps").Content {
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
script = run
break
}
}
if script == "" {
t.Fatal("environment check step not found")
}
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
cases := []struct {
name, response string
pass bool
}{
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
{"empty body", "HTTP/2.0 200 OK\n\n", false},
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
}
for _, tc := range cases {
dir := t.TempDir()
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
out, err := cmd.CombinedOutput()
passed := err == nil && strings.Contains(string(out), "PASSED")
if passed != tc.pass {
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
}
}
}
-165
View File
@@ -1,165 +0,0 @@
package main
import (
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-pointer")
if job == nil {
t.Fatal("cli-pointer job is missing")
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
needs := mappingValue(job, "needs")
if needs == nil || needs.Kind != yaml.SequenceNode {
t.Fatal("cli-pointer must list its needs")
}
seen := map[string]bool{}
for _, n := range needs.Content {
seen[n.Value] = true
}
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
}
concurrency := mappingValue(job, "concurrency")
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
}
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
t.Fatal("cli-pointer must not cancel a run that is mid-write")
}
permissions := mappingValue(job, "permissions")
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
mappingScalar(permissions, "contents") != "write" {
t.Fatal("cli-pointer permissions must be exactly contents: write")
}
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
}
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
}
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
}
}
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
gate := mappingValue(jobs, "cli-gate")
if gate == nil {
t.Fatal("cli-gate job is missing")
}
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
}
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
t.Fatal("cli-gate must run no repository code and read no secret")
}
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
needs := mappingValue(mappingValue(jobs, name), "needs")
found := false
for _, n := range needs.Content {
found = found || n.Value == "cli-gate"
}
if !found {
t.Fatalf("%s does not wait for cli-gate", name)
}
}
for _, name := range []string{"cli-channels", "cli-pointer"} {
found := false
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
found = found || n.Value == "cli-tag"
}
if !found {
t.Fatalf("%s does not wait for cli-tag", name)
}
}
found := false
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
found = found || n.Value == "cli-pointer"
}
if !found {
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
}
}
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
func workflowFiles(t *testing.T) []string {
t.Helper()
var files []string
for _, pattern := range []string{"*.yml", "*.yaml"} {
found, err := filepath.Glob("../../.github/workflows/" + pattern)
if err != nil {
t.Fatal(err)
}
files = append(files, found...)
}
if len(files) == 0 {
t.Fatal("no workflows found")
}
return files
}
// The token may appear in one inline step of Studio's cli-tag job and in no
// other env scope: not the workflow's, not a job's, not another step's.
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
holders := 0
for _, file := range workflowFiles(t) {
root := parseWorkflowFile(t, file)
studio := filepath.Base(file) == "release-studio.yml"
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value == "jobs" {
continue
}
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
}
}
jobs := mappingValue(root, "jobs")
if jobs == nil {
continue
}
for i := 0; i+1 < len(jobs.Content); i += 2 {
id, job := jobs.Content[i].Value, jobs.Content[i+1]
allowed := studio && id == "cli-tag"
for j := 0; j+1 < len(job.Content); j += 2 {
key, value := job.Content[j].Value, job.Content[j+1]
if key != "steps" {
if what, found := refersTo(value, releaseTagSecrets); found {
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
}
continue
}
for _, step := range value.Content {
if what, found := refersTo(step, releaseTagSecrets); found {
holders++
if !allowed {
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
}
if mappingScalar(step, "uses") != "" {
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
}
} else if allowed && mappingScalar(step, "uses") != "" {
t.Errorf("cli-tag must check out nothing and run no action")
}
}
}
}
}
if holders != 1 {
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

+1 -1
View File
@@ -19,7 +19,7 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.5.1",
"electron": "44.4.5",
"electron-builder": "^26.15.3",
"playwright": "1.63.0"
},
+4 -11
View File
@@ -7,17 +7,11 @@ const { chromium } = require("playwright");
const ASSETS = path.join(__dirname, "..", "assets");
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
// pixels and resizes whatever it is handed, so every Windows scale has its own
// file (src/trayimage.js picks it).
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
// matches the display, so a scaled screen never enlarges the 16px one.
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
// Linux keep the full-canvas artwork the platform templates expect.
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
const WIN_GLYPH_SCALE = "scale(2.4)";
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
const ICNS_TYPES = [
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
@@ -79,9 +73,8 @@ async function main() {
for (const [scale, suffix] of TRAY_SCALES) {
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
}
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
const icoPngs = [];
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
const icnsPngs = [];
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
electron:
specifier: 44.5.1
version: 44.5.1([email protected])
specifier: 44.4.5
version: 44.4.5([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@44.5.1:
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
electron@44.4.5:
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.5.1([email protected]):
electron@44.4.5([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
-2
View File
@@ -1,7 +1,6 @@
"use strict";
const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
// The size a page lays out at while nobody is looking at it: the kernel drives
@@ -97,7 +96,6 @@ class BrowserViews {
if (allowed(to)) onPopup(to);
return { action: "deny" };
});
installPaneReload(contents);
contents.debugger.attach("1.3");
contents.debugger.on("message", (_event, method, params, sessionId) => {
if (sessionId) return;
+13 -123
View File
@@ -1,131 +1,21 @@
"use strict";
// Role labels come from the platform's own catalogue, which follows the
// system locale rather than the language the interface was set to, so they are
// stated here for the two languages the interface draws.
const WORDS = {
zh: {
edit: "编辑",
undo: "撤销",
redo: "重做",
cut: "剪切",
copy: "复制",
paste: "粘贴",
selectAll: "全选",
pasteAndMatchStyle: "粘贴并匹配样式",
delete: "删除",
substitutions: "替换",
showSubstitutions: "显示替换",
smartQuotes: "智能引号",
smartDashes: "智能破折号",
textReplacement: "文本替换",
speech: "语音",
startSpeaking: "开始朗读",
stopSpeaking: "停止朗读",
},
en: {
edit: "Edit",
undo: "Undo",
redo: "Redo",
cut: "Cut",
copy: "Copy",
paste: "Paste",
selectAll: "Select All",
pasteAndMatchStyle: "Paste and Match Style",
delete: "Delete",
substitutions: "Substitutions",
showSubstitutions: "Show Substitutions",
smartQuotes: "Smart Quotes",
smartDashes: "Smart Dashes",
textReplacement: "Text Replacement",
speech: "Speech",
startSpeaking: "Start Speaking",
stopSpeaking: "Stop Speaking",
},
};
function wordsFor(lang) {
return WORDS[lang] ?? WORDS.en;
}
// The edit commands as roles carrying explicit labels: roles do the clipboard
// work, the labels follow the interface language.
function editItems(lang, can) {
const w = wordsFor(lang);
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
return [
{ role: "undo", label: w.undo, ...on("canUndo") },
{ role: "redo", label: w.redo, ...on("canRedo") },
{ type: "separator" },
{ role: "cut", label: w.cut, ...on("canCut") },
{ role: "copy", label: w.copy, ...on("canCopy") },
{ role: "paste", label: w.paste, ...on("canPaste") },
{ type: "separator" },
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
];
}
// The context menu's shape, kept apart from the platform so it can be checked
// without one. editFlags is the page's own account of what is possible at the
// click: this only decides what to offer.
function contextTemplate(params, lang) {
// click: roles do the clipboard work, this only decides what to offer.
function contextTemplate(params) {
if (!params.isEditable && !params.selectionText) return [];
return editItems(lang, params.editFlags ?? {});
}
// Everything the platform's own Edit menu carries, with the same roles, so
// only the words change.
function editMenuTemplate(lang) {
const w = wordsFor(lang);
const items = editItems(lang);
const at = items.findIndex((i) => i.role === "selectAll");
items.splice(at - 1, 1);
items.splice(at - 1, 0,
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
{ role: "delete", label: w.delete });
items.push(
const can = params.editFlags ?? {};
return [
{ role: "undo", enabled: !!can.canUndo },
{ role: "redo", enabled: !!can.canRedo },
{ type: "separator" },
{
label: w.substitutions,
submenu: [
{ role: "showSubstitutions", label: w.showSubstitutions },
{ type: "separator" },
{ role: "toggleSmartQuotes", label: w.smartQuotes },
{ role: "toggleSmartDashes", label: w.smartDashes },
{ role: "toggleTextReplacement", label: w.textReplacement },
],
},
{
label: w.speech,
submenu: [
{ role: "startSpeaking", label: w.startSpeaking },
{ role: "stopSpeaking", label: w.stopSpeaking },
],
},
);
return { label: w.edit, submenu: items };
{ role: "cut", enabled: !!can.canCut },
{ role: "copy", enabled: !!can.canCopy },
{ role: "paste", enabled: !!can.canPaste },
{ type: "separator" },
{ role: "selectAll", enabled: !!can.canSelectAll },
];
}
function applicationMenuTemplate(lang) {
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
}
// Only the Edit menu follows the interface language; the application and window
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
function menuInstaller(Menu, platform = process.platform) {
let installedLang = null;
return function installApplicationMenu(languageOf = () => "en") {
if (platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const lang = languageOf();
if (lang === installedLang) return Menu.getApplicationMenu();
installedLang = lang;
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
Menu.setApplicationMenu(menu);
return menu;
};
}
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
module.exports = { contextTemplate };
+13 -60
View File
@@ -5,42 +5,22 @@ const PROTOCOL_VERSION = 1;
// A child that never sends a handshake must not be able to grow this process
// while it waits, nor hold the launch open forever.
const HANDSHAKE_LIMIT = 4096;
const HANDSHAKE_TIMEOUT_MS = 60000;
const SLOW_AFTER_MS = 3000;
// HostStartError says why a launch has no kernel. code is one of "timeout",
// "exited", "spawn", "overflow" or "malformed"; callers branch on it, never on
// the sentence.
class HostStartError extends Error {
constructor(code, message, detail = {}) {
super(message);
this.name = "HostStartError";
this.code = code;
Object.assign(this, detail);
}
}
const HANDSHAKE_TIMEOUT_MS = 20000;
// start spawns the kernel and reads the handshake it writes to stdout. Every
// line after it is an act a handover asks of this process, because that pipe is
// the only channel pointing this way. stdin is the lease: the kernel drains
// when this end closes, which is what stops it outliving a parent that exited
// without being asked to.
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
const env = { ...process.env };
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
function start(binary, args, { onStderr, onExit, onAct } = {}) {
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.setEncoding("utf8");
if (onStderr) child.stderr.on("data", onStderr);
if (onExit) child.on("exit", onExit);
const ready = firstLine(child, { onSlow, timeoutMs }).then(({ line, rest }) => {
const ready = firstLine(child).then(({ line, rest }) => {
// Only once the handshake parses: a child whose first line was not one has
// said nothing this process should act on.
let handshake;
try {
handshake = parse(line);
} catch (err) {
throw new HostStartError("malformed", err.message);
}
const handshake = parse(line);
if (onAct) readActs(child, rest, onAct);
return handshake;
});
@@ -80,31 +60,19 @@ function readActs(child, rest, onAct) {
drain();
}
function firstLine(child, { onSlow, timeoutMs }) {
function firstLine(child) {
return new Promise((resolve, reject) => {
let buffered = "";
const began = Date.now();
const elapsed = () => Date.now() - began;
const settle = (fn, value) => {
clearTimeout(timer);
clearTimeout(slow);
child.stdout.off("data", onData);
child.off("exit", onEarlyExit);
child.off("error", onSpawnFailure);
fn(value);
};
const slow = onSlow ? setTimeout(onSlow, Math.min(SLOW_AFTER_MS, timeoutMs)) : undefined;
const timer = setTimeout(
() =>
settle(
reject,
new HostStartError(
"timeout",
`the kernel was still running but sent no handshake within ${Math.round(timeoutMs / 1000)} s`,
{ elapsedMs: elapsed() },
),
),
timeoutMs,
() => settle(reject, new Error("the kernel sent no handshake")),
HANDSHAKE_TIMEOUT_MS,
);
const onData = (chunk) => {
buffered += chunk;
@@ -113,22 +81,15 @@ function firstLine(child, { onSlow, timeoutMs }) {
return settle(resolve, { line: buffered.slice(0, end), rest: buffered.slice(end + 1) });
}
if (buffered.length > HANDSHAKE_LIMIT) {
settle(reject, new HostStartError("overflow", "the handshake ran past its limit"));
settle(reject, new Error("the handshake ran past its limit"));
}
};
const onEarlyExit = (code, signal) =>
settle(
reject,
new HostStartError("exited", `the kernel exited with ${code ?? `signal ${signal}`} before saying anything`, {
exitCode: code,
signal,
elapsedMs: elapsed(),
}),
);
const onEarlyExit = (code) =>
settle(reject, new Error(`the kernel exited with ${code} before saying anything`));
// A program that could not be started emits this and never exits, so without
// it the launch waited out the handshake timeout and then reported the
// kernel as silent — which sent the reader looking at the kernel.
const onSpawnFailure = (err) => settle(reject, new HostStartError("spawn", `the kernel could not be started: ${err.message}`, { elapsedMs: elapsed() }));
const onSpawnFailure = (err) => settle(reject, new Error(`the kernel could not be started: ${err.message}`));
child.stdout.setEncoding("utf8");
child.stdout.on("data", onData);
child.on("exit", onEarlyExit);
@@ -167,12 +128,4 @@ function parse(line) {
return { origin: url.origin, token: body.token };
}
const RETRY_WITHIN_MS = 10000;
// shouldRetry allows one more attempt only for a kernel that stopped, or could
// not be run, soon after it was started.
function shouldRetry(err, attempt, elapsedMs) {
return attempt < 2 && (err?.code === "exited" || err?.code === "spawn") && elapsedMs <= RETRY_WITHIN_MS;
}
module.exports = { shouldRetry, start, parse, readActs, HostStartError, PROTOCOL_VERSION, HANDSHAKE_TIMEOUT_MS };
module.exports = { start, parse, readActs, PROTOCOL_VERSION };
+25 -88
View File
@@ -7,17 +7,15 @@ if (relaunchForOzonePlatform(app, process)) return;
const fs = require("node:fs/promises");
const { existsSync } = require("node:fs");
const path = require("node:path");
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
const { showStarting } = require("./starting");
const { start } = require("./host");
const { StudioHost } = require("./hostclient");
const { installTray } = require("./tray");
const { instanceID, profileFor } = require("./instance");
const { installApplicationMenu, installContextMenu } = require("./menu");
const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal, revealWorkspace } = require("./reveal");
const { reveal } = require("./reveal");
const { appIcon } = require("./appicon");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
@@ -25,7 +23,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { prefsFile, registerPrefs } = require("./prefs");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -47,7 +45,6 @@ const LIGHTS = { x: 11, y: 20 };
const DEFAULT_SIZE = { width: 1440, height: 900 };
const MIN_SIZE = { width: 760, height: 480 };
const HOST_DRAIN_MS = 1000;
const RETRY_DELAY_MS = 1000;
const where = {
packaged: app.isPackaged,
@@ -73,70 +70,6 @@ let reload = null;
let logs = null;
let handshaken = false;
let starting = null;
function closeStarting() {
const win = starting;
starting = null;
if (win && !win.isDestroyed()) win.close();
}
function handshakeTimeout() {
const ms = Number(process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS);
return Number.isFinite(ms) && ms > 0 ? ms : HANDSHAKE_TIMEOUT_MS;
}
// A kernel that exits or cannot be spawned within seconds is tried once more:
// a scanner holding a freshly written binary lets go about that fast. A slow
// kernel is waited for instead, never restarted.
async function launchKernel(args) {
for (let attempt = 1; attempt <= 2; attempt++) {
if (quitting) throw new Error("the launch was ended by quit");
const began = Date.now();
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
kernel = start(hostBinary, args, {
systemLanguage: app.getPreferredSystemLanguages()[0] ?? app.getLocale(),
timeoutMs: handshakeTimeout(),
onSlow: () => {
logs.shell.line("host: no handshake yet; showing the starting window");
if (starting || quitting) return;
const win = showStarting(app.getLocale());
starting = win;
win.once("closed", () => {
if (starting !== win) return;
starting = null;
logs.shell.line("host: starting window closed; ending the launch");
app.quit();
});
},
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
current.child.stderr.on("close", () => logs.host.flush());
try {
return await current.ready;
} catch (err) {
if (quitting || !shouldRetry(err, attempt, Date.now() - began)) {
err.attempts = attempt;
closeStarting();
throw err;
}
logs.shell.line(`host: ${err.message}; retrying once`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
}
async function boot() {
// Which build this is belongs to the shell: inside the bundle the kernel's
// own os.Executable() names the host binary, not the application around it.
@@ -153,7 +86,23 @@ async function boot() {
// inside the bundle rather than being it.
args.push("-studio-app", process.execPath, "-studio-app-pid", String(process.pid));
}
const ready = await launchKernel(args);
logs.shell.line(`host: starting ${hostBinary}`);
kernel = start(hostBinary, args, {
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
kernel.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
kernel.child.stderr.on("close", () => logs.host.flush());
const ready = await kernel.ready;
logs.addSecret(ready.token);
handshaken = true;
logs.shell.line(`host: handshake from ${ready.origin}`);
@@ -161,14 +110,13 @@ async function boot() {
client = new StudioHost(ready.origin, ready.token);
await armCredential(ready);
win = createWindow();
closeStarting();
guard(win.webContents);
win.webContents.once("render-process-gone", (_event, details) => {
if (win.isVisible() || details.reason !== "crashed") return;
const cause = unpaintedWindowCause(grants, app.getLocale());
if (cause) dialog.showErrorBox(cause.title, cause.detail);
});
installContextMenu(win.webContents, win, uiLang);
installContextMenu(win.webContents, win);
installFullScreenKey(win.webContents, win);
reload = installReload(win.webContents, win);
win.once("ready-to-show", () => win.show());
@@ -253,7 +201,6 @@ function onWindowClose(event) {
// showWindow brings it back from wherever it went. Show alone is a no-op on a
// window that is merely buried, so the focus is what actually raises it.
function showWindow() {
if (starting && !starting.isDestroyed()) starting.focus();
if (quitting || !win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
reload?.revive();
@@ -316,10 +263,7 @@ function fromWindow(event) {
return win && !win.isDestroyed() && event.sender === win.webContents ? win : null;
}
// The page reads navigator.languages[0], which is the first preferred system
// language; app.getLocale() is the locale Chromium resolved, which can differ.
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow);
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
@@ -356,11 +300,8 @@ ipcMain.handle("browser:login-answer", (event, id, username, password) => {
ipcMain.handle("shell:open-external", (event, raw) => {
if (!fromWindow(event)) return;
const target = externalTarget(raw);
if (target) return shell.openExternal(target);
if (target) void shell.openExternal(target);
});
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
@@ -436,7 +377,7 @@ if (!primary) {
app.whenReady().then(() => {
if (!primary) return;
installApplicationMenu(uiLang);
installApplicationMenu();
boot().catch((err) => {
console.error("reasonix-studio:", err.message);
if (quitting) {
@@ -482,11 +423,7 @@ function handOver(act) {
if (act === "quit") app.quit();
}
// Before the handshake the only window is the starting one, and the launch
// decides for itself whether to go on.
app.on("window-all-closed", () => {
if (handshaken) app.quit();
});
app.on("window-all-closed", () => app.quit());
// Closing this end of the pipe is what tells the kernel to drain. Without it a
// session file is left being written by a process nobody is holding open.
+16 -4
View File
@@ -1,19 +1,31 @@
"use strict";
const { Menu } = require("electron");
const { contextTemplate, menuInstaller } = require("./editmenu");
const { contextTemplate } = require("./editmenu");
// Without an application menu a WebContents has no copy, paste or undo at all:
// macOS routes those shortcuts through it, and a window with none reads as a
// broken text editor. Elsewhere the bar would render inside the window, and
// those platforms bind the shortcuts themselves.
const installApplicationMenu = menuInstaller(Menu);
function installApplicationMenu() {
if (process.platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const menu = Menu.buildFromTemplate([
{ role: "appMenu" },
{ role: "editMenu" },
{ role: "windowMenu" },
]);
Menu.setApplicationMenu(menu);
return menu;
}
// A production build ships without one, so a right-click in a text field offered
// nothing at all. Roles rather than handlers: the clipboard work belongs to the
// platform, and editFlags is the page's own account of what is possible here.
function installContextMenu(contents, window, languageOf = () => "en") {
function installContextMenu(contents, window) {
contents.on("context-menu", (_event, params) => {
const template = contextTemplate(params, languageOf());
const template = contextTemplate(params);
if (!template.length) return;
Menu.buildFromTemplate(template).popup({ window });
});
+1 -2
View File
@@ -44,7 +44,7 @@ function savePrefs(file, prefs) {
// in the same process and must not reach this file.
// fileOf is asked on each use: the profile directory is settled during boot,
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
function registerPrefs(ipcMain, fileOf, fromWindow) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
});
@@ -52,7 +52,6 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
if (fromWindow(event)) {
try {
savePrefs(fileOf(), prefs);
onSave();
} catch {
// A preference that did not save is one relaunch of lost state, not a
// reason to fail the window.
-2
View File
@@ -28,8 +28,6 @@ contextBridge.exposeInMainWorld("reasonixHost", {
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
// Where a dropped file lives. Resolved here rather than in the page: the
// renderer is handed a File and never a path, and a turn that has to work on
// the file itself cannot do it on a copy of the bytes.
+10 -26
View File
@@ -1,38 +1,22 @@
"use strict";
// A page gone blank, whether its tree unmounted or its renderer died, has no
// way back but quitting. The kernel owns every running turn and a loaded page
// finds it again. F5 and Ctrl/Cmd+R belong to the page, so the recovery chord
// is the shifted one.
// No menu this shell installs carries a reload role, and a page gone blank,
// whether its tree unmounted or its renderer died, has no way back but
// quitting. The kernel owns every running turn and a loaded page finds it again.
const CRASH_RELOADS = 3;
const CRASH_WINDOW_MS = 60_000;
function reloadAction(input, platform = process.platform) {
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt) return null;
const mod = platform === "darwin" ? input.meta && !input.control : input.control && !input.meta;
const latin = typeof input.key === "string" && /^[a-z]$/i.test(input.key);
const isR = latin ? input.key.toLowerCase() === "r" : input.code === "KeyR";
if (mod && isR) return input.shift ? "hard" : "reload";
if (!input.control && !input.meta && !input.shift && input.key === "F5") return "reload";
return null;
}
function installPaneReload(contents, platform = process.platform) {
contents.on("before-input-event", (event, input) => {
const action = reloadAction(input, platform);
if (!action) return;
event.preventDefault();
if (action === "hard") contents.reloadIgnoringCache();
else contents.reload();
});
}
// A dead renderer's input never reaches before-input-event, so the crash itself
// has to trigger the reload, or for a hidden window the next revive before it is
// shown. A page that dies on every load stops being reloaded.
function installReload(contents, window, { platform = process.platform, now = Date.now } = {}) {
const mac = platform === "darwin";
contents.on("before-input-event", (event, input) => {
if (reloadAction(input, platform) !== "hard") return;
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt || input.shift) return;
const mod = mac ? input.meta && !input.control : input.control && !input.meta;
const bare = !input.control && !input.meta;
const chord = mod && (input.key === "r" || input.key === "R" || input.code === "KeyR");
if (!chord && !(bare && input.key === "F5")) return;
event.preventDefault();
contents.reload();
});
@@ -56,4 +40,4 @@ function installReload(contents, window, { platform = process.platform, now = Da
};
}
module.exports = { installReload, installPaneReload, reloadAction, CRASH_RELOADS, CRASH_WINDOW_MS };
module.exports = { installReload, CRASH_RELOADS, CRASH_WINDOW_MS };
+2 -15
View File
@@ -23,13 +23,9 @@ function local(target, platform) {
async function locate(client, base, rel, platform) {
if (!PANE_BASE.test(base)) return { refusal: refused("not a pane this window publishes") };
const query = rel ? `?path=${encodeURIComponent(rel)}` : "";
return ask(client, `${base}/workspace/locate${query}`, platform);
}
async function ask(client, url, platform) {
let res;
try {
res = await client.request("GET", url);
res = await client.request("GET", `${base}/workspace/locate${query}`);
} catch (err) {
return { refusal: refused(err.message) };
}
@@ -58,13 +54,4 @@ async function reveal(client, shell, base, rel, platform = process.platform) {
return null;
}
// A project the sidebar lists needs no pane: the hub answers for the folders
// it lists and refuses any other, so the page still names no location itself.
async function revealWorkspace(client, shell, root, platform = process.platform) {
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
if (found.refusal) return found.refusal;
shell.showItemInFolder(found.target);
return null;
}
module.exports = { reveal, revealWorkspace };
module.exports = { reveal };
+12 -25
View File
@@ -159,29 +159,17 @@ function openLogs(userData, options = {}) {
};
}
const HINTS = {
timeout: [
"The kernel started but did not finish starting in time. Security software scanning a new or updated install is a common cause; starting Studio again usually works.",
"内核已启动,但没有在限定时间内完成启动。安全软件扫描新安装或刚更新的程序是常见原因;再次启动通常可以。",
],
exited: [
"The kernel process stopped before it was ready. Anything it printed is above; host.log has the rest.",
"内核进程在就绪前就停止了。它输出过的内容在上面,host.log 里有更多。",
],
spawn: [
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
],
};
function startupFailure(locale, reason, logDir, code) {
const zh = String(locale).toLowerCase().startsWith("zh");
const hint = HINTS[code]?.[zh ? 1 : 0];
const body = hint ? `${reason}\n\n${hint}` : reason;
if (zh) {
return { title: "Reasonix Studio 无法启动", detail: `${body}\n\n日志位于:\n${logDir}` };
function startupFailure(locale, reason, logDir) {
if (String(locale).toLowerCase().startsWith("zh")) {
return {
title: "Reasonix Studio 无法启动",
detail: `${reason}\n\n日志位于:\n${logDir}`,
};
}
return { title: "Reasonix Studio could not start", detail: `${body}\n\nLogs are in:\n${logDir}` };
return {
title: "Reasonix Studio could not start",
detail: `${reason}\n\nLogs are in:\n${logDir}`,
};
}
// failStartup is the one way a launch that cannot come up ends: recorded, told
@@ -189,9 +177,8 @@ function startupFailure(locale, reason, logDir, code) {
// words when it never finished its handshake, and is already redacted.
function failStartup({ app, dialog, logs, locale }, err, hostOutput = "") {
logs.shell.line(`startup failed: ${err?.stack || err}`);
const tried = err?.attempts > 1 ? ` (after ${err.attempts} attempts)` : "";
const reason = [`${String(err?.message || err)}${tried}`, hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir, err?.code);
const reason = [String(err?.message || err), hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir);
try {
dialog.showErrorBox(text.title, text.detail);
} catch (e) {
-41
View File
@@ -1,41 +0,0 @@
"use strict";
const { BrowserWindow } = require("electron");
const TEXT = {
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
};
function startingPage(locale) {
const [title, note] = String(locale).toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
return `<!doctype html><meta charset="utf-8"><title>${title}</title><style>
:root{color-scheme:light dark;--bg:#fafafa;--fg:#1c1c1e;--mute:#6b6b70}
@media(prefers-color-scheme:dark){:root{--bg:#1b1b1d;--fg:#ececee;--mute:#9a9aa0}}
body{margin:0;height:100vh;display:flex;flex-direction:column;justify-content:center;gap:8px;padding:0 28px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
h1{margin:0;font-size:16px;font-weight:600}p{margin:0;color:var(--mute);line-height:1.5}
.bar{height:3px;margin-top:10px;border-radius:2px;background:var(--mute);opacity:.35;overflow:hidden;position:relative}
.bar::after{content:"";position:absolute;inset:0 70% 0 0;background:var(--fg);animation:run 1.4s ease-in-out infinite}
@keyframes run{from{transform:translateX(-100%)}to{transform:translateX(340%)}}
</style><h1>${title}</h1><p>${note}</p><div class="bar"></div>`;
}
// showStarting is the only sign of life while the kernel has not answered: until
// it does there is no window of the application's own to draw.
function showStarting(locale) {
const win = new BrowserWindow({
width: 380,
height: 150,
resizable: false,
minimizable: false,
maximizable: false,
frame: true,
title: "Reasonix Studio",
autoHideMenuBar: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false },
});
win.removeMenu();
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startingPage(locale))}`);
return win;
}
module.exports = { showStarting, startingPage };
+4 -7
View File
@@ -1,17 +1,18 @@
"use strict";
const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
const path = require("node:path");
const { Menu, Tray, nativeImage } = require("electron");
// How often the fold is re-read. It is a projection the kernel recomputes on
// demand, so a missed read costs nothing the next one does not restore — which
// is why it is pulled on a timer rather than carried on the stream.
const FOLD_INTERVAL_MS = 5000;
const ICON = path.join(__dirname, "..", "assets", "tray.png");
// installTray brings the icon up and reports whether there is one. A shell with
// no icon must never background its window: hiding it would leave a running
// session with nothing left to bring it back.
function installTray(host, { onOpen, onQuit }) {
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
const image = nativeImage.createFromPath(ICON);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
@@ -46,9 +47,6 @@ function installTray(host, { onOpen, onQuit }) {
);
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
if (process.platform === "win32") screen.on("display-metrics-changed", refit);
icon.on("click", onOpen);
const timer = setInterval(() => void refresh(), FOLD_INTERVAL_MS);
@@ -70,7 +68,6 @@ function installTray(host, { onOpen, onQuit }) {
refresh,
close() {
clearInterval(timer);
screen.removeListener("display-metrics-changed", refit);
icon.destroy();
},
};
-30
View File
@@ -1,30 +0,0 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const ASSETS = path.join(__dirname, "..", "assets");
const SCALES = [
[1, ""],
[1.25, "@1.25x"],
[1.5, "@1.5x"],
[1.75, "@1.75x"],
[2, "@2x"],
[2.5, "@2.5x"],
[3, "@3x"],
];
function trayAsset(scaleFactor) {
const want = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
const hit = SCALES.find(([scale]) => scale >= want - 1e-6) ?? SCALES[SCALES.length - 1];
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
}
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
// platforms choose among the @Nx files themselves.
function trayImage(nativeImage, scaleFactor) {
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
}
module.exports = { trayAsset, trayImage, SCALES };
-17
View File
@@ -1,17 +0,0 @@
"use strict";
// The interface language the page will draw in, decided from the same two
// facts it uses: its saved choice (rx-lang) and, for anything but an explicit
// zh or en, the machine's first locale.
function isChinese(tag) {
const s = String(tag ?? "").toLowerCase();
return s.startsWith("zh") || s.startsWith("yue") || s.startsWith("cmn") || s.includes("hans") || s.includes("hant");
}
function uiLanguage(prefs, machineLocale) {
const want = String(prefs?.["rx-lang"] ?? "").trim().toLowerCase();
if (want === "zh" || want === "en") return want;
return isChinese(machineLocale) ? "zh" : "en";
}
module.exports = { uiLanguage };
+3 -3
View File
@@ -303,11 +303,11 @@ async function menuChecks(win, js) {
const menu = Menu.getApplicationMenu();
const roles = (menu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the application menu is installed", !!menu);
check("it carries the three menus macOS routes through", ["appmenu", "windowmenu"].every((r) => roles.includes(r)) && menu.items.length === 3, roles);
check("it carries the three menus macOS routes through", ["appmenu", "editmenu", "windowmenu"].every((r) => roles.includes(r)), roles);
const edit = (menu?.items ?? [])[1];
const edit = (menu?.items ?? []).find((i) => String(i.role).toLowerCase() === "editmenu");
const editRoles = (edit?.submenu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "pasteandmatchstyle", "delete", "selectall"].every((r) => editRoles.includes(r)), editRoles);
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "selectall"].every((r) => editRoles.includes(r)), editRoles);
app.focus({ steal: true });
win.focus();
+28 -256
View File
@@ -6,9 +6,8 @@ import os from "node:os";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const { parse, readActs, start } = require("../src/host.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
const { uiLanguage } = require("../src/uilang.js");
const { parse, readActs } = require("../src/host.js");
const { contextTemplate } = require("../src/editmenu.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
@@ -17,23 +16,6 @@ const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js")
const TOKEN = "a".repeat(64);
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
test("the spawned host receives the system language and inherits explicit locale overrides", async () => {
const script = `console.log(${JSON.stringify(line())});
console.log(JSON.stringify({act: JSON.stringify({system: process.env.REASONIX_SYSTEM_LANG,
explicit: process.env.REASONIX_LANG})}));`;
const before = process.env.REASONIX_SYSTEM_LANG;
const explicit = process.env.REASONIX_LANG;
const state = new Promise((resolve) => {
const host = start(process.execPath, ["-e", script], {
systemLanguage: "zh-Hant-TW",
onAct: (act) => resolve(JSON.parse(act)),
});
host.ready.catch(resolve);
});
assert.deepEqual(await state, { system: "zh-Hant-TW", ...(explicit === undefined ? {} : { explicit }) });
assert.equal(process.env.REASONIX_SYSTEM_LANG, before);
});
test("the handshake is accepted only when every field accounts for itself", () => {
assert.deepEqual(parse(line()), { origin: "http://127.0.0.1:8080", token: TOKEN });
@@ -81,68 +63,6 @@ test("the context menu mirrors what the page says is possible", () => {
});
});
test("the edit menu's labels follow the interface language, not the system's", () => {
const params = { isEditable: true, selectionText: "", editFlags: {} };
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
assert.deepEqual(labels("zh"), ["撤销", "重做", "剪切", "复制", "粘贴", "全选"]);
assert.deepEqual(labels("en"), ["Undo", "Redo", "Cut", "Copy", "Paste", "Select All"]);
assert.equal(editMenuTemplate("zh").label, "编辑");
});
test("the edit menu keeps everything the platform's own one carries", () => {
const roles = (items) => items.flatMap((i) => [i.role, ...(i.submenu ? roles(i.submenu) : [])]).filter(Boolean);
const want = ["undo", "redo", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll",
"showSubstitutions", "toggleSmartQuotes", "toggleSmartDashes", "toggleTextReplacement", "startSpeaking", "stopSpeaking"];
for (const lang of ["zh", "en"]) {
assert.deepEqual(roles(editMenuTemplate(lang).submenu), want);
const labels = (items) => items.flatMap((i) => [i.label, ...(i.submenu ? labels(i.submenu) : [])]).filter((l) => l !== undefined);
assert.ok(labels(editMenuTemplate(lang).submenu).every((l) => l.length > 0));
}
assert.deepEqual(editMenuTemplate("en").submenu.map((i) => i.role ?? i.type ?? i.label),
["undo", "redo", "separator", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll", "separator", "Substitutions", "Speech"]);
assert.deepEqual(applicationMenuTemplate("zh").map((i) => i.role ?? "edit"), ["appMenu", "edit", "windowMenu"]);
});
test("the application menu is rebuilt when the language changes and not otherwise", () => {
const built = [];
const Menu = {
buildFromTemplate: (t) => (built.push(t), { template: t }),
setApplicationMenu: () => {},
getApplicationMenu: () => null,
};
const install = menuInstaller(Menu, "darwin");
let lang = "en";
install(() => lang);
install(() => lang);
assert.equal(built.length, 1);
lang = "zh";
install(() => lang);
assert.equal(built.length, 2);
assert.equal(built[1][1].label, "编辑");
let cleared = 0;
menuInstaller({ ...Menu, setApplicationMenu: () => cleared++ }, "linux")(() => "zh");
assert.equal(cleared, 1);
assert.equal(built.length, 2);
});
test("the interface language is the page's own choice, else the machine's", () => {
assert.equal(uiLanguage({ "rx-lang": "zh" }, "en-US"), "zh");
assert.equal(uiLanguage({ "rx-lang": "en" }, "zh-CN"), "en");
assert.equal(uiLanguage({ "rx-lang": "" }, "zh-Hans-CN"), "zh");
assert.equal(uiLanguage({}, "fr-FR"), "en");
assert.equal(uiLanguage(undefined, undefined), "en");
});
test("a saved preference tells the shell so it can follow it", () => {
const handlers = {};
const ipc = { on: (name, fn) => { handlers[name] = fn; } };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-prefs-"));
let saved = 0;
registerPrefs(ipc, () => path.join(dir, "p.json"), () => true, () => { saved++; });
handlers["prefs:save"]({ returnValue: null }, { "rx-lang": "zh" });
assert.equal(saved, 1);
});
test("only http and https ever reach the platform opener", () => {
assert.equal(externalTarget("https://example.test/a"), "https://example.test/a");
assert.equal(externalTarget("http://example.test/"), "http://example.test/");
@@ -204,7 +124,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
assert.equal(await dead.trayState(), null);
});
const { reveal, revealWorkspace } = require("../src/reveal.js");
const { reveal } = require("../src/reveal.js");
// A kernel that answers /workspace/locate as the test says, and a shell that
// only records what it was asked to open.
@@ -223,7 +143,7 @@ async function revealRig(answer, platform = process.platform) {
openPath: async (p) => (opened.push(["open", p]), ""),
showItemInFolder: (p) => opened.push(["select", p]),
};
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), close: () => server.close() };
}
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
@@ -274,22 +194,6 @@ test("the page cannot steer reveal to a location the kernel did not name", async
}
});
test("a listed project is shown through the hub and only on its answer", async () => {
const rig = await revealRig((url) =>
url.pathname === "/host/workspaces/locate" && url.searchParams.get("root") === "/work/a b"
? [200, { path: path.join(ROOT, "a b"), dir: true }]
: [404, { code: "workspace.not_listed", error: "not listed" }],
);
try {
assert.equal(await rig.workspace("/work/a b"), null);
assert.equal((await rig.workspace("/etc")).code, "workspace.not_listed");
assert.deepEqual(rig.opened, [["select", path.join(ROOT, "a b")]]);
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fwork%2Fa%20b");
} finally {
rig.close();
}
});
// A root that is an .app bundle, or a link to one, is a directory to stat and
// an application to `open`: handing it to openPath would launch it.
test("the workspace root is selected, never opened", async () => {
@@ -410,79 +314,6 @@ test("a kernel that cannot be started says so, rather than going quiet", async (
});
});
test("a launch failure carries a typed cause, and a slow kernel is waited for, not restarted", async () => {
const { start } = require("../src/host.js");
const node = process.execPath;
let slow = 0;
const silent = start(node, ["-e", "setTimeout(()=>{},5000)"], { timeoutMs: 400, onSlow: () => slow++ });
await assert.rejects(silent.ready, (err) => {
assert.equal(err.code, "timeout");
assert.match(err.message, /still running/);
return true;
});
silent.child.kill();
assert.equal(slow, 1, "the slow state was not reported once");
const quick = start(node, ["-e", "process.exit(7)"]);
await assert.rejects(quick.ready, (err) => err.code === "exited" && err.exitCode === 7 && /exited with 7/.test(err.message));
const missing = start(path.join(os.tmpdir(), "reasonix-no-such-kernel-c9"), []);
await assert.rejects(missing.ready, (err) => err.code === "spawn");
const handshake = JSON.stringify({ version: 1, origin: "http://127.0.0.1:1", token: "x".repeat(32) });
const fine = start(node, ["-e", `console.log(${JSON.stringify(handshake)});setTimeout(()=>{},300)`], { timeoutMs: 400 });
assert.equal((await fine.ready).origin, "http://127.0.0.1:1");
});
test("a retry needs an early stop, is capped at one, and never follows a timeout", () => {
const { shouldRetry } = require("../src/host.js");
const exited = { code: "exited" };
assert.equal(shouldRetry(exited, 1, 500), true);
assert.equal(shouldRetry({ code: "spawn" }, 1, 500), true);
assert.equal(shouldRetry(exited, 1, 10001), false, "an exit after the early window was retried");
assert.equal(shouldRetry(exited, 2, 500), false, "a second retry was allowed");
assert.equal(shouldRetry({ code: "timeout" }, 1, 500), false);
assert.equal(shouldRetry(new Error("x"), 1, 500), false);
});
test("a handshake that does not parse is a typed failure", async () => {
const { start } = require("../src/host.js");
const bad = start(process.execPath, ["-e", "console.log('hello')"]);
await assert.rejects(bad.ready, (err) => err.code === "malformed");
});
test("a kernel that never answers is started once and not retried", { skip: process.platform === "win32" }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-silent-"));
const host = path.join(dir, "host.sh");
fs.writeFileSync(host, "#!/bin/sh\nexec sleep 5\n", { mode: 0o755 });
process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS = "400";
const shell = loadShell({ lock: true, host });
try {
await shell.quitted;
const log = shell.shellLog();
assert.equal((log.match(/host: starting /g) || []).length, 1);
assert.doesNotMatch(log, /retrying once/);
assert.match(shell.calls[0][2], /still running/);
} finally {
delete process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS;
shell.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("each failure cause gets its own explanation in both languages", () => {
const { startupFailure } = require("../src/shelllog.js");
const seen = new Set();
for (const code of ["timeout", "exited", "spawn"]) {
for (const locale of ["en", "zh-CN"]) {
const { detail } = startupFailure(locale, "r", "/logs", code);
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
seen.add(detail);
}
}
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
});
// A fake child: only the stdout half readActs touches, and a way to push bytes
// through it in whatever chunks the test wants -- which is the point, since the
// bug this guards is a line split across two of them.
@@ -1018,67 +849,30 @@ function reloadRig(platform = "darwin") {
return { state, send, gone, revive };
}
const reloadPress = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const reloadMods = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
test("reloadAction reads the chord, not the layout", () => {
const { reloadAction } = require("../src/reload.js");
for (const [platform, mod, other] of reloadMods) {
const at = (input) => reloadAction(input, platform);
assert.equal(at(reloadPress(mod)), "reload");
assert.equal(at(reloadPress({ ...mod, key: "к" })), "reload");
assert.equal(at(reloadPress({ key: "F5", code: "F5" })), "reload");
assert.equal(at(reloadPress({ ...mod, key: "p", code: "KeyR" })), null, `${platform}: a Dvorak P reloaded`);
assert.equal(at(reloadPress({ ...mod, key: "r", code: "KeyP" })), "reload", `${platform}: a Dvorak R did not reload`);
assert.equal(at(reloadPress({ ...mod, shift: true })), "hard");
assert.equal(at(reloadPress({ ...mod, shift: true, key: "R" })), "hard");
for (const input of [
reloadPress(), reloadPress(other), reloadPress({ ...mod, alt: true }),
reloadPress({ ...mod, type: "keyUp" }), reloadPress({ ...mod, isAutoRepeat: true }),
reloadPress({ ...mod, key: "t", code: "KeyT" }), reloadPress({ key: "F5", code: "F5", control: true }),
reloadPress({ key: "F5", code: "F5", shift: true }), reloadPress({ shift: true }),
]) assert.equal(at(input), null, `${platform}: ${JSON.stringify(input)}`);
}
});
test("only the hard reload chord reloads the app window; F5 and Ctrl/Cmd+R reach the page", () => {
for (const [platform, mod] of reloadMods) {
test("the reload key brings back a window whose page stopped drawing", () => {
const press = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const cases = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
for (const [platform, mod, other] of cases) {
const { state, send } = reloadRig(platform);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 0, `${platform}: a plain reload key reloaded the UI`);
assert.equal(state.prevented, 0, `${platform}: a plain reload key was swallowed`);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.equal(state.reloads, 1, `${platform}: the recovery chord did not reload`);
assert.equal(state.prevented, 1);
}
});
test("a browser pane reloads on F5 and Ctrl/Cmd+R, ignoring cache on the shifted chord, and swallows nothing else", () => {
const { installPaneReload } = require("../src/reload.js");
for (const [platform, mod] of reloadMods) {
let handler;
const calls = { reload: 0, hard: 0, prevented: 0 };
const contents = {
on: (name, fn) => { if (name === "before-input-event") handler = fn; },
reload: () => { calls.reload += 1; },
reloadIgnoringCache: () => { calls.hard += 1; },
};
installPaneReload(contents, platform);
const send = (input) => handler({ preventDefault: () => { calls.prevented += 1; } }, input);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.deepEqual(calls, { reload: 2, hard: 0, prevented: 2 }, platform);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, platform);
send(reloadPress({ ...mod, key: "f", code: "KeyF" }));
send(reloadPress({ key: "F12", code: "F12" }));
send(reloadPress({ ...mod, alt: true }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, `${platform}: another key was handled`);
send(press(mod));
send(press({ ...mod, key: "R" }));
send(press({ ...mod, key: "к" }));
send(press({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 4, `${platform}: the reload keys did not reload`);
send(press());
send(press(other));
send(press({ ...mod, shift: true }));
send(press({ ...mod, alt: true }));
send(press({ ...mod, type: "keyUp" }));
send(press({ ...mod, isAutoRepeat: true }));
send(press({ ...mod, key: "t", code: "KeyT" }));
send(press({ key: "F5", code: "F5", control: true }));
assert.equal(state.reloads, 4, `${platform}: something other than a fresh reload press reloaded`);
assert.equal(state.prevented, 4);
}
});
@@ -1310,7 +1104,6 @@ function loadShell({ lock, host }) {
if (key === "getPath") return () => userData;
if (key === "getVersion") return () => "9.9.9";
if (key === "getLocale") return () => "en-US";
if (key === "getPreferredSystemLanguages") return () => ["en-US"];
if (key === "isPackaged") return false;
if (key === "quit") return () => { calls.push(["quit"]); quit(); };
return inert;
@@ -1370,29 +1163,8 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
assert.match(shell.hostLog(), /bad option/, "the host's stderr never reached host.log");
const log = shell.shellLog();
assert.match(log, /host: exited code=9 signal=null before its handshake/);
assert.match(log, /startup failed: HostStartError: /);
assert.equal((log.match(/host: starting /g) || []).length, 2, "an early exit was not retried exactly once");
assert.match(log, /retrying once/);
assert.match(detail, /after 2 attempts/);
assert.match(detail, /stopped before it was ready/, "no cause was given for an early exit");
assert.match(log, /startup failed: Error: /);
} finally {
shell.cleanup();
}
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
const b = fs.readFileSync(f);
return [b.readUInt32BE(16), b.readUInt32BE(20)];
};
for (const [scale] of SCALES) {
const { file, pixels } = trayAsset(scale);
assert.deepEqual(dim(file), [pixels, pixels], file);
assert.equal(pixels, Math.round(16 * scale));
}
assert.equal(trayAsset(1.75).pixels, 28);
assert.equal(trayAsset(1.8).pixels, 32);
assert.equal(trayAsset(5).pixels, 48);
assert.equal(trayAsset(NaN).pixels, 16);
});
+4 -4
View File
@@ -29,9 +29,9 @@
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.5",
"@lezer/highlight": "^1.2.4",
"codemirror": "^6.0.2",
"katex": "^0.18.10",
"katex": "^0.18.9",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
@@ -53,7 +53,7 @@
"jsdom": "^30.1.1",
"playwright": "^1.63.0",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vitest": "^5.0.3"
"vite": "^8.3.0",
"vitest": "^5.0.1"
}
}
-1
View File
@@ -46,7 +46,6 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
+2 -5
View File
@@ -4,7 +4,6 @@ import { track as trackWidth } from "../src/ui/viewport";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/studio.css";
import "../src/styles/feedback.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
@@ -45,12 +44,11 @@ class BenchPort extends MockPort {
}
async providerSetup() {
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
return null;
}
async appearance() {
let look = await super.appearance();
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
const look = await super.appearance();
return PREF === null ? look : { ...look, language: PREF };
}
@@ -150,7 +148,6 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
// ?pref= 是「内核记着的语言」。真机上它来自 config;这里由地址给,好让
// 一次验证能把两侧摆成同一个值——否则 adopt 会认为本地缓存过期。
const PREF = query.get("pref");
const ONBOARDING = query.has("onboarding");
const SESSIONS = Number(query.get("sess") ?? 0);
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
-108
View File
@@ -1,108 +0,0 @@
// The first-run "start" button must be reachable at every window height.
// The body clips, so a card taller than the window is only reachable if the
// stage itself scrolls. Focus is no evidence: the browser scrolls the clipped
// body to a focused element, so the wheel is what gets asserted.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?onboarding=1";
const SHOTS = process.env.ONBOARDING_SHOTS;
const SIZES = [
[1920, 700], [1600, 600], [2560, 560], [1366, 600], [1280, 540], [1440, 900], [1600, 760, 1.25],
[760, 480], [761, 480], [760, 480, 0.8], [761, 480, 1.8], [800, 480, 1.8], [840, 560, 1.8],
[840, 560, 1.5], [1280, 720, 1.8], [1600, 900, 1.8, [840, 560]], [840, 560, 1.8, [1600, 900]],
];
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [width, height, zoom = 1, resize] of SIZES) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const url = new URL(PAGE);
url.searchParams.set("zoom", String(zoom));
await page.goto(url.href, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".onb-card");
await page.waitForFunction((z) => Number(getComputedStyle(document.documentElement).zoom) === z, zoom);
const tag = `${scheme} ${width}x${height}${zoom !== 1 ? ` zoom${zoom}` : ""}${resize ? ` to ${resize.join("x")}` : ""}`;
const fitsCard = async (label) => {
const layout = await page.evaluate(() => {
const stage = document.querySelector(".onb-stage");
const shell = document.querySelector(".onb-shell");
const r = shell.getBoundingClientRect();
const fields = ["#onb-url", "#onb-key", ".onb-go", ".onb-note", ".onb-protocols", ".onb-pick"];
const clipped = fields.flatMap((selector) => {
const el = document.querySelector(selector);
if (!el) return [];
const box = el.getBoundingClientRect();
return box.left < Math.max(0, r.left) - 0.5 || box.right > Math.min(innerWidth, r.right) + 0.5
? [{ selector, left: box.left, right: box.right, shellRight: r.right }] : [];
});
return { clipped, columns: getComputedStyle(shell).gridTemplateColumns.split(" ").length,
expectedColumns: stage.clientWidth <= 760 ? 1 : 2, width: stage.scrollWidth, client: stage.clientWidth };
});
check(`${tag} ${label}: form stays within the card and viewport`, layout.clipped.length === 0, JSON.stringify(layout.clipped));
check(`${tag} ${label}: columns follow zoom-adjusted room`, layout.columns === layout.expectedColumns, JSON.stringify(layout));
check(`${tag} ${label}: stage does not scroll sideways`, layout.width <= layout.client + 1);
};
const reachable = async (label, enabled) => {
const viewport = page.viewportSize();
const go = page.locator(".onb-go");
await page.evaluate(() => document.activeElement?.blur());
await page.mouse.move(viewport.width / 2, viewport.height / 2);
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(250);
const box = await go.boundingBox();
check(`${tag} ${label}: after wheeling to the bottom the button is in the viewport`, !!box && box.y >= 0 && box.y + box.height <= viewport.height + 0.5, JSON.stringify(box));
const bar = await page.locator(".onb-brandbar").boundingBox();
check(`${tag} ${label}: the title bar stays in the viewport`, !!bar && bar.y >= -0.5 && bar.y + bar.height <= viewport.height + 0.5, JSON.stringify(bar));
if (!enabled) return;
const hit = await page.evaluate(() => {
const r = document.querySelector(".onb-go").getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return !!el?.closest(".onb-go");
});
check(`${tag} ${label}: the point under its centre hits it`, hit);
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${tag.replaceAll(" ", "-")}-${label.replaceAll(" ", "-")}.png`) });
const first = page.locator(".onb-chip").first();
await page.evaluate(() => document.activeElement?.blur());
await first.focus();
await page.waitForTimeout(250);
const [fb, bb] = [await first.boundingBox(), await page.locator(".onb-brandbar").boundingBox()];
check(`${tag} ${label}: a focused top-of-card element sits below the title bar`, !!fb && !!bb && fb.y >= bb.y + bb.height - 0.5, JSON.stringify({ fb, bb }));
};
await fitsCard("before connect");
await reachable("before connect", false);
await page.fill('input[placeholder="sk-…"]', "sk-test");
await page.locator(".onb-go").click();
await page.waitForSelector(".onb-found");
await fitsCard("after connect");
await reachable("after connect", true);
if (resize) {
await page.setViewportSize({ width: resize[0], height: resize[1] });
await page.waitForFunction(() => {
const stage = document.querySelector(".onb-stage");
return Math.abs(stage.getBoundingClientRect().width - innerWidth) < 1;
});
await fitsCard("after resize");
await reachable("after resize", true);
}
await page.locator(".onb-go").click();
await page.waitForSelector(".app .chrome");
check(`${tag}: Start finishes setup`, await page.locator(".onb-stage").count() === 0);
await ctx.close();
}
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
process.exit(fails.length ? 1 : 0);
+138 -125
View File
@@ -60,14 +60,14 @@ importers:
specifier: ^6.43.13
version: 6.43.13
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
specifier: ^1.2.4
version: 1.2.4
codemirror:
specifier: ^6.0.2
version: 6.0.2
katex:
specifier: ^0.18.10
version: 0.18.10
specifier: ^0.18.9
version: 0.18.9
react:
specifier: ^19.3.0
version: 19.3.0
@@ -116,7 +116,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected].2([email protected]))
version: 6.1.1([email protected].0([email protected]))
jsdom:
specifier: ^30.1.1
version: 30.1.1
@@ -127,11 +127,11 @@ importers:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.2
version: 8.3.2([email protected])
specifier: ^8.3.0
version: 8.3.0([email protected])
vitest:
specifier: ^5.0.3
version: 5.0.3([email protected])([email protected].2([email protected]))
specifier: ^5.0.1
version: 5.0.1([email protected])([email protected].0([email protected]))
packages:
@@ -453,8 +453,8 @@ packages:
'@lezer/[email protected]':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/[email protected].5':
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
'@lezer/[email protected].4':
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
'@lezer/[email protected]':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
@@ -489,101 +489,101 @@ packages:
'@marijn/[email protected]':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@oxc-project/[email protected]52.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
'@oxc-project/[email protected]47.0':
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -799,8 +799,8 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -810,8 +810,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1051,8 +1051,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected].10:
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
[email protected].9:
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
hasBin: true
[email protected]:
@@ -1396,8 +1396,8 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected].12:
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
[email protected].6:
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1408,6 +1408,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1415,6 +1418,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
@@ -1505,8 +1511,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
[email protected].2:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
[email protected].0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1548,20 +1554,20 @@ packages:
yaml:
optional: true
[email protected].3:
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
[email protected].1:
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.3
'@vitest/browser-preview': 5.0.3
'@vitest/browser-playwright': 5.0.1
'@vitest/browser-preview': 5.0.1
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.3
'@vitest/coverage-v8': 5.0.3
'@vitest/ui': 5.0.3
'@vitest/coverage-istanbul': 5.0.1
'@vitest/coverage-v8': 5.0.1
'@vitest/ui': 5.0.1
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -1615,9 +1621,9 @@ packages:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
why-is-node-running@3.2.1:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
@@ -1765,7 +1771,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@codemirror/[email protected]':
@@ -1783,7 +1789,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
@@ -1792,7 +1798,7 @@ snapshots:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
style-mod: 4.1.4
@@ -1937,41 +1943,41 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected].5':
'@lezer/[email protected].4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
@@ -1981,79 +1987,79 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@marijn/[email protected]': {}
'@oxc-project/[email protected]52.0': {}
'@oxc-project/[email protected]47.0': {}
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected]': {}
@@ -2188,21 +2194,21 @@ snapshots:
'@ungap/[email protected]': {}
'@vitejs/[email protected]([email protected].2([email protected]))':
'@vitejs/[email protected]([email protected].0([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.2([email protected])
vite: 8.3.0([email protected])
'@vitest/[email protected].3([email protected].2([email protected]))':
'@vitest/[email protected].1([email protected].0([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.2([email protected])
vite: 8.3.0([email protected])
'@vitest/[email protected].3': {}
'@vitest/[email protected].1': {}
[email protected]: {}
@@ -2513,7 +2519,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected].10:
[email protected].9:
dependencies:
commander: 15.0.0
@@ -3111,26 +3117,26 @@ snapshots:
[email protected]: {}
[email protected].12:
[email protected].6:
dependencies:
'@oxc-project/types': 0.152.0
'@oxc-project/types': 0.147.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.12
'@rolldown/binding-android-arm64': 1.2.12
'@rolldown/binding-darwin-arm64': 1.2.12
'@rolldown/binding-darwin-x64': 1.2.12
'@rolldown/binding-freebsd-x64': 1.2.12
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
'@rolldown/binding-linux-arm64-gnu': 1.2.12
'@rolldown/binding-linux-arm64-musl': 1.2.12
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
'@rolldown/binding-linux-s390x-gnu': 1.2.12
'@rolldown/binding-linux-x64-gnu': 1.2.12
'@rolldown/binding-linux-x64-musl': 1.2.12
'@rolldown/binding-openharmony-arm64': 1.2.12
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
'@rolldown/binding-android-arm-eabi': 1.2.6
'@rolldown/binding-android-arm64': 1.2.6
'@rolldown/binding-darwin-arm64': 1.2.6
'@rolldown/binding-darwin-x64': 1.2.6
'@rolldown/binding-freebsd-x64': 1.2.6
'@rolldown/binding-linux-arm-gnueabihf': 1.2.6
'@rolldown/binding-linux-arm64-gnu': 1.2.6
'@rolldown/binding-linux-arm64-musl': 1.2.6
'@rolldown/binding-linux-ppc64-gnu': 1.2.6
'@rolldown/binding-linux-s390x-gnu': 1.2.6
'@rolldown/binding-linux-x64-gnu': 1.2.6
'@rolldown/binding-linux-x64-musl': 1.2.6
'@rolldown/binding-openharmony-arm64': 1.2.6
'@rolldown/binding-win32-arm64-msvc': 1.2.6
'@rolldown/binding-win32-x64-msvc': 1.2.6
[email protected]:
dependencies:
@@ -3138,10 +3144,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -3269,21 +3279,21 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected].2([email protected]):
[email protected].0([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.12
rolldown: 1.2.6
tinyglobby: 0.2.17
optionalDependencies:
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].3([email protected])([email protected].2([email protected])):
[email protected].1([email protected])([email protected].0([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
'@vitest/mocker': 5.0.1([email protected].0([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3294,8 +3304,8 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.2([email protected])
why-is-node-running: 3.2.1
vite: 8.3.0([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
jsdom: 30.1.1
transitivePeerDependencies:
@@ -3329,7 +3339,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@3.2.1: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
[email protected]: {}
-65
View File
@@ -81,7 +81,6 @@ export const ACTIONS: UIAction[] = [
// refused while a turn runs, so it never stops work.
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -96,10 +95,6 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
// ── The turn ─────────────────────────────────────────────────────────────
// Send and stop remain two actions even when they sit together: during a
@@ -114,15 +109,6 @@ export const ACTIONS: UIAction[] = [
{ id: "prompt.adopt", kind: "view", target: "none", proof: "interaction" },
{ id: "prompt.discard", kind: "view", target: "none", proof: "interaction" },
// ── Committing what is staged ────────────────────────────────────────────
// Drafting reads the index and asks the model; only confirm records a commit,
// and only locally.
{ id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "commit.edit", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "commit.close", kind: "view", target: "none", proof: "interaction" },
// ── What is waiting to be sent ───────────────────────────────────────────
{ id: "queue.edit", kind: "kernel-mutation", target: "entity", proof: "interaction" },
// Giving a session a name of your own. The sidebar's tree and the pane tabs
@@ -165,37 +151,6 @@ export const ACTIONS: UIAction[] = [
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
@@ -222,7 +177,6 @@ export const ACTIONS: UIAction[] = [
{ id: "memory.forget", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "config.repair", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.reload", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.install", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "extensions.update", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -242,8 +196,6 @@ export const ACTIONS: UIAction[] = [
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "market.retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.detail-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -259,16 +211,12 @@ export const ACTIONS: UIAction[] = [
{ id: "market.signin", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.draft", kind: "view", target: "none", proof: "interaction" },
{ id: "market.publish", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.prepare-version", kind: "view", target: "none", proof: "interaction" },
{ id: "market.publish-again", kind: "view", target: "none", proof: "interaction" },
// The account's own packages: a preview, an install pinned to that preview's
// digest, and sending a private one to review.
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.own-retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.mine-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote-retry", kind: "view", target: "none", proof: "interaction" },
// Taking back the innermost open thing: a popover, an inline form. One
// intent, reached by pressing away and by Escape.
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
@@ -306,7 +254,6 @@ export const ACTIONS: UIAction[] = [
// state — which is why it is one id and why it is not a mutation.
{ id: "external.open", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -321,8 +268,6 @@ export const ACTIONS: UIAction[] = [
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// ── Taking a change back ─────────────────────────────────────────────────
// Planning a revert and performing one are two contracts: the first asks the
@@ -358,7 +303,6 @@ export const ACTIONS: UIAction[] = [
{ id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mcp.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mcp.inspect", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "mcp.scope", kind: "interaction", target: "none", proof: "interaction" },
{ id: "remotes.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -370,7 +314,6 @@ export const ACTIONS: UIAction[] = [
{ id: "share.cloud-offer", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "share.toggle", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.address", kind: "view", target: "none", proof: "interaction" },
{ id: "share.port", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.offer", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.ask-revoke", kind: "view", target: "entity", proof: "interaction" },
{ id: "share.keep", kind: "view", target: "entity", proof: "interaction" },
@@ -399,11 +342,6 @@ export const ACTIONS: UIAction[] = [
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
// Usage changes what report is read, not canonical session state.
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
// ── How the window looks ─────────────────────────────────────────────────
// Named for what a person is doing, not for the control they reached for.
// The interface scale has a row of presets and a slider beside it, and both
@@ -482,9 +420,6 @@ export const ACTIONS: UIAction[] = [
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,141 +0,0 @@
[
"SivanCola",
"KHG420",
"ttmouse",
"lifu963",
"HUQIANTAO",
"GTC2080",
"mchenziyi",
"Li-Charles-One",
"light-front-theory",
"eghrhegpe",
"Bernardxu123",
"HaoyueQin",
"BuGlessRB",
"chuiwenwei",
"CVEngineer66",
"JesonChou",
"lanshi17",
"SuMuxi66",
"paradoxSCH",
"CnsMaple",
"cyq1017",
"Guan0923",
"ipanta",
"XTLine",
"YoungDan-hero",
"erphenheimer",
"SauronSkywalker",
"ashishexee",
"lizhengwu",
"par73e",
"yizeyi18",
"Nath-Vikky",
"PMKang",
"qiaone",
"ferstar",
"Harbor404",
"liaoyl830",
"Lxiny-zy",
"zhangsanouo",
"AtmoOmen",
"codex",
"Diarica",
"ffff2004",
"ikelvingo",
"kekexunxun",
"lightfront",
"maoxin1234",
"nostalgia296",
"onionviolet",
"PaulDing98",
"scrappylabsai",
"skyzhao1223",
"Tatlatat",
"yekern",
"assoyuan",
"billycat",
"boscocp",
"closss",
"drafish",
"HBLADEH",
"heshuimu",
"iysun",
"jackie-cqz",
"LVGG",
"nanami7777777",
"qiukeren",
"sudoviber",
"warvyvr",
"whale-fall-ouo",
"wqshan-cn",
"xianyu-sheng",
"Xio-Shark",
"xxoingr",
"yhemeo613",
"zdjmrq",
"Abysstsuki",
"ahmedalbusaidy",
"Akashmdshetty",
"alxbnct",
"c020627",
"chengoak",
"CooperSheroy",
"dcs02280653-collab",
"dengbushi",
"EMEEEEMMMM",
"ERroooooR",
"gcoder1991",
"h4rk8s",
"HHHEEEWWW",
"hobostay",
"HorusJiang",
"i1j",
"innocarpe",
"j2bap",
"jackijianxa",
"jbts6",
"ji-tz",
"JimmyDaddy",
"kabaka9527",
"kate1128",
"LegGasai",
"leslie3747",
"LouF666",
"MagnumGoYB",
"MaitreyeeDeshmukh",
"myipanta",
"nanami-0713",
"Ninot1Quyi",
"NkAntony777",
"no-name-touse",
"Nontee22",
"onenewcode",
"Oxygen56",
"PorunC",
"Q1hangL",
"QingLong-C",
"Ray-lyy",
"rixzkiye",
"Robertoarce",
"ryukeilee",
"seankhliao",
"ShiroKSH",
"siwenyu666-beep",
"SprayHank",
"Starry0214",
"Surfire",
"taibai233",
"themarchrain",
"tic-top",
"weikengchen",
"wudiandy",
"xiaoshuai1024",
"xiaotaoya2022-cell",
"xinrenH",
"ying-hua",
"yuanchenglu",
"zhuguang-ZFG",
"Zhuocheng-Lang",
"zlwu"
]
@@ -1,31 +0,0 @@
// Why a fold installed nothing, keyed by the kernel's code. A code with no entry
// shows only that nothing folded, never a guess at the cause.
export const FOLD_WHY: Record<string, string> = {
summary_failed: "生成摘要的请求失败了",
summary_timeout: "生成摘要的请求超时了",
summary_truncated: "摘要在输出上限处被截断,没有采用",
summary_input_too_large: "待折叠的内容缩减后仍超过单次摘要请求的容量",
hook_refused: "扩展拒绝了这次折叠",
persist_failed: "折叠结果没能保存",
context_changed: "摘要生成期间对话发生了变化,已放弃这次折叠",
digest_lost_every_change: "摘要没有记下这段内容里的任何一处改动,已放弃这次折叠",
candidate_not_smaller: "折叠后的上下文不比原来小,没有采用",
candidate_above_ceiling: "折叠后仍超过检查点上限(受保护的内容太多),没有采用",
candidate_above_trigger: "折叠后仍不低于压缩阈值,没有采用",
candidate_above_physical_ceiling: "折叠后仍超过窗口的物理上限,没有采用",
savings_below_minimum: "固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用",
fixed_prefix_above_trigger: "无法折叠的固定部分本身已超过压缩阈值",
fold_empty_after_hooks: "扩展钩子把待折叠的内容清空了",
cancelled: "压缩被取消了",
unclassified: "压缩失败,原因未归类;详情见日志",
busy: "有一轮对话或会话切换正在进行,请稍后再试",
input_unchanged: "上下文自上次整理后没有变化",
no_new_closed_prefix: "自上个检查点以来没有已结束的内容",
fold_below_economics: "新增内容太少,不值得再生成一次摘要",
active_turn_boundary: "正在进行的这一轮需要先结束",
no_foldable_region: "没有可折叠的内容",
};
// The reason a /compact notice carries as its detail; an empty detail is the
// kernel's decline with no class.
export const NO_CODE_WHY = "没有值得折叠的内容";
+1 -89
View File
@@ -16,20 +16,16 @@ import { EN_STORAGE } from "./en_storage";
import { EN_USAGE } from "./en_usage";
import { EN_COMPOSER } from "./en_composer";
import { EN_BACKUP } from "./en_backup";
import { EN_FEEDBACK } from "./en_feedback";
import { EN_COMMUNITY } from "./en_community";
export const EN: Record<string, string> = {
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
...EN_COMMUNITY,
...EN_METRICS,
...EN_STORAGE,
...EN_USAGE,
...EN_COMPOSER,
...EN_BACKUP,
...EN_FEEDBACK,
...EN_KERNEL,
...EN_REMOTE,
...EN_WINDOW,
@@ -71,7 +67,6 @@ export const EN: Record<string, string> = {
"复制": "Copy",
"已复制": "Copied",
"复制不了": "Copy blocked",
"复制失败,请重试": "Copy failed. Try again.",
"复制这段回答": "Copy this answer",
"复制这段代码": "Copy this code",
"复制路径": "Copy path",
@@ -281,10 +276,6 @@ export const EN: Record<string, string> = {
"尚无文件夹": "No folders yet",
"正在读取文件夹…": "Reading folders…",
"打开项目…": "Open a project…",
"添加工作区": "Add a workspace",
"无法打开文件夹选择器。请输入内核所在机器上的路径。": "The folder picker cannot open. Enter a path on the machine running the kernel.",
"工作区路径": "Workspace path",
"此内核不支持添加工作区。": "This kernel does not support adding workspaces.",
"先打开一个项目": "Open a project first",
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
@@ -340,11 +331,6 @@ export const EN: Record<string, string> = {
"正在运行,暂停后才能关闭": "Running; pause it before closing",
"受阻": "blocked",
"状态不明": "uncertain",
"这条插话在本轮结束前没来得及送达,没有发给模型": "This guidance was not delivered before the turn ended, so the model never saw it",
"本轮已结束,但对话记录没能保存": "The turn ended, but the transcript could not be saved",
"本轮已结束,但队列没能确认这一条": "The turn ended, but the queue could not acknowledge this item",
"运行这一条的会话已不在了": "The session that was running this item is gone",
"队列记录损坏,这一条是从残留文件里找回的": "The queue record was corrupt; this item was salvaged from leftover files",
"冻结 {n} 文件": "{n} frozen",
"无法读取该条的正文,未打开编辑:{why}":
"Could not read this entry back, so it was not opened for editing: {why}",
@@ -546,9 +532,7 @@ export const EN: Record<string, string> = {
"编辑": "Edit",
"测试中…": "Testing…",
"测试连接": "Test it",
"保存失败": "Save failed",
"已保存,尚未生效": "Saved, not applied yet",
"刷新模型目录失败": "Could not refresh the model catalog",
"没保存成功": "Not saved",
"重新问一次有哪些模型": "Ask for the model list again",
"刷新模型目录": "Refresh model catalog",
"正在刷新…": "Refreshing…",
@@ -787,7 +771,6 @@ export const EN: Record<string, string> = {
"打开主题目录": "Open themes folder",
"阅读": "Read",
"设置需要一个打开的会话。请先在左栏添加一个文件夹。": "Settings need an open session. Add a folder in the sidebar first.",
"反馈需要一个打开的会话。请先在左栏添加一个文件夹。": "Feedback needs an open session. Add a folder in the sidebar first.",
"{host} 需要登录": "{host} asks you to sign in",
"代理 {host} 需要登录": "The proxy {host} asks you to sign in",
"用户名": "Username",
@@ -812,7 +795,6 @@ export const EN: Record<string, string> = {
"已导入「{name}」。": "Imported “{name}”.",
"立即使用": "Use now",
"未读取:{names}": "Not read: {names}",
"请单独选择一个 .zip,或选择不含压缩包的 theme.json 和图片。": "Choose a single .zip on its own, or select a theme folder’s theme.json and images without a ZIP.",
"尚未安装主题。选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "No themes installed. Choose a .zip, or select a theme folder’s theme.json and images together.",
"选择一个 .zip,或同时选中主题文件夹里的 theme.json 和图片。": "Choose a .zip, or select a theme folder’s theme.json and images together.",
@@ -837,7 +819,6 @@ export const EN: Record<string, string> = {
"已关闭": "Disabled",
"未连接": "Not connected",
"待命 · 首次调用时启动": "Standby · starts on first call",
"待授权 · 来自项目声明": "Awaiting approval · declared by the project",
"本地构建": "Local build",
"只在这台机器上": "This machine only",
"这个项目": "This project",
@@ -1057,7 +1038,6 @@ export const EN: Record<string, string> = {
"兼顾响应速度与可靠性,适合大多数任务": "Balance speed and reliability for most tasks",
"投入更多时间分析复杂上下文与执行方案": "Spend more time analyzing complex context and execution plans",
"用于最复杂的问题,等待时间与消耗最高": "For the hardest problems, with the longest wait and highest usage",
"思考仍开启并计费,该模型无法关闭思考": "Thinking stays on and is billed; this model cannot turn it off",
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
"按模型生效": "Per model",
"查看上下文与压缩": "View context and compaction",
@@ -1110,8 +1090,6 @@ export const EN: Record<string, string> = {
"项目操作:{name}": "Project actions: {name}",
"项目操作": "Project actions",
"不删除文件": "Keeps files",
"在文件管理器中显示": "Show in file manager",
"文件夹已不在磁盘上": "Folder is gone",
"删除会话:{title}": "Delete session: {title}",
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
"添加文件夹": "Add folder",
@@ -1124,11 +1102,6 @@ export const EN: Record<string, string> = {
"读取": "Read",
"本地工作区": "Local workspace",
"暂停执行": "Pause run",
"复制会话信息": "Copy session information",
"会话 ID": "Session ID",
"会话上下文路径": "Session context path",
"任务路径": "Task path",
"任务日志": "Task log",
"导出会话": "Export session",
"取消置顶": "Unpin",
"置顶会话": "Pin session",
@@ -1223,64 +1196,6 @@ export const EN: Record<string, string> = {
"The model returned nothing; try again",
"优化请求格式不正确":
"The refine request was malformed",
"提交…":
"Commit…",
"起草提交说明":
"Draft commit message",
"读取暂存区,起草提交说明…":
"Reading the staged changes and drafting a message…",
"重新起草":
"Redraft",
"提交说明":
"Commit message",
"暂存的文件":
"Staged files",
"{n} 个文件已暂存":
"{n} files staged",
"可能含有密钥:{files}":
"May contain secrets: {files}",
"新增的内容里有形似密钥的值":
"Added lines contain values shaped like credentials",
"我确认这些内容可以提交":
"I confirm these can be committed",
"提交到本地":
"Commit locally",
"只在本地提交,不会推送":
"Recorded locally only; nothing is pushed",
"已提交 {hash}:{subject}":
"Committed {hash}: {subject}",
"改过暂存区后请重新起草":
"Draft again after changing what is staged",
"暂存区内容过长,只按前面一部分起草":
"The staged diff is long; the draft reads only its first part",
"这个工作区不是 git 仓库,无法提交":
"This workspace is not a git repository, so there is nothing to commit",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区在你确认之后又变了,请重新起草":
"The staged changes changed after you reviewed them; draft again",
"暂存的内容里有疑似密钥的文件,需要你确认后才能提交":
"The staged changes include likely secrets; confirm them before committing",
"提交说明是空的":
"The commit message is empty",
"提交说明含有无法记录的字符":
"The commit message contains characters git cannot record",
"提交说明超过 {max_bytes} 字节":
"The commit message is over {max_bytes} bytes",
"git 没有配置提交者姓名和邮箱(user.name、user.email)":
"git has no author name and email configured (user.name, user.email)",
"git 没能记录这次提交":
"git could not record the commit",
"当前会话没有可用的模型,无法起草提交说明":
"This session has no model to draft a commit message with",
"起草提交说明超时,请重试":
"Drafting the commit message timed out; try again",
"模型没有给出提交说明,请重试":
"The model returned no commit message; try again",
"读取暂存区失败":
"Reading the staged changes failed",
"提交请求格式不正确":
"The commit request was malformed",
"新增工具":
"New tools",
"agent 可以调用,每次调用照常经过权限确认":
@@ -1298,8 +1213,5 @@ export const EN: Record<string, string> = {
"同一段内容被逐字重复": "The same block is being repeated verbatim",
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
};
@@ -21,9 +21,6 @@ export const EN_BACKUP: Record<string, string> = {
"备注(可选)": "Label (optional)",
"例如:公司笔记本": "e.g. work laptop",
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
"至少选择一项备份内容": "Tick at least one thing to back up",
"口令还差 {n} 个字符": "Passphrase too short: {n} more needed",
"请再输一次口令": "Repeat the passphrase to confirm it",
"再输一次": "Repeat it",
"两次输入的口令不一致": "The two passphrases differ",
"正在加密上传…": "Encrypting and uploading…",
@@ -1,20 +0,0 @@
export const EN_COMMUNITY: Record<string, string> = {
"社区与贡献者": "Community and contributors",
"遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.",
"QQ 群二维码": "QQ group QR code",
"群号": "Group number",
"复制群号": "Copy group number",
"用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.",
"加入 QQ 群": "Join the QQ group",
"贡献者": "Contributors",
"按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release",
"查看全部贡献者": "View all contributors",
"显示全部 {n} 位": "Show all {n}",
"抖音": "Douyin",
"抖音号": "Douyin ID",
"复制抖音号": "Copy Douyin ID",
"显示二维码": "Show QR code",
"收起二维码": "Hide QR code",
"抖音二维码": "Douyin QR code",
"作者与维护者": "Author and maintainer",
};
@@ -3,8 +3,6 @@
export const EN_COMPOSER: Record<string, string> = {
"↓ 回到最新": "↓ Back to latest",
"不思考": "No reasoning",
"最轻量": "Minimal",
"比快速档更轻;GLM-5.2 会跳过思考": "Lighter than Fast; GLM-5.2 skips thinking",
"超深入": "Extra deep",
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
@@ -23,7 +21,6 @@ export const EN_COMPOSER: Record<string, string> = {
"正在加载命令与技能…": "Loading commands and skills…",
"引用文件": "Reference files",
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
"点按插话 · 回车换行": "Tap Steer · Enter for a new line",
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
"知道了": "Got it",
"是否信任此文件夹": "Trust this folder",
@@ -69,7 +66,6 @@ export const EN_COMPOSER: Record<string, string> = {
"Nobody has said whether this model reads images · treated as no for now; tick it under Connection to send them straight through",
"自动": "Auto",
"Enter 发送 · Shift+Enter 换行": "Enter to send · Shift+Enter for a new line",
"点按发送 · 回车换行": "Tap Send · Enter for a new line",
"不询问": "Do not ask",
"全部放行": "Allow all",
"均衡": "Balanced",
@@ -1,133 +0,0 @@
// English for the in-app feedback dialog and its refusals.
export const EN_FEEDBACK: Record<string, string> = {
"反馈": "Feedback",
"已完成:": "Done: ",
"当前:": "Current: ",
"尚未开始:": "Not yet: ",
"发送反馈": "Send feedback",
"我的反馈": "My feedback",
"问题": "Bug",
"建议": "Idea",
"疑问": "Question",
"其他": "Other",
"已收到": "Received",
"已登记": "Recorded",
"处理中": "In progress",
"已修复": "Fixed",
"不予修复": "Won't fix",
"重复": "Duplicate",
"已登记为 {issue}": "Recorded as {issue}",
"已在 {version} 修复": "Fixed in {version}",
"已修复,将随下个版本发布": "Fixed, ships in the next version",
"与 {issue} 重复": "Duplicate of {issue}",
"已解决": "Resolved",
"有些反馈是用这台电脑以前的身份发出的,已经查不到它们的最新状态。": "Some reports were sent under this computer's earlier identity, so their current status can no longer be read.",
"状态已无法追踪": "Status no longer trackable",
"处理进展": "Progress",
"刷新列表": "Refresh list",
"每次打开这一页时刷新。状态和回复来自维护者,已登记的反馈会跟随对应的 GitHub 议题。":
"Refreshed each time you open this page. Status and replies come from the maintainers; a report that was filed follows its GitHub issue.",
"暂时连不上反馈服务。下面是保存在本机的记录,状态可能不是最新的。":
"Cannot reach the feedback service right now. This is what was saved on this machine, and statuses may be out of date.",
"正在读取你的反馈…": "Loading your feedback…",
"还没有提交过反馈。发出第一条之后,它的进展会出现在这里。": "No feedback sent yet. Once you send one, its progress shows up here.",
"复制回执号": "Copy receipt number",
"回执号": "Receipt number",
"已收到你的反馈": "Your feedback was received",
"回执号是这份反馈的凭据,请留着它。我们会先看一遍,只有被登记为 GitHub 议题的内容才会公开。进展、议题链接,以及我们的回复或提问,都会出现在「我的反馈」里。":
"Keep the receipt number: it identifies this report. We read it first, and only what we file as a GitHub issue becomes public. Progress, the issue link and any reply or question from us appear under My feedback.",
"你的文字里有看起来像密钥的内容,已在发出前打码。": "Text in your report that looked like a secret was masked before it was sent.",
"查看我的反馈": "View my feedback",
"再写一条": "Write another",
"只有被我们登记为议题的反馈才会公开":
"It becomes public only if we file it as an issue",
"如果登记为 GitHub 议题,你的文字、昵称和截图会发布在 esengine/DeepSeek-Reasonix 仓库,任何人都看得到;不登记就不会公开。你会在这里收到回执,登记后还会看到议题链接,我们也可能在这里回复或向你提问。":
"If we file it as a GitHub issue, your text, nickname and screenshots are posted in the esengine/DeepSeek-Reasonix repository, where anyone can read them; if we do not, it stays private. You will see a receipt here and, once filed, the issue link, and we may reply or ask you questions here.",
"请不要在文字或截图里写入密钥、账号密码、聊天记录或私有代码:一旦公开就收不回来。":
"Do not put secrets, passwords, chats or private code in the text or in screenshots: once public it cannot be taken back.",
"发现的是安全漏洞?请不要在这里提交,{link}。": "Found a security vulnerability? Do not post it here; {link}.",
"按安全策略私下报告": "report it privately under the security policy",
"反馈内容": "Feedback details",
"反馈类别": "Feedback category",
"发生了什么?": "What happened?",
"描述你遇到的情况,或你希望它怎样。": "Describe what you ran into, or what you would like instead.",
"{n} / {max} 字节": "{n} / {max} bytes",
"昵称(登记为议题时公开)": "Nickname (public if filed as an issue)",
"如果登记为议题,会显示为「由 {name} 提交」。用昵称即可,不必真名。":
"If it is filed as an issue it shows as \"Reported by {name}\". A nickname is fine; no real name needed.",
"联系方式(可选,私密)": "Contact (optional, private)",
"邮箱或 QQ。只有维护者看得到,绝不会出现在 GitHub 上。": "Email or QQ. Only the maintainers see it; it is never posted on GitHub.",
"截图(可选,登记为议题时公开)":
"Screenshots (optional, public if filed as an issue)",
"移除截图 {name}": "Remove screenshot {name}",
"添加截图…": "Add screenshots…",
"选择截图文件": "Choose screenshot files",
"也可以直接粘贴或拖进来。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB,较大的图会自动缩小。":
"You can also paste or drop them here. PNG or JPEG, up to {n} images, {size} MB each; large images are shrunk automatically.",
"如果这份反馈被登记为公开议题,截图会和文字一起公开。发送前请确认没有露出密钥、聊天记录或私有代码。":
"If this report is filed as a public issue, your screenshots are published with your text. Before sending, check they show no keys, chats or private code.",
"{name}:只支持 PNG 或 JPEG": "{name}: only PNG or JPEG is supported",
"{name}:超过 {size} MB,没有添加": "{name}: larger than {size} MB, not added",
"{name}:最多 {n} 张,没有添加": "{name}: the limit is {n} images, not added",
"随反馈一起发送的环境信息": "Environment sent with your report",
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
"去 GitHub": "Go to GitHub",
"重试发送": "Retry sending",
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
"正文太长了,请缩短后再发。": "The text is too long. Please shorten it and send again.",
"请填写昵称。": "Please enter a nickname.",
"昵称太长了,请缩短后再发。": "The nickname is too long. Please shorten it.",
"联系方式太长了,请缩短后再发。": "The contact is too long. Please shorten it.",
"请重新选择反馈类别。": "Please pick a category again.",
"截图数量超出上限,请去掉几张后再发。": "Too many screenshots. Remove a few and send again.",
"截图只支持 PNG 或 JPEG。": "Screenshots must be PNG or JPEG.",
"有截图太大了,请换小一点的图。": "A screenshot is too large. Use a smaller one.",
"有截图无法读取,请换一张图。": "A screenshot could not be read. Try another image.",
"内容不符合要求,请检查后重试。": "Something in the form was not accepted. Please check it and try again.",
"没能连上反馈服务。已填的内容都还在,网络恢复后可以直接重试,不会重复提交。":
"Could not reach the feedback service. Everything you entered is kept; retry once the network is back and it will not be filed twice.",
"这次的内容没能被解析,请检查后重试。": "The request could not be read. Please check the form and try again.",
"内容太大,服务端没有接收。请缩短文字,或去掉一张截图后再发。": "The report was too large for the service. Shorten the text or remove a screenshot, then send again.",
"提交得太频繁了,请等 {secs} 秒后再试。": "You are sending too fast. Try again in {secs} seconds.",
"提交得太频繁了,请稍等一会儿再试。": "You are sending too fast. Please wait a moment and try again.",
"相同内容刚刚已经提交过,不需要再发一次。可以在「我的反馈」里查看它。": "The same text was just sent, so there is no need to send it again. You can find it under My feedback.",
"反馈通道今天的接收量已满,不是你发得太多。内容都还在,请明天再试。": "The feedback channel has reached its capacity for today; you did not send too much. Your text is kept, so please try again tomorrow.",
"有一张截图没能清除其中的隐藏信息。请换一张,或重新导出后再添加。": "A screenshot could not be cleaned of its hidden metadata. Pick another one, or re-export it and add it again.",
"反馈通道暂时关闭。你可以直接到 GitHub 提交问题。": "The feedback channel is switched off for now. You can file an issue on GitHub directly.",
"反馈服务没有认出这台电脑,请稍后重试。": "The feedback service did not recognise this computer. Please try again later.",
"反馈服务暂时出了问题。已填的内容都还在,请稍等片刻再试,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. What you typed is kept. Try again in a moment, or file an issue on GitHub directly.",
"反馈服务暂时出了问题。你写的回复还在,请稍等片刻;重发前先刷新列表确认是否送达,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. Your reply is kept. Wait a moment, then refresh the list to check whether it arrived before resending, or file an issue on GitHub directly.",
"本机保存反馈记录时出错,请重试。": "Saving the feedback record on this machine failed. Please try again.",
"反馈没有发出去,请稍后重试。": "The feedback was not sent. Please try again later.",
"需要补充信息": "Needs more info",
"维护者已回复": "Answered by us",
"已关闭此反馈": "Closed",
"需要你回复": "Needs your reply",
"等你补充信息": "Waiting for your information",
"维护者": "Maintainers",
"新": "New",
"显示更早的 {n} 条": "Show {n} earlier messages",
"回复": "Reply",
"回复维护者": "Reply to the maintainers",
"你的回复": "Your reply",
"发送回复": "Send reply",
"离线时暂时不能回复。": "You cannot reply while offline.",
"这份反馈已有公开议题 #{n}:你的回复会被公开转发到那个议题里。请不要写入密钥、密码或私有内容。": "This report already has a public issue, #{n}: your reply is copied there publicly. Do not include secrets, passwords or private content.",
"只发给维护者,不会公开。请不要写入密钥、密码或私有代码。": "Sent to the maintainers only; it is not made public. Do not include secrets, passwords or private code.",
"找不到这条反馈,请刷新列表后再试。": "That report could not be found. Refresh the list and try again.",
"回复不能为空。": "The reply is empty.",
"回复太长了,请缩短后再发。": "The reply is too long. Please shorten it and send again.",
"回复不符合要求,请检查后重试。": "The reply was not accepted. Please check it and try again.",
"这份反馈的回复次数已到上限,或你回复得太频繁了。请稍后再试,必要时另外提交一条新反馈。": "This report has reached its reply limit, or you replied too often. Try again later, or send a new report.",
"这份反馈现在不接收回复,它可能已经处理完毕或被关闭。请刷新列表查看最新状态。": "This report takes no reply right now; it may be finished or closed. Refresh the list to see its latest status.",
"这台电脑的反馈身份已经变了,这份反馈不能再从这里回复。可以另外提交一条新反馈。": "This computer's feedback identity has changed, so this report can no longer be answered from here. You can send a new report.",
"反馈通道暂时关闭,暂时不能回复。": "The feedback channel is switched off for now, so replies are not possible.",
"没能连上反馈服务。你写的回复还在;如果不确定它有没有送达,请先刷新列表看看,再决定要不要重发。": "Could not reach the feedback service. Your reply is kept; if you are not sure it arrived, refresh the list first, then decide whether to send it again.",
"回复没有发出去,请稍后重试。": "The reply was not sent. Please try again later.",
"服务暂时要求额外验证,当前版本还不能显示验证步骤。请稍后再试,或直接到 GitHub 提交问题。": "The service temporarily asks for an extra verification step that this version cannot show. Try again later, or file an issue on GitHub directly.",
"{n} 项待查看": "{n} to review",
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
"回复已发送。": "Reply sent.",
};
+1 -51
View File
@@ -3,35 +3,6 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
"请使用授权目录内的字面删除路径,不要使用变量、展开、通配符或未知管道输入": "Use a literal delete path inside a granted directory, without variables, expansions, globs or unknown pipeline input",
"删除选项无法识别;请使用已知选项并提供公共参数的值": "The delete option is unknown; use known options and supply common parameter values",
"命令语法有误;请修正后重试": "The command has a syntax error; correct it and retry",
"主机命令解析器不可用;请在主机恢复后重试": "The host shell parser is unavailable; retry after the host parser is restored",
"主机命令解析超时或被取消;请重试": "The host shell parser timed out or was canceled; retry the call",
"命令超过主机长度限制;请拆分命令或从文件读取长文本": "The command exceeds the host length limit; split it or read long text from a file",
"另一个会话持有重叠的写入范围,取得所需范围后会自动继续": "Another session holds an overlapping write claim; this session will continue when its claim is available",
"这个会话已取得所需的写入范围,现已继续": "This session has acquired its requested write claim and continued",
"尚未取得所需的写入范围,这次等待已结束": "The wait ended before the requested write claim was granted",
"会话 {holder}({session})持有写入范围:{paths}": "Session {holder} ({session}) holds write claim: {paths}",
"所需写入范围:{paths}": "Requested write claim: {paths}",
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
"这份反馈现在不接收回复": "This report takes no reply right now",
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
"提交太频繁了,请稍后再试": "Too many submissions - try again a little later",
"反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题": "Feedback is switched off for now - try again later, or open an issue on GitHub",
"相同内容的反馈刚刚提交过了": "The same feedback was just sent",
"反馈服务没有接受本机的反馈身份,请重试": "The feedback service did not accept this install's identity - try again",
"无法连接反馈服务,请检查网络后重试,已填内容会保留": "The feedback service cannot be reached - check the network and retry; what you wrote is kept",
"反馈服务暂时无法处理请求,请稍后再试": "The feedback service could not handle that - try again later",
"本机保存反馈记录失败,请重试": "Feedback could not be recorded on this machine - try again",
"该决定已不符合当前状态:计划在你回答前已发生变更": "That decision is no longer current - the plan changed before you answered",
"该条已发送给模型,无法撤回": "That line already reached the model - it cannot be taken back",
// 能力开关:名字、这台机器的存档、以及服务器自己
@@ -148,7 +119,7 @@ export const EN_KERNEL: Record<string, string> = {
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
"请为该来源填写名称": "Give this source a name",
"名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符": "Use only letters, digits, dots, hyphens and underscores, starting with a letter or digit, up to 64 characters",
"名称只能包含字母、数字、点、连字符和下划线": "A name may use letters, digits, dot, dash and underscore only",
"已经有名为「{name}」的连接了,换一个名称": "A connection named “{name}” already exists; pick another name",
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
"请填写接口地址": "Enter an endpoint address",
@@ -176,34 +147,17 @@ export const EN_KERNEL: Record<string, string> = {
"互联网连接暂时不可用,请确认电脑端 Studio 已登录并保持在线。": "Internet access is temporarily unavailable. Make sure desktop Studio is signed in and stays online.",
"地址 {ip} 不是本机的局域网地址。": "{ip} is not a private address on this computer.",
"无法在 {ip} 上开启监听:{error}": "Could not listen on {ip}: {error}",
"端口 {port} 已被其他程序占用,请换一个端口,或清空后让系统自动选择。": "Port {port} is already in use by another program. Choose a different port, or clear it to let the system pick one.",
"端口需要在 {min}–{max} 之间,或清空后让系统自动选择。": "The port must be between {min} and {max}, or cleared to let the system pick one.",
"无法保存端口设置:{error}": "Could not save the port setting: {error}",
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
"名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。": "The name “{name}” cannot hold a key: the credential slot is derived from it, and a slot may not start with a digit. Use a name starting with a letter — the key itself is fine.",
"这个内核没有带界面,只提供接口": "This kernel serves no interface, only its API",
"当前模型还没有 API key,请先在设置里添加": "The current model has no API key yet. Add one in Settings first.",
"请填写 API key": "An API key is required",
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
"已连接,无需重复配置": "Already connected — no need to set it up again",
"远端配置未完成,请稍后重试": "The remote setup did not complete; try again shortly",
"没有这个模型连接": "There is no such model connection",
"该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env": "This connection has no variable to store a key in — set its api_key_env in the configuration first",
"该 key 含有无法安全保存的字符组合,请重新复制": "That key contains a character combination that cannot be stored safely — copy it again",
"该 key 超长或含换行,请重新复制": "That key is too long or spans several lines — copy it again",
"已保存的密钥在此期间被改动,请重新打开配置再试": "The stored keys changed in the meantime — reopen the setup and try again",
"密钥已保存,但连接尚未生效,请重试": "The key was saved but the connection is not active yet — try again",
"服务商拒绝了该 key,请检查是否复制完整": "The provider rejected that key — check it was copied in full",
"服务商没有及时应答,请稍后重试": "The provider did not answer in time — try again shortly",
"连不上服务商,请检查网络或服务地址": "Cannot reach the provider — check the network or the service address",
"服务商返回了错误,请稍后重试": "The provider returned an error — try again shortly",
"连接测试未通过": "The connection test did not pass",
"这台服务器已关闭会话切换": "This server has session switching turned off",
"该会话正在清理,请稍后再打开": "This session is being cleaned up; open it again in a moment",
"该会话正被其他位置占用,请先关闭该处": "This session is held elsewhere — close it there first",
@@ -242,7 +196,6 @@ export const EN_KERNEL: Record<string, string> = {
"无法解析该会话路径": "That conversation path does not resolve",
"打不开这个会话": "Could not open that conversation",
"该路径位于会话目录之外": "That path is outside the conversations directory",
"没有这个会话": "No such conversation",
"该地址不接受此种请求方式": "That address does not accept this request method",
"请求体必须是 application/json": "The request body must be application/json",
"这台服务器未开放权限编辑": "This server does not open permission editing",
@@ -275,7 +228,6 @@ export const EN_KERNEL: Record<string, string> = {
"这份备份已不存在": "That backup no longer exists",
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
@@ -333,8 +285,6 @@ export const EN_KERNEL: Record<string, string> = {
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
@@ -131,25 +131,6 @@ export const EN_REMOTE: Record<string, string> = {
"请先更新这台电脑上的 Studio,再连接。": "Update Studio on this computer before connecting.",
"无法授权 Web Studio,请重新登录后再试。": "Web Studio could not be authorized. Sign in again and retry.",
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
"网络已断开": "You're offline",
"当前设备没有网络。请检查网络连接后重新连接。": "This device has no network. Check your connection, then reconnect.",
"无法连接远程服务": "Can't reach the remote service",
"当前网络访问不到 Reasonix 远程服务,可能是网络受限或服务暂时中断。请切换网络后重新连接。":
"This network can't reach the Reasonix remote service. It may be restricted, or the service may be briefly down. Switch networks and reconnect.",
"此网址不能使用远程中转": "This address can't use the relay",
"打开 reasonix.io": "Open reasonix.io",
"远程中转拒绝了当前网页的来源。请从 reasonix.io 打开 Web Studio 后再连接。":
"The relay rejected this page's origin. Open Web Studio from reasonix.io, then connect.",
"尝试次数过多": "Too many attempts",
"请求过于频繁。请等待 {n} 秒后重新连接。": "Too many requests. Wait {n} seconds, then reconnect.",
"请求过于频繁。请稍等片刻后重新连接。": "Too many requests. Wait a moment, then reconnect.",
"中转拒绝了这次连接": "The relay refused this connection",
"连接凭证可能已过期,或这台电脑上同时连接的设备已满。请重新连接;仍然失败请等一分钟再试。":
"The connection ticket may have expired, or this computer already has its maximum number of connected devices. Reconnect; if it still fails, wait a minute.",
"长时间没有操作": "Inactive for a while",
"为节省资源,中转已回收这条空闲连接。点击重新连接即可继续。": "The relay released this idle connection. Reconnect to continue.",
"远程连接失败": "Remote connection failed",
"{action}({n})": "{action} ({n})",
"远程中转服务暂时不可用,请稍后重试。": "The remote relay is temporarily unavailable. Try again shortly.",
"远程 Studio 身份校验失败,请停止连接并检查设备。": "Remote Studio identity verification failed. Stop and check the device.",
"需要重新登录": "Sign in again",
@@ -174,8 +155,6 @@ export const EN_REMOTE: Record<string, string> = {
"手机和这台电脑在同一个网络里,扫码后就能看会话、发消息、批准操作。关闭或退出 Studio 会断开所有手机。": "A phone on the same network as this computer can scan a code to follow sessions, send messages and approve actions. Turning this off or quitting Studio disconnects every phone.",
"虚拟网卡": "virtual adapter",
"换网络会断开已连接的手机,它们需要扫新的二维码。": "Switching networks disconnects the connected phones; they will need to scan the new code.",
"留空则每次开启时由系统随机选择,范围 1024–65535。更改端口后,手机需要重新扫码配对。": "Leave empty to let the system pick a port each time, or choose one from 1024 to 65535. After changing the port, phones need to scan a new code to pair again.",
"改端口会断开已连接的手机,它们需要扫新的二维码。": "Changing the port disconnects the connected phones; they will need to scan the new code.",
"配对二维码": "Pairing QR code",
"复制配对链接": "Copy pairing link",
"设备 {n}": "Device {n}",
+2 -95
View File
@@ -187,20 +187,6 @@ export const EN_SETTINGS: Record<string, string> = {
"工具": "Tool",
"处理方式": "Verdict",
"添加规则": "Add a rule",
"工具执行前": "Before a tool runs",
"工具执行后": "After a tool runs",
"工具执行失败后": "After a tool fails",
"请求权限时": "When permission is requested",
"提交消息时": "When a message is submitted",
"本轮结束时": "When the turn ends",
"本轮失败时": "When the turn fails",
"模型响应完成后": "After a model response completes",
"会话开始时": "When a session starts",
"会话结束时": "When a session ends",
"子代理开始时": "When a subagent starts",
"子代理结束时": "When a subagent ends",
"需要你关注时": "When your attention is needed",
"压缩上下文前": "Before context is compacted",
"例如 git push:* (留空 = 整个工具)": "e.g. git push:* (empty = the whole tool)",
"例如 *.env* (留空 = 整个工具)": "e.g. *.env* (empty = the whole tool)",
"文件工具读写 .env 一律拒绝。bash 中的 cat 属于另一条路径,本规则不覆盖":
@@ -282,14 +268,6 @@ export const EN_SETTINGS: Record<string, string> = {
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置限制": "Limits per model",
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
"{model} 的上下文窗口": "Context window for {model}",
"{model} 的最大输出": "Max output for {model}",
"继承 {n}": "Inherits {n}",
"继承:未声明": "Inherits: not declared",
"继承:自动": "Inherits: automatic",
"按模型设置档位": "Levels per model",
"同一接入下有多家厂商的模型时,可为单个模型指定档位;「继承接入设置」沿用上面的档位。模型自己的档位优先。":
"When one connection serves several vendors' models, give a model levels of its own. Inherit uses the levels above; a model's own levels win.",
@@ -346,7 +324,6 @@ export const EN_SETTINGS: Record<string, string> = {
"Use this after editing extension code, or installing, removing, enabling or disabling a package. The current turn is unaffected; the next one uses the new configuration.",
"正在重启常驻进程,重新扫描技能、命令和钩子…": "Restarting resident processes and rescanning skills, commands and hooks…",
"已生效,下一轮开始用新的扩展": "Live — the next turn runs the new extensions",
"更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。": "Changes saved, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
"插件包": "Plugin packages",
"一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。":
"One package can bring skills, commands, automation hooks and external services at once. Installing and importing are the same action: give it a repository address or a folder on this machine.",
@@ -432,48 +409,12 @@ export const EN_SETTINGS: Record<string, string> = {
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
"填写这一项": "Fill this in",
"已提交给 {name}": "Sent to {name}",
"MCP 服务器 {name} 请求外部操作": "MCP server {name} is asking for an external interaction",
"请在自己的浏览器中完成操作,完成后再继续。打开页面不会自动确认,也不会把浏览器内容交给 agent。": "Complete the interaction in your own browser, then continue. Opening the page does not confirm completion or share its contents with the agent.",
"此链接指向本机或私有网络。打开前请确认你信任该服务器和目标地址。": "This link targets this device or a private network. Open it only if you trust the server and the destination.",
"请核对完整地址:此链接使用 HTTP 或国际化域名。": "Check the full address: this link uses HTTP or an internationalized domain.",
"无法打开浏览器。请重试,或手动打开上面的地址。": "Could not open the browser. Retry, or open the address above manually.",
"已确认完成": "Completion confirmed",
"打开浏览器": "Open browser",
"已完成,继续": "Completed, continue",
"已拒绝提供": "Declined",
"拒绝提供": "Decline",
"(补写过一次)": " (repaired once)",
"(缺失部分由主机补全)": " (gaps kept by the host)",
"正在折叠为简报": "Folding into a digest",
"本次未折叠任何内容": "This pass folded nothing",
"扩展拒绝了这次折叠": "An extension refused this fold",
"折叠结果没能保存": "The folded result could not be saved",
"压缩未完成": "Compaction did not complete",
"生成摘要的请求失败了": "The request that writes the summary failed",
"生成摘要的请求超时了": "The request that writes the summary timed out",
"摘要在输出上限处被截断,没有采用": "The summary hit the output limit and was discarded",
"待折叠的内容缩减后仍超过单次摘要请求的容量": "The content to fold still exceeds one summary request after shortening",
"摘要生成期间对话发生了变化,已放弃这次折叠": "The conversation changed while the summary was written, so this fold was dropped",
"摘要没有记下这段内容里的任何一处改动,已放弃这次折叠": "The summary recorded none of this span's changes, so this fold was dropped",
"折叠后的上下文不比原来小,没有采用": "The folded context was no smaller, so it was not used",
"折叠后仍超过检查点上限(受保护的内容太多),没有采用": "The folded context still exceeds the checkpoint limit (too much protected content), so it was not used",
"折叠后仍不低于压缩阈值,没有采用": "The folded context was still at or above the compaction threshold, so it was not used",
"折叠后仍超过窗口的物理上限,没有采用": "The folded context still exceeds the window's physical limit, so it was not used",
"固定前缀已占满检查点上限,这次折叠省下的空间太少,没有采用": "The fixed prefix fills the checkpoint limit and this fold saved too little, so it was not used",
"无法折叠的固定部分本身已超过压缩阈值": "The part that cannot be folded is already over the compaction threshold",
"扩展钩子把待折叠的内容清空了": "An extension hook emptied the content to fold",
"压缩被取消了": "The compaction was cancelled",
"压缩失败,原因未归类;详情见日志": "The compaction failed for a reason the host did not classify; see the log",
"有一轮对话或会话切换正在进行,请稍后再试": "A turn or a session change is in progress; try again shortly",
"上下文自上次整理后没有变化": "The context has not changed since it was last tidied",
"自上个检查点以来没有已结束的内容": "Nothing has closed since the last checkpoint",
"新增内容太少,不值得再生成一次摘要": "Too little has been added to pay for another summary",
"正在进行的这一轮需要先结束": "The turn in flight has to finish first",
"没有可折叠的内容": "No foldable region remains",
"没有值得折叠的内容": "Nothing left worth folding",
"已压缩": "Compacted",
"无需压缩:{why}": "Nothing to compact: {why}",
"压缩失败:{why}": "Compaction failed: {why}",
"本段的 {n} 处改动均已写入简报": "All {n} changes made here are in the digest",
"{kept}/{required} 处改动写进了简报": "{kept}/{required} changes are in the digest",
"另有 {n} 处仅保留索引地址,需通过 recall 取回原文": "{n} more are index entries only; recall fetches the original",
@@ -510,7 +451,6 @@ export const EN_SETTINGS: Record<string, string> = {
" · 缺 key": " · no key",
"安装这个版本": "Install this version",
"保存中…": "Saving…",
"无法保存": "Could not save",
"不发送": "Do not send",
"当前版本": "Current version",
"读取中…": "Reading…",
@@ -655,11 +595,8 @@ export const EN_SETTINGS: Record<string, string> = {
"在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}":
"Type this code into the page your browser opened. If none opened, go to {uri}",
"装好了,下一轮就能用": "Installed — the next turn can use it",
"装好了,但运行时未重载:{reason}。请用「重载运行时」重试。":
"Installed, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
"运行时未重载:{reason}。请用「重载运行时」重试。":
"The runtime did not reload: {reason}. Use Reload runtime to retry.",
"有项目未安装成功,原因见下方。": "Not all items were installed. See the failures below.",
"装好了,但这一轮还在跑:等它结束或新建会话后生效":
"Installed, but this turn is still running: it takes effect when the turn ends, or in a new session",
"没装上": "Not installed",
"这个地址上已经有「{name}」了。留空 key": "This address already holds “{name}”. Leave the key blank",
"已就位 · {n} 个工具,下一轮就能用": "In place · {n} tools, ready next turn",
@@ -690,8 +627,6 @@ export const EN_SETTINGS: Record<string, string> = {
"检测中…": "Checking…",
"验证连接并读取": "Check connection and read models",
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
"接口地址已补全为 {url}": "Address completed to {url}",
"刷新模型目录时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
"启用的模型": "Enabled models",
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
@@ -805,30 +740,8 @@ export const EN_SETTINGS: Record<string, string> = {
"这个来源包含 {n} 个技能,会全部安装": "This source holds {n} skills, and all of them will be installed",
"我已看过这 {n} 个技能,全部安装": "I have read these {n} skills - install all of them",
"以下项目需要逐项看过再安装": "Read each of these before installing",
"以下项目会在本机运行程序": "These run programs on this machine",
"以下项目会改变可用能力": "These change what is available",
"以下项目仅添加文件": "These only add files",
"运行于 Reasonix 内部,可读取整个会话、绕过权限并直接操作本机": "Runs inside Reasonix, can read the whole session, bypass permissions and operate this machine directly",
"自动化钩子": "Automatic hooks",
"在会话生命周期内自动执行": "Run automatically during the session lifecycle",
"为 agent 提供的能力与内置工具等同": "Provides capabilities equivalent to the agent's built-in tools",
"{n} 个 MCP 服务": "{n} MCP services",
"1 个 MCP 服务": "1 MCP service",
"1 条钩子": "1 hook",
"1 个技能": "1 skill",
"1 个命令": "1 command",
"1 个子代理": "1 subagent",
"1 个提示词": "1 prompt",
"1 套配色": "1 palette",
"将启动": "Will start",
"将连接至": "Will connect to",
"一个常驻进程": "a long-running process",
"工具 {names}": "tools {names}",
"一条钩子": "a hook",
"一个外部服务": "an external service",
"{n} 个外部服务": "{n} external services",
"一个独立的 MCP 服务": "a standalone MCP service",
"{n} 个独立的 MCP 服务": "{n} standalone MCP services",
"无法读取 {name}": "Could not read {name}",
"发布者": "Publisher",
"审核版本": "Reviewed version",
@@ -837,8 +750,6 @@ export const EN_SETTINGS: Record<string, string> = {
"未固定——审核时没有记录内容摘要": "Not pinned - no content digest was recorded at review",
"仓库": "Repository",
"标签": "Tags",
"已有标签 {n}": "Existing tag {n}",
"新增标签": "Add tags",
"已安装 {version}": "Installed {version}",
"先列出将安装的全部内容,确认后才会写入": "Everything it would install is listed first; nothing is written until you confirm",
"审核时没有记录内容摘要,需要信任发布者后安装": "No content digest was recorded at review; trust the publisher to install it",
@@ -854,8 +765,6 @@ export const EN_SETTINGS: Record<string, string> = {
// 社区市场:发布
"浏览": "Browse",
"我的发布": "My packages",
"发布新版本": "Publish new version",
"从 {slug} 复用发布资料;请填写本次发布的来源地址。": "Reuse details from {slug}; enter the source address for this release.",
"发布": "Publish",
"登录后可以在这里发布技能、插件、MCP 服务和主题,并查看审核进度。": "Sign in to publish skills, plugins, MCP servers and themes here, and to follow their review.",
"去登录": "Sign in",
@@ -864,10 +773,8 @@ export const EN_SETTINGS: Record<string, string> = {
"再发布一个": "Publish another",
"查看我的发布": "See my packages",
"以 @{handle} 的名义提交,审核通过后公开。只收来源地址,不上传文件。": "Submitted as @{handle} and made public once approved. Only the source address is sent; no files are uploaded.",
"以 @{handle} 的名义保存,仅自己可见,不提交审核。只收来源地址,不上传文件。": "Saved as @{handle}, visible only to you and not submitted for review. Only the source address is sent; no files are uploaded.",
"主题以插件包发布,包里只能有主题;带技能、钩子或 MCP 服务的包请按插件发布。": "A theme is published as a plugin package that holds only themes; a package with skills, hooks or MCP servers is published as a plugin.",
"小写字母、数字、点、下划线、连字符,最多 64 个字符。": "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters.",
"要发布这个包的新版本,请保持名称不变;改名会发布为另一个包。": "Keep this name to publish a new version of this package. Changing it publishes a different package.",
"留空时新包为 0.1.0,更新自动加一个补丁号。": "Left empty, a new package starts at 0.1.0 and an update bumps the patch number.",
"来源地址": "Source address",
"摘要": "Summary",
+3 -5
View File
@@ -9,9 +9,7 @@ export const EN_STORAGE: Record<string, string> = {
"选择旧版数据目录…": "Choose older data folder…",
"正在扫描…": "Scanning…",
"已找回 {n} 个会话。": "Recovered {n} sessions.",
"没有发现尚未导入的旧会话。": "Nothing new to import.",
"这个文件夹里没有找到旧版会话。请选择 Reasonix 的数据文件夹(里面有 sessions、projects 或 desktop-sessions-v5)。":
"No older sessions were found in this folder. Pick the Reasonix data folder (the one holding sessions, projects or desktop-sessions-v5).",
"没有发现尚未导入的旧会话。": "No sessions remain to be imported.",
"有 {n} 项无法读取。": "{n} items could not be read.",
"未能扫描这个文件夹。请确认它是旧版 Reasonix 的数据目录。": "This folder could not be scanned. Make sure it is an older Reasonix data folder.",
存储: "Storage",
@@ -23,8 +21,8 @@ export const EN_STORAGE: Record<string, string> = {
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
占用: "Space used",
原位置仍有残留数据: "Data remains in the previous location",
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此项目列表、全局指令与记忆、计划任务、壁纸、主题包或更新回滚备份可能显示为缺失。下次启动会自动并入当前位置;若仍显示,请退出后把这些文件或目录复制到当前位置,已有的文件不要覆盖。":
"These are still in {dir}: {names}. A move left them behind, so the project list, global instructions and memory, scheduled runs, a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. The next launch merges them into the current location; if they are still listed, quit and copy them there without overwriting files that already exist.",
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
"These are still in {dir}: {names}. A move left them behind, so a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. Copying those folders into the current location restores them.",
位置: "Locations",
迁移: "Migrate",
"迁移未能启动。": "The move could not be started.",
@@ -22,30 +22,11 @@ export const EN_USAGE: Record<string, string> = {
"以检查点版本覆盖": "Overwrite with the checkpoint's",
"保留当前版本": "Keep the current one",
"用量": "Usage",
"费用显示币种": "Cost display currency",
"只决定费用以哪种货币显示,不改动各来源的价目表。「自动」跟随账户余额的币种,没有余额信息时按价目表的币种显示。":
"Only decides which currency costs are shown in; each source's price list is untouched. Auto follows the account balance's currency, and falls back to the price list's currency when there is no balance.",
"显示币种": "Display currency",
"当前会话的费用、余额与用量页立即按所选币种显示,无需重启。": "The session's cost, the balance and the usage page switch to the chosen currency at once, no restart.",
"人民币 CNY": "Chinese yuan (CNY)",
"美元 USD": "US dollar (USD)",
"无法读取币种设置。": "Could not read the currency setting.",
"用量与成本": "Usage & cost",
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
"正在读取记录…": "Reading the records…",
"时间范围": "Time range",
"自然月": "Calendar month",
"开始日期": "Start date",
"结束日期": "End date",
"应用": "Apply",
"选择月份": "Choose month",
"每日明细": "Daily details",
"精确到每天": "Exact totals for each day",
"左右滚动查看": "Scroll sideways for all columns",
"日期": "Date",
"支出": "Spend",
"请求": "Requests",
"7 天": "7 days",
"30 天": "30 days",
"全部": "All",
@@ -73,7 +54,6 @@ export const EN_USAGE: Record<string, string> = {
"另有 {n} 天有用量但没有成本记录 —— 不是那几天没花费,是成本字段后来才开始持久化。": "{n} more days have usage but no cost recorded — not free days; the cost field started being persisted later.",
"按模型": "By model",
"按来源": "By source",
"未知来源": "Unknown source",
"尚无 key?前往获取": "No key yet? Get one",
"打开 {name} 控制台": "Open the {name} console",
"无法打开浏览器,请手动访问 {at}": "Could not open a browser — visit {at} yourself",
@@ -14,12 +14,6 @@ export const EN_WINDOW: Record<string, string> = {
"运行详情": "Runtime details",
"查看全部指标": "View all metrics",
"子代理": "Subagents",
"查看完整记录": "Open full transcript",
"查看完整记录:{name}": "Open full transcript: {name}",
"子代理完整记录:{name}": "Full transcript of subagent: {name}",
"还没有记录到步骤": "No steps recorded yet",
"显示全部": "Show all",
"输出过长,仅显示前 200000 个字符": "Output too long, showing the first 200000 characters",
"成本构成": "Cost breakdown",
"计划待确认": "The plan is yours to decide",
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
@@ -24,17 +24,6 @@ const coded = (message: string, code: string, params?: Record<string, string | n
// upstream of this — dotted codes, sentinels, typed families — buys nothing if
// the last hop prints the English that rode along for the log.
describe("what a reader is told a refusal was", () => {
it("renders shell refusal causes in Chinese and English", () => {
for (const code of ["shell.destructive_target", "shell.analysis_unknown", "shell.delete_sequence", "shell.delete_nonliteral", "shell.delete_option", "shell.syntax_error", "shell.parser_unavailable", "shell.parser_timeout", "shell.command_line_too_long"]) {
localStorage.setItem(STORAGE, "zh"); boot();
const chinese = reason(coded("fixture fallback", code));
expect(chinese).not.toBe("fixture fallback");
localStorage.setItem(STORAGE, "en"); boot();
const english = reason(coded("fixture fallback", code));
expect(english).not.toBe(chinese);
expect(english).not.toBe("fixture fallback");
}
});
it("says a coded refusal in the window's own language", () => {
expect(reason(coded("inbox item not found", "inbox.not_found"))).toBe("该条已不在待送达队列中");
});
@@ -42,14 +31,6 @@ describe("what a reader is told a refusal was", () => {
// The gate the codes were for. The kernel rewords its own English whenever a
// sentence reads better in a log; if that moves what a reader sees, wording
// authority never actually left the kernel.
it("explains the project limit in Chinese and English", () => {
const error = coded("storage limit", "workspace.limit_reached");
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
localStorage.setItem(STORAGE, "en");
boot();
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
});
it("does not move when the kernel rewords the same code", () => {
const before = reason(coded("inbox item not found", "inbox.not_found"));
const after = reason(coded("no such entry in this session's inbox", "inbox.not_found"));
+2 -63
View File
@@ -18,19 +18,9 @@ export const PROVIDER_EDIT_DISABLED = "provider.editing_disabled";
// A paired device answers this for every account route, votes included.
export const ACCOUNT_SIGNIN_DISABLED = "account.signin_disabled";
// The write landed and only applying it to the open conversation did not.
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed", "runtime.saved_while_running"];
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed"];
const SAID: Record<string, string> = {
"shell.destructive_target": "递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径",
"shell.analysis_unknown": "无法确定递归删除范围;请使用字面命令名、路径和解释器内容",
"shell.delete_sequence": "请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值",
"shell.delete_nonliteral": "请使用授权目录内的字面删除路径,不要使用变量、展开、通配符或未知管道输入",
"shell.delete_option": "删除选项无法识别;请使用已知选项并提供公共参数的值",
"shell.syntax_error": "命令语法有误;请修正后重试",
"shell.parser_unavailable": "主机命令解析器不可用;请在主机恢复后重试",
"shell.parser_timeout": "主机命令解析超时或被取消;请重试",
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
"plan.decision_stale": "该决定已不符合当前状态:计划在你回答前已发生变更",
"busy.switch_model": "任务正在运行,请先停止再切换模型",
@@ -52,7 +42,7 @@ const SAID: Record<string, string> = {
// ── 来源:填错了什么 ─────────────────────────────────────────────
"provider.name_required": "请为该来源填写名称",
"provider.name_invalid": "名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符",
"provider.name_invalid": "名称只能包含字母、数字、点、连字符和下划线",
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
"provider.endpoint_required": "请填写接口地址",
@@ -78,7 +68,6 @@ const SAID: Record<string, string> = {
"session.bad_path": "无法解析该会话路径",
"session.open_failed": "打不开这个会话",
"session.outside_dir": "该路径位于会话目录之外",
"session.unknown": "没有这个会话",
"request.method_not_allowed": "该地址不接受此种请求方式",
"request.bad_content_type": "请求体必须是 application/json",
"permissions.editing_disabled": "这台服务器未开放权限编辑",
@@ -115,7 +104,6 @@ const SAID: Record<string, string> = {
"backup.not_found": "这份备份已不存在",
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"settings.unknown_preset": "不存在该预设",
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
@@ -137,7 +125,6 @@ const SAID: Record<string, string> = {
"provider.no_current_model": "当前没有正在使用的模型,无法记录其窗口大小",
"context.window_after_this_turn": "窗口大小已记录,将在本轮结束后生效",
"provider.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
"runtime.saved_while_running": "已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效",
"provider.saved_model_unlisted": "已保存。当前对话使用的模型已不在该来源的列表中,切换模型后才会生效",
"provider.extra_body_null": "额外设置中的「{path}」不能为空值(null)",
"provider.no_websearch_wire": "该协议不支持由端点自行搜索",
@@ -149,8 +136,6 @@ const SAID: Record<string, string> = {
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
"editor.launch_failed": "编辑器没能启动:{error}",
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
@@ -163,30 +148,15 @@ const SAID: Record<string, string> = {
"share.cloud_unavailable": "互联网连接暂时不可用,请确认电脑端 Studio 已登录并保持在线。",
"share.address_rejected": "地址 {ip} 不是本机的局域网地址。",
"share.listen_failed": "无法在 {ip} 上开启监听:{error}",
"share.port_in_use": "端口 {port} 已被其他程序占用,请换一个端口,或清空后让系统自动选择。",
"share.port_out_of_range": "端口需要在 {min}–{max} 之间,或清空后让系统自动选择。",
"share.port_save_failed": "无法保存端口设置:{error}",
"share.device_unknown": "没有这台已配对的设备,可能已被移除。",
"picker.unsupported": "这个系统没有可用的文件夹选择框,请直接填写路径。",
"picker.failed": "打不开文件夹选择框:{error}",
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
"page.not_built": "这个内核没有带界面,只提供接口",
"provider.key_missing": "当前模型还没有 API key,请先在设置里添加",
"provider.key_required": "请填写 API key",
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
"provider.setup_done": "已连接,无需重复配置",
"provider.setup_failed": "远端配置未完成,请稍后重试",
"provider.unknown": "没有这个模型连接",
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
"provider.key_unstorable": "该 key 含有无法安全保存的字符组合,请重新复制",
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
"provider.test_auth": "服务商拒绝了该 key,请检查是否复制完整",
"provider.test_timeout": "服务商没有及时应答,请稍后重试",
"provider.test_unreachable": "连不上服务商,请检查网络或服务地址",
"provider.test_upstream": "服务商返回了错误,请稍后重试",
"provider.test_failed": "连接测试未通过",
"memory.unavailable": "该会话未启用记忆",
@@ -218,21 +188,6 @@ const SAID: Record<string, string> = {
"prompt_refine.failed": "优化失败,请重试",
"prompt_refine.bad_request": "优化请求格式不正确",
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
"commit.secrets_staged": "暂存的内容里有疑似密钥的文件,需要你确认后才能提交",
"commit.empty_message": "提交说明是空的",
"commit.message_invalid": "提交说明含有无法记录的字符",
"commit.message_too_long": "提交说明超过 {max_bytes} 字节",
"commit.identity_missing": "git 没有配置提交者姓名和邮箱(user.name、user.email)",
"commit.failed": "git 没能记录这次提交",
"commit.no_model": "当前会话没有可用的模型,无法起草提交说明",
"commit.timeout": "起草提交说明超时,请重试",
"commit.no_answer": "模型没有给出提交说明,请重试",
"commit.git_failed": "读取暂存区失败",
"commit.bad_request": "提交请求格式不正确",
// ── 会话 ─────────────────────────────────────────────────────────
"session.disabled": "这台服务器已关闭会话切换",
"session.pending_cleanup": "该会话正在清理,请稍后再打开",
@@ -308,8 +263,6 @@ const SAID: Record<string, string> = {
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
@@ -374,20 +327,6 @@ const SAID: Record<string, string> = {
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
"wallet.unreachable": "该供应商的余额接口无响应",
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
"feedback.invalid": "反馈内容不符合要求,请检查后重试",
"feedback.too_large": "反馈内容过大,请缩短文字或减少截图",
"feedback.rate_limited": "提交太频繁了,请稍后再试",
"feedback.disabled": "反馈通道暂时关闭,请稍后再试,或直接到 GitHub 提交问题",
"feedback.duplicate": "相同内容的反馈刚刚提交过了",
"feedback.bad_token": "反馈服务没有接受本机的反馈身份,请重试",
"feedback.offline": "无法连接反馈服务,请检查网络后重试,已填内容会保留",
"feedback.unavailable": "反馈服务暂时无法处理请求,请稍后再试",
"feedback.internal": "本机保存反馈记录失败,请重试",
"feedback.busy": "反馈通道今日已满,请明天再试",
"feedback.image_metadata": "截图无法清除其中的元数据,请换一张或先用截图工具重新导出",
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
"feedback.not_replyable": "这份反馈现在不接收回复",
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
// ── 远程连接停下来问的那一句 ───────────────────────────────────
"ask.not_found": "不存在该待回答的问题",
+3 -11
View File
@@ -6,9 +6,9 @@ export const NOTICE_TEXT: Record<string, string> = {
empty_final: "这一轮模型没有给出回答,正在让它重说一次",
executor_handoff: "模型直接给了答案却没动手,正在要求它先用工具",
verification_stalled: "同一个检查连着几轮都报同样的结果,是否继续由你定",
workspace_lease: "另一个会话持有重叠的写入范围,取得所需范围后会自动继续",
workspace_lease_resumed: "这个会话已取得所需的写入范围,现已继续",
workspace_lease_abandoned: "尚未取得所需的写入范围,这次等待已结束",
workspace_lease: "另一个会话正在写这个工作区,等它写完会自动继续",
workspace_lease_resumed: "工作区空出来了,这个会话已经继续",
workspace_lease_abandoned: "还没轮到这个会话写,这次等待就结束了",
permission_saved: "已记住这项授权,以后同样的操作不再询问",
permission_covered: "已有的授权规则覆盖了这项操作,无需另存",
permission_save_failed: "授权没能保存,只在本次会话内有效",
@@ -16,14 +16,6 @@ export const NOTICE_TEXT: Record<string, string> = {
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
display_currency: "费用显示币种已设为 {mode}",
compacted: "已压缩",
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
@@ -1,9 +0,0 @@
// Why a held queue entry stopped, keyed by the kernel's block code. A code with
// no entry shows the kernel's own diagnostic.
export const BLOCK_WHY: Record<string, string> = {
steer_unapplied: "这条插话在本轮结束前没来得及送达,没有发给模型",
turn_snapshot_failed: "本轮已结束,但对话记录没能保存",
turn_ack_failed: "本轮已结束,但队列没能确认这一条",
owner_inactive: "运行这一条的会话已不在了",
manifest_salvaged: "队列记录损坏,这一条是从残留文件里找回的",
};
@@ -1,25 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { boot, STORAGE, t } from "./index";
import { NOTICE_TEXT } from "./notices";
import { say } from "./kernel";
import { workspaceLeaseDetail } from "./workspace_lease";
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
describe("write claim attribution", () => {
for (const language of ["zh", "en"]) {
it(`carries holder identity and extent in ${language}`, () => {
localStorage.setItem(STORAGE, language);
boot();
const claim = { contended: 0, heldMs: 0, idleMs: 0, holder: "Fixture A", holderSessionId: "session-a", paths: ["src/a.go"], requestedPaths: ["src/b.go"] };
const detail = workspaceLeaseDetail(claim);
for (const value of ["Fixture A", "session-a", "src/a.go", "src/b.go"]) expect(detail).toContain(value);
expect(workspaceLeaseDetail(claim, false)).not.toContain("Fixture A");
expect(t(NOTICE_TEXT.workspace_lease_resumed)).toBe(language === "en"
? "This session has acquired its requested write claim and continued"
: "这个会话已取得所需的写入范围,现已继续");
expect(say({ code: "workspace.write_conflict" }, "")).not.toBe("");
});
}
});
Loaded 100 of 1469 files, more files were not shown because too many files have changed in this diff. Show more