Problem
The Fleet Manager renders a Directory column for every run (screens/runs.py::_directory_cell, fed by RunSummary.cwd ← the system.cwd the engine stamps into workflow_started), so the fleet is already understood to span directories. The TUI itself has no such flexibility: it is nailed to whatever directory conductor fleet was started in, cannot show you which one that is, and offers no way to change it. Working on a second project means quitting, cd, and relaunching.
That cwd is not cosmetic — it silently decides two things:
- Relative workflow references on the New Run screen resolve against it.
resolve_ref returns the reference verbatim as ResolvedRef(kind="file", path=Path(ref)) (registry/resolver.py:80) and resolve_workflow then calls workflow_path.exists() (fleet/launch.py). So ./examples/simple-qa.yaml resolves only if the TUI happened to be started in the repo root; from anywhere else it is a flat "Workflow file not found".
- Launched runs inherit it.
cli/bg_runner.py never passes cwd= to _spawn_detached_posix / _spawn_detached_windows, so the detached child gets the launcher's directory. That directory becomes the run's recorded system.cwd (engine/workflow.py:805) — the Directory column — and is the cwd a type: script step with no explicit working_dir executes in (executor/script.py:172). A TUI-launched run is therefore stamped with the TUI's directory whether or not it has anything to do with the workflow.
Proposal
A footer command — d — that switches the Fleet Manager's launch directory, on the Runs and New Run screens.
- Opens a modal picker (the TUI already has the
ModalScreen pattern: actions.py::ConfirmKillModal, GateOptionsModal). Either a path Input with ~ expansion, or Textual's DirectoryTree — the latter is more discoverable, the former faster for someone who knows the path; a tree with an editable path field is the obvious both.
- The chosen directory becomes the base for relative workflow references on New Run, and the cwd the next launched run inherits.
- Not persisted. It resets to the process's launch cwd on the next
conductor fleet. No config.toml key, no state file.
- Not a view filter. The Runs and History tables keep showing the whole fleet; the Directory column is already there for the eye. Scoping the views to a directory is a separate question and deliberately out of scope here.
Design notes
The footer has no room left — and is already one column over
Measured against the real app at 100×30 with a gated run selected:
Detail 0–10 Dash 10–18 Kill 18–26 Gate 26–34 New 35–43
Providers 43–56 Registries 56–70 History 70–81 Kill all 81–93 Quit 93–101
(command palette occupies 88–100)
q Quit already ends at 101 in a 100-wide footer — clipped, by the exact criterion test_footer_fits_without_truncation applies (tests/test_fleet/test_tui_runs.py:1768). That test passes only because its _write_record helper writes a plain record, so check_action hides g Gate and the tail lands at 93 instead.
Consequences for this feature:
- The description must be terse —
d Dir, not d Directory. The comment on RunsScreen.BINDINGS already explains that an overflowing footer truncates mid-word rather than wrapping, which is how h History vanished once before.
- The footer test's fixture should be extended to cover the gated case regardless of this feature — it is guarding a budget it cannot currently see the worst case of.
- If
d Dir still does not fit, columns have to be reclaimed before the binding lands (shortening Registries/Providers, or moving a fleet-scoped action into the command palette).
os.chdir vs. threading the directory explicitly
os.chdir gets both effects for free (relative resolution and the inherited child cwd) with no signature changes, but it mutates process-global state that the TUI's asyncio.to_thread workers (issue #437) share. In practice the blast radius looks small — everything those workers read is already absolute (rundir.runs_dir(), $TMPDIR-rooted event logs) — but it is invisible coupling.
The explicit alternative is resolve_workflow(ref, *, base_dir=...) plus a new cwd= parameter on launch_background forwarded to subprocess.Popen. It costs a change to bg_runner.py (a heavily load-bearing module) but keeps the state in signatures where it can be seen and tested. Preference is the explicit form; worth settling before implementation.
Trap: the workflow path must be absolutized before launch
Today a relative reference works by coincidence — launch_background puts str(workflow_path) straight into argv and the child inherits the parent's cwd, so parent and child agree on what it means. The moment those two directories can differ, a relative ResolvedWorkflow.path resolves against the wrong base (or nothing). resolve_workflow should return an absolute path as part of this change, independent of which mechanism above is chosen.
Switching must be visible
The Runs screen displays every run's directory and never its own. A d command that changes state nothing on screen reflects is a trap — the current launch directory needs a home in the UI (Runs header/subtitle, and the New Run screen near the reference field, where it explains what a relative path is relative to).
Explicitly unaffected
runtime.working_dir / agent.working_dir resolve against the workflow file's directory, not the process cwd (engine/workflow.py:638), as do sub-workflow references when workflow_path is set (engine/workflow.py:1802, :1929). Switching the fleet's launch directory must not change any of them.
Acceptance criteria
Problem
The Fleet Manager renders a Directory column for every run (
screens/runs.py::_directory_cell, fed byRunSummary.cwd← thesystem.cwdthe engine stamps intoworkflow_started), so the fleet is already understood to span directories. The TUI itself has no such flexibility: it is nailed to whatever directoryconductor fleetwas started in, cannot show you which one that is, and offers no way to change it. Working on a second project means quitting,cd, and relaunching.That cwd is not cosmetic — it silently decides two things:
resolve_refreturns the reference verbatim asResolvedRef(kind="file", path=Path(ref))(registry/resolver.py:80) andresolve_workflowthen callsworkflow_path.exists()(fleet/launch.py). So./examples/simple-qa.yamlresolves only if the TUI happened to be started in the repo root; from anywhere else it is a flat "Workflow file not found".cli/bg_runner.pynever passescwd=to_spawn_detached_posix/_spawn_detached_windows, so the detached child gets the launcher's directory. That directory becomes the run's recordedsystem.cwd(engine/workflow.py:805) — the Directory column — and is the cwd atype: scriptstep with no explicitworking_direxecutes in (executor/script.py:172). A TUI-launched run is therefore stamped with the TUI's directory whether or not it has anything to do with the workflow.Proposal
A footer command —
d— that switches the Fleet Manager's launch directory, on the Runs and New Run screens.ModalScreenpattern:actions.py::ConfirmKillModal,GateOptionsModal). Either a pathInputwith~expansion, or Textual'sDirectoryTree— the latter is more discoverable, the former faster for someone who knows the path; a tree with an editable path field is the obvious both.conductor fleet. Noconfig.tomlkey, no state file.Design notes
The footer has no room left — and is already one column over
Measured against the real app at 100×30 with a gated run selected:
q Quitalready ends at 101 in a 100-wide footer — clipped, by the exact criteriontest_footer_fits_without_truncationapplies (tests/test_fleet/test_tui_runs.py:1768). That test passes only because its_write_recordhelper writes a plain record, socheck_actionhidesg Gateand the tail lands at 93 instead.Consequences for this feature:
d Dir, notd Directory. The comment onRunsScreen.BINDINGSalready explains that an overflowing footer truncates mid-word rather than wrapping, which is howh Historyvanished once before.d Dirstill does not fit, columns have to be reclaimed before the binding lands (shorteningRegistries/Providers, or moving a fleet-scoped action into the command palette).os.chdirvs. threading the directory explicitlyos.chdirgets both effects for free (relative resolution and the inherited child cwd) with no signature changes, but it mutates process-global state that the TUI'sasyncio.to_threadworkers (issue #437) share. In practice the blast radius looks small — everything those workers read is already absolute (rundir.runs_dir(),$TMPDIR-rooted event logs) — but it is invisible coupling.The explicit alternative is
resolve_workflow(ref, *, base_dir=...)plus a newcwd=parameter onlaunch_backgroundforwarded tosubprocess.Popen. It costs a change tobg_runner.py(a heavily load-bearing module) but keeps the state in signatures where it can be seen and tested. Preference is the explicit form; worth settling before implementation.Trap: the workflow path must be absolutized before launch
Today a relative reference works by coincidence —
launch_backgroundputsstr(workflow_path)straight into argv and the child inherits the parent's cwd, so parent and child agree on what it means. The moment those two directories can differ, a relativeResolvedWorkflow.pathresolves against the wrong base (or nothing).resolve_workflowshould return an absolute path as part of this change, independent of which mechanism above is chosen.Switching must be visible
The Runs screen displays every run's directory and never its own. A
dcommand that changes state nothing on screen reflects is a trap — the current launch directory needs a home in the UI (Runs header/subtitle, and the New Run screen near the reference field, where it explains what a relative path is relative to).Explicitly unaffected
runtime.working_dir/agent.working_dirresolve against the workflow file's directory, not the process cwd (engine/workflow.py:638), as do sub-workflow references whenworkflow_pathis set (engine/workflow.py:1802,:1929). Switching the fleet's launch directory must not change any of them.Acceptance criteria
dappears in the footer on the Runs and New Run screens and opens a directory picker.NewRunScreen's existing error handling).system.cwdand shows it in the Runs table's Directory column.conductor fleet(no persistence).test_footer_fits_without_truncationcovers the gated (g Gatevisible) worst case and passes with the new binding present.