diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index a7e8a8f0b7..2d9ddc74b6 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -12,7 +12,7 @@ "reference-bug": "the reference passes it for the wrong reason; t27b is right not to", "n/a": "outside what t27b is for (say why in the PR that sets it)" }, - "max_not_pass": 132, + "max_not_pass": 133, "entries": [ {"path": "specs/a/b_c.t27", "t27b": "pass"}, {"path": "specs/a_b/c.t27", "t27b": "pass"}, @@ -1561,6 +1561,7 @@ {"path": "specs/t27f/particle_engine.t27", "t27b": "blocked", "blocker": "ExprCall(@sin)", "reason": "unimplemented"}, {"path": "specs/t27f/particle_field.t27", "t27b": "pass"}, {"path": "specs/t27f/pbr_sphere.t27", "t27b": "pass"}, + {"path": "specs/t27f/petal_glass.t27", "t27b": "blocked", "blocker": "ExprCall(@sin)", "reason": "unimplemented"}, {"path": "specs/t27f/petal_menu.t27", "t27b": "pass"}, {"path": "specs/t27f/quest.t27", "t27b": "pass"}, {"path": "specs/t27f/reel.t27", "t27b": "pass"}, @@ -1615,10 +1616,28 @@ {"path": "specs/t27mcp/telegram.t27", "t27b": "pass"}, {"path": "specs/t27mcp/tool_schema.t27", "t27b": "codegen", "blocker": "t27b: unsupported construct FnDecl(frame or branch range) at line 0 (fn the round trip fixtures: a tuple, arrays, a boolean, nested objects: conditional branch out of range (function too large))", "reason": "unimplemented"}, {"path": "specs/t27mcp/visual_output.t27", "t27b": "pass"}, + {"path": "specs/t27os/agents.t27", "t27b": "pass"}, + {"path": "specs/t27os/doctor.t27", "t27b": "pass"}, + {"path": "specs/t27os/editors.t27", "t27b": "pass"}, + {"path": "specs/t27os/frame_gate.t27", "t27b": "pass"}, + {"path": "specs/t27os/hot_reload.t27", "t27b": "pass"}, {"path": "specs/t27os/install.t27", "t27b": "pass"}, + {"path": "specs/t27os/keys.t27", "t27b": "pass"}, + {"path": "specs/t27os/layout.t27", "t27b": "pass"}, + {"path": "specs/t27os/pane_state.t27", "t27b": "pass"}, {"path": "specs/t27os/pty_client.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, + {"path": "specs/t27os/pty_logic.t27", "t27b": "pass"}, + {"path": "specs/t27os/pty_proto.t27", "t27b": "pass"}, {"path": "specs/t27os/pty_server.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, {"path": "specs/t27os/pty_sys.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, + {"path": "specs/t27os/queen.t27", "t27b": "pass"}, + {"path": "specs/t27os/router.t27", "t27b": "pass"}, + {"path": "specs/t27os/session_store.t27", "t27b": "pass"}, + {"path": "specs/t27os/term_pane.t27", "t27b": "pass"}, + {"path": "specs/t27os/text_cache.t27", "t27b": "pass"}, + {"path": "specs/t27os/vt.t27", "t27b": "pass"}, + {"path": "specs/t27os/window_keep.t27", "t27b": "pass"}, + {"path": "specs/t27os/windows.t27", "t27b": "pass"}, {"path": "specs/t27t/t27t.t27", "t27b": "pass"}, {"path": "specs/ternary/activation_quantizer.t27", "t27b": "pass"}, {"path": "specs/ternary/bitnet_layer.t27", "t27b": "pass"}, diff --git a/docs/t27os/keys-orchestration.md b/docs/t27os/keys-orchestration.md new file mode 100644 index 0000000000..7757e6090a --- /dev/null +++ b/docs/t27os/keys-orchestration.md @@ -0,0 +1,116 @@ +# T27 OS key orchestration, compared with Free Claude Code + +Issue: gHashTag/t27#11224. Code: `specs/t27os/keys.t27` (decisions and data), the host side in +`keys_host.t27`, `pty_server.t27` and `glass_window.t27`. Stage 2 (a local router) is +`specs/t27os/router.t27`, decisions only. No key value appears in this file or in any spec; every test uses +fake values. + +## Why + +A pane that starts Claude Code inherited whatever provider the parent shell pointed at, and that provider +answered `429 Insufficient balance` or `Your organization has disabled Claude subscription access`. The pane +was dead until a person edited the environment by hand. The owner asked (2026-10-20) for key orchestration +like the tool Free Claude Code. + +## The prior art, in our words + +Free Claude Code (https://github.com/Alishahryar1/free-claude-code, page read 2026-10-20) is a local proxy +that makes Claude Code talk to many other providers. Its configuration, as the page describes it: + +- one default model, plus an optional override for each tier (opus, sonnet, haiku, and a fourth tier); a tier + left empty falls back to the default; +- a model is written as `provider/model`; +- each provider has a list of numbered keys that can be reordered; key 1 is used first, and a fallback entry + may name a key number; +- an ordered fallback list: when a request still fails after its retries, the next entry is tried, so one + request may spend the quota of more than one provider; +- an optional bearer token that protects the local proxy; +- an admin page where keys, models and fallbacks are set. + +## The constraint that shapes our version + +T27 OS cannot run a local HTTP proxy yet: t27b has no listening INET socket and no streamed HTTPS client +(issue #10902), so `router.t27` stops at decisions. But several providers serve the Anthropic protocol +themselves. Claude Code can talk to them directly with `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` and the +tier model variables. So the orchestration is done per pane, not per request: + +1. At pane spawn, choose a profile (a provider, its base URL, its key variable, its tier models) and set that + environment, and nothing else (`pick_profile`, `profile_var_*`, `claude_strips`). +2. When the pane shows a provider error, mark that provider bad for this pane and respawn the pane, which + resumes its Claude Code session, on the next profile or the next key (`classify_line`, `fo_action`). + +No proxy is involved. + +## Comparison + +| Free Claude Code idea | In T27 OS | Spec function | What stays blocked | +|---|---|---|---| +| One default model | Yes, per profile | `MODELS_DEFAULT`, `tier_model(pr, TI_DEFAULT)`, sets `ANTHROPIC_MODEL` | Nothing | +| Per-tier overrides (opus, sonnet, haiku) | Yes, as data, one column per tier; an empty field falls back to the default; the small-fast variable follows haiku | `MODELS_OPUS/SONNET/HAIKU`, `tier_model`, `profile_var_name/text` | A fourth tier (the one the OpenRouter and Kimi pages call fable) is not mapped; changing a tier is a data edit, not a screen | +| `provider/model` syntax | A profile is a provider; its models sit in the tier table | `PROFILE_NAMES`, `PROFILE_PROVIDER` | The combined one-line syntax; it exists in Free Claude Code because its proxy picks the upstream per request | +| Many providers | Four profiles today: z.ai, Anthropic direct, OpenRouter, Ollama cloud, over the nine key rows | `PROFILE_PROVIDER`, `PROFILE_BASE_URLS`, `PROFILE_KEY_VARS` | A provider that does not speak the Anthropic protocol needs the local router and `specs/provider/transform.t27` (#10902). DeepSeek, Moonshot and MiniMax speak it but need key rows of their own (see below) | +| Numbered keys, key 1 first | Naming and choice are decided: key 1 keeps the existing Keychain item, keys 2 and 3 add `#2`, `#3`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | The KEYS tab still shows one row per provider and the host reads only key 1; storing and listing keys 2 and 3 is host work | +| A fallback entry names a key number | Decided: a throttled key (429) with a spare key swaps the key and keeps the profile | `fo_action` = `FA_RESPAWN_KEY`, `fo_target` | The host does not pass the key number to the respawn yet | +| Ordered fallback list after retries | Yes, per pane: the profile list is walked forward only | `next_profile`, `fo_action` = `FA_RESPAWN_NEXT`, `MAX_FAILOVERS` | Per request, inside one conversation turn: needs the local router. A respawn does not replay the request that failed; the person continues in the resumed session | +| Retries before the fallback | Not ours: Claude Code retries by itself, and the failover starts when the error text reaches the screen | `classify_line` | Nothing to build | +| Reasoning effort per route | No | none | Out of scope; not set by any profile | +| Bearer token for the local proxy | Not needed, there is no proxy | none | Needed with the router (it binds 127.0.0.1, `ROUTER_BIND`) | +| Admin page for keys | Yes: the KEYS tab, Keychain storage, masked display, a check button, a circuit breaker | `ky_*`, `mask_key`, `status_after_probe`, `circuit_fails`, `bad_mask` | The signed-in account keys on app.t27.ai cannot be spent from a pane: no proxy route exists server-side (see the MAP block in `keys.t27`) | +| Local models (LM Studio, llama.cpp, Ollama) | Ollama cloud only | profile `ollama-cloud` | Local Ollama needs no key, so the stored-keys mask cannot say it is available; a host fact (is the server up) must join the mask first | +| OAuth sign-in for some providers | No | none | Out of scope | + +## The pane failover, step by step + +All functions are pure; the host reads the screen and respawns. + +1. `classify_line(line)` sorts one screen line into a class by plain byte matching: `disabled Claude + subscription` is subscription disabled; `Insufficient balance` is out of balance (it wins over 429, + because a provider sends both for an empty account); a standalone `429` on a line that also says + "error" is rate limited; a standalone `401` or `403` there is login rejected; an `API Error` line that + matches nothing is other; anything else is no error. A number glued to other digits does not count, and + neither does "Edited 429 lines". +2. `fo_action(class, pr, stored, bad, done, alt)` answers keep, respawn on the next profile, respawn on the + next key of the same profile, or give up. `pr` is the profile in use, or none for a pane that started + without a key (where the "disabled Claude subscription access" line comes from). `alt` says another key of + the provider is usable (`rotate_pick_except`, the failed key skipped). +3. `fo_bad(class, pr, bad)` is the new bad mask for this pane; `fo_target(...)` is the profile to respawn on. +4. `fo_why(...)` writes the line for the KEYS tab: the profile, the failure and the move, never a key value. + +It cannot loop. The chain is walked forward only (`next_profile` returns a profile after the one that failed), +so each profile is tried at most once per pane. A key swap is counted in `done`, and `MAX_FAILOVERS` ends the +count even if the host always reports a spare key. After every profile has failed once, the answer is give up +and the pane stays where it is. A line that is no recognised failure keeps the pane as it is. + +## Providers read on 2026-10-20 + +Only providers whose own pages document an Anthropic-protocol base URL are used. Model names are copied from +the pages, not guessed. + +| Provider | Base URL | Key variable | Tier models shown | Page | In T27 OS | +|---|---|---|---|---|---| +| z.ai | `https://api.z.ai/api/anthropic` | `ANTHROPIC_AUTH_TOKEN` | A flash model for every tier is what the stored key answers (owner, 2026-10-19); the page lists larger ones for opus and sonnet | https://docs.z.ai/devpack/tool/claude | Profile `zai`, first in the chain | +| Anthropic direct | none (Claude Code's own) | `ANTHROPIC_API_KEY` | none set | Claude Code itself | Profile `anthropic-api`, second | +| OpenRouter | `https://openrouter.ai/api` | `ANTHROPIC_AUTH_TOKEN`, with `ANTHROPIC_API_KEY` set empty | opus, sonnet, haiku: the `~anthropic/claude-*-latest` aliases (`[1m]` on opus and sonnet) | https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration.md | Profile `openrouter`, third. The page warns that Claude Code is only guaranteed with Anthropic models there | +| Ollama cloud | `https://ollama.com` | `ANTHROPIC_AUTH_TOKEN`, with `ANTHROPIC_API_KEY` set empty | one cloud model, named on the page, used for every tier | https://docs.ollama.com/integrations/claude-code | Profile `ollama-cloud`, fourth | +| DeepSeek | `https://api.deepseek.com/anthropic` | `ANTHROPIC_AUTH_TOKEN` | one flash model for the default, opus and sonnet (with `[1m]`), the same without it for haiku and subagents | https://api-docs.deepseek.com/guides/coding_agents | Documented, no profile yet | +| Moonshot (Kimi) | `https://api.moonshot.ai/anthropic` | `ANTHROPIC_AUTH_TOKEN` | one model for default, opus and sonnet (with `[1m]`), a code model for haiku | https://platform.kimi.ai/docs/guide/claude-code-kimi | Documented, no profile yet | +| MiniMax | `https://api.minimax.io/anthropic` | `ANTHROPIC_AUTH_TOKEN` | one flash model for every tier (with `[1m]`) | https://platform.minimax.io/docs/token-plan/claude-code.md | Documented, no profile yet | +| Ollama, local | `http://localhost:11434` | token set to a fixed word, no real key | a local model, named on the page | https://docs.ollama.com/integrations/claude-code | Left out, see the comparison | + +The three "no profile yet" rows need a provider row of their own in `keys.t27` (nine rows today). The KEYS +window sizes its arrays by nine and the PTY server sizes its key table by nine, so the new rows are a +change in those files too; the profile data is then one more field in each table in `keys.t27`. + +## Defaults chosen, and what is left + +- Order: z.ai first, as before; the others follow in the order of the table. A profile is used only once its + provider's key is stored and has not failed its check. Nothing is enabled by this change on a Mac with no + extra key stored. +- A Mac that already holds an OpenRouter or Ollama key (stored for another agent) now also gives Claude panes + that profile after z.ai and the Anthropic API key. +- Host work, not decisions: call `classify_line` on the pane's error row only, never on the transcript body, + because a diff that shows these phrases would otherwise trigger a failover; keep the per-pane `bad` mask and + `done` count; pass the key number to the respawn; the window's start-up check loop covers the first two + profiles only and should run to `PROFILE_COUNT`. +- Blocked: the local router (per-request fallback, translation for non-Anthropic providers, a bearer token for + the proxy) until t27b has a listening socket and a streamed HTTPS client (#10902). diff --git a/specs/t27os/doctor.t27 b/specs/t27os/doctor.t27 index 57a4f082f8..c746e5040a 100644 --- a/specs/t27os/doctor.t27 +++ b/specs/t27os/doctor.t27 @@ -72,7 +72,7 @@ pub const ST_STASH : str = "# stash "; /// What the DOCTOR knows about a repository, one bit each. pub const F_CHECKED : u32 = 1; pub const F_WORKTREE : u32 = 2; -pub const F_AGENT : u32 = 4; +pub const DOC_F_AGENT : u32 = 4; pub const F_DUP : u32 = 8; pub const F_REPEATED : u32 = 16; pub const F_HEAVY : u32 = 32; @@ -267,7 +267,7 @@ pub fn keep_reason(flags: u32) -> u32 { /// copy, or one of a folder made several times. A space bound in the sidebar is in use, not a leftover. pub fn leftover(flags: u32) -> bool { if (has(flags, F_BOUND)) { return false; } - return has(flags, F_DUP) or has(flags, F_AGENT) or has(flags, F_REPEATED); + return has(flags, F_DUP) or has(flags, DOC_F_AGENT) or has(flags, F_REPEATED); } /// Whether a row offers the remove button: a leftover that nothing keeps. @@ -287,8 +287,8 @@ pub fn row_group(flags: u32) -> u32 { /// worktree, else the first; every other clone is a duplicate. `keeper_rank` orders the candidates (lower wins, /// ties go to the first discovered). pub fn keeper_rank(flags: u32) -> u32 { - if (has(flags, F_AGENT) == false and has(flags, F_WORKTREE) == false) { return 0; } - if (has(flags, F_AGENT) == false) { return 1; } + if (has(flags, DOC_F_AGENT) == false and has(flags, F_WORKTREE) == false) { return 0; } + if (has(flags, DOC_F_AGENT) == false) { return 1; } return 2; } @@ -302,7 +302,7 @@ pub fn dup_candidate(flags: u32) -> bool { /// hidden ones never. pub fn bind_rank(flags: u32) -> u32 { if (has(flags, F_HIDDEN)) { return RANK_NEVER; } - if (has(flags, F_AGENT) or has(flags, F_WORKTREE)) { return RANK_PLUS; } + if (has(flags, DOC_F_AGENT) or has(flags, F_WORKTREE)) { return RANK_PLUS; } return RANK_AUTO; } @@ -357,7 +357,7 @@ pub fn doc_scroll(off: i64, total: u32, rows: u32) -> u32 { } test a_leftover_with_work_in_it_is_never_offered { - const clean_agent : u32 = F_CHECKED + F_PUSH_CHECKED + F_AGENT; + const clean_agent : u32 = F_CHECKED + F_PUSH_CHECKED + DOC_F_AGENT; assert removable(clean_agent); assert removable(clean_agent + F_DIRTY) == false; assert keep_reason(clean_agent + F_DIRTY) == KEEP_DIRTY; @@ -368,8 +368,8 @@ test a_leftover_with_work_in_it_is_never_offered { assert removable(clean_agent + F_NESTS) == false; assert removable(clean_agent + F_NO_REMOTE) == false; // not checked yet, or only half checked: kept - assert keep_reason(F_AGENT) == KEEP_UNCHECKED; - assert removable(F_CHECKED + F_AGENT) == false; + assert keep_reason(DOC_F_AGENT) == KEEP_UNCHECKED; + assert removable(F_CHECKED + DOC_F_AGENT) == false; // a clean main clone with nothing left over is not a suggestion assert removable(F_CHECKED + F_PUSH_CHECKED) == false; // a space in use is not a leftover @@ -386,10 +386,10 @@ test rows_are_grouped_removable_first { test the_keeper_is_a_main_clone { assert keeper_rank(0) < keeper_rank(F_WORKTREE); - assert keeper_rank(F_WORKTREE) < keeper_rank(F_AGENT); + assert keeper_rank(F_WORKTREE) < keeper_rank(DOC_F_AGENT); assert dup_candidate(F_WORKTREE) == false; assert dup_candidate(F_NO_REMOTE) == false; - assert dup_candidate(F_AGENT); + assert dup_candidate(DOC_F_AGENT); } test two_clicks_remove_one_row { diff --git a/specs/t27os/editors.t27 b/specs/t27os/editors.t27 new file mode 100644 index 0000000000..9df66e5928 --- /dev/null +++ b/specs/t27os/editors.t27 @@ -0,0 +1,833 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS editors: "open in editor" for a SPACE or a pane's working directory. This spec is the table and +// the decisions; editors_host.t27 does the two system calls (access, posix_spawn). +// +// An editor is found by its app bundle (/Applications/.app or ~/Applications/.app) or, failing +// that, by its command-line launcher on the usual PATH directories. A bundle is launched through +// `open -a ` (the path that was found, so a name with a space or two apps with a shared +// prefix cannot be mixed up; no CLI install needed); a launcher alone is run as ` `. Xcode is the +// exception: `xed ` opens a plain folder, `open -a Xcode` does not, so the launcher wins there, but +// /usr/bin/xed is a shim that every Mac has, so for Xcode only the bundle counts as installed. Nothing is +// installed by T27 OS: an editor that is not there is reported missing, with the reason (open_result). +// +// The mask of installed editors is a u32: at most 32 entries (MAX_EDITORS). Not looked up: JetBrains +// Toolbox installs (~/Applications/JetBrains Toolbox/ .app carries an edition suffix the +// table cannot guess; its shell-script launchers live outside CLI_DIRS). +// +// Owner, 2026-10-20: "check that all code editors are supported and fix what does not open" -- the table +// now names every editor found on the bench Mac (Cursor, Xcode, Antigravity, Antigravity IDE, ZCode) +// and the usual others. +// +// Refs gHashTag/t27#10782, gHashTag/t27#11223. +module T27osEditors; + +use t27os::session_store::csv_field; +use t27os::session_store::put_bytes; +use t27os::session_store::SHELL_CELL; + +pub const MAX_EDITORS : u32 = 32; +pub const EDITOR_COUNT : u32 = 27; +pub const E_VSCODE : u32 = 0; +pub const E_CURSOR : u32 = 1; +pub const E_ZED : u32 = 2; +pub const E_WINDSURF : u32 = 3; +pub const E_XCODE : u32 = 4; +pub const E_IDEA : u32 = 5; +pub const E_WEBSTORM : u32 = 6; +pub const E_SUBLIME : u32 = 7; +pub const E_NOVA : u32 = 8; +pub const E_ANTIGRAVITY : u32 = 9; +pub const E_ANTIGRAVITY_IDE : u32 = 10; +pub const E_ZCODE : u32 = 11; +pub const E_PYCHARM : u32 = 12; +pub const E_GOLAND : u32 = 13; +pub const E_CLION : u32 = 14; +pub const E_RUSTROVER : u32 = 15; +pub const E_RIDER : u32 = 16; +pub const E_PHPSTORM : u32 = 17; +pub const E_ANDROID_STUDIO : u32 = 18; +pub const E_VSCODE_INSIDERS : u32 = 19; +pub const E_VSCODIUM : u32 = 20; +pub const E_KIRO : u32 = 21; +pub const E_TRAE : u32 = 22; +pub const E_POSITRON : u32 = 23; +pub const E_BBEDIT : u32 = 24; +pub const E_TEXTMATE : u32 = 25; +pub const E_FLEET : u32 = 26; + +/// What the menu shows (a chip is ED_SLOT_W wide: at most 16 characters). +pub const EDITOR_NAMES : str = "VS Code,Cursor,Zed,Windsurf,Xcode,IntelliJ IDEA,WebStorm,Sublime Text,Nova,Antigravity,Antigravity IDE,ZCode,PyCharm,GoLand,CLion,RustRover,Rider,PhpStorm,Android Studio,VS Code Insiders,VSCodium,Kiro,Trae,Positron,BBEdit,TextMate,Fleet"; +/// The command-line launcher each editor installs; empty where the editor ships none (at most 15 characters). +pub const EDITOR_CLIS : str = "code,cursor,zed,windsurf,xed,idea,webstorm,subl,nova,antigravity,,,pycharm,goland,clion,rustrover,rider,phpstorm,studio,code-insiders,codium,kiro,trae,positron,bbedit,mate,fleet"; +/// The app bundle name (without .app): the folder name under /Applications, checked against the bench Mac +/// for Cursor, Xcode, Antigravity, Antigravity IDE and ZCode (at most 31 characters). +pub const EDITOR_APPS : str = "Visual Studio Code,Cursor,Zed,Windsurf,Xcode,IntelliJ IDEA,WebStorm,Sublime Text,Nova,Antigravity,Antigravity IDE,ZCode,PyCharm,GoLand,CLion,RustRover,Rider,PhpStorm,Android Studio,Visual Studio Code - Insiders,VSCodium,Kiro,Trae,Positron,BBEdit,TextMate,Fleet"; + +/// The directories a launcher is looked up in. +pub const CLI_DIRS : str = "/usr/local/bin,/opt/homebrew/bin,/usr/bin"; + +pub const HOW_NONE : u32 = 0; +pub const HOW_OPEN_APP : u32 = 1; +pub const HOW_CLI : u32 = 2; + +pub fn editor_name(i: u32, out: []u8) -> usize { + return csv_field(EDITOR_NAMES, i, out); +} + +pub fn editor_cli(i: u32, out: []u8) -> usize { + return csv_field(EDITOR_CLIS, i, out); +} + +pub fn editor_app(i: u32, out: []u8) -> usize { + return csv_field(EDITOR_APPS, i, out); +} + +/// "/Applications/.app" (home == "" ) or "/Applications/.app". +pub fn bundle_path(i: u32, home: []const u8, out: []u8) -> usize { + var app : [32]u8 = undefined; + const an : usize = editor_app(i, app[0..32]); + if (an == 0) { return 0; } + var n : usize = 0; + if (home.len > 0) { + n = put_bytes(out, 0, home); + } + n = put_bytes(out, n, "/Applications/"); + n = put_bytes(out, n, app[0..an]); + n = put_bytes(out, n, ".app"); + return n; +} + +/// "/" for the k-th launcher directory. +pub fn cli_path(i: u32, k: u32, out: []u8) -> usize { + var dir : [24]u8 = undefined; + const dn : usize = csv_field(CLI_DIRS, k, dir[0..24]); + if (dn == 0) { return 0; } + var cli : [16]u8 = undefined; + const cn : usize = editor_cli(i, cli[0..16]); + if (cn == 0) { return 0; } + var n : usize = put_bytes(out, 0, dir[0..dn]); + n = put_bytes(out, n, "/"); + n = put_bytes(out, n, cli[0..cn]); + return n; +} + +pub const CLI_DIR_COUNT : u32 = 3; + +/// How to launch an editor given what was found: the app bundle wins over the launcher. +pub fn launch_how(has_bundle: bool, has_cli: bool) -> u32 { + if (has_bundle) { return HOW_OPEN_APP; } + if (has_cli) { return HOW_CLI; } + return HOW_NONE; +} + +/// Xcode opens a plain folder badly through `open -a`; `xed ` is made for it. And /usr/bin/xed is a +/// shim every Mac has, so for Xcode a launcher alone never counts as an installed editor. +pub fn cli_first(i: u32) -> bool { + return i == E_XCODE; +} + +/// How to launch editor i given what was found: launch_how, except Xcode -- the bundle must be there, and +/// then its launcher (xed) is used when it exists and `open -a` when it does not. +pub fn launch_pick(i: u32, has_bundle: bool, has_cli: bool) -> u32 { + if (cli_first(i)) { + if (has_bundle == false) { return HOW_NONE; } + if (has_cli) { return HOW_CLI; } + return HOW_OPEN_APP; + } + return launch_how(has_bundle, has_cli); +} + +/// Whether the OPEN IN strip lists editor i. +pub fn is_installed(i: u32, has_bundle: bool, has_cli: bool) -> bool { + return launch_pick(i, has_bundle, has_cli) != HOW_NONE; +} + +/// Why an open did or did not start -- the strip shows OPEN_WORDS[result]. +pub const OPEN_OK : u32 = 0; +pub const OPEN_BAD_DIR : u32 = 1; +pub const OPEN_NOT_FOUND : u32 = 2; +pub const OPEN_SPAWN_FAILED : u32 = 3; +pub const OPEN_BAD_EDITOR : u32 = 4; +/// No click yet: the strip says nothing. +pub const OPEN_NONE : u32 = 255; +pub const OPEN_WORDS : str = "opened,bad folder,not installed,could not start,unknown editor"; + +/// The result of opening a folder in editor i: `dir_ok` is dir_fits, `how` is launch_pick, `spawn_rc` is what +/// posix_spawn returned (0 = started). The first thing wrong wins, in the order the host finds it out. +pub fn open_result(i: u32, dir_ok: bool, how: u32, spawn_rc: i32) -> u32 { + if (i >= EDITOR_COUNT) { return OPEN_BAD_EDITOR; } + if (dir_ok == false) { return OPEN_BAD_DIR; } + if (how == HOW_NONE) { return OPEN_NOT_FOUND; } + if (spawn_rc != 0) { return OPEN_SPAWN_FAILED; } + return OPEN_OK; +} + +pub fn open_word(result: u32, out: []u8) -> usize { + return csv_field(OPEN_WORDS, result, out); +} + +/// True when editor i is called `name` in the menu. +pub fn name_is(i: u32, name: []const u8) -> bool { + if (i >= EDITOR_COUNT) { return false; } + var b : [48]u8 = undefined; + const n : usize = editor_name(i, b[0..48]); + return same_bytes(b[0..n], name); +} + +/// Two byte strings are equal. After the length check one loop ORs the XOR of every byte pair into `d`, and +/// the strings are equal when `d` stays 0. It deliberately is not the early-exit compare the corpus already +/// has (igla/coder/tokenizer.t27, tri/crypto/hex.t27): it reads every byte whether or not an earlier one +/// differed, so its time depends on the length and not on where the first difference is. +pub fn same_bytes(a: []const u8, b: []const u8) -> bool { + if (a.len != b.len) { return false; } + var d : u32 = 0; + var k : usize = 0; + while (k < a.len) { + d = d | @as(u32, a[k] ^ b[k]); + k = k + 1; + } + return d == 0; +} + +/// The table index of the editor the menu calls `name`; EDITOR_COUNT when there is none. +pub fn editor_index(name: []const u8) -> u32 { + var i : u32 = 0; + while (i < EDITOR_COUNT) { + if (name_is(i, name)) { return i; } + i = i + 1; + } + return EDITOR_COUNT; +} + +/// A directory is fit to hand to an editor when it is an absolute path with no control characters. +pub fn dir_fits(dir: []const u8) -> bool { + if (dir.len == 0) { return false; } + if (dir.len > 400) { return false; } + if (dir[0] != 47) { return false; } + var i : usize = 0; + while (i < dir.len) { + if (dir[i] < 32) { return false; } + if (dir[i] == 127) { return false; } + i = i + 1; + } + return true; +} + +test every_editor_has_a_name_and_a_bundle_that_fit_their_buffers { + // measured into 64 bytes; csv_field does not check the buffer, so each limit below is the real one + var b : [64]u8 = undefined; + var i : u32 = 0; + while (i < EDITOR_COUNT) { + const nn : usize = editor_name(i, b[0..64]); + assert nn > 0; + assert nn <= 16; + const an : usize = editor_app(i, b[0..64]); + assert an > 0; + assert an <= 31; + assert editor_cli(i, b[0..64]) <= 15; + i = i + 1; + } + assert EDITOR_COUNT <= MAX_EDITORS; + // the three lists are the same length: past the last entry every one is empty, on it none is + assert editor_name(EDITOR_COUNT, b[0..64]) == 0; + assert editor_app(EDITOR_COUNT, b[0..64]) == 0; + assert editor_cli(EDITOR_COUNT, b[0..64]) == 0; + assert editor_name(EDITOR_COUNT - 1, b[0..64]) > 0; + assert editor_app(EDITOR_COUNT - 1, b[0..64]) > 0; + assert editor_cli(EDITOR_COUNT - 1, b[0..64]) > 0; +} + +test the_editors_found_on_the_bench_mac_are_in_the_table { + // measured 2026-10-20: /Applications holds Cursor, Xcode, Antigravity, Antigravity IDE and ZCode + assert editor_index("Cursor") == E_CURSOR; + assert editor_index("Xcode") == E_XCODE; + assert editor_index("Antigravity") == E_ANTIGRAVITY; + assert editor_index("Antigravity IDE") == E_ANTIGRAVITY_IDE; + assert editor_index("ZCode") == E_ZCODE; + assert editor_index("Emacs") == EDITOR_COUNT; + assert editor_index("") == EDITOR_COUNT; + assert editor_index("Antigravity I") == EDITOR_COUNT; + assert name_is(E_ZCODE, "ZCode"); + assert name_is(E_ZCODE, "ZCod") == false; + assert name_is(EDITOR_COUNT, "") == false; + // the usual others are named too + assert editor_index("PyCharm") == E_PYCHARM; + assert editor_index("GoLand") == E_GOLAND; + assert editor_index("CLion") == E_CLION; + assert editor_index("RustRover") == E_RUSTROVER; + assert editor_index("Rider") == E_RIDER; + assert editor_index("PhpStorm") == E_PHPSTORM; + assert editor_index("Android Studio") == E_ANDROID_STUDIO; + assert editor_index("VS Code Insiders") == E_VSCODE_INSIDERS; + assert editor_index("VSCodium") == E_VSCODIUM; + assert editor_index("Kiro") == E_KIRO; + assert editor_index("Trae") == E_TRAE; + assert editor_index("Positron") == E_POSITRON; + assert editor_index("BBEdit") == E_BBEDIT; + assert editor_index("TextMate") == E_TEXTMATE; + assert editor_index("Fleet") == E_FLEET; +} + +test each_bench_editor_has_the_bundle_folder_name_the_disk_has { + var p : [160]u8 = undefined; + var n : usize = bundle_path(E_CURSOR, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Cursor.app"); + n = bundle_path(E_XCODE, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Xcode.app"); + n = bundle_path(E_ANTIGRAVITY, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Antigravity.app"); + // a name with a space stays one path: the host hands it over as one argv word + n = bundle_path(E_ANTIGRAVITY_IDE, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Antigravity IDE.app"); + n = bundle_path(E_ZCODE, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/ZCode.app"); + n = bundle_path(E_ANTIGRAVITY_IDE, "/Users/tester", p[0..160]); + assert same_bytes(p[0..n], "/Users/tester/Applications/Antigravity IDE.app"); + n = bundle_path(E_VSCODE, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Visual Studio Code.app"); + n = bundle_path(E_VSCODE_INSIDERS, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Visual Studio Code - Insiders.app"); + n = bundle_path(E_ANDROID_STUDIO, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Android Studio.app"); + n = bundle_path(E_SUBLIME, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/Sublime Text.app"); + n = bundle_path(E_IDEA, "", p[0..160]); + assert same_bytes(p[0..n], "/Applications/IntelliJ IDEA.app"); +} + +test an_editor_with_no_launcher_is_found_by_its_bundle_alone { + var b : [16]u8 = undefined; + var p : [64]u8 = undefined; + assert editor_cli(E_ZCODE, b[0..16]) == 0; + assert editor_cli(E_ANTIGRAVITY_IDE, b[0..16]) == 0; + assert cli_path(E_ZCODE, 0, p[0..64]) == 0; + assert cli_path(E_ANTIGRAVITY_IDE, 1, p[0..64]) == 0; + assert launch_pick(E_ZCODE, true, false) == HOW_OPEN_APP; + assert launch_pick(E_ZCODE, false, false) == HOW_NONE; + // the neighbours of the empty fields still read their own launcher + assert editor_cli(E_ANTIGRAVITY, b[0..16]) == 11; + assert editor_cli(E_PYCHARM, b[0..16]) == 7; + assert b[0] == 112; +} + +test xcode_opens_through_xed_and_only_when_the_bundle_is_there { + assert cli_first(E_XCODE); + assert cli_first(E_CURSOR) == false; + assert launch_pick(E_XCODE, true, true) == HOW_CLI; + assert launch_pick(E_XCODE, true, false) == HOW_OPEN_APP; + // /usr/bin/xed is a shim every Mac has: it alone is not Xcode + assert launch_pick(E_XCODE, false, true) == HOW_NONE; + assert launch_pick(E_XCODE, false, false) == HOW_NONE; + assert is_installed(E_XCODE, false, true) == false; + assert is_installed(E_XCODE, true, true); + // everything else: the bundle wins, a launcher alone is enough + assert launch_pick(E_CURSOR, true, true) == HOW_OPEN_APP; + assert launch_pick(E_CURSOR, false, true) == HOW_CLI; + assert launch_pick(E_CURSOR, false, false) == HOW_NONE; + assert is_installed(E_ZED, false, true); + assert is_installed(E_ZED, false, false) == false; +} + +test the_open_result_names_the_first_thing_that_went_wrong { + assert open_result(E_CURSOR, true, HOW_OPEN_APP, 0) == OPEN_OK; + assert open_result(E_XCODE, true, HOW_CLI, 0) == OPEN_OK; + assert open_result(E_CURSOR, false, HOW_OPEN_APP, 0) == OPEN_BAD_DIR; + assert open_result(E_CURSOR, true, HOW_NONE, 0) == OPEN_NOT_FOUND; + assert open_result(E_CURSOR, true, HOW_CLI, 1) == OPEN_SPAWN_FAILED; + assert open_result(E_CURSOR, true, HOW_CLI, 0 - 1) == OPEN_SPAWN_FAILED; + assert open_result(EDITOR_COUNT, true, HOW_OPEN_APP, 0) == OPEN_BAD_EDITOR; + // a bad folder is reported before a missing editor, a missing editor before a failed spawn + assert open_result(E_CURSOR, false, HOW_NONE, 0) == OPEN_BAD_DIR; + assert open_result(E_CURSOR, true, HOW_NONE, 1) == OPEN_NOT_FOUND; + // the folder test is dir_fits + assert open_result(E_ZED, dir_fits("/Users/tester/work"), HOW_OPEN_APP, 0) == OPEN_OK; + assert open_result(E_ZED, dir_fits("work"), HOW_OPEN_APP, 0) == OPEN_BAD_DIR; +} + +test every_open_result_has_a_word_the_strip_can_show { + var b : [32]u8 = undefined; + var r : u32 = 0; + while (r <= OPEN_BAD_EDITOR) { + const n : usize = open_word(r, b[0..32]); + assert n > 0; + assert n <= 16; + r = r + 1; + } + assert open_word(OPEN_BAD_EDITOR + 1, b[0..32]) == 0; + assert open_word(OPEN_NONE, b[0..32]) == 0; + const k : usize = open_word(OPEN_NOT_FOUND, b[0..32]); + assert same_bytes(b[0..k], "not installed"); + const o : usize = open_word(OPEN_OK, b[0..32]); + assert same_bytes(b[0..o], "opened"); +} + +test the_whole_table_fits_the_32_bit_mask_and_the_strip { + const all : u32 = 134217727; + assert installed_count(all) == EDITOR_COUNT; + assert nth_installed(all, EDITOR_COUNT - 1) == EDITOR_COUNT - 1; + assert nth_installed(all, EDITOR_COUNT) == ED_NONE; + // 27 chips are 9 rows of 3, each row ED_SLOT_H tall; the last chip stays inside the panel's 268 pt + assert ed_slot_x(0.0, 0.0, EDITOR_COUNT - 1) + ED_SLOT_W <= 268.0; + assert ED_COLS * ED_SLOT_W <= 268.0; +} + +test the_launchers_are_the_ones_each_editor_ships { + var b : [16]u8 = undefined; + assert editor_cli(E_VSCODE, b[0..16]) == 4; + assert b[0] == 99 and b[3] == 101; + assert editor_cli(E_XCODE, b[0..16]) == 3; + assert b[0] == 120; + assert editor_cli(E_SUBLIME, b[0..16]) == 4; + assert b[0] == 115 and b[3] == 108; + assert editor_cli(E_ZED, b[0..16]) == 3; +} + +test a_bundle_lives_under_applications { + var p : [96]u8 = undefined; + const n : usize = bundle_path(E_CURSOR, "", p[0..96]); + assert n == 24; + assert p[0] == 47 and p[14] == 67; + assert p[n - 1] == 112; + const h : usize = bundle_path(E_ZED, "/Users/tester", p[0..96]); + assert h == 13 + 14 + 3 + 4; + assert p[0] == 47 and p[6] == 47; + assert bundle_path(EDITOR_COUNT, "", p[0..96]) == 0; +} + +test a_launcher_is_looked_up_in_each_directory { + var p : [64]u8 = undefined; + const a : usize = cli_path(E_VSCODE, 0, p[0..64]); + assert a == 19; + assert p[0] == 47 and p[15] == 99; + const b : usize = cli_path(E_VSCODE, 1, p[0..64]); + assert b == 22; + assert cli_path(E_VSCODE, CLI_DIR_COUNT, p[0..64]) == 0; +} + +test the_bundle_wins_over_the_launcher { + assert launch_how(true, true) == HOW_OPEN_APP; + assert launch_how(true, false) == HOW_OPEN_APP; + assert launch_how(false, true) == HOW_CLI; + assert launch_how(false, false) == HOW_NONE; +} + +test only_an_absolute_clean_directory_is_handed_over { + assert dir_fits("/Users/tester/work"); + assert dir_fits("") == false; + assert dir_fits("relative/dir") == false; + assert dir_fits("-a /etc") == false; + var b : [8]u8 = undefined; + b[0] = 47; + b[1] = 97; + b[2] = 10; + assert dir_fits(b[0..3]) == false; +} + +// ---- the OPEN IN strip at the foot of the KEYS panel: the installed editors as a grid of text chips ---- + +/// Three chips of 88 pt fill the 268 pt of the Queen/KEYS panel (QP_W 300 less two QP_PAD): "Antigravity IDE" +/// and "VS Code Insiders" fit one chip at 9 pt, where four 66 pt chips overlapped. +pub const ED_COLS : u32 = 3; +pub const ED_SLOT_W : f64 = 88.0; +pub const ED_SLOT_H : f64 = 16.0; +pub const ED_NONE : u32 = 255; + +/// The editor (index into the table) that is the k-th installed one in `mask`; ED_NONE when there are fewer. +pub fn nth_installed(mask: u32, k: u32) -> u32 { + var seen : u32 = 0; + var i : u32 = 0; + while (i < EDITOR_COUNT) { + if (mask / editor_bit(i) % 2 == 1) { + if (seen == k) { return i; } + seen = seen + 1; + } + i = i + 1; + } + return ED_NONE; +} + +pub fn installed_count(mask: u32) -> u32 { + var n : u32 = 0; + var i : u32 = 0; + while (i < EDITOR_COUNT) { + if (mask / editor_bit(i) % 2 == 1) { n = n + 1; } + i = i + 1; + } + return n; +} + +fn editor_bit(i: u32) -> u32 { + var r : u32 = 1; + var k : u32 = 0; + while (k < i) { + r = r * 2; + k = k + 1; + } + return r; +} + +pub fn ed_slot_x(l: f64, pad: f64, k: u32) -> f64 { + return l + pad + @as(f64, @floatFromInt(k % ED_COLS)) * ED_SLOT_W; +} + +pub fn ed_slot_y(top: f64, k: u32) -> f64 { + return top + @as(f64, @floatFromInt(k / ED_COLS)) * ED_SLOT_H; +} + +/// Which installed-editor slot a click lands on (top = the strip's top edge); ED_NONE when none. +pub fn ed_hit(x: f64, y: f64, l: f64, pad: f64, top: f64, count: u32) -> u32 { + var k : u32 = 0; + while (k < count) { + const sx : f64 = ed_slot_x(l, pad, k); + const sy : f64 = ed_slot_y(top, k); + if (x >= sx and x < sx + ED_SLOT_W and y >= sy and y < sy + ED_SLOT_H) { return k; } + k = k + 1; + } + return ED_NONE; +} + +test the_kth_installed_editor_skips_the_missing_ones { + assert nth_installed(0, 0) == ED_NONE; + assert nth_installed(1 + 4 + 16, 0) == 0; + assert nth_installed(1 + 4 + 16, 1) == 2; + assert nth_installed(1 + 4 + 16, 2) == 4; + assert nth_installed(1 + 4 + 16, 3) == ED_NONE; + assert installed_count(1 + 4 + 16) == 3; + assert installed_count(0) == 0; +} + +test a_click_lands_on_one_editor_chip { + assert ed_hit(700.0 + 3.0, 400.0 + 3.0, 684.0, 16.0, 400.0, 6) == 0; + assert ed_hit(700.0 + ED_SLOT_W + 3.0, 400.0 + 3.0, 684.0, 16.0, 400.0, 6) == 1; + assert ed_hit(700.0 + 3.0, 400.0 + ED_SLOT_H + 3.0, 684.0, 16.0, 400.0, 6) == ED_COLS; + assert ed_hit(700.0 + 3.0, 400.0 + ED_SLOT_H + 3.0, 684.0, 16.0, 400.0, 3) == ED_NONE; + assert ed_hit(10.0, 10.0, 684.0, 16.0, 400.0, 6) == ED_NONE; +} + +// ---- terminal editors: the ones that run inside a pane of the PTY server ---- +// Owner, 2026-10-20: "T27OS opens inside!!". A GUI editor is another app's window; a terminal editor is a +// program a pane of pty_server.t27 can run, so its output is the window's own. The window sends a SPAWN for +// the shell cell (SHELL_CELL) whose model field is the editor's name (pty_logic.t27 launch_command), the +// directory or file to open is the pane's cwd, and the pane runs ` ''`. The editor is found +// on the server's PATH (the launchd plist carries the PATH of the shell that installed it); the window +// only chooses among those it saw in CLI_DIRS (term_path, term_pick). + +pub const TERM_COUNT : u32 = 5; +pub const TERM_NONE : u32 = 255; +/// In order of preference (vim ships with macOS); each name is also the command that is run. +pub const TERM_NAMES : str = "vim,nvim,hx,micro,nano"; +/// The longest path a pane can take as its cwd (pty_logic CWD_CAP - 1). +pub const TERM_PATH_MAX : usize = 255; + +pub fn term_name(i: u32, out: []u8) -> usize { + return csv_field(TERM_NAMES, i, out); +} + +/// "/" for the k-th launcher directory (CLI_DIRS): where the window looks for terminal editor i. +pub fn term_path(i: u32, k: u32, out: []u8) -> usize { + var dir : [24]u8 = undefined; + const dn : usize = csv_field(CLI_DIRS, k, dir[0..24]); + if (dn == 0) { return 0; } + var nm : [16]u8 = undefined; + const nn : usize = term_name(i, nm[0..16]); + if (nn == 0) { return 0; } + var n : usize = put_bytes(out, 0, dir[0..dn]); + n = put_bytes(out, n, "/"); + n = put_bytes(out, n, nm[0..nn]); + return n; +} + +/// The table index of the terminal editor called `name`; TERM_NONE when it is not one. +pub fn term_index(name: []const u8) -> u32 { + var b : [16]u8 = undefined; + var i : u32 = 0; + while (i < TERM_COUNT) { + const n : usize = term_name(i, b[0..16]); + if (same_bytes(b[0..n], name)) { return i; } + i = i + 1; + } + return TERM_NONE; +} + +/// The editor to use given a mask (bit i set = terminal editor i was found): the first in preference +/// order; TERM_NONE when none was found. +pub fn term_pick(mask: u32) -> u32 { + var i : u32 = 0; + while (i < TERM_COUNT) { + if (mask / editor_bit(i) % 2 == 1) { return i; } + i = i + 1; + } + return TERM_NONE; +} + +/// A path is fit to open inside a pane: dir_fits, short enough for a pane's cwd, and without the single +/// quote that would end the quoting around it in `sh -c`. +pub fn term_path_fits(path: []const u8) -> bool { + if (dir_fits(path) == false) { return false; } + if (path.len > TERM_PATH_MAX) { return false; } + var i : usize = 0; + while (i < path.len) { + if (path[i] == 39) { return false; } + i = i + 1; + } + return true; +} + +/// ` ''` written into out -- the command line a pane runs (argv_text puts "exec " in front). +/// Returns its length; 0 when `pick` is no terminal editor or the path is not fit. +pub fn editor_command(pick: u32, path: []const u8, out: []u8) -> usize { + if (pick >= TERM_COUNT or term_path_fits(path) == false) { return 0; } + const nn : usize = term_name(pick, out); + out[nn] = 32; + out[nn + 1] = 39; + const n : usize = put_bytes(out, nn + 2, path); + out[n] = 39; + return n + 1; +} + +/// The SPAWN payload (pty_proto.t27) that asks the PTY server for terminal editor `pick` on `path`, for the +/// space and slot of the chat it opens beside: [SHELL_CELL][space][slot][path] TAB [editor]. The wire format +/// is the old one -- the shell cell with a model field. 0 when `pick` is no terminal editor or the path is +/// not fit (term_path_fits). +pub fn editor_spawn_payload(space: u32, slot: u32, pick: u32, path: []const u8, out: []u8) -> usize { + if (pick >= TERM_COUNT or term_path_fits(path) == false) { return 0; } + out[0] = @as(u8, @intCast(SHELL_CELL)); + out[1] = @as(u8, @intCast(space)); + out[2] = @as(u8, @intCast(slot)); + var n : usize = put_bytes(out, 3, path); + out[n] = 9; + n = n + 1; + const nn : usize = term_name(pick, out[n .. n + 16]); + return n + nn; +} + +test the_terminal_editors_are_named_in_order_of_preference { + var b : [16]u8 = undefined; + var n : usize = term_name(0, b[0..16]); + assert same_bytes(b[0..n], "vim"); + n = term_name(1, b[0..16]); + assert same_bytes(b[0..n], "nvim"); + n = term_name(2, b[0..16]); + assert same_bytes(b[0..n], "hx"); + n = term_name(3, b[0..16]); + assert same_bytes(b[0..n], "micro"); + n = term_name(4, b[0..16]); + assert same_bytes(b[0..n], "nano"); + assert term_name(TERM_COUNT, b[0..16]) == 0; + assert term_index("vim") == 0; + assert term_index("nano") == 4; + assert term_index("emacs") == TERM_NONE; + assert term_index("") == TERM_NONE; + assert term_index("vi") == TERM_NONE; + assert term_index("vimx") == TERM_NONE; + // each name fits term_path's 16-byte buffer and the model field (model_ok: letters and digits) + var i : u32 = 0; + while (i < TERM_COUNT) { + n = term_name(i, b[0..16]); + assert n > 0; + assert n <= 15; + i = i + 1; + } +} + +test the_first_installed_terminal_editor_in_preference_order_is_picked { + // the bench Mac has vim and nano (/usr/bin), no nvim, hx or micro + assert term_pick(1 + 16) == 0; + assert term_pick(16) == 4; + assert term_pick(2 + 4 + 16) == 1; + assert term_pick(8 + 16) == 3; + assert term_pick(0) == TERM_NONE; + // a bit past the table is no editor + assert term_pick(32) == TERM_NONE; +} + +test a_terminal_editor_is_looked_up_in_each_launcher_directory { + var p : [64]u8 = undefined; + var n : usize = term_path(0, 2, p[0..64]); + assert same_bytes(p[0..n], "/usr/bin/vim"); + n = term_path(1, 1, p[0..64]); + assert same_bytes(p[0..n], "/opt/homebrew/bin/nvim"); + n = term_path(4, 0, p[0..64]); + assert same_bytes(p[0..n], "/usr/local/bin/nano"); + assert term_path(0, CLI_DIR_COUNT, p[0..64]) == 0; + assert term_path(TERM_COUNT, 0, p[0..64]) == 0; +} + +test only_a_clean_absolute_path_without_a_quote_opens_inside { + assert term_path_fits("/Users/tester/work"); + assert term_path_fits("/Users/tester/my project"); + assert term_path_fits("/Users/tester/it's") == false; + assert term_path_fits("") == false; + assert term_path_fits("work") == false; + assert term_path_fits("-c /etc/passwd") == false; + var b : [8]u8 = undefined; + b[0] = 47; + b[1] = 97; + b[2] = 10; + assert term_path_fits(b[0..3]) == false; + // a pane's cwd is shorter than 256 bytes + var long : [300]u8 = undefined; + var i : usize = 0; + while (i < 300) { + long[i] = 97; + i = i + 1; + } + long[0] = 47; + assert term_path_fits(long[0..255]); + assert term_path_fits(long[0..256]) == false; +} + +test an_editor_command_is_the_editor_and_the_quoted_path { + var o : [400]u8 = undefined; + var n : usize = editor_command(0, "/Users/tester/work", o[0..400]); + assert same_bytes(o[0..n], "vim '/Users/tester/work'"); + // a path with a space stays one word for sh + n = editor_command(4, "/Users/tester/my project/notes.txt", o[0..400]); + assert same_bytes(o[0..n], "nano '/Users/tester/my project/notes.txt'"); + n = editor_command(term_pick(2 + 8), "/h", o[0..400]); + assert same_bytes(o[0..n], "nvim '/h'"); + // refused: no such editor, a quote in the path, no path + assert editor_command(TERM_COUNT, "/Users/tester", o[0..400]) == 0; + assert editor_command(TERM_NONE, "/Users/tester", o[0..400]) == 0; + assert editor_command(0, "/Users/te'ster", o[0..400]) == 0; + assert editor_command(0, "", o[0..400]) == 0; + assert editor_command(0, "relative", o[0..400]) == 0; +} + +test the_editor_spawn_payload_is_the_shell_cell_the_path_a_tab_and_the_editor { + var p : [300]u8 = undefined; + const n : usize = editor_spawn_payload(2, 5, 4, "/h/w", p[0..300]); + assert n == 3 + 4 + 1 + 4; + assert p[0] == 9 and p[1] == 2 and p[2] == 5; + assert same_bytes(p[3..7], "/h/w"); + assert p[7] == 9; + assert same_bytes(p[8..n], "nano"); + assert SHELL_CELL == 9; + assert editor_spawn_payload(0, 0, TERM_COUNT, "/h", p[0..300]) == 0; + assert editor_spawn_payload(0, 0, 0, "/h'x", p[0..300]) == 0; +} + +// ---- the OPEN IN strip's first chip: "T27 OS" opens the folder inside the window ---- +// Owner, 2026-10-20: "T27OS opens inside!!". When a terminal editor was found (term_pick over +// editors_host term_mask, TERM_NONE when none) the strip leads with one chip that spawns an editor pane +// (client_spawn_editor) and shows it in the window; the installed outside editors follow it. + +/// The chip for opening inside: no editor index (they are below 32) and not ED_NONE. +pub const CHIP_INSIDE : u32 = 254; +pub const INSIDE_LABEL : str = "T27 OS"; + +/// Chips in the strip: "T27 OS" when `pick` names a terminal editor, then the installed outside editors. +pub fn strip_count(mask: u32, pick: u32) -> u32 { + var n : u32 = installed_count(mask); + if (pick != TERM_NONE) { n = n + 1; } + return n; +} + +/// What chip k is: CHIP_INSIDE, an editor index, or ED_NONE past the last chip. +pub fn strip_chip(mask: u32, pick: u32, k: u32) -> u32 { + if (pick == TERM_NONE) { return nth_installed(mask, k); } + if (k == 0) { return CHIP_INSIDE; } + return nth_installed(mask, k - 1); +} + +/// What a chip says: the editor's name, or INSIDE_LABEL. +pub fn strip_label(chip: u32, out: []u8) -> usize { + if (chip == CHIP_INSIDE) { return put_bytes(out, 0, INSIDE_LABEL); } + return editor_name(chip, out); +} + +// ---- the terminal area's two views: the selected Claude Code chat's pane, or the editor pane ---- + +/// A view key is what the terminal area shows: a chat index, VIEW_EDITOR, or VIEW_NONE (the same value as +/// term_pane PANE_NONE, which glass_window pins in a test). +pub const VIEW_NONE : u32 = 999; +pub const VIEW_EDITOR : u32 = 998; + +/// The view to show: the editor pane while it is open, else the selected chat's pane when its agent opens +/// one, else nothing. +pub fn view_key(eview: bool, chat_pane: bool, cur: u32) -> u32 { + if (eview) { return VIEW_EDITOR; } + if (chat_pane) { return cur; } + return VIEW_NONE; +} + +/// The editor view lasts while the chat screen stays on the chat and space it was opened on; another chat, +/// another space or leaving the chat screen closes it (the pane keeps running in the server). +pub fn editor_view_stays(eview: bool, screen: bool, cur: u32, seen_cur: u32, space: u32, seen_space: u32) -> bool { + return eview and screen and cur == seen_cur and space == seen_space; +} + +/// Escape twice within ESC_TWICE_S seconds closes the editor view; `last` is the second of the previous +/// Escape (0 when there was none). Both Escapes still reach the editor. +pub const ESC_TWICE_S : u64 = 1; +pub fn esc_twice(last: u64, now: u64) -> bool { + return last > 0 and now >= last and now - last <= ESC_TWICE_S; +} + +test the_strip_leads_with_t27_os_when_a_terminal_editor_was_found { + var b : [32]u8 = undefined; + // bench Mac: Cursor (1), Xcode (4), Antigravity (9), Antigravity IDE (10), ZCode (11) outside; vim found + const outside : u32 = 2 + 16 + 512 + 1024 + 2048; + assert strip_count(outside, 0) == 6; + assert strip_chip(outside, 0, 0) == CHIP_INSIDE; + assert strip_chip(outside, 0, 1) == E_CURSOR; + assert strip_chip(outside, 0, 2) == E_XCODE; + assert strip_chip(outside, 0, 5) == E_ZCODE; + assert strip_chip(outside, 0, 6) == ED_NONE; + var n : usize = strip_label(CHIP_INSIDE, b[0..32]); + assert same_bytes(b[0..n], "T27 OS"); + n = strip_label(strip_chip(outside, 0, 4), b[0..32]); + assert same_bytes(b[0..n], "Antigravity IDE"); + // no terminal editor: the strip is the outside editors, as before + assert strip_count(outside, TERM_NONE) == 5; + assert strip_chip(outside, TERM_NONE, 0) == E_CURSOR; + assert strip_chip(outside, TERM_NONE, 4) == E_ZCODE; + assert strip_chip(outside, TERM_NONE, 5) == ED_NONE; + // inside alone, and nothing at all + assert strip_count(0, 4) == 1; + assert strip_chip(0, 4, 0) == CHIP_INSIDE; + assert strip_chip(0, 4, 1) == ED_NONE; + assert strip_count(0, TERM_NONE) == 0; + assert strip_chip(0, TERM_NONE, 0) == ED_NONE; + // the inside chip is neither an editor nor ED_NONE + assert CHIP_INSIDE >= MAX_EDITORS; + assert CHIP_INSIDE != ED_NONE; +} + +test a_click_on_the_first_chip_is_inside_and_the_next_chips_are_the_outside_editors { + const outside : u32 = 2 + 16; + const n : u32 = strip_count(outside, 1); + assert n == 3; + // the strip's hit test is the old one: chip 0 is the top-left slot, chip 1 the one right of it + assert strip_chip(outside, 1, ed_hit(700.0 + 3.0, 400.0 + 3.0, 684.0, 16.0, 400.0, n)) == CHIP_INSIDE; + assert strip_chip(outside, 1, ed_hit(700.0 + ED_SLOT_W + 3.0, 400.0 + 3.0, 684.0, 16.0, 400.0, n)) == E_CURSOR; + assert strip_chip(outside, 1, ed_hit(700.0 + 2.0 * ED_SLOT_W + 3.0, 400.0 + 3.0, 684.0, 16.0, 400.0, n)) == E_XCODE; + // a click off the strip is no chip + assert strip_chip(outside, 1, ed_hit(10.0, 10.0, 684.0, 16.0, 400.0, n)) == ED_NONE; +} + +test the_terminal_area_shows_the_editor_the_chat_or_nothing { + assert view_key(true, true, 3) == VIEW_EDITOR; + assert view_key(true, false, 3) == VIEW_EDITOR; + assert view_key(false, true, 3) == 3; + assert view_key(false, false, 3) == VIEW_NONE; + // a chat index never collides with the two special keys (16 chats at most) + assert VIEW_EDITOR != VIEW_NONE; + assert VIEW_EDITOR > 255; +} + +test the_editor_view_ends_when_the_screen_moves_off_the_chat_it_was_opened_on { + assert editor_view_stays(true, true, 2, 2, 0, 0); + assert editor_view_stays(true, true, 3, 2, 0, 0) == false; + assert editor_view_stays(true, true, 2, 2, 1, 0) == false; + assert editor_view_stays(true, false, 2, 2, 0, 0) == false; + assert editor_view_stays(false, true, 2, 2, 0, 0) == false; +} + +test escape_twice_within_a_second_closes_the_editor_view { + assert esc_twice(100, 100); + assert esc_twice(100, 101); + assert esc_twice(100, 102) == false; + assert esc_twice(0, 5) == false; + assert esc_twice(100, 99) == false; +} diff --git a/specs/t27os/editors_host.t27 b/specs/t27os/editors_host.t27 new file mode 100644 index 0000000000..b103c5ea32 --- /dev/null +++ b/specs/t27os/editors_host.t27 @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS editors_host: the two system calls behind "open in editor" -- access(2) to see which editors are +// installed and posix_spawn(3) to launch one on a directory. Every decision (the table, the bundle and +// launcher paths, bundle over launcher, Xcode through xed, which directory is fit, why an open failed) is a +// tested fn in editors.t27. +// +// A bundle is run as `/usr/bin/open -a ` -- the path access(2) found, never a name rebuilt +// from the table, so a name with a space ("Antigravity IDE", "Android Studio") is one argv word and cannot +// be mixed up with "Antigravity" -- a launcher as ` `. The child is never waited for; SIGCHLD is +// ignored so it leaves no zombie. open_in_editor answers with an OPEN_* code (editors.t27 open_word). +// +// Refs gHashTag/t27#10782, gHashTag/t27#11223. +module T27osEditorsHost; + +use t27os::editors::EDITOR_COUNT; +use t27os::editors::CLI_DIR_COUNT; +use t27os::editors::HOW_NONE; +use t27os::editors::HOW_OPEN_APP; +use t27os::editors::HOW_CLI; +use t27os::editors::bundle_path; +use t27os::editors::cli_path; +use t27os::editors::TERM_COUNT; +use t27os::editors::term_path; +use t27os::editors::launch_pick; +use t27os::editors::is_installed; +use t27os::editors::open_result; +use t27os::editors::dir_fits; +use t27os::pty_proto::get_u64; +use t27os::pty_sys::sys_peek; +use t27os::pty_sys::sys_environ; +use t27os::pty_sys::sys_signal; + +pub const F_OK : i32 = 0; +pub const SIGCHLD : i32 = 20; +pub const SIG_IGN : u64 = 1; + +fn sys_access(path: []const u8, mode: i32) -> i32 { return @extern("access"); } +fn spawn_p(pid: []u8, path: []const u8, actions: u64, attr: u64, argv: [6]u64, envp: u64) -> i32 { return @extern("posix_spawn"); } +fn addr_of(dst: []u8, src: []const u8, n: u64) -> u64 { return @extern("memmove"); } + +fn nul_terminated(dst: []u8, src: []const u8) -> usize { + var i : usize = 0; + while (i < src.len and i + 1 < dst.len) { + dst[i] = src[i]; + i = i + 1; + } + dst[i] = 0; + return i; +} + +fn exists(path: []const u8) -> bool { + // a path that would be cut to fit the buffer could name a parent folder that does exist + if (path.len >= 159) { return false; } + var z : [160]u8 = undefined; + nul_terminated(z[0..160], path); + return sys_access(z[0..160], F_OK) == 0; +} + +/// Where editor i is installed as a bundle: the system folder first, then the user's. 0 when neither. +pub fn find_bundle(i: u32, home: []const u8, out: []u8) -> usize { + var n : usize = bundle_path(i, "", out); + if (n > 0 and exists(out[0..n])) { return n; } + n = bundle_path(i, home, out); + if (n > 0 and exists(out[0..n])) { return n; } + return 0; +} + +/// Where editor i's launcher is. 0 when it is on none of the usual directories. +pub fn find_cli(i: u32, out: []u8) -> usize { + var k : u32 = 0; + while (k < CLI_DIR_COUNT) { + const n : usize = cli_path(i, k, out); + if (n > 0 and exists(out[0..n])) { return n; } + k = k + 1; + } + return 0; +} + +/// A bit per editor: set when the strip lists it (editors.t27 is_installed: a bundle or a launcher, but for +/// Xcode the bundle -- /usr/bin/xed alone is a shim every Mac has). +pub fn installed_mask(home: []const u8) -> u32 { + var b : [160]u8 = undefined; + var c : [160]u8 = undefined; + var m : u32 = 0; + var i : u32 = 0; + while (i < EDITOR_COUNT) { + const has_bundle : bool = find_bundle(i, home, b[0..160]) > 0; + const has_cli : bool = find_cli(i, c[0..160]) > 0; + if (is_installed(i, has_bundle, has_cli)) { + m = m + (1 << i); + } + i = i + 1; + } + return m; +} + +/// A bit per terminal editor (editors.t27 TERM_NAMES) found in one of the launcher directories: what the +/// window may ask the PTY server to run inside a pane (editors.t27 term_pick). +pub fn term_mask() -> u32 { + var b : [160]u8 = undefined; + var m : u32 = 0; + var i : u32 = 0; + while (i < TERM_COUNT) { + var k : u32 = 0; + var found : bool = false; + while (k < CLI_DIR_COUNT and found == false) { + const n : usize = term_path(i, k, b[0..160]); + if (n > 0 and exists(b[0..n])) { found = true; } + k = k + 1; + } + if (found) { m = m + (1 << i); } + i = i + 1; + } + return m; +} + +/// Open directory `dir` in editor i. The answer is an OPEN_* code: OPEN_OK when the process was started, +/// otherwise why not (bad folder, not installed, could not start, unknown editor). +pub fn open_in_editor(i: u32, home: []const u8, dir: []const u8) -> u32 { + const fits : bool = dir_fits(dir); + if (i >= EDITOR_COUNT or fits == false) { return open_result(i, fits, HOW_NONE, 0); } + var bp : [160]u8 = undefined; + var cp : [160]u8 = undefined; + const bn : usize = find_bundle(i, home, bp[0..160]); + const cn : usize = find_cli(i, cp[0..160]); + const how : u32 = launch_pick(i, bn > 0, cn > 0); + if (how == HOW_NONE) { return open_result(i, fits, how, 0); } + var app : [160]u8 = undefined; + var d : [420]u8 = undefined; + var exe : [160]u8 = undefined; + var flag : [4]u8 = undefined; + var argv : [6]u64 = undefined; + nul_terminated(d[0..420], dir); + argv[4] = 0; + argv[5] = 0; + if (how == HOW_OPEN_APP) { + nul_terminated(app[0..160], bp[0..bn]); + flag[0] = 45; + flag[1] = 97; + flag[2] = 0; + flag[3] = 0; + nul_terminated(exe[0..160], "/usr/bin/open"); + argv[0] = addr_of(exe[0..160], exe[0..160], 0); + argv[1] = addr_of(flag[0..4], flag[0..4], 0); + argv[2] = addr_of(app[0..160], app[0..160], 0); + argv[3] = addr_of(d[0..420], d[0..420], 0); + } else { + nul_terminated(exe[0..160], cp[0..cn]); + argv[0] = addr_of(exe[0..160], exe[0..160], 0); + argv[1] = addr_of(d[0..420], d[0..420], 0); + argv[2] = 0; + argv[3] = 0; + } + var envp : [8]u8 = undefined; + sys_peek(envp[0..8], sys_environ(), 8); + var pid : [4]u8 = undefined; + sys_signal(SIGCHLD, SIG_IGN); + const rc : i32 = spawn_p(pid[0..4], exe[0..160], 0, 0, argv, get_u64(envp[0..8], 0)); + return open_result(i, fits, how, rc); +} + +test the_editor_host_is_declared { + var b : [4]u8 = undefined; + b[0] = 1; + assert b[0] == 1; +} diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 index 8575698d05..221fd10ff9 100644 --- a/specs/t27os/glass_window.t27 +++ b/specs/t27os/glass_window.t27 @@ -7,6 +7,9 @@ ; It behaves as the trios app does (BrowserOS/trios TriosTabView + QueenUI MainView, read 2026-10-19): ; W window -- NSWindow 960 x 540 pt, transparent title bar, content = NSVisualEffectView, dark ; appearance, behind-window blending, black tint 0.30 (trios blackGlass nativeTint). +; The frame itself is clear in a window -- matte black glass, as in trios (owner, +; 2026-10-20, superseding the 2026-10-19 opaque black for a window). Full screen has no +; transparency, so there the layer and the frame are opaque black #000000. ; T title -- trios title bar, 34 pt: the logo 17 x 14 (petal_menu style, 0.5 pt outline) + "TRINITY" = home, ; centred in the bar as one group (owner, 2026-10-19), fullscreen control on the right, ; 1 pt white 0.14 divider under it. The back chevron stays left, right of the traffic lights. @@ -41,6 +44,11 @@ ; T term -- a Claude Code chat shows a live terminal pane (term_pane.t27, vt.t27): the agent runs ; in the PTY server (pty_server.t27) reached over ~/.t27os/pty.sock, so it outlives the ; window; reopening the window re-finds the same pane. No server: offline, keys echo. +; E editor -- T27 OS opens inside (owner, 2026-10-20): KEYS > OPEN IN leads with a "T27 OS" chip that +; runs a terminal editor (vim, nvim, hx, micro, nano; editors.t27) on the space's folder in +; a pane of the PTY server and shows it in the same terminal area in place of the chat's +; pane. Escape twice, the chip again, or another chat or space brings the chat back; the +; editor keeps running in the server. ; D dock -- app icon: the logo white on a black squircle, drawn at launch. ; Not drawn (no server behind them yet): the Online dot, search, swarm and ; history lists, Cmd+K / Cmd+/ overlays, the formula in the tooltip. The Queen has no model @@ -58,6 +66,85 @@ use t27os::layout::baseline_for; use t27os::layout::cap_mid; use t27os::layout::same_line; use t27os::agents::AGENT_NAMES; +use t27os::keys::PROVIDER_COUNT; +use t27os::keys::MAX_KEY_CHARS; +use t27os::keys::KEYS_WORDS; +use t27os::keys::KY_NONE; +use t27os::keys::KY_ROW_H; +use t27os::keys::KY_BTN_W; +use t27os::keys::KY_BTN_H; +use t27os::keys::KY_BTN_DY; +use t27os::keys::KY_LINK_W; +use t27os::keys::B_SET; +use t27os::keys::B_DELETE; +use t27os::keys::B_CHECK; +use t27os::keys::KA_EDIT; +use t27os::keys::KA_ARM; +use t27os::keys::KA_DELETE; +use t27os::keys::KA_CHECK; +use t27os::keys::KE_SAVE; +use t27os::keys::KE_CANCEL; +use t27os::keys::KE_BACK; +use t27os::keys::KE_PASTE; +use t27os::keys::KE_CHAR; +use t27os::keys::ST_STORED; +use t27os::keys::ST_VALID; +use t27os::keys::ST_MISSING; +use t27os::keys::STATUS_NAMES; +use t27os::keys::ky_row_top; +use t27os::keys::ky_btn_x; +use t27os::keys::ky_hit; +use t27os::keys::ky_click; +use t27os::keys::ky_key; +use t27os::keys::ky_accepts; +use t27os::keys::on_keys_link; +use t27os::keys::CIRCUIT_FAILS; +use t27os::keys::trim_key; +use t27os::keys::mask_key; +use t27os::keys::key_fits; +use t27os::keys::probe_has; +use t27os::keys::bad_mask; +use t27os::keys::profile_why; +use t27os::keys::PROFILE_PROVIDER; +use t27os::keys::provider_name; +use t27os::keys::status_after_probe; +use t27os::keys::circuit_fails; +use t27os::keys::status_shown; +use t27os::keys_host::kc_put; +use t27os::keys_host::kc_get; +use t27os::keys_host::kc_del; +use t27os::keys_host::kc_has; +use t27os::keys_host::probe; +use t27os::keys_host::push_keys; +use t27os::pty_client::client_set_key; +use t27os::editors::EDITOR_COUNT; +use t27os::editors::ED_NONE; +use t27os::editors::ED_SLOT_H; +use t27os::editors::TERM_NONE; +use t27os::editors::nth_installed; +use t27os::editors::CHIP_INSIDE; +use t27os::editors::OPEN_SPAWN_FAILED; +use t27os::editors::term_pick; +use t27os::editors::strip_count; +use t27os::editors::strip_chip; +use t27os::editors::strip_label; +use t27os::editors::VIEW_NONE; +use t27os::editors::VIEW_EDITOR; +use t27os::editors::view_key; +use t27os::editors::editor_view_stays; +use t27os::editors::esc_twice; +use t27os::editors::ed_slot_x; +use t27os::editors::ed_slot_y; +use t27os::editors::ed_hit; +use t27os::editors::open_word; +use t27os::editors::OPEN_NONE; +use t27os::editors::OPEN_OK; +use t27os::editors_host::installed_mask; +use t27os::editors_host::term_mask; +use t27os::pty_client::client_spawn_editor; +use t27os::pty_client::client_kill; +use t27os::term_pane::tp_pane_live; +use t27os::editors_host::open_in_editor; use t27os::agents::AGENT_QUEEN; use t27os::agents::S_STOPPED; use t27os::windows::SPACE_NAMES; @@ -155,7 +242,15 @@ use t27os::queen::view_of; use t27os::queen::ring_next; use t27os::queen::ring_at; use t27os::queen::age_word; -use t27os::queen::suggest; +use t27os::queen::suggest_for; +use t27os::queen::edit_mark; +use t27os::queen::open_target; +use t27os::queen::A_EDIT; +use t27os::queen::OT_INSIDE; +use t27os::queen::OT_OUTSIDE; +use t27os::queen::IE_NONE; +use t27os::queen::IE_USER; +use t27os::queen::IE_GOD; use t27os::queen::shown_suggestion; use t27os::queen::queen_control; use t27os::queen::god_due; @@ -235,7 +330,7 @@ use t27os::doctor::ST_HEAD; use t27os::doctor::ST_STASH; use t27os::doctor::F_CHECKED; use t27os::doctor::F_WORKTREE; -use t27os::doctor::F_AGENT; +use t27os::doctor::DOC_F_AGENT; use t27os::doctor::F_DUP; use t27os::doctor::F_REPEATED; use t27os::doctor::F_HEAVY; @@ -375,6 +470,7 @@ use t27os::pty_client::client_connect; use t27os::pty_client::client_list; use t27os::pty_client::client_spawn; use t27os::pty_client::client_attach; +use t27os::session_store::csv_field; use t27os::pty_client::client_keys; use t27os::pty_client::client_read; use t27os::pty_client::client_resize; @@ -503,13 +599,20 @@ pub const CH_MAX : f64 = 1300.0; pub const SW_MAX : f64 = 1920.0; pub const SH_MAX : f64 = 1200.0; pub const WIDE_RETINA : f64 = 2.0; -/// F. In full screen the behind-window blur sits over the desktop wallpaper and reads grey; the tint -/// rises to 0.55: black glass that still lets the wallpaper show through. -pub const TINT_FULL : f64 = 0.55; -/// F. Owner, 2026-10-19: the background is black (#000000). It was glass (0.60 over the behind-window -/// blur), but the blurred wallpaper read as a warm glow along the edges; the edges are pure black now -/// and the only edge light is the blue gas-flame GOD MODE glow (queen.t27, the FR-006 exception). +/// F. Owner, 2026-10-20: transparency is not supported in full screen, so there the layer background is +/// opaque black (#000000) and no wallpaper shows (it was 0.55, black glass over the blurred wallpaper). +pub const TINT_FULL : f64 = 1.0; +/// F. The frame's own fill in full screen: opaque black (#000000), owner 2026-10-19. The blurred +/// wallpaper read as a warm glow along the edges; the only edge light is the blue gas-flame GOD MODE +/// glow (queen.t27, the FR-006 exception). pub const BACK_BLACK : f64 = 1.0; +/// F. Owner, 2026-10-20 (supersedes 2026-10-19 for a window): the window is matte black glass as in +/// trios. The frame's own fill is clear, so the NSVisualEffectView blur and the layer tint TINT_BLACK +/// show through. +pub const BACK_GLASS : f64 = 0.0; +/// F. The wipe reaches this far past the scene on every side: a fractional edge is anti-aliased, and a +/// partly covered pixel would keep part of the last frame under it. +pub const BACK_BLEED : f64 = 1.0; /// F. The strict chat palette: near-black surfaces, 0.5 pt hairlines, small radii, outline-only cards. pub const HAIR : f64 = 0.5; pub const LINE_WHITE : f64 = 0.12; @@ -1873,10 +1976,41 @@ fn draw_cards(c: u64, kit: [48]u64, task: u32, a: f64, collapsed: bool, mx: f64, } } -/// F. The background: one opaque black fill over the whole frame (#000000), in a window and in full screen. +/// F. The frame's fill alpha: clear in a window (matte black glass, the blur and the layer tint show +/// through), opaque black #000000 in full screen (no transparency there). +pub fn backdrop_alpha(full: bool) -> f64 { + if (full) { return BACK_BLACK; } + return BACK_GLASS; +} + +/// F. A fill that is not opaque must first wipe the bitmap, or the last frame's pixels stay under it. +pub fn backdrop_clears(full: bool) -> bool { + return backdrop_alpha(full) < 1.0; +} + +/// F. The alpha of the layer's background colour: the window tint, or opaque black in full screen. +pub fn tint_alpha(full: bool) -> f64 { + if (full) { return TINT_FULL; } + return TINT_BLACK; +} + +/// F. The layer's background colour is set again only when the full-screen state changes. +pub fn tint_swap(full: bool, was_full: bool) -> bool { + return full != was_full; +} + +/// F. The background: clear (BLEND_CLEAR) in a window, one opaque black fill over the whole frame in full screen. fn draw_backdrop(c: u64, sw: f64, sh: f64, full: bool) -> void { - cg_fill_rgba(c, 0.0, 0.0, 0.0, BACK_BLACK); - cg_fill_rect(c, 0.0, 0.0, sw, sh); + if (backdrop_clears(full)) { + cg_blend(c, BLEND_CLEAR); + cg_fill_rect(c, 0.0 - BACK_BLEED, 0.0 - BACK_BLEED, sw + 2.0 * BACK_BLEED, sh + 2.0 * BACK_BLEED); + cg_blend(c, BLEND_NORMAL); + } + const a : f64 = backdrop_alpha(full); + if (a > 0.0) { + cg_fill_rgba(c, 0.0, 0.0, 0.0, a); + cg_fill_rect(c, 0.0 - BACK_BLEED, 0.0 - BACK_BLEED, sw + 2.0 * BACK_BLEED, sh + 2.0 * BACK_BLEED); + } } /// One frame over the tinted glass: the home logo at 1 - ma, the screen for `shown` at ma, the title bar. @@ -2065,10 +2199,13 @@ fn ns_bytes(s: u64, out: []u8) -> usize { return @as(usize, n); } -/// Connect to the server under the home directory; the fd, -1 when nothing listens. +/// Connect to the server under the home directory; the fd, -1 when nothing listens. A new connection means a +/// server that may have restarted and forgotten its keys, so every stored key is handed over again. fn term_connect(hb: []u8) -> i32 { const hn : usize = ns_bytes(home_dir(), hb); - return client_connect(hb[0 .. hn]); + const fd : i32 = client_connect(hb[0 .. hn]); + if (fd >= 0) { push_keys(fd); } + return fd; } /// The server pane for chat `cur`: the k-th pane of its agent when the server has one (a reopened @@ -2084,6 +2221,17 @@ fn term_open(fd: i32, ss: u64, cur: u32, cwd: u64, out: []u8, db: []u8) -> u32 { return tp_spawned(out, sn); } +/// The editor pane for `path` in `space` (T27 OS opens inside): the one already open when the server still +/// lists it live and it was opened for this space, else a new one; an old pane is killed first so panes do +/// not pile up. PANE_NONE when the server refuses or the path is not fit (editors.t27 term_path_fits). +fn edit_open(fd: i32, epane: u32, espace: u32, space: u32, pick: u32, path: []const u8, out: []u8) -> u32 { + const ln : usize = client_list(fd, out); + if (epane != PANE_NONE and espace == space and tp_pane_live(out, ln, epane)) { return epane; } + if (epane != PANE_NONE) { client_kill(fd, epane, out); } + const sn : usize = client_spawn_editor(fd, space, 0, pick, path, out); + return tp_spawned(out, sn); +} + /// The offline banner: title, the cwd the pane opened in, the offline note and a prompt. fn term_banner(st: []u32, cells: []u32, cwd: u64) -> bool { var nl : [2]u8 = undefined; @@ -2161,8 +2309,195 @@ fn draw_term(c: u64, kit: [48]u64, st: []const u32, cells: []const u32, curon: b } } -fn draw(c: u64, kit: [48]u64, frame: u32, state: u32, hov: u32, ma: f64, shown: u32, task: u32, space: u32, sp: u32, ss: u64, cur: u32, collapsed: bool, mx: f64, my: f64, z: f64, sw: f64, sh: f64, ws: u32, full: bool, qp: bool, ring: [8]u64, n: u32, now: u64, sug: u32, god: bool, glow: f64, dk: [128]u64, dp: bool, darmed: u32, doff: u32, dgo: u32, dlive: bool, dlabel: u64, tst: []const u32, tcells: []const u32, topen: u32, tcur: bool) -> void { - cg_clear(c, 0.0, 0.0, sw, sh); +// ---- K. the KEYS panel (keys.t27): one row per provider, where the Queen and DOCTOR panels sit ---------- + +/// A word of the KEYS panel, drawn from bytes; the width drawn. +fn draw_bytes(c: u64, kit: [48]u64, s: []const u8, n: usize, x: f64, base: f64, size: f64, weight: f64, mono: bool) -> f64 { + const ns : u64 = cf_bytes(0, s, n, UTF8, false); + if (ns == 0) { return 0.0; } + const w : f64 = draw_text(c, kit, ns, x, base, size, weight, mono); + cf_release(ns); + return w; +} + +fn bytes_w(kit: [48]u64, s: []const u8, n: usize, size: f64, weight: f64, mono: bool) -> f64 { + const ns : u64 = cf_bytes(0, s, n, UTF8, false); + if (ns == 0) { return 0.0; } + const w : f64 = text_w(kit, ns, size, weight, mono); + cf_release(ns); + return w; +} + +/// The Keychain is read here and nowhere else in the window: which providers hold a key, and the masked +/// text of each (16 bytes a provider). The full key is wiped from the buffer before the next one is read. +fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { + var key : [256]u8 = undefined; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + const n : usize = kc_get(p, key[0..256]); + const at : usize = @as(usize, p) * 16; + mlen[p] = 0; + stored[p] = 0; + if (n > 0) { + stored[p] = 1; + mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); + } + var i : usize = 0; + while (i < 256) { + key[i] = 0; + i = i + 1; + } + p = p + 1; + } +} + +fn keys_wipe(buf: []u8) -> void { + var i : usize = 0; + while (i < buf.len) { + buf[i] = 0; + i = i + 1; + } +} + +fn status_dot(st: u32) -> u32 { + if (st == ST_MISSING) { return DOT_RING; } + if (st == ST_STORED) { return DOT_DIM; } + if (st == ST_VALID) { return DOT_FILLED; } + return DOT_ALERT; +} + +/// One of the three buttons of a row: a black plate, a rounded outline, the word centred; `on` draws it +/// filled (the armed DELETE), `live` false draws it dim (nothing to delete or check). +fn draw_key_btn(c: u64, kit: [48]u64, bx: f64, by: f64, word: u32, on: bool, live: bool, over: bool) -> void { + var w : [24]u8 = undefined; + const n : usize = csv_field(KEYS_WORDS, word, w[0..24]); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_fill_rect(c, bx - 2.0, by - 2.0, KY_BTN_W + 4.0, KY_BTN_H + 4.0); + round_rect(c, bx, by, KY_BTN_W, KY_BTN_H, 5.0); + var ink : f64 = TEXT_WHITE; + if (on) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + cg_fill_path(c); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + } else { + if (live == false) { ink = MUTED_WHITE; } + cg_line_width(c, 1.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, STRICT_HOVER_EDGE); + if (over and live) { cg_stroke_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); } + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, ink); + } + const lw : f64 = bytes_w(kit, w[0..24], n, 9.5, WEIGHT_SEMIBOLD, false); + draw_bytes(c, kit, w[0..24], n, bx + (KY_BTN_W - lw) / 2.0, by + 13.0, 9.5, WEIGHT_SEMIBOLD, false); +} + +fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, stored: []const u32, mask: []const u8, mlen: []const u32, kst: []const u32, kfail: []const u32, kopen: []const u64, now: u64, kedit: u32, kbuf: []const u8, klen: u32, karm: u32, kbusy: u32, kemask: u32, kewhy: u32, ktpick: u32) -> void { + const l : f64 = qp_left(sw); + const t : f64 = WS_TOP; + const x : f64 = l + QP_PAD; + var w : [48]u8 = undefined; + cg_fill_rgba(c, 0.0, 0.0, 0.0, 0.94); + cg_fill_rect(c, l, t, QP_W, sh - t); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, l, t, HAIR, sh - t); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + var n : usize = csv_field(KEYS_WORDS, 0, w[0..48]); + draw_bytes(c, kit, w[0..48], n, x, t + 25.0, 11.5, WEIGHT_SEMIBOLD, false); + draw_close_x(c, sw - QP_PAD - 8.0, t + 20.0, on_panel_close(mx, my, sw, t), 1.0); + var hint : u32 = 8; + if (kedit != KY_NONE) { hint = 9; } + n = csv_field(KEYS_WORDS, hint, w[0..48]); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_bytes(c, kit, w[0..48], n, x, t + 46.0, 9.5, 0.0, false); + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + const y : f64 = ky_row_top(t, p); + const has : bool = stored[p] == 1; + const st : u32 = status_shown(has, kst[p], kfail[p], kopen[p], now); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + n = provider_name(p, w[0..48]); + draw_bytes(c, kit, w[0..48], n, x, y + 14.0, 11.0, WEIGHT_SEMIBOLD, false); + draw_dot(c, status_dot(st), x + 112.0, y + 10.0, 1.0); + n = csv_field(STATUS_NAMES, st, w[0..48]); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_bytes(c, kit, w[0..48], n, x + 124.0, y + 14.0, 9.5, 0.0, false); + // the key as typed (masked, with a caret) while it is being entered; else the stored key, masked + cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + if (kedit == p) { + var tm : [24]u8 = undefined; + var tn : usize = 0; + if (klen > 0) { tn = mask_key(kbuf[0 .. @as(usize, klen)], tm[0..24]); } + tm[tn] = 95; + draw_bytes(c, kit, tm[0..24], tn + 1, x, y + 33.0, 10.0, 0.0, true); + } else if (has) { + draw_bytes(c, kit, mask[@as(usize, p) * 16 .. @as(usize, p) * 16 + 16], @as(usize, mlen[p]), x, y + 33.0, 10.0, 0.0, true); + } else { + n = csv_field(KEYS_WORDS, 7, w[0..48]); + draw_bytes(c, kit, w[0..48], n, x, y + 33.0, 10.0, 0.0, false); + } + const hit : u32 = ky_hit(mx, my, l, QP_W, QP_PAD, t); + const by : f64 = y + KY_BTN_DY; + var setw : u32 = 1; + if (has) { setw = 2; } + draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_SET), by, setw, false, true, hit == p * 4 + B_SET); + var delw : u32 = 3; + if (karm == p) { delw = 4; } + draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_DELETE), by, delw, karm == p, has, hit == p * 4 + B_DELETE); + var chkw : u32 = 5; + if (kbusy == p) { chkw = 6; } + draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_CHECK), by, chkw, kbusy == p, has and probe_has(p), hit == p * 4 + B_CHECK); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, x, y + KY_ROW_H - 2.0, QP_W - 2.0 * QP_PAD, HAIR); + p = p + 1; + } + // the claude profile and why (keys.t27 profile_why): which provider a new claude pane gets; no key text + var sm : u32 = 0; + var pi : u32 = 0; + while (pi < PROVIDER_COUNT) { + if (stored[pi] == 1) { sm = sm + (1 << pi); } + pi = pi + 1; + } + var wy : [96]u8 = undefined; + n = profile_why(sm, bad_mask(kst[0..9]), wy[0..96]); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_bytes(c, kit, wy[0..96], n, x, sh - 14.0, 9.5, 0.0, false); + // the OPEN IN strip: the editors found on this Mac (editors.t27), one chip each, below the rows + const ec : u32 = strip_count(kemask, ktpick); + if (ec > 0) { + const top : f64 = ky_row_top(t, PROVIDER_COUNT) + 4.0; + if (top + 14.0 + ED_SLOT_H <= sh) { + n = csv_field(KEYS_WORDS, 10, w[0..48]); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + const hw : f64 = draw_bytes(c, kit, w[0..48], n, x, top + 8.0, 9.5, 0.0, false); + // why the last click did not open an editor (editors.t27 open_word); nothing after an open that started + if (kewhy != OPEN_NONE and kewhy != OPEN_OK) { + n = open_word(kewhy, w[0..48]); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_bytes(c, kit, w[0..48], n, x + hw + 8.0, top + 8.0, 9.5, 0.0, false); + } + const hv : u32 = ed_hit(mx, my, l, QP_PAD, top + 14.0, ec); + var k : u32 = 0; + while (k < ec) { + n = strip_label(strip_chip(kemask, ktpick, k), w[0..48]); + var ea : f64 = MUTED_WHITE; + if (hv == k) { ea = TEXT_WHITE; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, ea); + const cx : f64 = ed_slot_x(l, QP_PAD, k); + const cy : f64 = ed_slot_y(top + 14.0, k); + const cw : f64 = draw_bytes(c, kit, w[0..48], n, cx, cy + 11.0, 9.0, 0.0, false); + if (hv == k) { cg_fill_rect(c, cx, cy + 13.0, cw, HAIR); } + k = k + 1; + } + } + } +} + +/// K. KEYS in the sidebar foot, left of DOCTOR (keys.t27 on_keys_link). +pub fn on_keys_foot(x: f64, y: f64, sh: f64, ws: u32, collapsed: bool) -> bool { + return in_side(x, ws, collapsed) and on_keys_link(x, y, SIDE_W, sh, FOOT_H, DOC_LINK_W); +} + +fn draw(c: u64, kit: [48]u64, frame: u32, state: u32, hov: u32, ma: f64, shown: u32, task: u32, space: u32, sp: u32, ss: u64, cur: u32, collapsed: bool, mx: f64, my: f64, z: f64, sw: f64, sh: f64, ws: u32, full: bool, qp: bool, ring: [8]u64, n: u32, now: u64, sug: u32, god: bool, glow: f64, dk: [128]u64, dp: bool, darmed: u32, doff: u32, dgo: u32, dlive: bool, dlabel: u64, tst: []const u32, tcells: []const u32, topen: u32, tcur: bool, kyp: bool, kstored: []const u32, kmask: []const u8, kmlen: []const u32, kst: []const u32, kfail: []const u32, kopen: []const u64, kedit: u32, kbuf: []const u8, klen: u32, karm: u32, kbusy: u32, kemask: u32, kewhy: u32, ktpick: u32) -> void { draw_backdrop(c, sw, sh, full); const ha : f64 = 1.0 - ma; if (ha > 0.0) { @@ -2175,6 +2510,16 @@ fn draw(c: u64, kit: [48]u64, frame: u32, state: u32, hov: u32, ma: f64, shown: } if (qp) { draw_queen_panel(c, kit, sw, sh, mx, my, view_of(menu_open(state), shown), space, ss, cur, ring, n, now, sug, god); } if (dp and qp == false) { draw_doctor_panel(c, kit, dk, sw, sh, mx, my, darmed, doff, dgo, dlive); } + if (kyp and qp == false and dp == false) { draw_keys_panel(c, kit, sw, sh, mx, my, kstored, kmask, kmlen, kst, kfail, kopen, now, kedit, kbuf, klen, karm, kbusy, kemask, kewhy, ktpick); } + if (ma > 0.0 and ws == WS_EXPANDED and collapsed == false) { + // K. KEYS in the foot, left of DOCTOR + var ka : f64 = LABEL_WHITE; + if (kyp or on_keys_foot(mx, my, sh, ws, collapsed)) { ka = TEXT_WHITE; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, ka * ma); + var kw : [16]u8 = undefined; + const kn : usize = csv_field(KEYS_WORDS, 0, kw[0..16]); + draw_bytes(c, kit, kw[0..16], kn, SIDE_W - 14.0 - DOC_LINK_W - 6.0 - KY_LINK_W + 8.0, sh - 13.0, 9.5, WEIGHT_SEMIBOLD, false); + } draw_titlebar(c, kit, sw, full, ma); draw_edge_glow(c, sw, sh, glow, frame); } @@ -2368,7 +2713,7 @@ fn dk_register(dk: [128]u64, p: u64, pass: u64) -> void { const na : u64 = dk_count(dk, dk[DK_AGENTS]); var a : u64 = 0; while (a < na) { - if (msg1(slashed, dk[DK_RANGE], dk_at(dk, dk[DK_AGENTS], a)) != NS_NOT_FOUND) { f = with_flag(f, F_AGENT, true); } + if (msg1(slashed, dk[DK_RANGE], dk_at(dk, dk[DK_AGENTS], a)) != NS_NOT_FOUND) { f = with_flag(f, DOC_F_AGENT, true); } a = a + 1; } // a repository below another: the outer one holds it @@ -2944,8 +3289,8 @@ pub fn main() -> void { cg_translate(c, 0.0, SH_MAX * WIDE_RETINA); cg_scale(c, WIDE_RETINA, 0.0 - WIDE_RETINA); // F. the window tint and the full-screen black glass, swapped when the style mask changes. - const tint_win : u64 = cg_color(0.0, 0.0, 0.0, TINT_BLACK); - const tint_full : u64 = cg_color(0.0, 0.0, 0.0, TINT_FULL); + const tint_win : u64 = cg_color(0.0, 0.0, 0.0, tint_alpha(false)); + const tint_full : u64 = cg_color(0.0, 0.0, 0.0, tint_alpha(true)); var was_full : bool = false; // K. the narrow panel draws point for point into its own bitmap, cropped to the view each frame. const cc : u64 = cg_bitmap(0, 3040, 5200, 8, 12160, cg_rgb(), ALPHA_PREMUL_LAST); @@ -3229,6 +3574,52 @@ pub fn main() -> void { var dacc : f64 = 0.0; var dgo : u32 = 0; var dnow : bool = true; + // the KEYS panel: open, which providers hold a key, their masked text, the last check's status, the + // failure count and when the circuit opened, the provider being typed (and the typed bytes, wiped after + // use), the DELETE that waits for its second click, the check in flight, and the editors found + var kyp : bool = false; + var kstored : [9]u32 = undefined; + var kmask : [144]u8 = undefined; + var kmlen : [9]u32 = undefined; + var kst : [9]u32 = undefined; + var kfail : [9]u32 = undefined; + var kopen : [9]u64 = undefined; + var kbuf : [256]u8 = undefined; + var klen : u32 = 0; + var kedit : u32 = KY_NONE; + var karm : u32 = KY_NONE; + var kbusy : u32 = KY_NONE; + var kemask : u32 = 0; + var kewhy : u32 = OPEN_NONE; + // the terminal editor the strip's first chip opens inside (editors.t27 term_pick; TERM_NONE when none was + // found) and the editor pane that chip spawned: its server pane id and the space it was opened for + var ktpick : u32 = term_pick(term_mask()); + // a request to open the editor inside, served in one place below (queen.t27 IE_*), and the spaces the + // editor was already opened for (queen.t27 edit_mark), so the Queen offers it once per space + var iedit : u32 = IE_NONE; + var edone : u64 = 0; + var epane : u32 = PANE_NONE; + var espace : u32 = 0; + // the editor view: on while the editor pane is on screen, the chat and space it was opened on (the view + // ends when either changes) and the second of the last Escape (Escape twice closes the view) + var eview : bool = false; + var ecur : u32 = SESSION_NONE; + var esp : u32 = 0; + var eesc : u64 = 0; + var kpend : u32 = KY_NONE; + var kpre : u32 = 0; + var ki : u32 = 0; + while (ki < 9) { + kstored[ki] = 0; + kmlen[ki] = 0; + kst[ki] = ST_STORED; + kfail[ki] = 0; + kopen[ki] = 0; + ki = ki + 1; + } + var khome : [256]u8 = undefined; + var kdir : [512]u8 = undefined; + var ktmp : [256]u8 = undefined; var auto_open : bool = true; // the terminal pane: the vt grid, the session it shows, offline or live, the server's pane id, // frames since the last key (the cursor stays solid while typing), the bytes in and out, the socket, @@ -3310,7 +3701,7 @@ pub fn main() -> void { const full : bool = has_mod(msg0(win, s_mask), MASK_FULL_SCREEN); var z : f64 = 1.0; if (full) { z = FULL_LOGO; } - if (full != was_full) { + if (tint_swap(full, was_full)) { if (full) { msg1(layer, sel("setBackgroundColor:"), tint_full); } else { msg1(layer, sel("setBackgroundColor:"), tint_win); } was_full = full; } @@ -3320,7 +3711,7 @@ pub fn main() -> void { const home_live : bool = menu_open(state) == false and ma == 0.0 and my > AREA_TOP; const screen : bool = menu_open(state) and ma >= 0.5; now = unix_time(0); - var sug : u32 = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + var sug : u32 = shown_suggestion(suggest_for(sessions_in(ss, space), cur != SESSION_NONE, screen and ktpick != TERM_NONE, edone, space), dismissed); var ev : u64 = msg_event(app, s_next, 0xffffffffffffffff, msg_f(date_cls, s_wait, fwait), mode, true); while (ev != 0) { const ty : u64 = msg0(ev, s_type); @@ -3348,11 +3739,74 @@ pub fn main() -> void { cur = act_cur(ss, sug, space, cur); ss = act_sessions(ss, sug, space); if (cur != SESSION_NONE) { task = session_agent(ss, cur); carg = task; } + // her editor suggestion: open this space's folder inside, once for the space + if (sug == A_EDIT) { + iedit = IE_USER; + edone = edit_mark(edone, space); + } ctl = C_CONFIRM; } else if (sug != A_NONE and on_dismiss(mx, my, sw, WS_TOP)) { dismissed = sug; ctl = C_DISMISS; } + } else if (kyp and qp == false and dp == false and on_panel(mx, my, sw, WS_TOP)) { + // K. the KEYS panel: close, a row's add / replace / delete (asks twice) / check, an editor chip + const kl : f64 = qp_left(sw); + if (on_panel_close(mx, my, sw, WS_TOP)) { + kyp = false; + karm = KY_NONE; + kedit = KY_NONE; + klen = 0; + } else { + const kh : u32 = ky_hit(mx, my, kl, QP_W, QP_PAD, WS_TOP); + const kq : u32 = kh / 4; + if (kh != KY_NONE) { + const kact : u32 = ky_click(kh % 4, kstored[kq] == 1, probe_has(kq), karm, kq); + if (kact == KA_EDIT) { + kedit = kq; + klen = 0; + karm = KY_NONE; + } else if (kact == KA_ARM) { + karm = kq; + kedit = KY_NONE; + klen = 0; + } else if (kact == KA_DELETE) { + kc_del(kq); + karm = KY_NONE; + kst[kq] = ST_STORED; + kfail[kq] = 0; + keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); + if (tfd >= 0) { client_set_key(tfd, kq, "", tout[0..4100]); } + } else if (kact == KA_CHECK) { + kbusy = kq; + kpend = 0; + karm = KY_NONE; + } else { + karm = KY_NONE; + } + } else { + karm = KY_NONE; + const ktop : f64 = ky_row_top(WS_TOP, PROVIDER_COUNT) + 4.0 + 14.0; + const keh : u32 = ed_hit(mx, my, kl, QP_PAD, ktop, strip_count(kemask, ktpick)); + if (keh != ED_NONE) { + // the editor opens the folder of the space on screen (home when it is unbound) + var kdirs : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, space)); + if (dk_len(dk, kdirs) == 0) { kdirs = home_dir(); } + const khn : usize = ns_bytes(home_dir(), khome[0..256]); + const kdn : usize = ns_bytes(kdirs, kdir[0..512]); + const kchip : u32 = strip_chip(kemask, ktpick, keh); + if (kchip == CHIP_INSIDE and eview) { + // the chip is also the way back to the chat; the editor keeps running in the server + eview = false; + } else if (kchip == CHIP_INSIDE) { + // T27 OS opens inside: the request is served below, where the Queen's is too + iedit = IE_USER; + } else { + kewhy = open_in_editor(kchip, khome[0..khn], kdir[0..kdn]); + } + } + } + } } else if (dp and on_panel(mx, my, sw, WS_TOP)) { // D. the DOCTOR panel: close, or a row's remove / confirm -- one item, one human click each if (on_panel_close(mx, my, sw, WS_TOP)) { @@ -3428,10 +3882,26 @@ pub fn main() -> void { dnow = true; carg = space; if (cur != SESSION_NONE) { cur = SESSION_NONE; task = CARD_SLOTS; } + } else if (screen and on_keys_foot(mx, my, sh, ws, collapsed)) { + // K. KEYS in the foot opens or closes the panel; it takes the Queen and DOCTOR panels' place + kyp = kyp == false; + karm = KY_NONE; + kedit = KY_NONE; + klen = 0; + if (kyp) { + qp = false; + god = false; + dp = false; + keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); + const khn : usize = ns_bytes(home_dir(), khome[0..256]); + kemask = installed_mask(khome[0..khn]); + ktpick = term_pick(term_mask()); + kewhy = OPEN_NONE; + } } else if (screen and on_doctor_link(mx, my, sh, ws, collapsed)) { // D. DOCTOR in the foot opens or closes the panel; it takes the Queen panel's place dp = dp == false; - if (dp) { qp = false; god = false; } + if (dp) { qp = false; god = false; kyp = false; } darmed = ARMED_NONE; } else if (screen and on_repo_link(mx, my, sh, ws, collapsed)) { // W. OPEN SOURCE T27 opens the repository in the default browser @@ -3443,7 +3913,8 @@ pub fn main() -> void { task = AGENT_QUEEN; cur = SESSION_NONE; qp = qp == false; - if (qp == false) { god = false; } + qp = qp == false; + if (qp) { kyp = false; } ctl = C_QUEEN_ROW; } else if (screen and in_side(mx, ws, collapsed) and row_at(WS_TOP, GROUP_SPACES, spaces_shown(sp), spaces_shown(sp), my) != ROW_NONE) { // W. a space row selects the space; a chat that lives elsewhere is let go @@ -3521,7 +3992,58 @@ pub fn main() -> void { const r : u32 = on_queen_key(qp, god); if (god) { ctl = C_GOD_OFF; } else { ctl = C_QUEEN_KEY; } qp = r % 2 == 1; + if (qp) { kyp = false; } god = r / 2 % 2 == 1; + } else if (kyp and kedit != KY_NONE and qp == false and dp == false) { + // K. a key is being typed: Return saves it to the Keychain, Esc cancels, Cmd+V pastes + const kk : u32 = ky_key(@as(u32, @intCast(key)), cmd); + if (kk == KE_CANCEL) { + keys_wipe(kbuf[0..256]); + kedit = KY_NONE; + klen = 0; + } else if (kk == KE_BACK) { + if (klen > 0) { + klen = klen - 1; + kbuf[@as(usize, klen)] = 0; + } + } else if (kk == KE_SAVE) { + if (key_fits(kbuf[0 .. @as(usize, klen)])) { + if (kc_put(kedit, kbuf[0 .. @as(usize, klen)])) { + kst[kedit] = ST_STORED; + kfail[kedit] = 0; + if (tfd >= 0) { client_set_key(tfd, kedit, kbuf[0 .. @as(usize, klen)], tout[0..4100]); } + } + keys_wipe(kbuf[0..256]); + keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); + kedit = KY_NONE; + klen = 0; + } + } else if (kk == KE_PASTE) { + const pb : u64 = msg0(cls("NSPasteboard"), sel("generalPasteboard")); + const ps : u64 = msg1(pb, sel("stringForType:"), cf("public.utf8-plain-text")); + if (ps != 0) { + const pn : usize = ns_bytes(ps, ktmp[0..256]); + const tn : usize = trim_key(ktmp[0..pn], kbuf[0..256]); + klen = @as(u32, @intCast(tn)); + if (klen > MAX_KEY_CHARS) { klen = MAX_KEY_CHARS; } + keys_wipe(ktmp[0..256]); + } + } else if (kk == KE_CHAR) { + const kc : u64 = msg0(ev, s_chars); + var kn : u64 = 0; + if (kc != 0) { kn = dk_len(dk, kc); } + var kj : u64 = 0; + while (kj < kn) { + const ch : u32 = @as(u32, @intCast(msg_char(kc, s_char_at, kj) & 65535)); + if (ky_accepts(klen, ch)) { + kbuf[@as(usize, klen)] = @as(u8, @intCast(ch)); + klen = klen + 1; + } + kj = kj + 1; + } + } else { + used = false; + } } else if (key == KEY_ESCAPE and god) { god = false; ctl = C_GOD_OFF; @@ -3535,7 +4057,17 @@ pub fn main() -> void { if (ctrl) { chs = msg0(ev, s_chars_raw); } var cp : u32 = 0; if (chs != 0 and dk_len(dk, chs) > 0) { cp = @as(u32, @intCast(msg_char(chs, s_char_at, 0) & 65535)); } - const kn : u32 = tp_key_bytes(@as(u32, @intCast(key)), cp, ctrl, tkey[0..8]); + var kn : u32 = tp_key_bytes(@as(u32, @intCast(key)), cp, ctrl, tkey[0..8]); + // the editor view: Escape twice in a second closes it (esc_twice); the first still reaches the editor + if (topen == VIEW_EDITOR and key == KEY_ESCAPE) { + if (esc_twice(eesc, now)) { + eview = false; + eesc = 0; + kn = 0; + } else { + eesc = now; + } + } if (kn > 0) { if (tlink == PANE_LIVE) { if (client_keys(tfd, tpid, tkey[0 .. @as(usize, kn)], tout[0..4100]) == 0) { tlink = PANE_OFFLINE; } @@ -3571,6 +4103,42 @@ pub fn main() -> void { if (used == false) { msg1(app, s_send, ev); } ev = msg_event(app, s_next, 0xffffffffffffffff, 0, mode, true); } + // K. a check button: the frame after the click shows "checking", then the probe runs (it blocks up to + // PROBE_TIMEOUT_S) and its HTTP answer sets the status and feeds the circuit breaker (keys.t27) + if (kbusy != KY_NONE) { + if (kpend == 0) { + kpend = 1; + } else { + const kn2 : usize = kc_get(kbusy, ktmp[0..256]); + var http : u32 = 1; + if (kn2 > 0) { http = probe(kbusy, ktmp[0 .. kn2]); } + kfail[kbusy] = circuit_fails(kfail[kbusy], http); + if (kfail[kbusy] >= CIRCUIT_FAILS and kopen[kbusy] + 60 <= now) { kopen[kbusy] = now; } + kst[kbusy] = status_after_probe(kst[kbusy], http); + // M12: a key that failed its check is withheld from the server, so the next claude pane + // takes the next profile (keys.t27 pick_profile); a key that passed is handed over again + if (tfd >= 0 and kn2 > 0) { + if (bad_mask(kst[0..9]) / (1 << kbusy) % 2 == 1) { + client_set_key(tfd, kbusy, "", tout[0..4100]); + } else { + client_set_key(tfd, kbusy, ktmp[0 .. kn2], tout[0..4100]); + } + } + keys_wipe(ktmp[0..256]); + kbusy = KY_NONE; + kpend = KY_NONE; + } + } + // K. pre-flight (M12): once at start, check the stored key behind each claude profile, in the order + // keys.t27 PROFILE_PROVIDER gives, so the first pane already avoids a dead key + if (kbusy == KY_NONE and kpre < 2) { + const kpp : u32 = PROFILE_PROVIDER[kpre]; + kpre = kpre + 1; + if (probe_has(kpp) and kc_has(kpp)) { + kbusy = kpp; + kpend = 0; + } + } // D. the walk: a slice of folders per frame; a finished pass forgets what it did not see, then rests if (walking) { qhead = dk_walk(dk, qhead, wpass); @@ -3665,8 +4233,12 @@ pub fn main() -> void { if (dgo > 0) { dlabel = dk_cat(dk, dk_word(dk, DL_FOOT), dk_int(dk, @as(u64, dgo))); } // T. the terminal pane: a Claude Code chat on screen opens it in its space's folder (home when // the space is unbound); a new chat or a new size starts the grid over and replays the pane's - // output from the server's ring; an offline pane tries the server again every TP_RETRY frames - if (screen and cur != SESSION_NONE and opens_pane(session_agent(ss, cur))) { + // output from the server's ring; an offline pane tries the server again every TP_RETRY frames. + // The same code shows the editor pane when the strip's "T27 OS" chip opened one (view_key): `want` is + // the chat index, VIEW_EDITOR or nothing; the editor view ends when the screen leaves its chat or space. + if (editor_view_stays(eview, screen, cur, ecur, space, esp) == false) { eview = false; } + const want : u32 = view_key(eview, cur != SESSION_NONE and opens_pane(session_agent(ss, cur)), cur); + if (screen and want != PANE_NONE) { const tox : f64 = content_x(ws, collapsed); const toy : f64 = WS_TOP + HEADER_H; const tw : f64 = sw - tox; @@ -3676,8 +4248,8 @@ pub fn main() -> void { tstable = tp_settle(tstable, tp_cols(tw) == twc and tp_rows(th) == twr); twc = tp_cols(tw); twr = tp_rows(th); - var tgo : bool = tp_reset_due(topen, cur) or tp_size_due(tst[ST_COLS], tst[ST_ROWS], tw, th, tstable); - if (tlink == PANE_OFFLINE and topen == cur) { + var tgo : bool = tp_reset_due(topen, want) or tp_size_due(tst[ST_COLS], tst[ST_ROWS], tw, th, tstable); + if (tlink == PANE_OFFLINE and topen == want) { ttry = ttry + 1; if (tp_retry_due(ttry)) { ttry = 0; @@ -3688,14 +4260,23 @@ pub fn main() -> void { if (tgo) { vt_init(tst[0..10], tcells[0..48000], tp_cols(tw), tp_rows(th)); tcursor = 0; - var tcwd : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, session_space(ss, cur))); + var tsp : u32 = espace; + if (want != VIEW_EDITOR) { tsp = session_space(ss, cur); } + var tcwd : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, tsp)); if (dk_len(dk, tcwd) == 0) { tcwd = home_dir(); } - if (topen != cur or tlink != PANE_LIVE) { + if (topen != want or tlink != PANE_LIVE) { tpid = PANE_NONE; if (tfd < 0) { tfd = term_connect(thome[0..256]); } - if (tfd >= 0) { tpid = term_open(tfd, ss, cur, tcwd, tout[0..4100], tdir[0..512]); } + if (tfd >= 0) { + if (want == VIEW_EDITOR) { tpid = epane; } else { tpid = term_open(tfd, ss, cur, tcwd, tout[0..4100], tdir[0..512]); } + } if (tpid != PANE_NONE and client_attach(tfd, tpid, tout[0..4100]) == 0) { tpid = PANE_NONE; } if (tpid == PANE_NONE) { tlink = PANE_OFFLINE; } else { tlink = PANE_LIVE; } + // the editor pane is gone (the server restarted, or it was killed): back to the chat + if (tpid == PANE_NONE and want == VIEW_EDITOR) { + eview = false; + epane = PANE_NONE; + } if (tpid == PANE_NONE and tfd >= 0) { sys_close(tfd); tfd = -1; @@ -3704,7 +4285,7 @@ pub fn main() -> void { // the pty takes the new grid before the replay, so the program redraws at the full width if (tlink == PANE_LIVE) { client_resize(tfd, tpid, tst[ST_COLS], tst[ST_ROWS], tout[0..4100]); } if (tlink == PANE_OFFLINE) { term_banner(tst[0..10], tcells[0..48000], tcwd); } - topen = cur; + topen = want; tsince = 0; ttry = 0; tfed = tfed + 1; @@ -3739,17 +4320,55 @@ pub fn main() -> void { if (tsince < 999) { tsince = tsince + 1; } tcur = tp_cursor_on(frame, tsince); // Q. GOD MODE: with the panel open and the user still, the Queen runs her suggestion herself - sug = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + sug = shown_suggestion(suggest_for(sessions_in(ss, space), cur != SESSION_NONE, screen and ktpick != TERM_NONE, edone, space), dismissed); if (god_due(god, qp, sug, now, last_user, last_act)) { const qc : u32 = queen_control(sug); cur = act_cur(ss, sug, space, cur); ss = act_sessions(ss, sug, space); var qa : u32 = 0; if (cur != SESSION_NONE) { task = session_agent(ss, cur); qa = task; } + // the editor suggestion: GOD MODE opens this space's folder inside, once for the space (edit_mark), + // so she does not reopen it every GOD_WAIT seconds; the log names the space + if (sug == A_EDIT) { + iedit = IE_GOD; + edone = edit_mark(edone, space); + qa = space; + } ring[ring_next(evn)] = ev_pack(view_of(menu_open(state), shown), qc, qa, now); evn = evn + 1; last_act = now; - sug = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + sug = shown_suggestion(suggest_for(sessions_in(ss, space), cur != SESSION_NONE, screen and ktpick != TERM_NONE, edone, space), dismissed); + } + // E. the editor opens inside in one place, whoever asked: the strip's "T27 OS" chip or a confirmed A_EDIT + // (IE_USER), or GOD MODE (IE_GOD). open_target keeps it inside; the editor pane takes the terminal area + // on the next frame (view_key), on the folder of the space on screen (home when it is unbound). + if (iedit != IE_NONE) { + const itg : u32 = open_target(iedit == IE_GOD, ktpick != TERM_NONE, kemask != 0); + var idirs : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, space)); + if (dk_len(dk, idirs) == 0) { idirs = home_dir(); } + const ihn : usize = ns_bytes(home_dir(), khome[0..256]); + const idn : usize = ns_bytes(idirs, kdir[0..512]); + edone = edit_mark(edone, space); + if (itg == OT_INSIDE) { + if (tfd < 0) { tfd = term_connect(thome[0..256]); } + var iep : u32 = PANE_NONE; + if (tfd >= 0) { iep = edit_open(tfd, epane, espace, space, ktpick, kdir[0..idn], tout[0..4100]); } + epane = iep; + espace = space; + kewhy = OPEN_OK; + if (epane == PANE_NONE) { + kewhy = OPEN_SPAWN_FAILED; + } else { + eview = true; + ecur = cur; + esp = space; + eesc = 0; + kyp = false; + } + } else if (itg == OT_OUTSIDE) { + kewhy = open_in_editor(nth_installed(kemask, 0), khome[0..ihn], kdir[0..idn]); + } + iedit = IE_NONE; } ma = wave(ma, menu_open(state)); glow = glow_level(glow, glow_on(god)); @@ -3798,14 +4417,14 @@ pub fn main() -> void { fwait = fg_wait(fquiet, tlink == PANE_LIVE and topen != PANE_NONE, FRAME_S); msg_flag(tx_cls, s_quiet, true); if (fdraw and narrow) { - draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur); + draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(cc); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * RETINA, sh * RETINA); msg1(layer, s_contents, img); cg_image_release(img); cg_image_release(whole); } else if (fdraw) { - draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur); + draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(c); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * WIDE_RETINA, sh * WIDE_RETINA); msg1(layer, s_contents, img); @@ -4103,9 +4722,13 @@ test full_screen_draws_point_for_point_on_black_glass { // the hairline is one device pixel at the display's own scale assert HAIR * WIDE_RETINA == 1.0; assert TINT_FULL > TINT_BLACK; - assert TINT_FULL < 1.0; - // F. the background is black glass: R = G = B = 0, translucent in a window, opaque in full screen + // F. full screen has no transparency: the layer is opaque black #000000 there (owner, 2026-10-20) + assert TINT_FULL == 1.0; + // F. the background is black glass: R = G = B = 0, clear in a window, opaque in full screen assert BACK_BLACK == 1.0; + assert BACK_GLASS == 0.0; + // the wipe covers a fractional edge: it reaches past the scene by at least a point + assert BACK_BLEED >= 1.0; assert CROWN_WHITE == 0.70; assert STRICT_CARD_EDGE > STRICT_CARD_FILL; // the cards still tile the readable column in full screen @@ -4239,3 +4862,37 @@ test the_new_chat_plus_and_the_row_close_answer_only_on_their_boxes { assert on_row_close(SIDE_W - 26.0, top + 40.0, top) == false; assert in_side(SIDE_W - 26.0, WS_EXPANDED, false); } + +test a_window_is_matte_black_glass_and_full_screen_is_opaque_black { + // owner, 2026-10-20: a window is clear over the blur and the 0.30 layer tint, as in trios + assert backdrop_alpha(false) == 0.0; + assert backdrop_clears(false); + assert tint_alpha(false) == TINT_BLACK; + // full screen has no transparency: the frame and the layer are #000000 with nothing under them + assert backdrop_alpha(true) == 1.0; + assert backdrop_clears(true) == false; + assert tint_alpha(true) == 1.0; + assert tint_alpha(true) > tint_alpha(false); + // the two states never agree, so a swap can never leave a see-through full screen + assert backdrop_alpha(true) > backdrop_alpha(false); +} + +test the_layer_colour_changes_only_when_full_screen_does { + assert tint_swap(true, false); + assert tint_swap(false, true); + assert tint_swap(false, false) == false; + assert tint_swap(true, true) == false; +} + +test the_editor_view_shares_the_terminal_areas_key_space_with_the_chat_panes { + // editors.t27 keeps its own copies of these two numbers; the window's pane table is the truth + assert VIEW_NONE == PANE_NONE; + assert VIEW_EDITOR < PANE_NONE; + assert VIEW_EDITOR > 255; + // with no editor open the area shows what it always did: the chat's pane for Claude Code, else nothing + assert view_key(false, true, 2) == 2; + assert view_key(false, false, 2) == PANE_NONE; + // the editor wins over the chat and does not need one + assert view_key(true, true, 2) == VIEW_EDITOR; + assert view_key(true, false, SESSION_NONE) == VIEW_EDITOR; +} diff --git a/specs/t27os/keys.t27 b/specs/t27os/keys.t27 new file mode 100644 index 0000000000..38d52134fa --- /dev/null +++ b/specs/t27os/keys.t27 @@ -0,0 +1,1440 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/keys.t27 -- the one key manager of T27 OS: which providers hold a key, which environment +; variable each agent reads, how a key is shown, what a probe answer does to a provider's status, which of +; several keys goes next, and what a freshly spawned pane gets in its environment. +; Owner, 2026-10-19: a KEYS tab modelled on trios, every key managed by one manager written in .t27. +; Mirrors the trios SR-00 decisions (not the Swift): ModelProvider, ModelKeyRotation (least recently used +; plus cooldown), ProviderCircuitBreaker, ProviderStatusService, KeychainSecrets. +; Storage is the macOS Keychain, service "ai.t27.os", one item per provider (account = provider name). +; Never a plain file and never the repo. This spec holds no key and no syscall: the Keychain, the probe and +; the child environment are host calls (keys_host.t27, pty_server.t27); only the decisions live here, so +; they run their tests (a module with @extern imports cannot). Every test uses fake values. +; Refs gHashTag/t27#10782. +; ---- MAP: the app.t27.ai account and its keys (read 2026-10-19, read-only; M12 follow-up) ----------------- +; R = 999-multibots-telegraf apps/vibee-editor/render; Q = BrowserOS trios/agent-server/apps/server/src/api +; (queen-contributor-keys.ts lives at git commit 2b25f0bcf, not in the BrowserOS working tree). +; IDENTITY (EXISTS). A person signs in with a Telegram Mini App initData (R/auth.ts:509-591) or the Telegram +; Login Widget (R/session-routes.ts:909). The server mints an HS256 session JWT, 600 s, claims sub=telegram +; id, sid, jti (R/session.ts:137-170), sent as `Authorization: Bearer`. The long-lived refresh token is the +; cookie __Host-t27_refresh (HttpOnly, Secure, SameSite=Strict; R/src/auth/refresh-cookie.ts:72) renewed by +; POST /api/auth/refresh (R/session-routes.ts:1306). The key owner is `telegram:` +; (R/src/hive/contributor-keys-route.ts:31-45). +; KEYS (EXISTS, metadata only). Table queen_contributor_keys in the Queen's PostgreSQL, AES-256-GCM sealed +; (Q/services/queen-contributor-keys.ts:185-259). Browser door GET/POST /api/hive/keys[...] -> +; R/render-server.ts:9154 -> Queen with a shared server-to-server token. A key object is {id, provider +; (nvidia|zai), label, fingerprint, model, enabled, createdAt, lastProbe{status, checkedAt, latencyMs}, +; contribution}. The secret is accepted on POST add and never returned. +; PROXY (MISSING). No route lets a client spend a stored key: no Anthropic-compatible /v1/messages, no +; chat-completions proxy on a per-user key, no per-user delegated token. The keys are used only by the +; Queen's own bees (Q queen-tick.ts:46, queen-dispatch.ts:36). So a pane cannot use server-side keys without +; a copy on this Mac until that route exists (filed as an issue against 999-multibots-telegraf). +; NOT BUILT HERE: the KEYS tab listing the signed-in user's server keys needs a session token on this Mac; +; the Mac app has no sign-in yet (refresh cookie lives on the auth.t27.ai host). +module T27osKeys; + +use t27os::agents::AGENT_COUNT; +use t27os::session_store::csv_field; +use t27os::session_store::put_bytes; +use t27os::session_store::put_dec; + +/// Keychain service every item is stored under. +pub const KEYCHAIN_SERVICE : str = "ai.t27.os"; + +pub const PROVIDER_COUNT : u32 = 9; +pub const P_ANTHROPIC : u32 = 0; +pub const P_OPENAI : u32 = 1; +pub const P_GEMINI : u32 = 2; +pub const P_OPENROUTER : u32 = 3; +pub const P_ZAI : u32 = 4; +pub const P_OLLAMA : u32 = 5; +pub const P_GITHUB : u32 = 6; +pub const P_CURSOR : u32 = 7; +pub const P_GONKA : u32 = 8; + +/// Provider names in cell order; also the Keychain account of each. +pub const PROVIDER_NAMES : str = "anthropic,openai,gemini,openrouter,zai,ollama,github,cursor,gonka"; +/// The environment variable the agent CLIs read the provider's key from. +pub const PROVIDER_ENVS : str = "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY,ZAI_API_KEY,OLLAMA_API_KEY,GITHUB_TOKEN,CURSOR_API_KEY,GONKA_API_KEY"; + +pub const MAX_KEY_CHARS : u32 = 254; + +/// Provider status, trios ProviderStatusService. +pub const ST_MISSING : u32 = 0; +pub const ST_STORED : u32 = 1; +pub const ST_VALID : u32 = 2; +pub const ST_INVALID : u32 = 3; +pub const ST_RATE_LIMITED : u32 = 4; +pub const ST_CIRCUIT_OPEN : u32 = 5; +pub const STATUS_NAMES : str = "missing,stored,valid,invalid,rate-limited,circuit-open"; + +/// Circuit breaker: this many failed probes in a row open the circuit, for CIRCUIT_COOLDOWN_S seconds. +pub const CIRCUIT_FAILS : u32 = 3; +pub const CIRCUIT_COOLDOWN_S : u64 = 60; +/// A key that answered 429 rests this long before rotation picks it again. +pub const RATE_COOLDOWN_S : u64 = 30; + +/// Per agent cell (agents.t27 order: Claude, Codex, Gemini, OpenCode, Queen, Crush, Cursor, Aider, Copilot): +/// a bitmask over providers, bit p set when the agent reads provider p's key. +pub const AGENT_PROVIDERS : [9]u32 = [1, 2, 4, 11, 1, 63, 128, 15, 64]; + +/// Does agent a read provider p's key? +pub fn agent_uses(a: u32, p: u32) -> bool { + if (a >= AGENT_COUNT) { return false; } + if (p >= PROVIDER_COUNT) { return false; } + return (AGENT_PROVIDERS[a] >> p) % 2 == 1; +} + +/// Copy the environment variable name of provider p into out; returns its length (0 when p is unknown). +pub fn provider_env(p: u32, out: []u8) -> usize { + return csv_field(PROVIDER_ENVS, p, out); +} + +/// Copy the name of provider p into out; returns its length. +pub fn provider_name(p: u32, out: []u8) -> usize { + return csv_field(PROVIDER_NAMES, p, out); +} + +/// A key is fit to store when it is non-empty, within MAX_KEY_CHARS and holds only printable ASCII +/// without a space (a pasted newline or a space is a paste mistake, not a key). +pub fn key_fits(key: []const u8) -> bool { + if (key.len == 0) { return false; } + if (key.len > MAX_KEY_CHARS) { return false; } + var i : usize = 0; + while (i < key.len) { + if (key[i] <= 32) { return false; } + if (key[i] >= 127) { return false; } + i = i + 1; + } + return true; +} + +/// How a key is shown: stars, then the last four characters when the key is at least 12 long (a shorter +/// key shows stars only, so at most a third of a key ever reaches the screen). Returns the length. +pub fn mask_key(key: []const u8, out: []u8) -> usize { + var n : usize = put_bytes(out, 0, "********"); + if (key.len >= 12) { + n = put_bytes(out, n, key[key.len - 4 .. key.len]); + } + return n; +} + +/// The status after a probe answered with HTTP status code http (0 = no answer: network down or timeout). +/// `prev` is the status before. A 5xx or no answer does not condemn the key: it keeps `prev` (the failure +/// feeds the circuit breaker, circuit_fails). +pub fn status_after_probe(prev: u32, http: u32) -> u32 { + if (http >= 200 and http < 300) { return ST_VALID; } + if (http == 401 or http == 403) { return ST_INVALID; } + if (http == 429) { return ST_RATE_LIMITED; } + if (prev == ST_MISSING) { return ST_MISSING; } + return prev; +} + +/// A probe that proves nothing about the key (no answer, 5xx) counts as a transport failure. +pub fn is_transport_failure(http: u32) -> bool { + return http == 0 or http >= 500; +} + +/// Consecutive transport failures after one more probe: reset by any answer that came from the provider. +pub fn circuit_fails(fails: u32, http: u32) -> u32 { + if (is_transport_failure(http)) { return fails + 1; } + return 0; +} + +/// Is the circuit open at `now`? `opened` is when it opened (seconds), fails the failures in a row. +pub fn circuit_open(fails: u32, opened: u64, now: u64) -> bool { + if (fails < CIRCUIT_FAILS) { return false; } + return now < opened + CIRCUIT_COOLDOWN_S; +} + +/// The status to show: a stored key whose circuit is open reads circuit-open; a missing key is missing. +pub fn status_shown(has_key: bool, status: u32, fails: u32, opened: u64, now: u64) -> u32 { + if (has_key == false) { return ST_MISSING; } + if (circuit_open(fails, opened, now)) { return ST_CIRCUIT_OPEN; } + if (status == ST_MISSING) { return ST_STORED; } + return status; +} + +/// Rotation among n keys of one provider (trios ModelKeyRotation): the usable key used longest ago wins. +/// A key is usable when it is not cooling down (`rest_until[k] <= now`) and is not key `skip` (the one that +/// just failed; pass n to skip none). Returns n when none is usable. +pub fn rotate_pick_except(n: u32, skip: u32, last_used: []const u64, rest_until: []const u64, now: u64) -> u32 { + var best : u32 = n; + var k : u32 = 0; + while (k < n) { + if (rest_until[k] <= now and k != skip) { + if (best == n) { + best = k; + } else if (last_used[k] < last_used[best]) { + best = k; + } + } + k = k + 1; + } + return best; +} + +/// Rotation with no key skipped. +pub fn rotate_pick(n: u32, last_used: []const u64, rest_until: []const u64, now: u64) -> u32 { + return rotate_pick_except(n, n, last_used, rest_until, now); +} + +/// Names a child process must not inherit: the Claude Code session markers of whatever started the server. +/// They make the pane believe it is a child session ("Transcript saving is off") and cut its login. +pub fn env_stripped(name: []const u8) -> bool { + if (name.len == 10) { + if (name[0] == 67 and name[1] == 76 and name[2] == 65 and name[3] == 85 and name[4] == 68 and name[5] == 69) { + if (name[6] == 67 and name[7] == 79 and name[8] == 68 and name[9] == 69) { return true; } + } + } + if (name.len < 12) { return false; } + var i : usize = 0; + const pre : str = "CLAUDE_CODE_"; + while (i < 12) { + if (name[i] != pre[i]) { return false; } + i = i + 1; + } + return true; +} + +/// The providers whose keys a pane of agent a is given: the bitmask, narrowed to those stored. +pub fn spawn_mask(a: u32, stored: u32) -> u32 { + if (a >= AGENT_COUNT) { return 0; } + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (agent_uses(a, p) and (stored >> p) % 2 == 1) { m = m + (1 << p); } + p = p + 1; + } + return m; +} + +/// The Keychain account of provider p (its name) plus the service: "ai.t27.os/anthropic". Returns length. +pub fn keychain_label(p: u32, out: []u8) -> usize { + var n : usize = put_bytes(out, 0, KEYCHAIN_SERVICE); + n = put_bytes(out, n, "/"); + var tmp : [24]u8 = undefined; + const l : usize = csv_field(PROVIDER_NAMES, p, tmp[0..24]); + n = put_bytes(out, n, tmp[0..l]); + return n; +} + +test every_agent_reads_its_own_key { + assert agent_uses(0, P_ANTHROPIC); + assert agent_uses(0, P_OPENAI) == false; + assert agent_uses(1, P_OPENAI); + assert agent_uses(2, P_GEMINI); + assert agent_uses(4, P_ANTHROPIC); + assert agent_uses(6, P_CURSOR); + assert agent_uses(8, P_GITHUB); + assert agent_uses(9, P_ANTHROPIC) == false; + assert agent_uses(0, 9) == false; +} + +test multi_provider_agents_get_every_provider_they_read { + assert agent_uses(3, P_ANTHROPIC) and agent_uses(3, P_OPENAI) and agent_uses(3, P_OPENROUTER); + assert agent_uses(3, P_ZAI) == false; + assert agent_uses(5, P_ZAI) and agent_uses(5, P_OLLAMA) and agent_uses(5, P_GEMINI); + assert agent_uses(7, P_ANTHROPIC) and agent_uses(7, P_GEMINI) and agent_uses(7, P_OPENROUTER); +} + +test provider_tables_line_up { + var out : [40]u8 = undefined; + assert provider_name(P_ANTHROPIC, out[0..40]) == 9; + assert provider_name(P_GONKA, out[0..40]) == 5; + assert provider_name(9, out[0..40]) == 0; + assert provider_env(P_ANTHROPIC, out[0..40]) == 17; + assert out[0] == 65 and out[16] == 89; + assert provider_env(P_GITHUB, out[0..40]) == 12; + assert provider_env(P_GONKA, out[0..40]) == 13; + assert provider_env(9, out[0..40]) == 0; +} + +test a_key_is_masked_to_four_characters_at_most { + var out : [32]u8 = undefined; + const n : usize = mask_key("sk-test-0000-abcd", out[0..32]); + assert n == 12; + assert out[0] == 42 and out[7] == 42; + assert out[8] == 97 and out[11] == 100; + const s : usize = mask_key("sk-test-00", out[0..32]); + assert s == 8; + assert out[7] == 42; + const e : usize = mask_key("", out[0..32]); + assert e == 8; +} + +test a_pasted_key_must_be_one_clean_token { + assert key_fits("sk-test-0000"); + assert key_fits("") == false; + assert key_fits("sk test") == false; + assert key_fits("sk-test\n") == false; + assert key_fits("sk\ttest") == false; +} + +test a_probe_answer_sets_the_status { + assert status_after_probe(ST_STORED, 200) == ST_VALID; + assert status_after_probe(ST_STORED, 401) == ST_INVALID; + assert status_after_probe(ST_VALID, 403) == ST_INVALID; + assert status_after_probe(ST_VALID, 429) == ST_RATE_LIMITED; + assert status_after_probe(ST_RATE_LIMITED, 200) == ST_VALID; + assert status_after_probe(ST_VALID, 503) == ST_VALID; + assert status_after_probe(ST_STORED, 0) == ST_STORED; + assert status_after_probe(ST_MISSING, 0) == ST_MISSING; +} + +test three_failures_open_the_circuit_for_a_minute { + var f : u32 = 0; + f = circuit_fails(f, 0); + f = circuit_fails(f, 503); + assert circuit_open(f, 100, 101) == false; + f = circuit_fails(f, 0); + assert f == 3; + assert circuit_open(f, 100, 101); + assert circuit_open(f, 100, 159); + assert circuit_open(f, 100, 160) == false; + assert circuit_fails(f, 200) == 0; + assert circuit_fails(f, 401) == 0; +} + +test the_shown_status_follows_the_key_and_the_circuit { + assert status_shown(false, ST_VALID, 0, 0, 0) == ST_MISSING; + assert status_shown(true, ST_MISSING, 0, 0, 0) == ST_STORED; + assert status_shown(true, ST_VALID, 0, 0, 0) == ST_VALID; + assert status_shown(true, ST_VALID, 3, 100, 120) == ST_CIRCUIT_OPEN; + assert status_shown(true, ST_VALID, 3, 100, 200) == ST_VALID; +} + +test rotation_takes_the_key_used_longest_ago { + var used : [3]u64 = [50, 10, 30]; + var rest : [3]u64 = [0, 0, 0]; + assert rotate_pick(3, used[0..3], rest[0..3], 100) == 1; + rest[1] = 200; + assert rotate_pick(3, used[0..3], rest[0..3], 100) == 2; + rest[2] = 200; + rest[0] = 200; + assert rotate_pick(3, used[0..3], rest[0..3], 100) == 3; + assert rotate_pick(3, used[0..3], rest[0..3], 200) == 1; + assert rotate_pick(0, used[0..3], rest[0..3], 200) == 0; +} + +test the_claude_code_markers_are_stripped_from_a_pane { + assert env_stripped("CLAUDECODE"); + assert env_stripped("CLAUDE_CODE_CHILD_SESSION"); + assert env_stripped("CLAUDE_CODE_ENTRYPOINT"); + assert env_stripped("CLAUDE_CODE_"); + assert env_stripped("ANTHROPIC_API_KEY") == false; + assert env_stripped("CLAUDE_CONFIG_DIR") == false; + assert env_stripped("CLAUDE") == false; + assert env_stripped("CLAUDECODEX") == false; + assert env_stripped("HOME") == false; +} + +test a_pane_gets_only_the_stored_keys_its_agent_reads { + const all : u32 = 511; + assert spawn_mask(0, all) == 1; + assert spawn_mask(0, 2) == 0; + assert spawn_mask(3, all) == 11; + assert spawn_mask(3, 8) == 8; + assert spawn_mask(8, 64) == 64; + assert spawn_mask(8, 1) == 0; + assert spawn_mask(9, all) == 0; +} + +test the_keychain_item_is_named_by_service_and_provider { + var out : [48]u8 = undefined; + const n : usize = keychain_label(P_OPENAI, out[0..48]); + assert n == 16; + assert out[0] == 97 and out[9] == 47 and out[10] == 111; +} + +test a_key_longer_than_the_server_slot_is_refused { + var long : [300]u8 = undefined; + var i : usize = 0; + while (i < 300) { long[i] = 97; i = i + 1; } + assert key_fits(long[0..254]); + assert key_fits(long[0..255]) == false; +} + +// ---- the check button: the cheapest authenticated request each provider answers ---- +// A GET of the provider's model list (or whoami) with the key in a header -- never in the URL. The answer's +// HTTP status is all that is read (status_after_probe). cursor and gonka have no such public endpoint: their +// check stays "stored". +pub const PROBE_URLS : str = "https://api.anthropic.com/v1/models,https://api.openai.com/v1/models,https://generativelanguage.googleapis.com/v1beta/models,https://openrouter.ai/api/v1/auth/key,https://api.z.ai/api/anthropic/v1/messages,https://ollama.com/api/tags,https://api.github.com/user,,"; +pub const AUTH_NONE : u32 = 0; +pub const AUTH_BEARER : u32 = 1; +pub const AUTH_X_API_KEY : u32 = 2; +pub const AUTH_X_GOOG : u32 = 3; +pub const PROBE_AUTH : [9]u32 = [2, 1, 3, 1, 2, 1, 1, 0, 0]; +pub const PROBE_TIMEOUT_S : u32 = 6; + +pub fn probe_url(p: u32, out: []u8) -> usize { + return csv_field(PROBE_URLS, p, out); +} + +pub fn probe_has(p: u32) -> bool { + if (p >= PROVIDER_COUNT) { return false; } + return PROBE_AUTH[p] != AUTH_NONE; +} + +/// The header that carries the key, and its value (the key itself, or "Bearer " + key). +pub fn probe_header_name(p: u32, out: []u8) -> usize { + if (p >= PROVIDER_COUNT) { return 0; } + const a : u32 = PROBE_AUTH[p]; + if (a == AUTH_BEARER) { return put_bytes(out, 0, "Authorization"); } + if (a == AUTH_X_API_KEY) { return put_bytes(out, 0, "x-api-key"); } + if (a == AUTH_X_GOOG) { return put_bytes(out, 0, "x-goog-api-key"); } + return 0; +} + +pub fn probe_header_value(p: u32, key: []const u8, out: []u8) -> usize { + if (p >= PROVIDER_COUNT) { return 0; } + var n : usize = 0; + if (PROBE_AUTH[p] == AUTH_BEARER) { n = put_bytes(out, 0, "Bearer "); } + return put_bytes(out, n, key); +} + +test every_probe_has_a_url_and_a_header { + var u : [160]u8 = undefined; + var h : [40]u8 = undefined; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (probe_has(p)) { + assert probe_url(p, u[0..160]) > 20; + assert u[0] == 104 and u[4] == 115; + assert probe_header_name(p, h[0..40]) > 0; + } else { + assert probe_url(p, u[0..160]) == 0; + } + p = p + 1; + } + assert probe_has(P_CURSOR) == false; + assert probe_has(P_GONKA) == false; + assert probe_has(9) == false; +} + +test the_key_travels_in_a_header_never_in_the_url { + var u : [160]u8 = undefined; + var v : [64]u8 = undefined; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (probe_has(p)) { + const n : usize = probe_url(p, u[0..160]); + var i : usize = 0; + while (i < n) { + assert u[i] != 63 and u[i] != 61; + i = i + 1; + } + } + p = p + 1; + } + assert probe_header_value(P_OPENAI, "sk-test-0000", v[0..64]) == 19; + assert v[0] == 66 and v[7] == 115; + assert probe_header_value(P_ANTHROPIC, "sk-test-0000", v[0..64]) == 12; + assert v[0] == 115; +} + +// ---- the KEYS panel: layout, hit tests and what a click or a key means ---------------------------------- +// The window (glass_window.t27) only draws and forwards; every decision is a tested fn here. + +pub const KY_ROWS_Y : f64 = 56.0; +pub const KY_ROW_H : f64 = 44.0; +pub const KY_BTN_W : f64 = 56.0; +pub const KY_BTN_H : f64 = 18.0; +pub const KY_BTN_DY : f64 = 20.0; +pub const KY_BTN_GAP : f64 = 6.0; +pub const KY_LINK_W : f64 = 44.0; +pub const KY_NONE : u32 = 255; + +pub const B_SET : u32 = 0; +pub const B_DELETE : u32 = 1; +pub const B_CHECK : u32 = 2; + +pub const KA_NONE : u32 = 0; +pub const KA_EDIT : u32 = 1; +pub const KA_ARM : u32 = 2; +pub const KA_DELETE : u32 = 3; +pub const KA_CHECK : u32 = 4; + +pub const KE_NONE : u32 = 0; +pub const KE_SAVE : u32 = 1; +pub const KE_CANCEL : u32 = 2; +pub const KE_BACK : u32 = 3; +pub const KE_PASTE : u32 = 4; +pub const KE_CHAR : u32 = 5; + +pub const KEY_RETURN : u32 = 36; +pub const KEY_BACKSPACE : u32 = 51; +pub const KEY_ESC : u32 = 53; +pub const KEY_V : u32 = 9; + +pub const KEYS_WORDS : str = "KEYS,add,replace,delete,confirm,check,checking,no key,paste or type a key,enter saves esc cancels,open in"; + +/// Top edge of provider row r in a panel whose top is t. +pub fn ky_row_top(t: f64, r: u32) -> f64 { + return t + KY_ROWS_Y + @as(f64, @floatFromInt(r)) * KY_ROW_H; +} + +/// Left edge of button b (SET, DELETE, CHECK): the three sit together at the right end of the row. +pub fn ky_btn_x(l: f64, w: f64, pad: f64, b: u32) -> f64 { + return l + w - pad - 3.0 * KY_BTN_W - 2.0 * KY_BTN_GAP + @as(f64, @floatFromInt(b)) * (KY_BTN_W + KY_BTN_GAP); +} + +/// Which button of which row a click lands on, as row * 4 + button; KY_NONE when it lands on none. +pub fn ky_hit(x: f64, y: f64, l: f64, w: f64, pad: f64, t: f64) -> u32 { + var r : u32 = 0; + while (r < PROVIDER_COUNT) { + const by : f64 = ky_row_top(t, r) + KY_BTN_DY; + if (y >= by and y <= by + KY_BTN_H) { + var b : u32 = 0; + while (b < 3) { + const bx : f64 = ky_btn_x(l, w, pad, b); + if (x >= bx and x <= bx + KY_BTN_W) { return r * 4 + b; } + b = b + 1; + } + } + r = r + 1; + } + return KY_NONE; +} + +/// The KEYS link in the sidebar foot, left of the DOCTOR link (which ends 14 from the right edge). +pub fn on_keys_link(x: f64, y: f64, side_w: f64, sh: f64, foot_h: f64, doc_w: f64) -> bool { + const right : f64 = side_w - 14.0 - doc_w - 6.0; + return x >= right - KY_LINK_W and x <= right and y >= sh - foot_h and y <= sh - 6.0; +} + +/// What a click on button b of a row means. `stored`: the row has a key; `probed`: its provider has a check; +/// `armed`: the row whose DELETE was clicked once (KY_NONE when none); `row`: the clicked row. +pub fn ky_click(b: u32, stored: bool, probed: bool, armed: u32, row: u32) -> u32 { + if (b == B_SET) { return KA_EDIT; } + if (stored == false) { return KA_NONE; } + if (b == B_DELETE) { + if (armed == row) { return KA_DELETE; } + return KA_ARM; + } + if (b == B_CHECK and probed) { return KA_CHECK; } + return KA_NONE; +} + +/// What a key press means while a key is being typed. `code` is the macOS key code, `cmd` the Command bit. +pub fn ky_key(code: u32, cmd: bool) -> u32 { + if (cmd) { + if (code == KEY_V) { return KE_PASTE; } + return KE_NONE; + } + if (code == KEY_RETURN) { return KE_SAVE; } + if (code == KEY_ESC) { return KE_CANCEL; } + if (code == KEY_BACKSPACE) { return KE_BACK; } + return KE_CHAR; +} + +/// A typed character is taken when it is printable ASCII without a space and the buffer has room. +pub fn ky_accepts(len: u32, ch: u32) -> bool { + if (len >= MAX_KEY_CHARS) { return false; } + return ch > 32 and ch < 127; +} + +/// A pasted text with its surrounding white space and control characters cut off; the length written. +pub fn trim_key(src: []const u8, out: []u8) -> usize { + var a : usize = 0; + var b : usize = src.len; + while (a < b and src[a] <= 32) { a = a + 1; } + while (b > a and src[b - 1] <= 32) { b = b - 1; } + var n : usize = 0; + while (a + n < b and n < out.len) { + out[n] = src[a + n]; + n = n + 1; + } + return n; +} + +test a_click_finds_its_row_and_button { + const y : f64 = ky_row_top(35.0, 2) + KY_BTN_DY + 4.0; + assert ky_hit(ky_btn_x(660.0, 300.0, 16.0, 0) + 3.0, y, 660.0, 300.0, 16.0, 35.0) == 2 * 4 + B_SET; + assert ky_hit(ky_btn_x(660.0, 300.0, 16.0, 1) + 5.0, y, 660.0, 300.0, 16.0, 35.0) == 2 * 4 + B_DELETE; + assert ky_hit(ky_btn_x(660.0, 300.0, 16.0, 2) + 5.0, y, 660.0, 300.0, 16.0, 35.0) == 2 * 4 + B_CHECK; + assert ky_hit(665.0, y, 660.0, 300.0, 16.0, 35.0) == KY_NONE; + assert ky_hit(900.0, 35.0, 660.0, 300.0, 16.0, 35.0) == KY_NONE; +} + +test delete_asks_twice { + assert ky_click(B_DELETE, true, true, KY_NONE, 3) == KA_ARM; + assert ky_click(B_DELETE, true, true, 3, 3) == KA_DELETE; + assert ky_click(B_DELETE, true, true, 2, 3) == KA_ARM; + assert ky_click(B_DELETE, false, true, KY_NONE, 3) == KA_NONE; +} + +test add_always_edits_and_check_needs_a_key_and_a_probe { + assert ky_click(B_SET, false, false, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_SET, true, true, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_CHECK, true, true, KY_NONE, 0) == KA_CHECK; + assert ky_click(B_CHECK, false, true, KY_NONE, 0) == KA_NONE; + assert ky_click(B_CHECK, true, false, KY_NONE, 7) == KA_NONE; +} + +test keys_while_typing { + assert ky_key(KEY_RETURN, false) == KE_SAVE; + assert ky_key(KEY_ESC, false) == KE_CANCEL; + assert ky_key(KEY_BACKSPACE, false) == KE_BACK; + assert ky_key(KEY_V, true) == KE_PASTE; + assert ky_key(0, true) == KE_NONE; + assert ky_key(0, false) == KE_CHAR; + assert ky_accepts(0, 65); + assert ky_accepts(0, 32) == false; + assert ky_accepts(0, 10) == false; + assert ky_accepts(MAX_KEY_CHARS, 65) == false; +} + +test a_pasted_text_loses_its_white_space { + var out : [32]u8 = undefined; + const n : usize = trim_key(" sk-abc\n", out[0..32]); + assert n == 6; + assert out[0] == 115 and out[5] == 99; + assert trim_key("\n \t", out[0..32]) == 0; + assert trim_key("sk-abc", out[0..4]) == 4; +} + +test the_keys_link_sits_left_of_the_doctor_link { + assert on_keys_link(272.0 - 14.0 - 84.0 - 6.0 - 20.0, 530.0, 272.0, 540.0, 30.0, 84.0); + assert on_keys_link(272.0 - 20.0, 530.0, 272.0, 540.0, 30.0, 84.0) == false; + assert on_keys_link(10.0, 530.0, 272.0, 540.0, 30.0, 84.0) == false; +} + +// ---- the claude pane's PROFILE: which provider the Claude Code CLI talks to (M12) ---------------------- +// The owner's screenshot (2026-10-19): a claude pane answered "Your organization has disabled Claude +// subscription access". A pane never uses the subscription login; it gets one of the API profiles below from +// the Keychain. Order: the free z.ai first, as the chain in specs/automation/agent-provider-chain.t27 +// (ORDER_DEFAULT "zai,kie,...", WHY: the free route first, a paid one behind it). That spec is written in +// the brace module syntax and cannot be `use`d from here, so the order is restated once, below, and +// nowhere else. Kie and nemotron are not Anthropic-protocol endpoints; they join in stage 2 (the local +// router, which translates through specs/provider/transform.t27). OpenRouter and Ollama's cloud endpoint +// do document the Anthropic protocol (see PROFILE_PROVIDER below), so they are profiles now. +pub const PR_ZAI : u32 = 0; +pub const PR_ANTHROPIC : u32 = 1; +pub const PR_OPENROUTER : u32 = 2; +pub const PR_OLLAMA_CLOUD : u32 = 3; +pub const PR_NONE : u32 = 255; +pub const PROFILE_COUNT : u32 = 4; +pub const PROFILE_NAMES : str = "zai,anthropic-api,openrouter,ollama-cloud"; +/// The provider whose Keychain key backs each profile. +pub const PROFILE_PROVIDER : [4]u32 = [4, 0, 3, 5]; +// A profile exists only for a provider that documents an Anthropic-protocol base URL on its own pages, read +// on 2026-10-20 (the page is named at each row below). None is used until its provider's key is stored. +// zai https://docs.z.ai/devpack/tool/claude +// anthropic-api Claude Code's own direct API (no base URL, the API key variable) +// openrouter https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration.md +// ollama-cloud https://docs.ollama.com/integrations/claude-code (section "Connecting directly to Ollama's cloud") +// Documented too, but with no profile yet: DeepSeek, Moonshot (Kimi) and MiniMax. Each would need a +// provider row of its own above (PROVIDER_COUNT 9 -> 12), and the window and the server size their key +// tables by the nine rows; see docs/t27os/keys-orchestration.md for what their pages say. + +// ---- the tier map: a default model plus a model per tier, as pure data (gHashTag/t27#11224, FR-001) ----- +// Prior art: the open-source tool Free Claude Code (https://github.com/Alishahryar1/free-claude-code, +// read 2026-10-20) keeps one default model and lets each tier (opus, sonnet, haiku) override it; a tier left +// unset falls back to the default. Claude Code asks for a model by tier, and a provider that is not +// Anthropic serves models of its own name, so each profile maps every tier to a model it serves. +// One column per tier, one comma field per profile in PROFILE_NAMES order. An empty field means "same as +// the default model"; when the default is empty too, the variable is left unset and Claude Code keeps its +// own name for the tier. The small-fast model has no column: it follows the haiku tier. +pub const TI_DEFAULT : u32 = 0; +pub const TI_FAST : u32 = 1; +pub const TI_HAIKU : u32 = 2; +pub const TI_SONNET : u32 = 3; +pub const TI_OPUS : u32 = 4; +pub const TIER_COUNT : u32 = 5; +/// The variable each tier is set through (ANTHROPIC_SMALL_FAST_MODEL is the older name of the haiku slot). +pub const TIER_VARS : str = "ANTHROPIC_MODEL,ANTHROPIC_SMALL_FAST_MODEL,ANTHROPIC_DEFAULT_HAIKU_MODEL,ANTHROPIC_DEFAULT_SONNET_MODEL,ANTHROPIC_DEFAULT_OPUS_MODEL"; +/// Only the flash model answered on the second z.ai key (owner, 2026-10-19), so every tier of the zai +/// profile names it. The z.ai page https://docs.z.ai/devpack/tool/claude (read 2026-10-20) lists bigger +/// models for the opus and sonnet tiers; they join here as one data edit when a key that answers them is stored. +/// Fields: zai, anthropic-api, openrouter, ollama-cloud. OpenRouter's page gives the three tier names and no +/// default (so ANTHROPIC_MODEL stays unset there); Ollama's page names one cloud model, used for every tier. +pub const MODELS_DEFAULT : str = "glm-4.7-flash,,,glm-5.3-flash"; +pub const MODELS_HAIKU : str = ",,~anthropic/claude-haiku-latest,"; +pub const MODELS_SONNET : str = ",,~anthropic/claude-sonnet-latest[1m],"; +pub const MODELS_OPUS : str = ",,~anthropic/claude-opus-latest[1m],"; +/// The Anthropic-protocol base URL of each profile; empty = Claude Code's own (the direct API). +pub const PROFILE_BASE_URLS : str = "https://api.z.ai/api/anthropic,,https://openrouter.ai/api,https://ollama.com"; +/// The variable that carries the Keychain key: a bearer token for a gateway, the API key for Anthropic itself. +pub const PROFILE_KEY_VARS : str = "ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_AUTH_TOKEN"; +/// 1 when the profile also sets ANTHROPIC_API_KEY to the empty string: OpenRouter's and Ollama's pages both +/// say a stray API key in the environment makes Claude Code authenticate against Anthropic instead of the +/// gateway, so the variable is set empty rather than left to the parent shell. +pub const PROFILE_EMPTY_API_KEY : [4]u32 = [0, 0, 1, 1]; + +/// The variables of a profile come from fixed slots, in this order; a slot with no value is skipped. +pub const PS_BASE : u32 = 0; +pub const PS_KEY : u32 = 1; +pub const PS_TIER0 : u32 = 2; +pub const PS_EMPTY_KEY : u32 = 7; +pub const PS_COUNT : u32 = 8; +pub const PS_NONE : u32 = 255; + +/// What a claude pane must not inherit from the shell that started the server: every variable that picks +/// an endpoint, a login or a model. CLAUDE_CODE_* (including the OAuth token) goes by env_stripped. +pub const CLAUDE_STRIPS : str = "ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,ANTHROPIC_MODEL,ANTHROPIC_SMALL_FAST_MODEL,ANTHROPIC_DEFAULT_HAIKU_MODEL,ANTHROPIC_DEFAULT_SONNET_MODEL,ANTHROPIC_DEFAULT_OPUS_MODEL,ANTHROPIC_CUSTOM_HEADERS"; +pub const CLAUDE_STRIP_COUNT : u32 = 9; + +/// Does agent a (cell order of agents.t27) get a profile? Only Claude Code (cell 0). +pub fn profile_agent(a: u32) -> bool { + return a == 0; +} + +pub fn profile_name(pr: u32, out: []u8) -> usize { + return csv_field(PROFILE_NAMES, pr, out); +} + +/// How many comma-separated fields a table string has (an empty table has one). +pub fn csv_fields(list: []const u8) -> u32 { + var n : u32 = 1; + var i : usize = 0; + while (i < list.len) { + if (list[i] == 44) { n = n + 1; } + i = i + 1; + } + return n; +} + +/// The model profile pr serves for tier ti (TI_*), copied into out (64 bytes are enough); 0 when there is +/// none. A tier with no model of its own takes the profile's default model, as in Free Claude Code; the +/// small-fast tier takes the haiku one. +pub fn tier_model(pr: u32, ti: u32, out: []u8) -> usize { + if (pr >= PROFILE_COUNT) { return 0; } + if (ti >= TIER_COUNT) { return 0; } + if (ti == TI_DEFAULT) { return csv_field(MODELS_DEFAULT, pr, out); } + var n : usize = 0; + if (ti == TI_SONNET) { n = csv_field(MODELS_SONNET, pr, out); } + if (ti == TI_OPUS) { n = csv_field(MODELS_OPUS, pr, out); } + if (ti == TI_HAIKU or ti == TI_FAST) { n = csv_field(MODELS_HAIKU, pr, out); } + if (n > 0) { return n; } + return csv_field(MODELS_DEFAULT, pr, out); +} + +/// The base URL of profile pr (0 = none: the direct Anthropic API). +pub fn profile_base_url(pr: u32, out: []u8) -> usize { + if (pr >= PROFILE_COUNT) { return 0; } + return csv_field(PROFILE_BASE_URLS, pr, out); +} + +/// Does profile pr set slot s (PS_*)? The key slot always; the others when they carry a value. +fn slot_present(pr: u32, s: u32) -> bool { + if (pr >= PROFILE_COUNT) { return false; } + if (s == PS_KEY) { return true; } + if (s == PS_EMPTY_KEY) { return PROFILE_EMPTY_API_KEY[pr] == 1; } + var tmp : [64]u8 = undefined; + if (s == PS_BASE) { return profile_base_url(pr, tmp[0..64]) > 0; } + if (s >= PS_TIER0 and s < PS_TIER0 + TIER_COUNT) { return tier_model(pr, s - PS_TIER0, tmp[0..64]) > 0; } + return false; +} + +/// The slot of the k-th variable of profile pr; PS_NONE past the last one. +fn profile_slot(pr: u32, k: u32) -> u32 { + var seen : u32 = 0; + var s : u32 = 0; + while (s < PS_COUNT) { + if (slot_present(pr, s)) { + if (seen == k) { return s; } + seen = seen + 1; + } + s = s + 1; + } + return PS_NONE; +} + +pub fn profile_var_count(pr: u32) -> u32 { + var n : u32 = 0; + var s : u32 = 0; + while (s < PS_COUNT) { + if (slot_present(pr, s)) { n = n + 1; } + s = s + 1; + } + return n; +} + +/// The name of variable k (40 bytes are enough). +pub fn profile_var_name(pr: u32, k: u32, out: []u8) -> usize { + const s : u32 = profile_slot(pr, k); + if (s == PS_NONE) { return 0; } + if (s == PS_BASE) { return put_bytes(out, 0, "ANTHROPIC_BASE_URL"); } + if (s == PS_KEY) { return csv_field(PROFILE_KEY_VARS, pr, out); } + if (s == PS_EMPTY_KEY) { return put_bytes(out, 0, "ANTHROPIC_API_KEY"); } + return csv_field(TIER_VARS, s - PS_TIER0, out); +} + +/// True when variable k of the profile carries the Keychain key. +pub fn profile_var_is_key(pr: u32, k: u32) -> bool { + return profile_slot(pr, k) == PS_KEY; +} + +/// The constant value of variable k (0 for the key variable; a variable that is present with value 0 is set +/// to the empty string). +pub fn profile_var_text(pr: u32, k: u32, out: []u8) -> usize { + const s : u32 = profile_slot(pr, k); + if (s == PS_BASE) { return profile_base_url(pr, out); } + if (s >= PS_TIER0 and s < PS_TIER0 + TIER_COUNT) { return tier_model(pr, s - PS_TIER0, out); } + return 0; +} + +/// Is `name` one of the variables a claude pane starts without? +pub fn claude_strips(name: []const u8) -> bool { + var tmp : [40]u8 = undefined; + var k : u32 = 0; + while (k < CLAUDE_STRIP_COUNT) { + const n : usize = csv_field(CLAUDE_STRIPS, k, tmp[0..40]); + if (n == name.len) { + var i : usize = 0; + var same : bool = true; + while (i < n) { + if (tmp[i] != name[i]) { same = false; } + i = i + 1; + } + if (same) { return true; } + } + k = k + 1; + } + return false; +} + +// ---- the profile chain with numbered keys (gHashTag/t27#11224, FR-003) ------------------------------------ +// Free Claude Code walks an ordered fallback list when a request fails after its retries, and each fallback +// entry may name a key number of its provider (key 1 first). Here the list is PROFILE_NAMES, walked at pane +// spawn and pane failover (the failover decision is below), forward only: a profile that failed once is not +// tried again by the same pane, so the walk always ends. + +/// The first profile after `after` (PR_NONE = from the start) whose provider key is stored and has not +/// failed (bad bit set); PR_NONE when the chain is over. Any other `after` out of range ends the chain. +pub fn next_profile(after: u32, stored: u32, bad: u32) -> u32 { + var pr : u32 = 0; + if (after != PR_NONE) { + if (after >= PROFILE_COUNT) { return PR_NONE; } + pr = after + 1; + } + while (pr < PROFILE_COUNT) { + const bit : u32 = PROFILE_PROVIDER[pr]; + if ((stored >> bit) % 2 == 1 and (bad >> bit) % 2 == 0) { return pr; } + pr = pr + 1; + } + return PR_NONE; +} + +/// The profile a new claude pane gets: the first in order whose provider key is stored and has not +/// failed its check (bad bit set). PR_NONE when there is none: the pane starts without a key and says so. +pub fn pick_profile(stored: u32, bad: u32) -> u32 { + return next_profile(PR_NONE, stored, bad); +} + +/// How many keys one provider may hold, numbered from 1 (Free Claude Code: "key 1 first"). +pub const MAX_KEYS : u32 = 3; + +/// The Keychain item of key number k (0-based; the panel says "key k+1") of provider p. Key 1 keeps the +/// plain label of keychain_label, so a key stored before numbering stays where it is; keys 2 and 3 add +/// "#2" and "#3". 0 for an unknown provider or a number past MAX_KEYS. +pub fn keychain_label_n(p: u32, k: u32, out: []u8) -> usize { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } + var n : usize = keychain_label(p, out); + if (k == 0) { return n; } + n = put_bytes(out, n, "#"); + return put_dec(out, n, @as(u64, k) + 1); +} + +/// The bad mask from the providers' statuses (st[p]): invalid and circuit-open keys are not offered. +pub fn bad_mask(st: []const u32) -> u32 { + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT and p < st.len) { + if (st[p] == ST_INVALID or st[p] == ST_CIRCUIT_OPEN) { m = m + (1 << p); } + p = p + 1; + } + return m; +} + +/// The "why" line of the KEYS tab: which profile a claude pane gets and why. No key value, ever. +pub fn profile_why(stored: u32, bad: u32, out: []u8) -> usize { + const pr : u32 = pick_profile(stored, bad); + var tmp : [24]u8 = undefined; + if (pr == PR_NONE) { + if (stored == 0) { return put_bytes(out, 0, "claude: no key stored, the pane asks for a login"); } + return put_bytes(out, 0, "claude: every stored key failed its check"); + } + var n : usize = put_bytes(out, 0, "claude: "); + const l : usize = profile_name(pr, tmp[0..24]); + n = put_bytes(out, n, tmp[0..l]); + if (pr == PR_ZAI) { return put_bytes(out, n, ", the first key in the chain order"); } + if (pr > PR_ANTHROPIC) { return put_bytes(out, n, ", no earlier profile has a usable key"); } + const first : u32 = PROFILE_PROVIDER[PR_ZAI]; + if ((stored >> first) % 2 == 0) { return put_bytes(out, n, ", no z.ai key stored"); } + return put_bytes(out, n, ", z.ai failed its check"); +} + +// ---- the pane failover: what to do when a claude pane shows a provider error (gHashTag/t27#11224, FR-004) -- +// Owner, 2026-10-20: panes that start Claude Code inherited a provider that answered 429 "Insufficient +// balance" or "organization has disabled Claude subscription access". Free Claude Code solves this inside a +// local proxy that retries and then walks a fallback list per request. T27 OS cannot run that proxy yet (no +// AF_INET server and no streamed HTTPS client in t27b, t27c issue 10902; router.t27 stops at decisions), but +// z.ai, OpenRouter and Ollama's cloud serve the Anthropic protocol themselves, so the walk is done per PANE: +// read the error off the screen, mark the profile bad for this pane, respawn the pane (Claude Code resumes its +// session) on the next profile. Everything here is a pure decision; the host reads the screen and respawns. +// It cannot loop: a respawn onto the next profile moves strictly forward in the chain, a respawn onto the next +// key of the same provider is counted, and MAX_FAILOVERS caps the count whatever the host reports. + +/// What the screen showed. +pub const FC_NONE : u32 = 0; +pub const FC_RATE_LIMITED : u32 = 1; +pub const FC_NO_BALANCE : u32 = 2; +pub const FC_SUBSCRIPTION : u32 = 3; +pub const FC_LOGIN_REJECTED : u32 = 4; +pub const FC_OTHER : u32 = 5; +pub const FC_WORDS : str = "no error,rate limited (429),out of balance,subscription disabled,login rejected (401/403),other error"; + +/// What to do about it. +pub const FA_KEEP : u32 = 0; +pub const FA_RESPAWN_NEXT : u32 = 1; +pub const FA_RESPAWN_KEY : u32 = 2; +pub const FA_GIVE_UP : u32 = 3; + +/// At most this many failovers per pane: every profile once, and a few key swaps inside a provider. +pub const MAX_FAILOVERS : u32 = 12; + +/// The index of the first occurrence of needle in hay at or after `from`; hay.len + 1 when absent. +fn find_bytes(hay: []const u8, needle: []const u8, from: usize) -> usize { + if (needle.len == 0) { return hay.len + 1; } + var i : usize = from; + while (i + needle.len <= hay.len) { + var j : usize = 0; + while (j < needle.len and hay[i + j] == needle[j]) { j = j + 1; } + if (j == needle.len) { return i; } + i = i + 1; + } + return hay.len + 1; +} + +/// Does needle occur in hay? +pub fn has_bytes(hay: []const u8, needle: []const u8) -> bool { + return find_bytes(hay, needle, 0) <= hay.len; +} + +/// Does the status code occur in hay as a number of its own: not glued to a digit on either side, so "429" +/// does not match "14290 tokens"? +pub fn has_code(hay: []const u8, code: []const u8) -> bool { + var at : usize = find_bytes(hay, code, 0); + while (at <= hay.len) { + var glued : bool = false; + if (at > 0) { + if (hay[at - 1] >= 48 and hay[at - 1] <= 57) { glued = true; } + } + const e : usize = at + code.len; + if (e < hay.len) { + if (hay[e] >= 48 and hay[e] <= 57) { glued = true; } + } + if (glued == false) { return true; } + at = find_bytes(hay, code, at + 1); + } + return false; +} + +/// The failure class of one screen line, by plain byte matching. The two phrases are specific enough alone; +/// a bare status number counts only on a line that also says "error" (so "Edited 429 lines" is not a 429). +/// "Insufficient balance" wins over 429: the provider sends both for an empty account. An "API Error" line +/// that matches nothing is FC_OTHER; any other line is FC_NONE. +pub fn classify_line(line: []const u8) -> u32 { + if (has_bytes(line, "disabled Claude subscription")) { return FC_SUBSCRIPTION; } + if (has_bytes(line, "Insufficient balance")) { return FC_NO_BALANCE; } + if (has_bytes(line, "rror") == false) { return FC_NONE; } + if (has_code(line, "429")) { return FC_RATE_LIMITED; } + if (has_code(line, "401") or has_code(line, "403")) { return FC_LOGIN_REJECTED; } + if (has_bytes(line, "API Error")) { return FC_OTHER; } + return FC_NONE; +} + +/// The bad mask after a failure of class `class` on profile pr: the profile's provider joins it. A line that +/// is no failure (FC_NONE, FC_OTHER) or a pane with no profile changes nothing. +pub fn fo_bad(class: u32, pr: u32, bad: u32) -> u32 { + if (class == FC_NONE or class == FC_OTHER) { return bad; } + if (pr >= PROFILE_COUNT) { return bad; } + const bit : u32 = PROFILE_PROVIDER[pr]; + if ((bad >> bit) % 2 == 1) { return bad; } + return bad + (1 << bit); +} + +/// The failover decision for a pane whose screen showed `class` while it ran profile pr (PR_NONE = it +/// started without a key, which is where the "disabled Claude subscription access" line comes from). +/// `stored` and `bad` are the provider masks (bad: providers that failed for this pane or their check), +/// `done` the failovers this pane has had, `alt` whether another key of pr's provider is usable now +/// (rotate_pick_except over the keys, the failed one skipped). A throttled key (429) with another key at hand +/// swaps the key and keeps the profile; every other failure leaves the profile for the next one in the chain; +/// when none is left, or the cap is reached, the pane stays as it is. +pub fn fo_action(class: u32, pr: u32, stored: u32, bad: u32, done: u32, alt: bool) -> u32 { + if (class == FC_NONE or class == FC_OTHER) { return FA_KEEP; } + if (done >= MAX_FAILOVERS) { return FA_GIVE_UP; } + if (class == FC_RATE_LIMITED and alt and pr < PROFILE_COUNT) { return FA_RESPAWN_KEY; } + if (next_profile(pr, stored, fo_bad(class, pr, bad)) == PR_NONE) { return FA_GIVE_UP; } + return FA_RESPAWN_NEXT; +} + +/// The profile to respawn on for the action fo_action gave: the same one for a key swap, the next one in +/// the chain for FA_RESPAWN_NEXT, PR_NONE when the pane stays. +pub fn fo_target(class: u32, pr: u32, stored: u32, bad: u32, done: u32, alt: bool) -> u32 { + const act : u32 = fo_action(class, pr, stored, bad, done, alt); + if (act == FA_RESPAWN_KEY) { return pr; } + if (act == FA_RESPAWN_NEXT) { return next_profile(pr, stored, fo_bad(class, pr, bad)); } + return PR_NONE; +} + +/// The "why" line for a failover, for the KEYS tab and the pane's title (128 bytes are enough): names the +/// profile, the failure and the move. No key value, ever. +pub fn fo_why(class: u32, pr: u32, act: u32, nxt: u32, done: u32, out: []u8) -> usize { + if (act == FA_KEEP) { return put_bytes(out, 0, "claude: no provider error, the pane stays"); } + var tmp : [32]u8 = undefined; + var n : usize = put_bytes(out, 0, "claude: "); + if (pr < PROFILE_COUNT) { + const l : usize = profile_name(pr, tmp[0..32]); + n = put_bytes(out, n, tmp[0..l]); + } else { + n = put_bytes(out, n, "no profile"); + } + n = put_bytes(out, n, " "); + const w : usize = csv_field(FC_WORDS, class, tmp[0..32]); + n = put_bytes(out, n, tmp[0..w]); + if (act == FA_RESPAWN_KEY) { return put_bytes(out, n, ", same profile with the next key"); } + if (act == FA_RESPAWN_NEXT) { + n = put_bytes(out, n, ", respawn on "); + const m : usize = profile_name(nxt, tmp[0..32]); + return put_bytes(out, n, tmp[0..m]); + } + if (done >= MAX_FAILOVERS) { return put_bytes(out, n, ", failover limit reached, the pane stays"); } + return put_bytes(out, n, ", no profile left in the chain, the pane stays"); +} + +/// z.ai has no cheap GET that proves a key opens the Anthropic-protocol route, so its check is one +/// one-token message to the flash model (free); the answer is read by status only. +pub fn probe_post(p: u32) -> bool { + return p == P_ZAI; +} + +/// Providers whose route wants the anthropic-version header. +pub fn probe_needs_version(p: u32) -> bool { + return p == P_ANTHROPIC or p == P_ZAI; +} + +pub fn probe_body(p: u32, out: []u8) -> usize { + if (p != P_ZAI) { return 0; } + var model : [64]u8 = undefined; + const ml : usize = tier_model(PR_ZAI, TI_DEFAULT, model[0..64]); + var n : usize = put_bytes(out, 0, "{\"model\":\""); + n = put_bytes(out, n, model[0..ml]); + return put_bytes(out, n, "\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); +} + +test the_free_zai_profile_comes_first { + assert pick_profile(17, 0) == PR_ZAI; + assert pick_profile(1, 0) == PR_ANTHROPIC; + assert pick_profile(16, 0) == PR_ZAI; + assert pick_profile(0, 0) == PR_NONE; + assert pick_profile(2, 0) == PR_NONE; +} + +test a_failed_key_hands_the_pane_to_the_next_profile { + assert pick_profile(17, 16) == PR_ANTHROPIC; + assert pick_profile(17, 1) == PR_ZAI; + assert pick_profile(17, 17) == PR_NONE; + var st : [9]u32 = [1, 0, 0, 0, 3, 0, 0, 0, 0]; + assert bad_mask(st[0..9]) == 16; + st[0] = ST_CIRCUIT_OPEN; + assert bad_mask(st[0..9]) == 17; + st[4] = ST_RATE_LIMITED; + assert bad_mask(st[0..9]) == 1; +} + +test the_zai_profile_sets_the_base_url_the_token_and_flash_everywhere { + var o : [80]u8 = undefined; + assert profile_var_count(PR_ZAI) == 7; + assert profile_var_name(PR_ZAI, 0, o[0..80]) == 18; + assert profile_var_name(PR_ZAI, 1, o[0..80]) == 20; + assert profile_var_is_key(PR_ZAI, 1); + assert profile_var_is_key(PR_ZAI, 0) == false; + assert profile_var_text(PR_ZAI, 0, o[0..80]) == 30; + assert o[0] == 104 and o[8] == 97; + assert profile_var_text(PR_ZAI, 1, o[0..80]) == 0; + assert profile_var_text(PR_ZAI, 2, o[0..80]) == 13; + assert profile_var_text(PR_ZAI, 3, o[0..80]) == 13; + assert profile_var_text(PR_ZAI, 4, o[0..80]) == 13; + assert profile_var_name(PR_ZAI, 4, o[0..80]) == 29; +} + +test every_tier_of_the_zai_profile_names_the_flash_model { + var o : [64]u8 = undefined; + var ti : u32 = 0; + while (ti < TIER_COUNT) { + assert tier_model(PR_ZAI, ti, o[0..64]) == 13; + assert o[0] == 103 and o[12] == 104; + ti = ti + 1; + } + assert tier_model(PR_ZAI, TIER_COUNT, o[0..64]) == 0; + assert tier_model(PR_NONE, TI_DEFAULT, o[0..64]) == 0; +} + +test the_anthropic_profile_names_no_model_so_claude_code_keeps_its_own { + var o : [64]u8 = undefined; + var ti : u32 = 0; + while (ti < TIER_COUNT) { + assert tier_model(PR_ANTHROPIC, ti, o[0..64]) == 0; + ti = ti + 1; + } + assert profile_base_url(PR_ANTHROPIC, o[0..64]) == 0; +} + +test the_zai_variables_come_in_slot_order_with_the_sonnet_and_opus_tiers { + var o : [64]u8 = undefined; + assert profile_var_name(PR_ZAI, 0, o[0..64]) == 18; + assert profile_var_name(PR_ZAI, 2, o[0..64]) == 15; + assert o[10] == 77; + assert profile_var_name(PR_ZAI, 3, o[0..64]) == 26; + assert profile_var_name(PR_ZAI, 5, o[0..64]) == 30; + assert o[18] == 83; + assert profile_var_name(PR_ZAI, 6, o[0..64]) == 28; + assert o[18] == 79; + assert profile_var_name(PR_ZAI, 7, o[0..64]) == 0; + assert profile_var_text(PR_ZAI, 5, o[0..64]) == 13; + assert profile_var_text(PR_ZAI, 6, o[0..64]) == 13; + assert profile_var_text(PR_ZAI, 7, o[0..64]) == 0; + assert profile_var_is_key(PR_ZAI, 5) == false; +} + +test the_openrouter_profile_sets_its_three_tiers_and_an_empty_api_key { + var o : [64]u8 = undefined; + assert profile_var_count(PR_OPENROUTER) == 7; + assert profile_var_text(PR_OPENROUTER, 0, o[0..64]) == 25; + assert o[8] == 111 and o[24] == 105; + assert profile_var_is_key(PR_OPENROUTER, 1); + assert profile_var_name(PR_OPENROUTER, 1, o[0..64]) == 20; + assert profile_var_name(PR_OPENROUTER, 2, o[0..64]) == 26; + assert profile_var_text(PR_OPENROUTER, 2, o[0..64]) == 30; + assert o[0] == 126 and o[11] == 99; + assert profile_var_name(PR_OPENROUTER, 3, o[0..64]) == 29; + assert profile_var_text(PR_OPENROUTER, 3, o[0..64]) == 30; + assert profile_var_name(PR_OPENROUTER, 4, o[0..64]) == 30; + assert profile_var_text(PR_OPENROUTER, 4, o[0..64]) == 35; + assert o[34] == 93; + assert profile_var_name(PR_OPENROUTER, 5, o[0..64]) == 28; + assert profile_var_text(PR_OPENROUTER, 5, o[0..64]) == 33; + assert profile_var_name(PR_OPENROUTER, 6, o[0..64]) == 17; + assert profile_var_is_key(PR_OPENROUTER, 6) == false; + assert profile_var_text(PR_OPENROUTER, 6, o[0..64]) == 0; + assert tier_model(PR_OPENROUTER, TI_DEFAULT, o[0..64]) == 0; +} + +test the_ollama_cloud_profile_names_one_cloud_model_for_every_tier { + var o : [64]u8 = undefined; + assert profile_var_count(PR_OLLAMA_CLOUD) == 8; + assert profile_var_text(PR_OLLAMA_CLOUD, 0, o[0..64]) == 18; + assert o[8] == 111 and o[17] == 109; + assert profile_var_is_key(PR_OLLAMA_CLOUD, 1); + var ti : u32 = 0; + while (ti < TIER_COUNT) { + assert tier_model(PR_OLLAMA_CLOUD, ti, o[0..64]) == 13; + assert o[4] == 53; + ti = ti + 1; + } + assert profile_var_name(PR_OLLAMA_CLOUD, 7, o[0..64]) == 17; + assert profile_var_is_key(PR_OLLAMA_CLOUD, 7) == false; + assert profile_var_text(PR_OLLAMA_CLOUD, 7, o[0..64]) == 0; +} + +test the_new_profiles_follow_the_old_two_and_wait_for_their_own_key { + assert pick_profile(8, 0) == PR_OPENROUTER; + assert pick_profile(32, 0) == PR_OLLAMA_CLOUD; + assert pick_profile(40, 0) == PR_OPENROUTER; + assert pick_profile(40, 8) == PR_OLLAMA_CLOUD; + assert pick_profile(40, 40) == PR_NONE; + assert pick_profile(57, 0) == PR_ZAI; + assert pick_profile(57, 16) == PR_ANTHROPIC; + assert pick_profile(57, 17) == PR_OPENROUTER; + assert pick_profile(57, 25) == PR_OLLAMA_CLOUD; + assert pick_profile(4, 0) == PR_NONE; + assert pick_profile(448, 0) == PR_NONE; +} + +test the_chain_goes_forward_only_and_skips_a_missing_or_bad_key { + assert next_profile(PR_NONE, 57, 0) == PR_ZAI; + assert next_profile(PR_ZAI, 57, 0) == PR_ANTHROPIC; + assert next_profile(PR_ANTHROPIC, 57, 0) == PR_OPENROUTER; + assert next_profile(PR_OPENROUTER, 57, 0) == PR_OLLAMA_CLOUD; + assert next_profile(PR_OLLAMA_CLOUD, 57, 0) == PR_NONE; + assert next_profile(PR_ZAI, 57, 1) == PR_OPENROUTER; + assert next_profile(PR_ZAI, 17, 0) == PR_ANTHROPIC; + assert next_profile(PR_ANTHROPIC, 17, 0) == PR_NONE; + assert next_profile(PR_OPENROUTER, 25, 0) == PR_NONE; + assert next_profile(PR_OPENROUTER, 57, 32) == PR_NONE; + assert next_profile(PR_NONE, 0, 0) == PR_NONE; + assert next_profile(PROFILE_COUNT, 57, 0) == PR_NONE; + assert next_profile(7, 57, 0) == PR_NONE; +} + +test pick_profile_is_the_start_of_the_chain { + var stored : u32 = 0; + while (stored < 64) { + var bad : u32 = 0; + while (bad < 64) { + assert pick_profile(stored, bad) == next_profile(PR_NONE, stored, bad); + bad = bad + 7; + } + stored = stored + 1; + } +} + +test a_profile_is_never_offered_twice_by_one_walk { + var pr : u32 = next_profile(PR_NONE, 57, 0); + var steps : u32 = 0; + var prev : u32 = 255; + while (pr != PR_NONE) { + if (prev != 255) { assert pr > prev; } + prev = pr; + pr = next_profile(pr, 57, 0); + steps = steps + 1; + } + assert steps == PROFILE_COUNT; +} + +test rotation_can_skip_the_key_that_just_failed { + var used : [3]u64 = [50, 10, 30]; + var rest : [3]u64 = [0, 0, 0]; + assert rotate_pick_except(3, 1, used[0..3], rest[0..3], 100) == 2; + assert rotate_pick_except(3, 2, used[0..3], rest[0..3], 100) == 1; + assert rotate_pick_except(3, 3, used[0..3], rest[0..3], 100) == 1; + rest[0] = 200; + assert rotate_pick_except(3, 1, used[0..3], rest[0..3], 100) == 2; + rest[2] = 200; + assert rotate_pick_except(3, 1, used[0..3], rest[0..3], 100) == 3; + assert rotate_pick_except(1, 0, used[0..3], rest[0..3], 100) == 1; +} + +test a_second_key_has_its_own_keychain_item_and_the_first_keeps_the_old_one { + var a : [48]u8 = undefined; + var b : [48]u8 = undefined; + assert keychain_label_n(P_OPENAI, 0, a[0..48]) == 16; + assert keychain_label(P_OPENAI, b[0..48]) == 16; + assert a[15] == b[15] and a[10] == b[10]; + assert keychain_label_n(P_OPENAI, 1, a[0..48]) == 18; + assert a[16] == 35 and a[17] == 50; + assert keychain_label_n(P_OPENAI, 2, a[0..48]) == 18; + assert a[16] == 35 and a[17] == 51; + assert keychain_label_n(P_OPENAI, MAX_KEYS, a[0..48]) == 0; + assert keychain_label_n(PROVIDER_COUNT, 0, a[0..48]) == 0; +} + +test a_status_code_must_stand_alone_to_count { + assert has_code("API Error: 429 slow down", "429"); + assert has_code("error 429", "429"); + assert has_code("429 error", "429"); + assert has_code("error 429x", "429"); + assert has_code("error 1429", "429") == false; + assert has_code("error 4290", "429") == false; + assert has_code("error 14290 and 429", "429"); + assert has_code("error", "429") == false; + assert has_code("", "429") == false; + assert has_bytes("abc", "bc"); + assert has_bytes("abc", "abcd") == false; + assert has_bytes("abc", "") == false; +} + +test a_screen_line_is_sorted_into_a_failure_class { + assert classify_line("API Error: 429 {\"error\":{\"message\":\"Insufficient balance\"}}") == FC_NO_BALANCE; + assert classify_line("Insufficient balance") == FC_NO_BALANCE; + assert classify_line("API Error: 429 rate limit reached, retry later") == FC_RATE_LIMITED; + assert classify_line("Your organization has disabled Claude subscription access") == FC_SUBSCRIPTION; + assert classify_line("API Error: 401 invalid credentials") == FC_LOGIN_REJECTED; + assert classify_line("Request failed with error 403 forbidden") == FC_LOGIN_REJECTED; + assert classify_line("API Error: 500 upstream overloaded") == FC_OTHER; + assert classify_line("Edited 429 lines in 3 files") == FC_NONE; + assert classify_line("error: read 14290 tokens") == FC_NONE; + assert classify_line("Reading specs/t27os/keys.t27") == FC_NONE; + assert classify_line("") == FC_NONE; +} + +test a_failure_marks_the_provider_bad_for_this_pane { + assert fo_bad(FC_NO_BALANCE, PR_ZAI, 0) == 16; + assert fo_bad(FC_LOGIN_REJECTED, PR_ANTHROPIC, 16) == 17; + assert fo_bad(FC_LOGIN_REJECTED, PR_ANTHROPIC, 17) == 17; + assert fo_bad(FC_RATE_LIMITED, PR_OPENROUTER, 0) == 8; + assert fo_bad(FC_RATE_LIMITED, PR_OLLAMA_CLOUD, 0) == 32; + assert fo_bad(FC_NONE, PR_ZAI, 0) == 0; + assert fo_bad(FC_OTHER, PR_ZAI, 0) == 0; + assert fo_bad(FC_NO_BALANCE, PR_NONE, 5) == 5; +} + +test no_error_or_an_unknown_one_leaves_the_pane_alone { + assert fo_action(FC_NONE, PR_ZAI, 57, 0, 0, false) == FA_KEEP; + assert fo_action(FC_OTHER, PR_ZAI, 57, 0, 0, true) == FA_KEEP; + assert fo_target(FC_NONE, PR_ZAI, 57, 0, 0, false) == PR_NONE; +} + +test a_dead_profile_hands_the_pane_to_the_next_one { + assert fo_action(FC_NO_BALANCE, PR_ZAI, 57, 0, 0, false) == FA_RESPAWN_NEXT; + assert fo_target(FC_NO_BALANCE, PR_ZAI, 57, 0, 0, false) == PR_ANTHROPIC; + assert fo_target(FC_LOGIN_REJECTED, PR_ANTHROPIC, 57, 16, 1, false) == PR_OPENROUTER; + assert fo_target(FC_RATE_LIMITED, PR_OPENROUTER, 57, 17, 2, false) == PR_OLLAMA_CLOUD; + assert fo_target(FC_NO_BALANCE, PR_ZAI, 41, 0, 0, false) == PR_ANTHROPIC; + assert fo_target(FC_NO_BALANCE, PR_ZAI, 40, 0, 0, false) == PR_OPENROUTER; +} + +test a_throttled_key_with_a_spare_swaps_the_key_and_keeps_the_profile { + assert fo_action(FC_RATE_LIMITED, PR_ZAI, 57, 0, 0, true) == FA_RESPAWN_KEY; + assert fo_target(FC_RATE_LIMITED, PR_ZAI, 57, 0, 0, true) == PR_ZAI; + assert fo_bad(FC_RATE_LIMITED, PR_ZAI, 0) == 16; + assert fo_action(FC_NO_BALANCE, PR_ZAI, 57, 0, 0, true) == FA_RESPAWN_NEXT; + assert fo_action(FC_LOGIN_REJECTED, PR_ZAI, 57, 0, 0, true) == FA_RESPAWN_NEXT; + assert fo_action(FC_RATE_LIMITED, PR_NONE, 57, 0, 0, true) == FA_RESPAWN_NEXT; +} + +test a_pane_without_a_key_takes_the_first_stored_profile { + assert fo_action(FC_SUBSCRIPTION, PR_NONE, 16, 0, 0, false) == FA_RESPAWN_NEXT; + assert fo_target(FC_SUBSCRIPTION, PR_NONE, 16, 0, 0, false) == PR_ZAI; + assert fo_target(FC_SUBSCRIPTION, PR_NONE, 8, 0, 0, false) == PR_OPENROUTER; + assert fo_action(FC_SUBSCRIPTION, PR_NONE, 0, 0, 0, false) == FA_GIVE_UP; + assert fo_action(FC_SUBSCRIPTION, PR_NONE, 16, 16, 0, false) == FA_GIVE_UP; + assert fo_action(FC_SUBSCRIPTION, PR_NONE, 6, 0, 0, false) == FA_GIVE_UP; +} + +test the_last_profile_failing_gives_up { + assert fo_action(FC_NO_BALANCE, PR_OLLAMA_CLOUD, 57, 57, 3, false) == FA_GIVE_UP; + assert fo_target(FC_NO_BALANCE, PR_OLLAMA_CLOUD, 57, 57, 3, false) == PR_NONE; + assert fo_action(FC_LOGIN_REJECTED, PR_ANTHROPIC, 17, 16, 1, false) == FA_GIVE_UP; + assert fo_action(FC_RATE_LIMITED, PR_OLLAMA_CLOUD, 57, 0, 3, false) == FA_GIVE_UP; + assert fo_action(FC_NO_BALANCE, 7, 57, 0, 0, false) == FA_GIVE_UP; +} + +test a_pane_that_fails_on_every_profile_ends_in_give_up_after_each_was_tried_once { + var pr : u32 = PR_NONE; + var bad : u32 = 0; + var done : u32 = 0; + var tried : u32 = 0; + var act : u32 = FA_KEEP; + var guard : u32 = 0; + while (guard < 30) { + act = fo_action(FC_NO_BALANCE, pr, 57, bad, done, false); + if (act != FA_RESPAWN_NEXT) { guard = 30; } + else { + bad = fo_bad(FC_NO_BALANCE, pr, bad); + pr = fo_target(FC_NO_BALANCE, pr, 57, bad, done, false); + assert (tried >> pr) % 2 == 0; + tried = tried + (1 << pr); + done = done + 1; + guard = guard + 1; + } + } + assert act == FA_GIVE_UP; + assert done == PROFILE_COUNT; + assert tried == 15; +} + +test a_host_that_always_reports_a_spare_key_still_cannot_loop { + var done : u32 = 0; + while (done < 40 and fo_action(FC_RATE_LIMITED, PR_ZAI, 57, 0, done, true) == FA_RESPAWN_KEY) { + done = done + 1; + } + assert done == MAX_FAILOVERS; + assert fo_action(FC_RATE_LIMITED, PR_ZAI, 57, 0, done, true) == FA_GIVE_UP; + assert fo_action(FC_NO_BALANCE, PR_ZAI, 57, 0, MAX_FAILOVERS, false) == FA_GIVE_UP; + assert MAX_FAILOVERS >= PROFILE_COUNT; +} + +test the_failover_why_line_names_the_profile_and_the_move_and_never_a_key { + var o : [128]u8 = undefined; + var n : usize = fo_why(FC_NONE, PR_ZAI, FA_KEEP, PR_NONE, 0, o[0..128]); + assert has_bytes(o[0..n], "no provider error"); + n = fo_why(FC_NO_BALANCE, PR_ZAI, FA_RESPAWN_NEXT, PR_ANTHROPIC, 0, o[0..128]); + assert has_bytes(o[0..n], "claude: zai out of balance, respawn on anthropic-api"); + n = fo_why(FC_RATE_LIMITED, PR_OPENROUTER, FA_RESPAWN_KEY, PR_OPENROUTER, 2, o[0..128]); + assert has_bytes(o[0..n], "openrouter rate limited (429), same profile with the next key"); + n = fo_why(FC_SUBSCRIPTION, PR_NONE, FA_RESPAWN_NEXT, PR_ZAI, 0, o[0..128]); + assert has_bytes(o[0..n], "no profile subscription disabled, respawn on zai"); + n = fo_why(FC_LOGIN_REJECTED, PR_OLLAMA_CLOUD, FA_GIVE_UP, PR_NONE, 3, o[0..128]); + assert has_bytes(o[0..n], "ollama-cloud login rejected (401/403), no profile left in the chain"); + assert n < 128; + n = fo_why(FC_RATE_LIMITED, PR_ZAI, FA_GIVE_UP, PR_NONE, MAX_FAILOVERS, o[0..128]); + assert has_bytes(o[0..n], "failover limit reached"); +} + +test the_profile_tables_have_one_field_per_profile { + assert csv_fields(PROFILE_NAMES) == PROFILE_COUNT; + assert csv_fields(MODELS_DEFAULT) == PROFILE_COUNT; + assert csv_fields(MODELS_HAIKU) == PROFILE_COUNT; + assert csv_fields(MODELS_SONNET) == PROFILE_COUNT; + assert csv_fields(MODELS_OPUS) == PROFILE_COUNT; + assert csv_fields(PROFILE_BASE_URLS) == PROFILE_COUNT; + assert csv_fields(PROFILE_KEY_VARS) == PROFILE_COUNT; + assert csv_fields(TIER_VARS) == TIER_COUNT; + assert PROFILE_PROVIDER.len == PROFILE_COUNT; + assert PROFILE_EMPTY_API_KEY.len == PROFILE_COUNT; + assert PS_TIER0 + TIER_COUNT == PS_EMPTY_KEY; + assert PS_EMPTY_KEY + 1 == PS_COUNT; +} + +test no_profile_sets_a_variable_a_claude_pane_would_inherit { + var o : [64]u8 = undefined; + var pr : u32 = 0; + while (pr < PROFILE_COUNT) { + var k : u32 = 0; + while (k < profile_var_count(pr)) { + const n : usize = profile_var_name(pr, k, o[0..64]); + assert n > 0; + assert claude_strips(o[0..n]); + k = k + 1; + } + pr = pr + 1; + } +} + +test the_anthropic_profile_sets_one_key_variable { + var o : [80]u8 = undefined; + assert profile_var_count(PR_ANTHROPIC) == 1; + assert profile_var_name(PR_ANTHROPIC, 0, o[0..80]) == 17; + assert profile_var_is_key(PR_ANTHROPIC, 0); + assert profile_var_text(PR_ANTHROPIC, 0, o[0..80]) == 0; + assert profile_var_count(PR_NONE) == 0; +} + +test a_claude_pane_starts_without_the_endpoint_variables_of_its_parent { + assert claude_strips("ANTHROPIC_API_KEY"); + assert claude_strips("ANTHROPIC_AUTH_TOKEN"); + assert claude_strips("ANTHROPIC_BASE_URL"); + assert claude_strips("ANTHROPIC_MODEL"); + assert claude_strips("ANTHROPIC_DEFAULT_OPUS_MODEL"); + assert claude_strips("ANTHROPIC_CUSTOM_HEADERS"); + assert claude_strips("ANTHROPIC_API_KEYS") == false; + assert claude_strips("ANTHROPIC") == false; + assert claude_strips("OPENAI_API_KEY") == false; + assert claude_strips("PATH") == false; + assert profile_agent(0); + assert profile_agent(1) == false; + assert profile_agent(4) == false; +} + +test the_why_line_names_the_profile_and_never_a_key { + var o : [96]u8 = undefined; + assert profile_why(0, 0, o[0..96]) > 30; + assert o[8] == 110; + assert profile_why(17, 0, o[0..96]) > 30; + assert o[8] == 122; + assert profile_why(17, 16, o[0..96]) > 30; + assert o[8] == 97; + assert profile_why(1, 0, o[0..96]) > 30; + assert o[8] == 97; + assert profile_why(16, 16, o[0..96]) > 30; + assert o[8] == 101; + assert profile_why(8, 0, o[0..96]) > 30; + assert o[8] == 111 and o[9] == 112; + assert profile_why(32, 0, o[0..96]) > 30; + assert o[8] == 111 and o[9] == 108; +} + +test the_zai_check_is_one_message_to_the_flash_model { + var b : [160]u8 = undefined; + assert probe_post(P_ZAI); + assert probe_post(P_ANTHROPIC) == false; + assert probe_needs_version(P_ZAI) and probe_needs_version(P_ANTHROPIC); + assert probe_needs_version(P_OPENAI) == false; + const n : usize = probe_body(P_ZAI, b[0..160]); + assert n > 40; + assert b[0] == 123 and b[n - 1] == 125; + assert probe_body(P_OPENAI, b[0..160]) == 0; +} diff --git a/specs/t27os/keys_host.t27 b/specs/t27os/keys_host.t27 new file mode 100644 index 0000000000..d7923ee5fc --- /dev/null +++ b/specs/t27os/keys_host.t27 @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS keys_host: where the key manager (keys.t27) touches the machine. Three jobs, no decisions: +// - the macOS Keychain: kc_put / kc_get / kc_del, one generic-password item per provider under the +// service "ai.t27.os" (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete). A key is never +// written to a file and never reaches the repo. +// - the check button: probe() sends the cheapest authenticated request of a provider (a GET; for z.ai one one-token POST) (table: keys.t27) and +// hands back the HTTP status; keys.t27 status_after_probe decides what it means. The key travels in a +// request header, never in the URL. +// - push_keys(): hand every stored key to the PTY server (OP_SETKEY) so a pane it spawns is logged in. +// +// The kSec* constants are global CFStringRef variables, not functions, so an @extern call cannot read them: +// dlsym finds the variable's address and sys_peek copies the pointer out. Specs holding @extern cannot run +// tests under t27c; every rule these fns follow is a tested fn in keys.t27. +// +// Refs gHashTag/t27#10782. +module T27osKeysHost; + +use t27os::keys::PROVIDER_COUNT; +use t27os::keys::KEYCHAIN_SERVICE; +use t27os::keys::PROBE_TIMEOUT_S; +use t27os::keys::provider_name; +use t27os::keys::probe_url; +use t27os::keys::probe_has; +use t27os::keys::probe_header_name; +use t27os::keys::probe_header_value; +use t27os::keys::probe_post; +use t27os::keys::probe_body; +use t27os::keys::probe_needs_version; +use t27os::pty_proto::get_u64; +use t27os::pty_proto::MAX_FRAME; +use t27os::pty_sys::sys_peek; +use t27os::pty_client::client_set_key; +use t27os::session_store::put_bytes; + +pub const UTF8_ENC : u32 = 134217984; +pub const SEC_DUPLICATE : i32 = -25299; +pub const RTLD_LAZY : i32 = 1; + +fn dl_open(path: u64, mode: i32) -> u64 { return @extern("dlopen"); } +fn dl_sym(h: u64, name: []const u8) -> u64 { return @extern("dlsym"); } +fn addr_of(dst: []u8, src: []const u8, n: u64) -> u64 { return @extern("memmove"); } +fn cf_str(alloc: u64, bytes: []const u8, n: usize, enc: u32, ext: bool) -> u64 { return @extern("CFStringCreateWithBytes"); } +fn cf_data(alloc: u64, bytes: []const u8, n: u64) -> u64 { return @extern("CFDataCreate"); } +fn cf_data_len(d: u64) -> u64 { return @extern("CFDataGetLength"); } +fn cf_data_ptr(d: u64) -> u64 { return @extern("CFDataGetBytePtr"); } +fn cf_dict_new(alloc: u64, cap: u64, kcb: u64, vcb: u64) -> u64 { return @extern("CFDictionaryCreateMutable"); } +fn cf_dict_set(d: u64, k: u64, v: u64) -> void { @extern("CFDictionarySetValue"); } +fn cf_release(o: u64) -> void { @extern("CFRelease"); } +fn sec_add(attrs: u64, result: u64) -> i32 { return @extern("SecItemAdd"); } +fn sec_copy(query: u64, result: []u8) -> i32 { return @extern("SecItemCopyMatching"); } +fn sec_update(query: u64, attrs: u64) -> i32 { return @extern("SecItemUpdate"); } +fn sec_delete(query: u64) -> i32 { return @extern("SecItemDelete"); } +fn class_named(name: u64) -> u64 { return @extern("NSClassFromString"); } +fn sel_named(name: u64) -> u64 { return @extern("NSSelectorFromString"); } +fn msg0(o: u64, s: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg1(o: u64, s: u64, a: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg2(o: u64, s: u64, a: u64, b: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg3(o: u64, s: u64, a: u64, b: u64, c: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg_f(o: u64, s: u64, t: f64) -> u64 { return @extern("objc_msgSend"); } + +fn cf(s: []const u8) -> u64 { + return cf_str(0, s, s.len, UTF8_ENC, false); +} + +fn cls(s: []const u8) -> u64 { + const n : u64 = cf(s); + const c : u64 = class_named(n); + cf_release(n); + return c; +} + +fn sel(s: []const u8) -> u64 { + const n : u64 = cf(s); + const c : u64 = sel_named(n); + cf_release(n); + return c; +} + +/// The value of a global CFStringRef (or CFBooleanRef) variable of the loaded frameworks. +fn global_ref(name: []const u8) -> u64 { + var z : [40]u8 = undefined; + const n : usize = put_bytes(z[0..40], 0, name); + z[n] = 0; + const h : u64 = dl_open(0, RTLD_LAZY); + const at : u64 = dl_sym(h, z[0..40]); + if (at == 0) { return 0; } + var b : [8]u8 = undefined; + sys_peek(b[0..8], at, 8); + return get_u64(b[0..8], 0); +} + +/// The address of a global struct (the dictionary callbacks): dlsym's answer itself. +fn global_addr(name: []const u8) -> u64 { + var z : [40]u8 = undefined; + const n : usize = put_bytes(z[0..40], 0, name); + z[n] = 0; + return dl_sym(dl_open(0, RTLD_LAZY), z[0..40]); +} + +fn new_dict() -> u64 { + return cf_dict_new(0, 0, global_addr("kCFTypeDictionaryKeyCallBacks"), global_addr("kCFTypeDictionaryValueCallBacks")); +} + +fn set_text(d: u64, key_name: []const u8, text: []const u8) -> void { + const s : u64 = cf(text); + cf_dict_set(d, global_ref(key_name), s); + cf_release(s); +} + +/// The query that names provider p's item: generic password, service "ai.t27.os", account = provider name. +fn item_query(p: u32) -> u64 { + const d : u64 = new_dict(); + cf_dict_set(d, global_ref("kSecClass"), global_ref("kSecClassGenericPassword")); + set_text(d, "kSecAttrService", KEYCHAIN_SERVICE); + var nm : [24]u8 = undefined; + const n : usize = provider_name(p, nm[0..24]); + set_text(d, "kSecAttrAccount", nm[0..n]); + return d; +} + +fn set_data(d: u64, key: []const u8) -> void { + const data : u64 = cf_data(0, key, @as(u64, key.len)); + cf_dict_set(d, global_ref("kSecValueData"), data); + cf_release(data); +} + +/// Store provider p's key (replacing an earlier one). True when the Keychain accepted it. +pub fn kc_put(p: u32, key: []const u8) -> bool { + if (p >= PROVIDER_COUNT) { return false; } + if (key.len == 0) { return false; } + const a : u64 = item_query(p); + set_data(a, key); + var st : i32 = sec_add(a, 0); + cf_release(a); + if (st == SEC_DUPLICATE) { + const q : u64 = item_query(p); + const u : u64 = new_dict(); + set_data(u, key); + st = sec_update(q, u); + cf_release(q); + cf_release(u); + } + return st == 0; +} + +/// Read provider p's key into out; its length, 0 when none is stored. +pub fn kc_get(p: u32, out: []u8) -> usize { + if (p >= PROVIDER_COUNT) { return 0; } + const q : u64 = item_query(p); + cf_dict_set(q, global_ref("kSecReturnData"), global_ref("kCFBooleanTrue")); + cf_dict_set(q, global_ref("kSecMatchLimit"), global_ref("kSecMatchLimitOne")); + var res : [8]u8 = undefined; + const st : i32 = sec_copy(q, res[0..8]); + cf_release(q); + if (st != 0) { return 0; } + const d : u64 = get_u64(res[0..8], 0); + if (d == 0) { return 0; } + var n : u64 = cf_data_len(d); + if (n > @as(u64, out.len)) { n = @as(u64, out.len); } + sys_peek(out, cf_data_ptr(d), n); + cf_release(d); + return @as(usize, n); +} + +/// Delete provider p's item. True when it is gone (also when none was there). +pub fn kc_del(p: u32) -> bool { + if (p >= PROVIDER_COUNT) { return false; } + const q : u64 = item_query(p); + const st : i32 = sec_delete(q); + cf_release(q); + return st == 0 or st == -25300; +} + +/// True when the Keychain holds a key for provider p (reads it, then drops the copy). +pub fn kc_has(p: u32) -> bool { + var b : [256]u8 = undefined; + return kc_get(p, b[0..256]) > 0; +} + +fn add_header(req: u64, name: []const u8, value: []const u8) -> void { + const n : u64 = cf(name); + const v : u64 = cf(value); + msg2(req, sel("setValue:forHTTPHeaderField:"), v, n); + cf_release(n); + cf_release(v); +} + +/// The check button: one authenticated GET for provider p with `key`; the HTTP status, 0 when nothing +/// answered (no network, timeout) and 1 when the provider has no probe. Blocks up to PROBE_TIMEOUT_S. +pub fn probe(p: u32, key: []const u8) -> u32 { + if (probe_has(p) == false) { return 1; } + var ub : [160]u8 = undefined; + const un : usize = probe_url(p, ub[0..160]); + const us : u64 = cf(ub[0..un]); + const url : u64 = msg1(cls("NSURL"), sel("URLWithString:"), us); + cf_release(us); + if (url == 0) { return 0; } + const req : u64 = msg1(cls("NSMutableURLRequest"), sel("requestWithURL:"), url); + if (req == 0) { return 0; } + msg_f(req, sel("setTimeoutInterval:"), @as(f64, @floatFromInt(PROBE_TIMEOUT_S))); + var hn : [24]u8 = undefined; + var hv : [300]u8 = undefined; + const nl : usize = probe_header_name(p, hn[0..24]); + const vl : usize = probe_header_value(p, key, hv[0..300]); + add_header(req, hn[0..nl], hv[0..vl]); + if (probe_needs_version(p)) { add_header(req, "anthropic-version", "2023-06-01"); } + if (probe_post(p)) { + var bb : [200]u8 = undefined; + const bl : usize = probe_body(p, bb[0..200]); + const data : u64 = cf_data(0, bb[0..bl], @as(u64, bl)); + const post : u64 = cf("POST"); + msg1(req, sel("setHTTPMethod:"), post); + msg1(req, sel("setHTTPBody:"), data); + add_header(req, "content-type", "application/json"); + cf_release(post); + cf_release(data); + } + add_header(req, "User-Agent", "t27os"); + var resp : [8]u8 = undefined; + var err : [8]u8 = undefined; + var i : usize = 0; + while (i < 8) { + resp[i] = 0; + err[i] = 0; + i = i + 1; + } + msg3(cls("NSURLConnection"), sel("sendSynchronousRequest:returningResponse:error:"), req, addr_of(resp[0..8], resp[0..8], 0), addr_of(err[0..8], err[0..8], 0)); + const r : u64 = get_u64(resp[0..8], 0); + if (r == 0) { return 0; } + return @as(u32, @intCast(msg0(r, sel("statusCode")))); +} + +/// Hand every stored key to the PTY server on `fd`; the count pushed. Called on connect and before a spawn, +/// because a server that restarted has forgotten them. +pub fn push_keys(fd: i32) -> u32 { + var key : [256]u8 = undefined; + var reply : [MAX_FRAME]u8 = undefined; + var pushed : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + const n : usize = kc_get(p, key[0..256]); + if (n > 0) { + if (client_set_key(fd, p, key[0..n], reply[0..MAX_FRAME]) > 0) { pushed = pushed + 1; } + } + p = p + 1; + } + return pushed; +} + +test the_host_module_is_declared { + var b : [4]u8 = undefined; + b[0] = 1; + assert b[0] == 1; +} diff --git a/specs/t27os/pty_client.t27 b/specs/t27os/pty_client.t27 index 2acfb1d48b..b84bf28dd8 100644 --- a/specs/t27os/pty_client.t27 +++ b/specs/t27os/pty_client.t27 @@ -9,6 +9,7 @@ // Refs gHashTag/t27#10782. module T27osPtyClient; +use t27os::editors::editor_spawn_payload; use t27os::pty_proto::OP_LIST; use t27os::pty_proto::OP_SPAWN; use t27os::pty_proto::OP_ATTACH; @@ -16,6 +17,7 @@ use t27os::pty_proto::OP_DETACH; use t27os::pty_proto::OP_KEYS; use t27os::pty_proto::OP_READ; use t27os::pty_proto::OP_KILL; +use t27os::pty_proto::OP_SETKEY; use t27os::pty_proto::OP_RESIZE; use t27os::pty_proto::put_u16; use t27os::pty_proto::HEADER; @@ -113,6 +115,15 @@ pub fn client_spawn(fd: i32, agent: u32, space: u32, slot: u32, cwd: []const u8, return client_call(fd, OP_SPAWN, 0, p[0 .. 3 + i], out); } +/// SPAWN terminal editor `pick` (editors.t27 TERM_NAMES) on `path`, a folder or a file, for a space/slot: an +/// editor pane (T27 OS opens inside). Same reply as client_spawn; 0 bytes when pick or path is not fit. +pub fn client_spawn_editor(fd: i32, space: u32, slot: u32, pick: u32, path: []const u8, out: []u8) -> usize { + var p : [300]u8 = undefined; + const n : usize = editor_spawn_payload(space, slot, pick, path, p[0..300]); + if (n == 0) { return 0; } + return client_call(fd, OP_SPAWN, 0, p[0 .. n], out); +} + /// ATTACH marks the pane seen; the reply's payload is the running byte count (the cursor to read from). pub fn client_attach(fd: i32, pane: u32, out: []u8) -> usize { return client_call(fd, OP_ATTACH, @as(u8, @intCast(pane)), "", out); @@ -127,6 +138,11 @@ pub fn client_keys(fd: i32, pane: u32, keys: []const u8, out: []u8) -> usize { return client_call(fd, OP_KEYS, @as(u8, @intCast(pane)), keys, out); } +/// SETKEY: hand the server provider p's key (empty = forget it); it lives in the server's memory only. +pub fn client_set_key(fd: i32, provider: u32, key: []const u8, out: []u8) -> usize { + return client_call(fd, OP_SETKEY, @as(u8, @intCast(provider)), key, out); +} + /// READ from `cursor`: the reply payload is the new cursor (8 bytes) then the output bytes. pub fn client_read(fd: i32, pane: u32, cursor: u64, out: []u8) -> usize { var c : [8]u8 = undefined; diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 index 125a7eb765..c3523485e7 100644 --- a/specs/t27os/pty_logic.t27 +++ b/specs/t27os/pty_logic.t27 @@ -42,6 +42,8 @@ use t27os::session_store::F_SID_LEN; use t27os::session_store::F_CWD_AT; use t27os::session_store::F_CWD_LEN; use t27os::session_store::F_COUNT; +use t27os::session_store::F_MODEL_AT; +use t27os::session_store::F_MODEL_LEN; use t27os::session_store::put_bytes; use t27os::session_store::put_dec; use t27os::session_store::put_record; @@ -86,6 +88,14 @@ use t27os::pty_proto::ring_copy; use t27os::pty_proto::put_list_entry; use t27os::pty_proto::spawn_ok; use t27os::pty_proto::socket_path; +use t27os::editors::TERM_COUNT; +use t27os::editors::TERM_NONE; +use t27os::editors::TERM_PATH_MAX; +use t27os::editors::editor_spawn_payload; +use t27os::editors::term_index; +use t27os::editors::term_path_fits; +use t27os::editors::editor_command; +use t27os::editors::same_bytes; // ---- the server's memory: one byte arena and one u64 table ---- @@ -135,7 +145,9 @@ pub const O_BIN : usize = 92400; pub const MODEL_CAP : usize = 64; pub const O_MODEL : usize = 97280; pub const O_RING : usize = 98304; -pub const MEM_SIZE : usize = 1146880; +pub const O_KEYS : usize = 1146880; +pub const KEY_CAP : usize = 256; +pub const MEM_SIZE : usize = 1149184; pub const TICK_MS : i32 = 500; pub const SIGHUP_RING : u64 = 65536; @@ -236,6 +248,30 @@ pub fn pane_command(agent: u32, plan: u32, sid: []const u8, model: []const u8, h return n; } +/// An editor pane (owner, 2026-10-20: "T27OS opens inside"): a SPAWN of the shell cell whose model field +/// names a terminal editor (editors.t27 TERM_NAMES). The pane's cwd is the directory or file to open. The +/// wire format is the one above, unchanged: [SHELL_CELL][space][slot][path] TAB [editor]. +pub fn wants_editor(agent: u32, model: []const u8) -> bool { + return agent == SHELL_CELL and term_index(model) != TERM_NONE; +} + +/// The server refuses an editor SPAWN whose path would break the quoting around it in `sh -c`; every other +/// SPAWN passes as before. +pub fn editor_spawn_ok(agent: u32, model: []const u8, cwd: []const u8) -> bool { + if (wants_editor(agent, model) == false) { return true; } + return term_path_fits(cwd); +} + +/// The command a pane starts with: an editor pane runs ` ''` (also when it is restored from +/// its record, which keeps the editor in the model field and the path in the cwd), every other pane runs +/// pane_command as before. +pub fn launch_command(agent: u32, plan: u32, sid: []const u8, model: []const u8, home: []const u8, cwd: []const u8, out: []u8) -> usize { + if (wants_editor(agent, model) and term_path_fits(cwd)) { + return editor_command(term_index(model), cwd, out); + } + return pane_command(agent, plan, sid, model, home, out); +} + /// The argv strings of `/bin/sh -c "exec "` in one buffer, NUL separated; offsets go to f: /// f[0] the -c at, f[1] the command at. Returns the buffer length. pub fn argv_text(cmd: []const u8, out: []u8, f: []u64) -> usize { @@ -527,3 +563,83 @@ test a_hooked_working_pane_that_went_silent_is_finished { assert !hook_stale(pane_step(0, P_SPAWNED), 99999, 0); assert !hook_stale(pane_step(w, P_HOOK_STOP), 99999, 0); } + +test a_shell_spawn_that_names_a_terminal_editor_runs_it_on_the_path { + var out : [400]u8 = undefined; + var n : usize = launch_command(SHELL_CELL, R_RELAUNCH, "-", "vim", "/Users/x", "/Users/x/work", out[0..400]); + assert same_bytes(out[0..n], "vim '/Users/x/work'"); + // restoring the pane from its record (R_RELAUNCH too) reopens the same file + n = launch_command(SHELL_CELL, R_RELAUNCH, "-", "nano", "/Users/x", "/Users/x/my notes/todo.txt", out[0..400]); + assert same_bytes(out[0..n], "nano '/Users/x/my notes/todo.txt'"); + // the pane is started through sh as `exec `: the quoted path stays one word + var buf : [500]u8 = undefined; + var f : [2]u64 = undefined; + const tn : usize = argv_text(out[0..n], buf[0..500], f[0..2]); + assert tn == 7 + 1 + 2 + 1 + 5 + n + 1; + assert buf[@as(usize, @intCast(f[1]))] == 101; +} + +test every_other_pane_runs_the_command_it_always_did { + var out : [400]u8 = undefined; + // a plain shell, with a model that is not an editor name or none at all + var n : usize = launch_command(SHELL_CELL, R_RELAUNCH, "-", "opus", "/Users/x", "/Users/x/work", out[0..400]); + assert same_bytes(out[0..n], "/bin/bash"); + n = launch_command(SHELL_CELL, R_RELAUNCH, "-", "", "/Users/x", "/Users/x/work", out[0..400]); + assert same_bytes(out[0..n], "/bin/bash"); + // an agent cell never becomes an editor, whatever its model field says + n = launch_command(4, R_RELAUNCH, "-", "vim", "/Users/x", "/Users/x/work", out[0..400]); + assert same_bytes(out[0..n], "queen"); + n = launch_command(1, R_RELAUNCH, "-", "vim", "/Users/x", "/Users/x/work", out[0..400]); + assert same_bytes(out[0..n], "codex --model 'vim'"); + // an editor request with a quote in the path falls back to the shell; the server refuses the SPAWN first + n = launch_command(SHELL_CELL, R_RELAUNCH, "-", "vim", "/Users/x", "/Users/x/it's", out[0..400]); + assert same_bytes(out[0..n], "/bin/bash"); + assert wants_editor(SHELL_CELL, "vim"); + assert wants_editor(SHELL_CELL, "emacs") == false; + assert wants_editor(SHELL_CELL, "") == false; + assert wants_editor(0, "vim") == false; + assert editor_spawn_ok(SHELL_CELL, "vim", "/Users/x/work"); + assert editor_spawn_ok(SHELL_CELL, "vim", "/Users/x/it's") == false; + assert editor_spawn_ok(SHELL_CELL, "", "/Users/x/it's"); + assert editor_spawn_ok(0, "vim", "/Users/x/it's"); +} + +test the_editor_spawn_payload_is_the_old_wire_format { + var p : [400]u8 = undefined; + const n : usize = editor_spawn_payload(2, 5, 1, "/Users/x/my work", p[0..400]); + assert n == 3 + 16 + 1 + 4; + assert p[0] == 9 and p[1] == 2 and p[2] == 5; + // the server parses it with the same two functions as every SPAWN + assert spawn_ok(p[0..n]); + const cl : usize = spawn_cwd_len(p[0..n]); + assert cl == 16; + assert same_bytes(p[3 .. 3 + cl], "/Users/x/my work"); + assert p[3 + cl] == 9; + assert same_bytes(p[4 + cl .. n], "nvim"); + // and what it carries is an editor request that the server accepts + assert editor_spawn_ok(p[0], p[4 + cl .. n], p[3 .. 3 + cl]); + assert wants_editor(p[0], p[4 + cl .. n]); + // the model field passes the same check as any other model, so the record keeps it + assert model_ok(p[4 + cl .. n]); + // refused: no such editor, a quote, a relative path + assert editor_spawn_payload(0, 0, TERM_COUNT, "/Users/x", p[0..400]) == 0; + assert editor_spawn_payload(0, 0, TERM_NONE, "/Users/x", p[0..400]) == 0; + assert editor_spawn_payload(0, 0, 0, "/Users/x/it's", p[0..400]) == 0; + assert editor_spawn_payload(0, 0, 0, "x", p[0..400]) == 0; +} + +test an_editor_pane_survives_a_restart_in_its_record { + var rec : [400]u8 = undefined; + const n : usize = put_record(rec[0..400], 1, SHELL_CELL, 3, 0, "", "vim", "/Users/x/my work"); + var f : [10]u64 = undefined; + assert parse_record(rec[0..n], f[0..10]); + assert f[F_AGENT] == SHELL_CELL; + assert f[F_MODEL_LEN] == 3; + assert f[F_CWD_LEN] == 16; + const at : usize = @as(usize, @intCast(f[F_MODEL_AT])); + assert same_bytes(rec[at .. at + 3], "vim"); + // a shell pane is relaunched, not skipped, so the editor comes back on the same path + assert restore_plan(SHELL_CELL, 0, "") == R_RELAUNCH; + // the path limit is the pane cwd's + assert TERM_PATH_MAX == CWD_CAP - 1; +} diff --git a/specs/t27os/pty_proto.t27 b/specs/t27os/pty_proto.t27 index e8c8a53c1b..a87a2c57b7 100644 --- a/specs/t27os/pty_proto.t27 +++ b/specs/t27os/pty_proto.t27 @@ -29,6 +29,9 @@ pub const OP_RESIZE : u8 = 6; pub const OP_READ : u8 = 7; pub const OP_KILL : u8 = 8; pub const OP_HOOK : u8 = 9; +/// SETKEY: the pane byte is the provider (keys.t27), the payload the key; the server keeps it in memory +/// only and gives it to the panes it spawns afterwards. An empty payload forgets the key. +pub const OP_SETKEY : u8 = 10; pub const RE_OK : u8 = 128; pub const RE_ERR : u8 = 129; pub const RE_DATA : u8 = 130; @@ -107,7 +110,7 @@ pub fn frame_complete(buf: []const u8) -> bool { return need > 1 and buf.len >= need; } -pub fn is_request(op: u8) -> bool { return op >= OP_LIST and op <= OP_HOOK; } +pub fn is_request(op: u8) -> bool { return op >= OP_LIST and op <= OP_SETKEY; } pub fn is_reply(op: u8) -> bool { return op >= RE_OK and op <= RE_LISTING; } /// The running count of the oldest byte still in the ring and how many bytes a reader at `cursor` gets, @@ -266,6 +269,11 @@ test a_frame_round_trips_through_a_partial_read { test an_oversized_frame_is_refused_at_both_ends { var buf : [MAX_FRAME + 8]u8 = undefined; var big : [4200]u8 = undefined; + var z : usize = 0; + while (z < 4200) { + big[z] = 0; + z = z + 1; + } assert put_frame(buf[0..MAX_FRAME], OP_KEYS, 0, big[0..4097]) == 0; assert put_frame(buf[0..MAX_FRAME], OP_KEYS, 0, big[0..4096]) == 4100; var h : usize = put_header(buf[0..MAX_FRAME], OP_KEYS, 0, 5000); @@ -284,7 +292,7 @@ test ops_are_split_into_requests_and_replies { assert !(is_request(op) and is_reply(op)); op = op + 1; } - assert requests == 9; + assert requests == 10; assert replies == 4; } diff --git a/specs/t27os/pty_server.t27 b/specs/t27os/pty_server.t27 index d2d64bd50e..d8416e7978 100644 --- a/specs/t27os/pty_server.t27 +++ b/specs/t27os/pty_server.t27 @@ -10,8 +10,11 @@ // (none) serve // T27OS_HOOK=1 a Claude Code hook: read the payload on stdin, forward it as OP_HOOK to pane // T27OS_PANE, exit. The server writes ~/.t27os/hooks.json so claude panes run it. -// T27OS_CTL= a tiny client for scripts and the live test: list, spawn, kill, read, keys +// T27OS_CTL= a tiny client for scripts and the live test: list, spawn, kill, read, write (keys) // (T27OS_AGENT, T27OS_CWD, T27OS_MODEL, T27OS_PANE, T27OS_KEYS carry the arguments). +// An editor pane (T27 OS opens inside, 2026-10-20) is spawn with T27OS_AGENT=9 (the +// shell cell) and T27OS_MODEL=vim|nvim|hx|micro|nano: the pane runs that editor on +// T27OS_CWD, a folder or a file, instead of bash (pty_logic launch_command). // T27OS_INSTALL=1 write ~/Library/LaunchAgents/ai.t27.os-server.plist for this binary: it starts // the server at login, so panes come back (by session id, model kept) after a reboot. // @@ -137,6 +140,25 @@ use t27os::pty_sys::sys_signal; use t27os::pty_sys::sys_gettimeofday; use t27os::pty_sys::sys_random; use t27os::pty_sys::sys_exe_path; +use t27os::pty_sys::sys_environ; +use t27os::pty_sys::sys_unsetenv; +use t27os::pty_logic::O_KEYS; +use t27os::pty_logic::KEY_CAP; +use t27os::pty_proto::OP_SETKEY; +use t27os::keys::PROVIDER_COUNT; +use t27os::keys::agent_uses; +use t27os::keys::provider_env; +use t27os::keys::env_stripped; +use t27os::keys::key_fits; +use t27os::keys::profile_agent; +use t27os::keys::claude_strips; +use t27os::keys::pick_profile; +use t27os::keys::PROFILE_PROVIDER; +use t27os::keys::PR_NONE; +use t27os::keys::profile_var_count; +use t27os::keys::profile_var_name; +use t27os::keys::profile_var_is_key; +use t27os::keys::profile_var_text; use t27os::pty_client::sockaddr_un; use t27os::pty_client::client_connect; use t27os::pty_client::client_list; @@ -202,7 +224,8 @@ use t27os::pty_logic::poll_revents; use t27os::pty_logic::exit_event; use t27os::pty_logic::hooked_status; use t27os::pty_logic::list_flags; -use t27os::pty_logic::pane_command; +use t27os::pty_logic::launch_command; +use t27os::pty_logic::editor_spawn_ok; use t27os::pty_logic::argv_text; use t27os::pty_logic::plist_text; use t27os::pty_logic::c_path; @@ -326,6 +349,88 @@ fn drop_record(st: []const u64, mem: []u8, i: usize) -> void { sys_unlink(mem[O_TMP2 + 256 .. O_TMP2 + 256 + fn_ + 1]); } +/// In the child: unset every environment variable env_stripped names (CLAUDECODE, CLAUDE_CODE_*) and, for +/// the claude agent, every endpoint/login/model variable claude_strips names; these are the +/// markers of the session that started this server. Each unset changes the array, so the walk restarts. +fn strip_claude_env(mem: []u8, agent: u32) -> void { + var round : u32 = 0; + var again : bool = true; + while (again and round < 64) { + again = false; + round = round + 1; + sys_peek(mem[O_TMP2 .. O_TMP2 + 8], sys_environ(), 8); + const arr : u64 = get_u64(mem[O_TMP2 .. O_TMP2 + 8], 0); + var i : u64 = 0; + var live : bool = arr != 0; + while (live and i < 1024) { + sys_peek(mem[O_TMP2 + 8 .. O_TMP2 + 16], arr + i * 8, 8); + const ent : u64 = get_u64(mem[O_TMP2 + 8 .. O_TMP2 + 16], 0); + if (ent == 0) { + live = false; + } else { + var n : u64 = sys_strlen(ent); + if (n > 100) { n = 100; } + sys_peek(mem[O_TMP2 + 16 .. O_TMP2 + 120], ent, n); + var e : usize = 0; + while (e < @as(usize, @intCast(n)) and mem[O_TMP2 + 16 + e] != 61) { e = e + 1; } + if (e < @as(usize, @intCast(n)) and (env_stripped(mem[O_TMP2 + 16 .. O_TMP2 + 16 + e]) or (profile_agent(agent) and claude_strips(mem[O_TMP2 + 16 .. O_TMP2 + 16 + e])))) { + mem[O_TMP2 + 16 + e] = 0; + sys_unsetenv(mem[O_TMP2 + 16 .. O_TMP2 + 16 + e + 1]); + again = true; + live = false; + } + } + i = i + 1; + } + } +} + +/// In the child, for the claude agent: pick the profile (keys.t27 pick_profile over the stored keys; the +/// window withholds a key that failed its check) and set exactly that profile's variables. No stored key +/// leaves the pane without one, and none of the other profile's variables is set. +fn inject_profile(mem: []u8) -> void { + var stored : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + if (mem[O_KEYS + @as(usize, p) * KEY_CAP] != 0) { stored = stored + (1 << p); } + p = p + 1; + } + const pr : u32 = pick_profile(stored, 0); + if (pr == PR_NONE) { return; } + const at : usize = O_KEYS + @as(usize, PROFILE_PROVIDER[pr]) * KEY_CAP; + var k : u32 = 0; + while (k < profile_var_count(pr)) { + const nl : usize = profile_var_name(pr, k, mem[O_TMP2 .. O_TMP2 + 40]); + mem[O_TMP2 + nl] = 0; + if (profile_var_is_key(pr, k)) { + sys_setenv(mem[O_TMP2 .. O_TMP2 + nl + 1], mem[at + 1 .. at + 2 + @as(usize, mem[at])], 1); + } else { + const vl : usize = profile_var_text(pr, k, mem[O_TMP2 + 64 .. O_TMP2 + 200]); + mem[O_TMP2 + 64 + vl] = 0; + sys_setenv(mem[O_TMP2 .. O_TMP2 + nl + 1], mem[O_TMP2 + 64 .. O_TMP2 + 64 + vl + 1], 1); + } + k = k + 1; + } +} + +/// In the child: set the environment variable of every stored key the agent reads (OP_SETKEY filled them). +fn inject_keys(mem: []u8, agent: u32) -> void { + if (profile_agent(agent)) { + inject_profile(mem); + return; + } + var p : u32 = 0; + while (p < PROVIDER_COUNT) { + const at : usize = O_KEYS + @as(usize, p) * KEY_CAP; + if (agent_uses(agent, p) and mem[at] != 0) { + const nl : usize = provider_env(p, mem[O_TMP2 .. O_TMP2 + 40]); + mem[O_TMP2 + nl] = 0; + sys_setenv(mem[O_TMP2 .. O_TMP2 + nl + 1], mem[at + 1 .. at + 2 + @as(usize, mem[at])], 1); + } + p = p + 1; + } +} + /// Start the process of pane i (fields already in st/mem) under plan R_RESUME or R_RELAUNCH. fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { const base : usize = i * PF; @@ -336,7 +441,7 @@ fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { st[base + PF_SIDLEN] = @as(u64, un); } const home : []const u8 = home_slice(st, mem); - const cn : usize = pane_command(agent, plan, sid_of(st, mem, i), model_of(mem, i), home, mem[O_TMP1 + 4096 .. O_TMP1 + 8000]); + const cn : usize = launch_command(agent, plan, sid_of(st, mem, i), model_of(mem, i), home, cwd_of(st, mem, i), mem[O_TMP1 + 4096 .. O_TMP1 + 8000]); var f : [4]u64 = undefined; const tn : usize = argv_text(mem[O_TMP1 + 4096 .. O_TMP1 + 4096 + cn], mem[O_TMP1 .. O_TMP1 + 3000], f[0..4]); const cw : usize = c_path(mem[O_TMP1 + 3000 .. O_TMP1 + 3300], cwd_of(st, mem, i)); @@ -364,6 +469,8 @@ fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { } sys_chdir(mem[O_TMP1 + 3000 .. O_TMP1 + 3000 + cw + 1]); sys_setenv("T27OS_PANE", mem[O_TMP1 + 3300 .. O_TMP1 + 3300 + idn + 1], 1); + strip_claude_env(mem, agent); + inject_keys(mem, agent); sys_execv(mem[O_TMP1 .. O_TMP1 + 8], argv); sys_exit(127); return false; @@ -447,7 +554,12 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { if (!spawn_ok(mem[p0 .. p0 + plen])) { reply(fd, mem, RE_ERR, 0, 0); return; } const slot : u32 = pane_alloc(st); const cl : usize = spawn_cwd_len(mem[p0 .. p0 + plen]); - if (slot >= MAX_PANES or cl > CWD_CAP - 1 or cl == 0 or mem[p0] >= AGENT_COUNT and mem[p0] != SHELL_CELL) { + // an editor pane (the shell cell with a terminal editor as its model; pty_logic wants_editor) takes the + // path as ` ''`: a path that would break the quoting is refused here + var ml : usize = 0; + if (3 + cl < plen) { ml = plen - 4 - cl; } + const edit_ok : bool = editor_spawn_ok(@as(u32, mem[p0]), mem[p0 + 4 + cl .. p0 + 4 + cl + ml], mem[p0 + 3 .. p0 + 3 + cl]); + if (slot >= MAX_PANES or cl > CWD_CAP - 1 or cl == 0 or mem[p0] >= AGENT_COUNT and mem[p0] != SHELL_CELL or edit_ok == false) { reply(fd, mem, RE_ERR, 0, 0); return; } @@ -473,6 +585,20 @@ fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { reply(fd, mem, RE_OK, @as(u8, @intCast(slot)), 0); return; } + if (op == OP_SETKEY) { + if (pane >= PROVIDER_COUNT or plen > KEY_CAP - 2) { reply(fd, mem, RE_ERR, 0, 0); return; } + if (plen > 0 and !key_fits(mem[p0 .. p0 + plen])) { reply(fd, mem, RE_ERR, 0, 0); return; } + const ka : usize = O_KEYS + @as(usize, pane) * KEY_CAP; + mem[ka] = @as(u8, @intCast(plen)); + var j : usize = 0; + while (j < plen) { + mem[ka + 1 + j] = mem[p0 + j]; + j = j + 1; + } + mem[ka + 1 + plen] = 0; + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); + return; + } if (pane >= MAX_PANES or st[base + PF_USED] == 0) { reply(fd, mem, RE_ERR, 0, 0); return; } if (op == OP_ATTACH) { st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), P_SEEN)); @@ -813,7 +939,7 @@ fn print_dec(v: u64, mem: []u8) -> void { sys_write(1, mem[O_MISC + 200 .. O_MISC + 200 + n], @as(u64, n)); } -/// T27OS_CTL=: list | spawn | kill | read | keys against a running server. +/// T27OS_CTL=: list | spawn | kill | read | write (keys) against a running server. fn run_ctl(st: []u64, mem: []u8, verb: u8) -> void { const fd : i32 = client_connect(home_slice(st, mem)); if (fd < 0) { say("no server\n"); return; } @@ -891,7 +1017,7 @@ fn run_install(st: []u64, mem: []u8) -> void { pub fn main() -> void { var st : [256]u64 = undefined; - var mem : [1146880]u8 = undefined; + var mem : [1149184]u8 = undefined; var z : usize = 0; while (z < ST_SIZE) { st[z] = 0; @@ -927,5 +1053,6 @@ test the_arena_regions_do_not_overlap { assert O_POLL + 512 <= O_MISC; assert O_MISC + 328 <= O_BIN; assert O_BIN + 256 <= O_RING; - assert O_RING + 16 * 65536 <= MEM_SIZE; + assert O_RING + 16 * 65536 <= O_KEYS; + assert O_KEYS + 9 * KEY_CAP <= MEM_SIZE; } diff --git a/specs/t27os/pty_sys.t27 b/specs/t27os/pty_sys.t27 index 3c61e4ea49..5cbae7fc5f 100644 --- a/specs/t27os/pty_sys.t27 +++ b/specs/t27os/pty_sys.t27 @@ -46,6 +46,9 @@ pub fn sys_set_winsize(fd: i32, req: u64, ws: []const u8) -> i32 { return @exter pub fn sys_signal(sig: i32, handler: u64) -> u64 { return @extern("signal"); } pub fn sys_gettimeofday(tv: []u8, tz: u64) -> i32 { return @extern("gettimeofday"); } pub fn sys_random(b: []u8, n: u64) -> void { @extern("arc4random_buf"); } +/// The address of the `environ` variable (a char***): read twice to walk the process environment. +pub fn sys_environ() -> u64 { return @extern("_NSGetEnviron"); } +pub fn sys_unsetenv(name: []const u8) -> i32 { return @extern("unsetenv"); } pub fn sys_exe_path(b: []u8, size: [2]u32) -> i32 { return @extern("_NSGetExecutablePath"); } test the_imports_are_declared { diff --git a/specs/t27os/queen.t27 b/specs/t27os/queen.t27 index 7ec931fb0f..bbdec42db4 100644 --- a/specs/t27os/queen.t27 +++ b/specs/t27os/queen.t27 @@ -23,11 +23,17 @@ ; while GOD MODE is on a blue flame (FLAME_*, defined here and nowhere else) lies along the window ; edges: a bright cyan cone at the edge, a vivid blue body, a deep violet-blue base, softly blurred, ; breathing and flickering on the frame count, fading in and out over 1 / GLOW_STEP frames. -; In the normal human-confirm mode the edges are pure black: no glow, and the backdrop is opaque so -; the desktop wallpaper does not show through as a warm wash. +; In the normal human-confirm mode the edges have no glow. The window is clear matte black glass as in +; trios (owner, 2026-10-20; glass_window.t27 backdrop_alpha), and full screen, which cannot be +; transparent, is opaque black #000000, so no wallpaper washes through there. ; Not here yet (no server behind them): a real Queen model reading the context, tasks on a board, and ; prompts sent into agent processes. The only actions are the ones the app can do today: open a chat, -; select a chat. +; select a chat, open the space's folder in a terminal editor. +; Opening a path (owner, 2026-10-20: "T27OS opens inside!!"): open_target says where it goes -- a pane of +; the PTY server running a terminal editor, shown inside the window (editors.t27 editor_command, +; pty_logic.t27 launch_command), never a window of another app that she starts on her own. When she has +; nothing else to suggest and a terminal editor is there, suggest_for offers A_EDIT for the space (path = +; the space's folder); GOD MODE runs it once per space (edit_todo / edit_mark), then it is done. module T27osQueen; @@ -39,6 +45,7 @@ use t27os::windows::session_agent; use t27os::windows::add_session; use t27os::windows::session_at_row; use t27os::windows::sessions_in; +use t27os::session_store::csv_field; /// The ring holds the last RING events. pub const RING : u32 = 8; @@ -68,9 +75,10 @@ pub const C_WORLD : u32 = 16; pub const C_NEW_SPACE : u32 = 17; pub const C_CLOSE_SPACE : u32 = 18; pub const C_REPO_LINK : u32 = 19; -pub const C_COUNT : u32 = 20; +pub const C_QUEEN_EDITED : u32 = 20; +pub const C_COUNT : u32 = 21; pub const C_NONE : u32 = 4294967295; -pub const CONTROL_NAMES : str = "tapped a cell,+ new chat,closed a chat,Queen row,picked a space,picked a chat,sidebar,dashboard,Queen panel,confirmed,dismissed,GOD MODE on,GOD MODE off,she opened,she picked,went home,opened a screen,+ new space,closed a space,opened the repo"; +pub const CONTROL_NAMES : str = "tapped a cell,+ new chat,closed a chat,Queen row,picked a space,picked a chat,sidebar,dashboard,Queen panel,confirmed,dismissed,GOD MODE on,GOD MODE off,she opened,she picked,went home,opened a screen,+ new space,closed a space,opened the repo,she opened the editor"; /// What an event's argument names. pub const ARG_NONE : u32 = 0; @@ -81,7 +89,8 @@ pub const ARG_SPACE : u32 = 2; pub const A_NONE : u32 = 0; pub const A_OPEN : u32 = 1; pub const A_SELECT : u32 = 2; -pub const SUGGEST_WORDS : str = "Nothing to do here,Open a Claude Code chat in this space,Open the first chat of this space"; +pub const A_EDIT : u32 = 3; +pub const SUGGEST_WORDS : str = "Nothing to do here,Open a Claude Code chat in this space,Open the first chat of this space,Open this space in a terminal editor"; /// The age of an event, in AGE_WORDS order. pub const AGE_WORDS : str = "just now,seconds ago,a minute ago,minutes ago,earlier"; @@ -250,13 +259,13 @@ pub fn ev_sec(e: u64) -> u64 { pub fn arg_kind(control: u32) -> u32 { if (control == C_CELL or control == C_NEW_CHAT or control == C_CLOSE_CHAT or control == C_AGENT_ROW) { return ARG_AGENT; } if (control == C_QUEEN_OPENED or control == C_QUEEN_PICKED) { return ARG_AGENT; } - if (control == C_SPACE_ROW or control == C_NEW_SPACE or control == C_CLOSE_SPACE) { return ARG_SPACE; } + if (control == C_SPACE_ROW or control == C_NEW_SPACE or control == C_CLOSE_SPACE or control == C_QUEEN_EDITED) { return ARG_SPACE; } return ARG_NONE; } /// The Queen's own actions, so the log can tell them from the user's. pub fn by_queen(control: u32) -> bool { - return control == C_QUEEN_OPENED or control == C_QUEEN_PICKED; + return control == C_QUEEN_OPENED or control == C_QUEEN_PICKED or control == C_QUEEN_EDITED; } /// The view a frame is on: the open world, or home. @@ -313,9 +322,55 @@ pub fn shown_suggestion(s: u32, dismissed: u32) -> u32 { pub fn queen_control(s: u32) -> u32 { if (s == A_OPEN) { return C_QUEEN_OPENED; } if (s == A_SELECT) { return C_QUEEN_PICKED; } + if (s == A_EDIT) { return C_QUEEN_EDITED; } return C_NONE; } +/// One bit per space (spaces 0 .. 63) records that the editor was opened for it, by the user's chip, a +/// confirmed suggestion or GOD MODE, so she suggests it once per space and never again -- GOD MODE does not +/// reopen it every GOD_WAIT seconds. A space past 63 is never offered it. +pub fn edit_todo(done: u64, space: u32) -> bool { + if (space >= 64) { return false; } + return done / pow2(space) % 2 == 0; +} + +pub fn edit_mark(done: u64, space: u32) -> u64 { + if (edit_todo(done, space) == false) { return done; } + return done + pow2(space); +} + +/// The suggestion with the editor offered last: when the rules above have nothing to do, a terminal editor +/// is there (`can_open`) and the space has not had it yet, she suggests opening the space in it -- inside +/// the window (open_target). The path is the space's folder, which the window knows. +pub fn suggest_for(rows: u32, selected: bool, can_open: bool, done: u64, space: u32) -> u32 { + const s : u32 = suggest(rows, selected); + if (s == A_NONE and can_open and edit_todo(done, space)) { return A_EDIT; } + return s; +} + +/// Who asked the window to open the editor (a request the main loop serves in one place): the user -- the +/// strip's chip or Confirm -- or GOD MODE, which open_target keeps inside. +pub const IE_NONE : u32 = 0; +pub const IE_USER : u32 = 1; +pub const IE_GOD : u32 = 2; + +/// Where "open this path" goes. OT_INSIDE: a pane of the PTY server running a terminal editor, shown in +/// the window. OT_OUTSIDE: another app's window (a GUI editor from editors.t27). OT_NONE: nothing to open it with. +pub const OT_NONE : u32 = 0; +pub const OT_INSIDE : u32 = 1; +pub const OT_OUTSIDE : u32 = 2; + +/// T27 OS opens inside (owner, 2026-10-20): with a terminal editor to hand the path opens in a pane, for +/// the user's Confirm and for GOD MODE alike. Without one, GOD MODE does nothing -- she never starts +/// another app's window on her own while nobody is watching -- and a suggestion the user confirmed falls +/// back to an installed GUI editor. +pub fn open_target(is_god: bool, term_found: bool, ext_found: bool) -> u32 { + if (term_found) { return OT_INSIDE; } + if (is_god) { return OT_NONE; } + if (ext_found) { return OT_OUTSIDE; } + return OT_NONE; +} + /// True when GOD MODE should act now: on, the panel open, something to do, and the user and the /// Queen herself both still for GOD_WAIT seconds. pub fn god_due(god: bool, open: bool, s: u32, now: u64, last_user: u64, last_act: u64) -> bool { @@ -551,3 +606,88 @@ test the_edge_glow_is_a_blue_gas_flame_and_only_in_god_mode { assert glow_alpha(0.0, 0, 0) == 0.0; assert glow_alpha(1.0, GLOW_BANDS, 0) == 0.0; } + +test t27_os_opens_inside_and_god_mode_never_starts_another_apps_window { + // a terminal editor is there: inside, whoever asked (the Mac always has vim) + assert open_target(false, true, true) == OT_INSIDE; + assert open_target(true, true, true) == OT_INSIDE; + assert open_target(false, true, false) == OT_INSIDE; + assert open_target(true, true, false) == OT_INSIDE; + // none: a confirmed suggestion falls back to a GUI editor, GOD MODE does not act alone + assert open_target(false, false, true) == OT_OUTSIDE; + assert open_target(true, false, true) == OT_NONE; + // nothing at all + assert open_target(false, false, false) == OT_NONE; + assert open_target(true, false, false) == OT_NONE; + // the three answers are distinct + assert OT_NONE != OT_INSIDE; + assert OT_INSIDE != OT_OUTSIDE; + assert OT_NONE != OT_OUTSIDE; +} + +test the_editor_is_offered_last_and_once_per_space { + // the old rules come first: an empty space is offered a chat, a chat that is not selected is selected + assert suggest_for(0, false, true, 0, 1) == A_OPEN; + assert suggest_for(2, false, true, 0, 1) == A_SELECT; + // nothing else to do, a terminal editor there, the space not done: the editor + assert suggest_for(2, true, true, 0, 1) == A_EDIT; + // no terminal editor to open it in: nothing, as before + assert suggest_for(2, true, false, 0, 1) == A_NONE; + // done for space 1 is not done for space 2 + const d1 : u64 = edit_mark(0, 1); + assert suggest_for(2, true, true, d1, 1) == A_NONE; + assert suggest_for(2, true, true, d1, 2) == A_EDIT; + assert edit_mark(d1, 1) == d1; + assert edit_mark(d1, 2) == d1 + 4; + assert edit_todo(d1, 0); + assert edit_todo(d1, 1) == false; + // spaces 0 .. 63 only; the top bit works, past it never + assert edit_todo(0, 63); + assert edit_todo(edit_mark(0, 63), 63) == false; + assert edit_todo(0, 64) == false; + assert edit_mark(0, 64) == 0; + // the suggestion is dismissible like any other and reads as the Queen's own action + assert shown_suggestion(A_EDIT, A_EDIT) == A_NONE; + assert shown_suggestion(A_EDIT, A_OPEN) == A_EDIT; + assert queen_control(A_EDIT) == C_QUEEN_EDITED; + assert by_queen(C_QUEEN_EDITED); + assert arg_kind(C_QUEEN_EDITED) == ARG_SPACE; +} + +test god_mode_opens_the_editor_once_not_every_three_seconds { + // 200 ticks one second apart in space 3: GOD MODE is on, the panel open, the user still. The first tick that + // is due acts and marks the space; she never acts again, however long she is left alone. + var done : u64 = 0; + var last_act : u64 = 0; + var acts : u32 = 0; + var now : u64 = 1000; + while (now < 1200) { + const sug : u32 = suggest_for(2, true, true, done, 3); + if (god_due(true, true, sug, now, 0, last_act)) { + acts = acts + 1; + last_act = now; + if (sug == A_EDIT) { done = edit_mark(done, 3); } + } + now = now + 1; + } + assert acts == 1; + assert edit_todo(done, 3) == false; + // another space gets its own one + assert suggest_for(2, true, true, done, 4) == A_EDIT; +} + +test the_event_names_and_suggestion_words_cover_the_new_entries { + var b : [64]u8 = undefined; + assert csv_field(CONTROL_NAMES, C_QUEEN_EDITED, b[0..64]) > 0; + assert csv_field(CONTROL_NAMES, C_COUNT - 1, b[0..64]) > 0; + assert csv_field(CONTROL_NAMES, C_COUNT, b[0..64]) == 0; + assert csv_field(SUGGEST_WORDS, A_EDIT, b[0..64]) > 0; + assert csv_field(SUGGEST_WORDS, A_EDIT + 1, b[0..64]) == 0; + // who asked: the three requests are distinct + assert IE_NONE != IE_USER; + assert IE_USER != IE_GOD; + assert IE_NONE != IE_GOD; + // inside whoever asked, once a terminal editor is there + assert open_target(true, true, false) == OT_INSIDE; + assert open_target(false, true, false) == OT_INSIDE; +} diff --git a/specs/t27os/router.t27 b/specs/t27os/router.t27 new file mode 100644 index 0000000000..846adb9eeb --- /dev/null +++ b/specs/t27os/router.t27 @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS router (M12 stage 2, DESIGN + DECISIONS ONLY): the local Anthropic-compatible endpoint a claude +// pane can point ANTHROPIC_BASE_URL at, so one pane survives the death of one provider. Stage 1 (keys.t27 +// PROFILE) picks ONE provider per pane at spawn; this router picks per REQUEST and walks the chain. +// +// Shape (nothing below is wired yet; each box is a later spec): +// claude pane --POST /v1/messages--> 127.0.0.1:ROUTER_PORT (this module's decisions) +// walk the chain: zai -> kie -> nemotron -> ollama (order: specs/automation/agent-provider-chain.t27 +// ORDER_DEFAULT "zai,kie,zai-lite,nemotron,reserve,ollama"; restated here because that spec uses the +// brace module syntax and cannot be `use`d; zai-lite and reserve join when they have a key path) +// a route whose protocol is not Anthropic's goes through specs/provider/transform.t27 (request) and +// specs/provider/stream.t27 (response); an Anthropic-protocol route (z.ai) passes through untouched. +// failover on 429 / 529 / 402 / transport failure / 5xx: next route, same request, same pane. +// +// What t27b must offer, and what is checked (t27c gap, issue-shaped if missing): +// - sockets: AF_INET + bind + listen + accept. pty_server.t27 already does AF_UNIX bind/listen/accept +// through pty_sys.t27, so only the sockaddr_in layout (family, port, 127.0.0.1) is new. +// - an outbound HTTPS client with a streaming body. keys_host.t27 uses NSURLConnection synchronously, which +// covers a non-streaming probe; a streamed /v1/messages reply needs NSURLSession or a raw TLS socket. +// That is the real gap. Until it is closed the router cannot stream, and stage 2 stops at these decisions. +// - the app.t27.ai route (the user's server-side keys, no secret on this Mac) is a second upstream of the +// same walk: it is route R_QUEEN below, first in the order once a usable proxy endpoint exists. +// +// Refs gHashTag/t27#10782. +module T27osRouter; + +use t27os::session_store::csv_field; + +pub const R_QUEEN : u32 = 0; +pub const R_ZAI : u32 = 1; +pub const R_KIE : u32 = 2; +pub const R_NEMOTRON : u32 = 3; +pub const R_OLLAMA : u32 = 4; +pub const R_NONE : u32 = 255; +pub const ROUTE_COUNT : u32 = 5; +pub const ROUTE_NAMES : str = "queen,zai,kie,nemotron,ollama"; +/// 1 = the upstream already speaks the Anthropic protocol; 0 = a request goes through transform.t27 first. +pub const ROUTE_ANTHROPIC : [5]u32 = [1, 1, 0, 0, 0]; +pub const ROUTER_PORT : u32 = 47127; +pub const ROUTER_BIND : str = "127.0.0.1"; + +/// What to do with an upstream answer. +pub const A_DONE : u32 = 0; +pub const A_NEXT : u32 = 1; +pub const A_NEXT_BAD : u32 = 2; +pub const A_RETURN : u32 = 3; + +/// 2xx ends the walk. Rate limited, overloaded, payment required, a server error or no answer at all +/// (http 0) moves to the next route. 401/403 also moves on and marks the key bad (the window withholds it). +/// Any other 4xx is the caller's own mistake: it goes back unchanged, another provider would say the same. +pub fn answer_action(http: u32) -> u32 { + if (http >= 200 and http < 300) { return A_DONE; } + if (http == 0 or http == 402 or http == 429 or http == 529) { return A_NEXT; } + if (http >= 500) { return A_NEXT; } + if (http == 401 or http == 403) { return A_NEXT_BAD; } + return A_RETURN; +} + +/// The next route to try: lowest route not yet tried and in the available mask; R_NONE when the walk is over. +pub fn next_route(tried: u32, avail: u32) -> u32 { + var r : u32 = 0; + while (r < ROUTE_COUNT) { + if ((avail >> r) % 2 == 1 and (tried >> r) % 2 == 0) { return r; } + r = r + 1; + } + return R_NONE; +} + +pub fn needs_transform(r: u32) -> bool { + if (r >= ROUTE_COUNT) { return false; } + return ROUTE_ANTHROPIC[r] == 0; +} + +/// Does the router serve this request line? Only POST /v1/messages (the model call); everything else, count +/// tokens included, is refused with 404 so a pane never gets a half-working endpoint. +pub fn serves(is_post: bool, path_is_messages: bool) -> bool { + return is_post and path_is_messages; +} + +pub fn route_name(r: u32, out: []u8) -> usize { + return csv_field(ROUTE_NAMES, r, out); +} + +test a_good_answer_ends_the_walk_and_a_throttled_one_moves_on { + assert answer_action(200) == A_DONE; + assert answer_action(204) == A_DONE; + assert answer_action(429) == A_NEXT; + assert answer_action(529) == A_NEXT; + assert answer_action(402) == A_NEXT; + assert answer_action(0) == A_NEXT; + assert answer_action(500) == A_NEXT; + assert answer_action(503) == A_NEXT; +} + +test a_rejected_login_moves_on_and_marks_the_key { + assert answer_action(401) == A_NEXT_BAD; + assert answer_action(403) == A_NEXT_BAD; +} + +test the_callers_own_mistake_goes_back_unchanged { + assert answer_action(400) == A_RETURN; + assert answer_action(404) == A_RETURN; + assert answer_action(413) == A_RETURN; + assert answer_action(422) == A_RETURN; +} + +test the_walk_visits_each_available_route_once_in_order { + assert next_route(0, 30) == R_ZAI; + assert next_route(2, 30) == R_KIE; + assert next_route(6, 30) == R_NEMOTRON; + assert next_route(14, 30) == R_OLLAMA; + assert next_route(30, 30) == R_NONE; + assert next_route(0, 0) == R_NONE; +} + +test the_queen_route_goes_first_when_it_exists { + assert next_route(0, 31) == R_QUEEN; + assert next_route(1, 31) == R_ZAI; +} + +test only_the_anthropic_routes_skip_the_transform { + assert needs_transform(R_QUEEN) == false; + assert needs_transform(R_ZAI) == false; + assert needs_transform(R_KIE); + assert needs_transform(R_NEMOTRON); + assert needs_transform(R_OLLAMA); + assert needs_transform(R_NONE) == false; +} + +test the_router_serves_one_request_shape { + assert serves(true, true); + assert serves(true, false) == false; + assert serves(false, true) == false; + assert ROUTER_PORT > 1024; +} diff --git a/specs/t27os/term_pane.t27 b/specs/t27os/term_pane.t27 index 20fed28a2b..ffd085e6be 100644 --- a/specs/t27os/term_pane.t27 +++ b/specs/t27os/term_pane.t27 @@ -266,6 +266,18 @@ pub fn tp_pick_pane(frame: []const u8, len: usize, agent: u32, k: u32) -> u32 { return found; } +/// True when a LIST reply (`len` bytes of frame) holds pane `pane` with its live flag (bit 0) set: the +/// editor pane the window opened earlier is still running. +pub fn tp_pane_live(frame: []const u8, len: usize, pane: u32) -> bool { + if (len < HEADER or frame[0] != RE_LISTING) { return false; } + var at : usize = HEADER; + while (at + 4 <= len) { + if (@as(u32, frame[at]) == pane) { return frame[at + 3] % 2 == 1; } + at = at + 4; + } + return false; +} + /// Which of the window's chats of its own agent chat `cur` is: 0 for the first, 1 for the second, ... pub fn tp_ordinal(ss: u64, cur: u32) -> u32 { const a : u32 = session_agent(ss, cur); @@ -430,3 +442,21 @@ test a_resize_waits_until_the_size_holds { assert tp_size_due(80, 24, 760.0, 452.0, TP_SETTLE); assert tp_size_due(101, 28, 760.0, 452.0, TP_SETTLE) == false; } + +test an_editor_pane_is_reused_only_while_the_server_lists_it_live { + // LIST reply: header, then pane 3 (live), pane 5 (stopped), pane 7 (live, needs you) + var f : [16]u8 = undefined; + f[0] = RE_LISTING; f[1] = 0; f[2] = 12; f[3] = 0; + f[4] = 3; f[5] = 9; f[6] = 1; f[7] = 1; + f[8] = 5; f[9] = 9; f[10] = 0; f[11] = 0; + f[12] = 7; f[13] = 9; f[14] = 2; f[15] = 3; + assert tp_pane_live(f[0..16], 16, 3); + assert tp_pane_live(f[0..16], 16, 5) == false; + assert tp_pane_live(f[0..16], 16, 7); + assert tp_pane_live(f[0..16], 16, 4) == false; + assert tp_pane_live(f[0..16], 16, PANE_NONE) == false; + assert tp_pane_live(f[0..16], 8, 7) == false; + assert tp_pane_live(f[0..16], 3, 3) == false; + f[0] = RE_OK; + assert tp_pane_live(f[0..16], 16, 3) == false; +} diff --git a/tools/verilog_width_baseline.txt b/tools/verilog_width_baseline.txt index e0e1ef5c6e..9d58983c0e 100644 --- a/tools/verilog_width_baseline.txt +++ b/tools/verilog_width_baseline.txt @@ -61,6 +61,16 @@ specs/claw/suggest_feed.t27 # Same class as big_frame.t27 and suggest_feed.t27. specs/t27f/token_floor.t27 # +# specs/t27os/pty_server.t27 and specs/t27os/glass_window.t27 -- not buses: host +# working memory of the T27 OS window (#10782). pty_server keeps one arena +# (MEM_SIZE = 1,149,184 bytes = 9,193,472 bits -> [9193471:0]: the pane ring +# buffers, the request/reply frames and the key arena) and glass_window the +# terminal cell grid (tcells [48000]u32 = 1,536,000 bits -> [1535999:0]). Both +# are macOS processes built by t27b into an arm64 Mach-O; neither has a +# hardware form. Same class as big_frame.t27 and token_floor.t27. +specs/t27os/glass_window.t27 +specs/t27os/pty_server.t27 +# # specs/t27f/why_card.t27 and specs/t27f/deps_card.t27 -- not buses: each # main() reads the whole `t27c use-edges` text from stdin into one byte # array, and the arithmetic is exact: