Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Config precedence (highest wins):

Project `.fx.json` accepts only repo-safe defaults: `sandbox`, `max_agent_steps`, `max_tool_result_bytes`, and `context`. Profile-owned keys such as `model`, `effort`, `fast_mode`, `slash_menu_categories`, `startup_scrollback`, `prompt_history`, `statusLine`, `skill_match_fuzzy`, `first_call_tool_choice`, `auto_upgrade`, `permission_mode`, `credential_source`, and `permission` are ignored from project config before their values are parsed.

Runtime state lives under `~/.fx/sessions/<session-id>/` (`session.json`, `background/`, `subagent/`, `logs/`). Sessions are global and portable across workspaces — each session tracks its `workspace_root` which updates when resumed in a different workspace. A subagent child is an ordinary session with its own directory; `subagent/` holds create-operation identities on a parent and the control record on a child.
Runtime state lives under `~/.fx/sessions/<session-id>/` (`session.json`, `background/`, `subagent/`, `logs/`). Sessions are global and portable across workspaces. Each session tracks its `workspace_root`, which updates when resumed in a different workspace. A subagent child is an internal ordinary session with its own history. Its parent owns one bounded `subagent/children.json` registry, and the child carries only an immutable owner marker. Child sessions stay out of ordinary session discovery and cannot be resumed directly. A first `subagent.message` creates a named persistent child in that parent; later messages continue it, and optional instructions replace only its child-specific system overlay.

## Permissions

Expand Down Expand Up @@ -283,7 +283,7 @@ A Full CI result is valid only when it belongs to the exact current commit and a

## Reproducing Render Bugs

fx's rendering is inline by default and deliberately emits a small ANSI subset. Five owner classes are the narrow exceptions, and each takes the alternate buffer exclusively through `AlternateScreenOwner` in `src/ui/shell_runtime.zig`: interactive permission review, the full-transcript screen, catalog menus, the ctrl+x subagent manager, and a hosted child-terminal takeover. The terminal-session owner is entered only by an explicit manager handoff after the host grants the human write lease; it renders the shared terminal-engine grid without permanent fx chrome and releases that lease on detach. Only one class may own the buffer at a time, and each must leave it and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes when it closes. Transcript rendering, question prompts, and command-output expansion remain inline. Three tools exist for reproducing and regression-proofing render bugs:
fx's rendering is inline by default and deliberately emits a small ANSI subset. Three owner classes are the narrow exceptions, and each takes the alternate buffer exclusively through `AlternateScreenOwner` in `src/ui/shell_runtime.zig`: interactive permission review, the full-transcript screen, and catalog menus. Only one class may own the buffer at a time, and each must leave it and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes when it closes. Transcript rendering, question prompts, command-output expansion, and subagent delegation remain inline. Three tools exist for reproducing and regression-proofing render bugs:

### tmux (live TTY repros)

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Runtime state lives under `~/.fx/`:

Sessions are global and portable across workspaces. Each session tracks a `workspace_root` that updates when resumed from a different directory.

Subagent children are ordinary sessions with their own `~/.fx/sessions/<child-id>/` directory and their own history. The `subagent/` directory is per session on both sides of the relationship: a parent records create-operation identities there, and a child records its own control state there.
Subagent children are internal ordinary sessions with their own `~/.fx/sessions/<child-id>/` directory and history. The parent owns one bounded `subagent/children.json` registry; each child carries only an immutable owner marker. Child sessions are hidden from ordinary session discovery and cannot be resumed directly. A first `subagent.message` creates a named persistent child for that parent; later messages continue it, and optional instructions replace only its child-specific system overlay.

## Skills

Expand Down Expand Up @@ -408,7 +408,7 @@ Check in the golden file and wire a regression test that re-runs `fx replay` in

* Do not commit generated state from `.fx/`, `.zig-cache/`, or `zig-out/`

* Do not add a general alternate-screen (`\x1b[?1049h/l`) render path. fx is inline by design except for the five exclusive owner classes represented by `AlternateScreenOwner`: interactive tool-approval review, the full-transcript screen, catalog menus, the ctrl+x subagent manager, and the hosted child-terminal takeover. The terminal-session owner is entered only from the manager after `TerminalHost` grants the human write lease, has no permanent fx chrome, and must release the lease on detach. Every owner must leave or explicitly hand off the alternate buffer and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes before resolving, cancelling, or shutting down
* Do not add a general alternate-screen (`\x1b[?1049h/l`) render path. fx is inline by design except for the three exclusive owner classes represented by `AlternateScreenOwner`: interactive tool-approval review, the full-transcript screen, and catalog menus. Every owner must leave or explicitly hand off the alternate buffer and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes before resolving, cancelling, or shutting down

## Releases

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ In the interactive shell, bare `/mcp` opens an inline browser for servers, tools

Add reusable instructions with [skills](https://fx.sh/docs/capabilities/skills), connect external tools through [MCP](https://fx.sh/docs/capabilities/mcp), or delegate independent work to [subagents](https://fx.sh/docs/capabilities/subagents). Run `fx mcp add NAME COMMAND [ARGS...]` for a local server or `fx mcp add --transport http NAME URL` for Streamable HTTP without opening the interactive shell; the equivalent `/mcp add` forms remain available inside fx. A workspace may also provide Claude-compatible `.mcp.json` with a top-level `mcpServers` object. Pending project servers stay disconnected on every surface until they are approved with `/mcp trust approve <server>` or `fx mcp trust approve <server>`. Interactive fx presents the trust prompt after startup. `fx ask` reports skipped pending servers on stderr, and ACP leaves them unavailable. Repository files cannot persist approval or expose environment-expanded values before approval. `/mcp trust reject <server>` rejects one and `/mcp trust reset` clears the workspace choices. Profile entries win same-name collisions. Profile `~/.fx/mcp.json` accepts `mcpServers` as an alias for `mcp`, while writes always use `mcp` and ambiguous server-like keys produce a visible warning. Project instruction files may link within their scope, and read-only workspace or compatibility skill directories and their primary `SKILL.md` files may link within their owning workspace or home; managed skills, secondary resources, and escaping links remain no-follow. Skills installed via symlinks that resolve outside home or workspace (e.g. Nix store paths) are loaded when their resolved target is inside a directory listed in the `FX_SKILL_SYMLINK_AUTHORITIES` environment variable (colon-separated absolute paths). `fx status` and `fx doctor` report invalid or suspicious trusted MCP profiles without starting their servers.

The `subagent` tool has four operations: `run` delegates one temporary task, `message` creates or continues a named persistent agent, `wait` observes a child, and `stop` cancels its current work. A first message creates the named child immediately; optional instructions set or replace that child's system overlay while preserving fx's trusted base prompt. Child sessions remain private to their saved parent session.

Use `fx mcp list`, `fx mcp path`, and `fx mcp remove NAME` for noninteractive profile management. `fx mcp trust approve|reject NAME`, `fx mcp trust approve-all`, and `fx mcp trust reset` manage workspace-scoped project trust. `fx mcp auth NAME` and `fx mcp logout NAME` run the existing remote credential lifecycle without opening the TUI or contacting the Gateway.

MCP servers have a 30-second startup timeout by default; set `startup_timeout_ms` on a server when its cold start needs a different bound. For direct `docker run` stdio entries, fx uses a private container ID file to remove the owned container after shutdown or startup failure. A configuration that already supplies `--cidfile` keeps ownership of its own cleanup policy.
Expand Down
2 changes: 1 addition & 1 deletion scripts/pgso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Candidate behavior qualification records each scenario's debug trace under `cand

## Qualification policy

Startup compares `help`, `--version`, `status --json`, `background --json`, `doctor --json`, and `sessions --json`. It first executes each verified immutable artifact once to require successful output and empty stderr. Timing then uses pinned Hyperfine with no intermediate shell, ten warmups per artifact in each of at least 100 alternating rounds, and at least 1,000 measured samples per artifact. No contiguous block exceeds ten measured runs, so short machine-noise bursts are distributed between control and candidate while p95 retains 50 tail observations. Startup measurement sets `FX_DISABLE_KEYCHAIN=1` so the compiler comparison cannot be dominated by host-global macOS Keychain subprocess latency; the deterministic behavior corpus remains responsible for exercising Keychain integration. No per-sample Python process management or evidence-file write is included in the timed boundary, and measurement never replaces `zig-out/bin/fx`. Heavy qualification compares file indexing at 100,000 paths, UI activity, and approval transcript, diff, combined, and large-payload workloads.
Startup compares `help`, `--version`, `status --json`, `doctor --json`, and `sessions --json`. It first executes each verified immutable artifact once to require successful output and empty stderr. Timing then uses pinned Hyperfine with no intermediate shell, ten warmups per artifact in each of at least 100 alternating rounds, and at least 1,000 measured samples per artifact. No contiguous block exceeds ten measured runs, so short machine-noise bursts are distributed between control and candidate while p95 retains 50 tail observations. Startup measurement sets `FX_DISABLE_KEYCHAIN=1` so the compiler comparison cannot be dominated by host-global macOS Keychain subprocess latency; the deterministic behavior corpus remains responsible for exercising Keychain integration. No per-sample Python process management or evidence-file write is included in the timed boundary, and measurement never replaces `zig-out/bin/fx`. Heavy qualification compares file indexing at 100,000 paths, UI activity, and approval transcript, diff, combined, and large-payload workloads.

Heavy comparisons use at least 50 measured samples for each artifact and alternate pair order AB then BA. Command failures and timeouts fail qualification and are never replaced. A candidate fails when either p50 or p95 is more than 10% slower than its matching control. The existing Linux startup workflow remains the authority for the repository's absolute 2 ms command budget.

Expand Down
9 changes: 0 additions & 9 deletions scripts/pgso/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,6 @@
"timeout_seconds": 60,
"requires_tmux": false
},
{
"name": "direct-background",
"argv": ["{binary}", "background", "--json"],
"cwd": ".",
"env_set": {"FX_SKIP_ONBOARDING": "1"},
"timeout_seconds": 60,
"requires_tmux": false
},
{
"name": "direct-doctor",
"argv": ["{binary}", "doctor", "--json"],
Expand Down Expand Up @@ -107,7 +99,6 @@
{"name": "e2e-tui-resume-brutal", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-resume-brutal.test.ts"], "test_file": "tui-resume-brutal.test.ts"},
{"name": "e2e-tui-permissions", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-permissions.test.ts"], "test_file": "tui-permissions.test.ts"},
{"name": "e2e-tui-interrupt-recovery", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-interrupt-recovery.test.ts"], "test_file": "tui-interrupt-recovery.test.ts"},
{"name": "e2e-tui-subagent-manager", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-subagent-manager.test.ts"], "test_file": "tui-subagent-manager.test.ts"},
{"name": "e2e-tui-terminal-tool", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-terminal-tool.test.ts"], "test_file": "tui-terminal-tool.test.ts"},
{"name": "e2e-tui-native-clear-recovery", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-native-clear-recovery.test.ts"], "test_file": "tui-native-clear-recovery.test.ts"},
{"name": "e2e-tui-gateway-stream-lifecycle", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-gateway-stream-lifecycle.test.ts"], "test_file": "tui-gateway-stream-lifecycle.test.ts"}
Expand Down
1 change: 0 additions & 1 deletion scripts/pgso/corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
("help",),
("--version",),
("status", "--json"),
("background", "--json"),
("doctor", "--json"),
("sessions", "--json"),
)
Expand Down
14 changes: 7 additions & 7 deletions scripts/pgso/qualify.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
("help", ("help",)),
("version", ("--version",)),
("status", ("status", "--json")),
("background", ("background", "--json")),
("doctor", ("doctor", "--json")),
("sessions", ("sessions", "--json")),
)
Expand Down Expand Up @@ -693,22 +692,23 @@ def build_profile_linked_benchmarks(
production_paths.logs / "supplements" / plan.selector
),
)
linked[plan.selector] = ProfileLinkedBenchmark(
pair=pair,
supplement_path=supplement_path,
supplement=supplement,
)
for plan in BENCHMARK_PLANS:
merge_profile_supplement(
toolchain,
production_profile=production_paths.merged_profile,
supplement_text=supplement_path,
supplement_text=linked[plan.selector].supplement_path,
log_path=(
production_paths.logs
/ "supplements"
/ plan.selector
/ "merge.json"
),
)
linked[plan.selector] = ProfileLinkedBenchmark(
pair=pair,
supplement_path=supplement_path,
supplement=supplement,
)
return linked


Expand Down
10 changes: 4 additions & 6 deletions scripts/pgso/tests/test_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
"tui-resume-brutal.test.ts",
"tui-permissions.test.ts",
"tui-interrupt-recovery.test.ts",
"tui-subagent-manager.test.ts",
"tui-terminal-tool.test.ts",
"tui-native-clear-recovery.test.ts",
"tui-gateway-stream-lifecycle.test.ts",
Expand Down Expand Up @@ -112,7 +111,6 @@ def direct_scenarios(self) -> list[dict[str, object]]:
("direct-help", ("help",)),
("direct-version", ("--version",)),
("direct-status", ("status", "--json")),
("direct-background", ("background", "--json")),
("direct-doctor", ("doctor", "--json")),
("direct-sessions", ("sessions", "--json")),
)
Expand Down Expand Up @@ -176,12 +174,12 @@ def test_load_separates_training_and_verification_scenarios(self) -> None:

corpus = load_corpus(self.write_manifest(payload), repo_root=self.root)

self.assertEqual(6, len(corpus.scenarios))
self.assertEqual(5, len(corpus.scenarios))
self.assertEqual(
("e2e-new-feature",),
tuple(scenario.name for scenario in corpus.verification_scenarios),
)
self.assertEqual(7, len(corpus.candidate_scenarios))
self.assertEqual(6, len(corpus.candidate_scenarios))

def test_load_rejects_duplicate_test_files_across_phases(self) -> None:
test_file = "shared.test.ts"
Expand Down Expand Up @@ -365,8 +363,8 @@ def test_production_manifest_classifies_every_e2e_file(self) -> None:
EXCLUDED_E2E_TESTS,
tuple(test_file for test_file, _ in corpus.intentional_exclusions),
)
self.assertEqual(36, len(corpus.scenarios))
self.assertEqual(53, len(corpus.candidate_scenarios))
self.assertEqual(34, len(corpus.scenarios))
self.assertEqual(51, len(corpus.candidate_scenarios))
self.assertEqual(
{
"direct-help": 100,
Expand Down
2 changes: 1 addition & 1 deletion scripts/pgso/tests/test_distributed.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def test_measurement_plans_come_from_the_authoritative_definitions(self) -> None
{
"include": [
{"name": name}
for name in ("help", "version", "status", "background", "doctor", "sessions")
for name in ("help", "version", "status", "doctor", "sessions")
]
},
run_plan("startup", missing_corpus, 20),
Expand Down
23 changes: 19 additions & 4 deletions scripts/pgso/tests/test_qualify.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ def test_percentile_uses_nearest_rank(self) -> None:
self.assertEqual(50.0, percentile(samples, 0.50))
self.assertEqual(95.0, percentile(samples, 0.95))

def test_production_plans_cover_six_startup_and_six_heavy_workloads(self) -> None:
self.assertEqual(6, len(STARTUP_COMMANDS))
def test_production_plans_cover_five_startup_and_six_heavy_workloads(self) -> None:
self.assertEqual(5, len(STARTUP_COMMANDS))
workload_names = tuple(
workload.name
for plan in BENCHMARK_PLANS
Expand Down Expand Up @@ -308,7 +308,7 @@ def test_startup_measurement_uses_one_thousand_samples_in_balanced_blocks(self)
hyperfine_calls = [
command for command in calls if command[0] == str(hyperfine)
]
self.assertEqual(600, len(hyperfine_calls))
self.assertEqual(500, len(hyperfine_calls))
for command_start in range(0, len(hyperfine_calls), 100):
command_rounds = hyperfine_calls[command_start : command_start + 100]
for round_index, command in enumerate(command_rounds):
Expand Down Expand Up @@ -595,6 +595,7 @@ def test_builds_all_profile_linked_benchmarks_before_candidate_link(self) -> Non
paths = PipelinePaths.create(self.root / "run")
paths.merged_profile.write_bytes(b"production profile")
built_selectors: list[str] = []
supplement_events: list[str] = []

def fake_build(_toolchain, _repo_root, output_dir, plan):
built_selectors.append(plan.selector)
Expand All @@ -616,6 +617,7 @@ def fake_build(_toolchain, _repo_root, output_dir, plan):
)

def fake_create(_toolchain, **kwargs):
supplement_events.append(f"create:{kwargs['output_text'].stem}")
output_text = kwargs["output_text"]
output_text.write_text("supplement\n")
return ProfileSupplement(
Expand All @@ -624,6 +626,11 @@ def fake_create(_toolchain, **kwargs):
total_counter_value=8,
)

def fake_merge(_toolchain, **kwargs):
supplement_events.append(
f"merge:{kwargs['supplement_text'].stem}"
)

with (
mock.patch(
"scripts.pgso.qualify.build_benchmark_pair",
Expand All @@ -634,7 +641,8 @@ def fake_create(_toolchain, **kwargs):
side_effect=fake_create,
),
mock.patch(
"scripts.pgso.qualify.merge_profile_supplement"
"scripts.pgso.qualify.merge_profile_supplement",
side_effect=fake_merge,
) as merge,
):
linked = build_profile_linked_benchmarks(
Expand All @@ -652,6 +660,13 @@ def fake_create(_toolchain, **kwargs):
tuple(built_selectors),
)
self.assertEqual(len(BENCHMARK_PLANS), merge.call_count)
self.assertEqual(
[
*(f"create:{plan.selector}" for plan in BENCHMARK_PLANS),
*(f"merge:{plan.selector}" for plan in BENCHMARK_PLANS),
],
supplement_events,
)

def fake_map(_toolchain, **kwargs):
kwargs["output_text"].write_text("mapped text\n")
Expand Down
Loading
Loading