fix(doctor): check the shell every new tab is going to launch

$ cat config.json
    { "shell": { "program": "/nonexistent/shell" } }
    $ tty7 new
    tty7: spawning a shell: daemon refused Spawn:
          no such program on this machine: /nonexistent/shell
    $ tty7 doctor | grep config
    config           ok

Nothing can open a tab, and the verb that checks the install says the config
is fine — which it is, in the only sense that row means: the file parses. The
same table already reports an unusable `custom_shells` entry, and that costs a
menu row. This costs every tab, every `tty7 new`, and the GUI's new-tab button.

The check is the daemon's own `shell_program_problem`, moved from
`daemon::pane` to `core::shells` beside `unusable_custom_shells` so both
callers share one definition and the row cannot drift from the refusal it is
predicting — it prints the same sentence the spawn will. Missing, a directory,
and not executable are told apart, because they are three different fixes.

A program given as a bare name is still not reported: the OS resolves it
through PATH and guessing at that would be worse than silence — the moved
comment says so and the moved tests pin it. Skipped under `-m` for the same
reason the hooks row is: a path checked here would answer about the wrong
machine.

Verified against a live daemon across all five cases — missing, directory,
non-executable, bare name, and no `shell` set — with exit 1 for the three that
break and 0 for the two that do not.
This commit is contained in:
l0ng-ai
2026-08-23 06:11:12 +08:00
parent 4e5699dc89
commit fa5b901d8d
5 changed files with 80 additions and 33 deletions
+7 -3
View File
@@ -320,7 +320,7 @@ nothing, so `tty7 agents` shows it standing still and `tty7 wait` sits there
until it times out. Outdated hooks fail the same quiet way. Hooks are a local
install, so under `-m` the row reads `unknown`.
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"config":{"ok","state","dir_writable"},"hooks":{"installed","outdated","not_installed"}}`
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"config":{"ok","state","dir_writable","shell_problem"},"hooks":{"installed","outdated","not_installed"}}`
— the context fields are booleans, not values, and each `hooks` field is a list
of agent slugs. `context` also carries `workspace_gone` and `pane_gone` when a
server answered and could be asked; both are absent when none did, so that "it
@@ -329,8 +329,12 @@ false only when the file failed to parse and tty7 is running on defaults;
`config.state` is the same sentence the table prints. `config.dir_writable` is
probed by writing, not read off the mode bits, because a read-only mount or an
ACL leaves a `0700` directory that refuses every write — false means settings
cannot be saved and `tty7 new` cannot file a workspace. Either of those false
exits `1`, so `tty7 doctor || alert` fires. The extra rows the table can show —
cannot be saved and `tty7 new` cannot file a workspace. `config.shell_problem` is null unless the
configured `shell` names something that cannot be launched — missing, a
directory, not executable — in which case every new tab and every `tty7 new`
fails; a shell given as a bare name is left to `PATH` and never reported here,
and under `-m` the field is null because the shell resolves on the far machine.
Any of those three exits `1`, so `tty7 doctor || alert` fires. The extra rows the table can show —
unread keys, unusable `custom_shells` — are prose, not JSON fields.
## `ws` — workspaces