Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c3e8f27
feat(doctor): add opt-in piped stdin for streaming fix execution
matt2e Aug 19, 2026
ff7ef84
fix(doctor): error on a reused FixStdin instead of spawning a doomed fix
matt2e Aug 19, 2026
cd4dae7
fix(doctor): bound the fix runner's two unbounded waits
matt2e Aug 19, 2026
00f994a
fix(staged): stop the doctor fix options literal from breaking on new…
matt2e Aug 19, 2026
a89a536
fix(doctor): write fix stdin inline so `Ok` from `send_line` means de…
matt2e Aug 19, 2026
1191487
feat(staged): recover agent authentication from failed sessions
matt2e Aug 21, 2026
460ffe5
fix(doctor): close the fix stdin pipe when the fix never launches
matt2e Sep 22, 2026
32a0fe4
fix(doctor): key the fix runner's process group to fd 0, not to piped…
matt2e Sep 23, 2026
003665b
fix(doctor): keep FixTimeout honest when a stdin write parks under th…
matt2e Sep 23, 2026
a190fa9
fix(staged): make the login code box reachable in the flow that needs it
matt2e Sep 23, 2026
473c3f2
feat(doctor): add a cancellation handle to the streaming fix runner
matt2e Sep 23, 2026
16912ea
feat(staged): cancel, re-attach and recover agent logins from any client
matt2e Sep 23, 2026
7335370
fix(staged): offer Log in when doctor's auth probe cannot see an expi…
matt2e Sep 23, 2026
ffffa30
fix(staged): key login events, codes and cancels to a per-run id
matt2e Sep 23, 2026
0d4b08a
fix(staged): leave attached logins alone on dialog close and status h…
matt2e Sep 23, 2026
fbd39d8
fix(staged): let the record say whose login the fix dialog ends, and …
matt2e Sep 23, 2026
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
918 changes: 904 additions & 14 deletions apps/staged/src-tauri/src/doctor.rs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2458,6 +2458,10 @@ pub fn run() {
doctor::run_doctor,
doctor::run_doctor_freshness,
doctor::run_doctor_fix,
doctor::start_doctor_login,
doctor::cancel_doctor_login,
doctor::doctor_login_status,
doctor::send_doctor_login_code,
doctor::run_doctor_update,
])
.build(tauri::generate_context!())
Expand Down
25 changes: 24 additions & 1 deletion apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3820,10 +3820,33 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
let report = crate::doctor::run_doctor_freshness().await;
Ok(serde_json::to_value(report).unwrap())
}
"start_doctor_login" => {
let check_id: String = arg(&args, "checkId")?;
let start = crate::doctor::start_doctor_login(app_handle.clone(), check_id).await?;
Ok(serde_json::to_value(start).unwrap())
}
"cancel_doctor_login" => {
let check_id: String = arg(&args, "checkId")?;
let run_id: String = arg(&args, "runId")?;
let cancelled = crate::doctor::cancel_doctor_login(check_id, run_id).await;
Ok(Value::Bool(cancelled))
}
"doctor_login_status" => {
let check_id: String = arg(&args, "checkId")?;
let status = crate::doctor::doctor_login_status(check_id).await;
Ok(serde_json::to_value(status).unwrap())
}
"send_doctor_login_code" => {
let check_id: String = arg(&args, "checkId")?;
let run_id: String = arg(&args, "runId")?;
let code: String = arg(&args, "code")?;
crate::doctor::send_doctor_login_code(check_id, run_id, code).await?;
Ok(Value::Null)
}
"run_doctor_fix" => {
let check_id: String = arg(&args, "checkId")?;
let fix_type: doctor::FixType = arg(&args, "fixType")?;
crate::doctor::run_doctor_fix(check_id, fix_type).await?;
crate::doctor::run_doctor_fix(app_handle.clone(), check_id, fix_type).await?;
Ok(Value::Null)
}
"run_doctor_update" => {
Expand Down
108 changes: 107 additions & 1 deletion apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,16 @@ export interface DoctorCheck {
bridgePath: string | null;
rawOutput: string | null;
authStatus: 'authenticated' | 'notAuthenticated' | 'notApplicable' | 'unknown' | null;
/**
* The provider's interactive login command whenever its binary resolved,
* regardless of `authStatus` — a static capability, not doctor's verdict.
* Unlike `fixCommand`, which is set only when the probe positively reported
* a signed-out agent, this says a login *exists*: the probe can't see an
* expired Claude token, so a live authentication failure has to be able to
* offer one on a check the probe calls `authenticated`. `null` for providers
* without a login command (Pi, Goose) and for non-agent checks.
*/
loginCommand: string | null;
/** Flat version fields mirror the bridge readout (else main) for compat. */
installedVersion: string | null;
latestVersion: string | null;
Expand Down Expand Up @@ -1608,14 +1618,110 @@ export function runDoctorFreshness(): Promise<DoctorReport> {
return invokeCommand('run_doctor_freshness');
}

/** Run a fix for a doctor check, identified by check ID and fix type. */
/**
* Run a fix for a doctor check, identified by check ID and fix type.
*
* Resolves when the fix finishes. An `auth` fix is interactive, so the backend
* runs it on a piped stdin and streams it exactly as `startDoctorLogin` does —
* a caller that wants to show the sign-in URL or feed the code back should
* prefer `startDoctorLogin`, which resolves as soon as the fix is running
* rather than blocking until it ends.
*/
export function runDoctorFix(
checkId: string,
fixType: 'command' | 'bridge' | 'auth'
): Promise<void> {
return invokeCommand('run_doctor_fix', { checkId, fixType });
}

/**
* What `startDoctorLogin` did — began a login, or found one already running for
* the check — and the run id of that login. The latter is not a failure: it is
* the same subprocess the caller wanted, reachable under that run id through
* `doctorLoginStatus`, the `doctor-login-output` stream and
* `sendDoctorLoginCode`.
*
* The run id is the login's identity, not the check id. The backend allows one
* login per check and releases the check's slot before the run's final event
* goes out, so a fresh start can land between the two and then see the earlier
* run's `done` under its own check id. Follow the run id instead.
*/
export interface DoctorLoginStart {
outcome: 'started' | 'alreadyRunning';
runId: string;
}

/**
* Start an interactive login fix, resolving once it is running with the run id
* its events carry. Its output and its completion are delivered through
* `doctor-login-output` events.
*/
export function startDoctorLogin(checkId: string): Promise<DoctorLoginStart> {
return invokeCommand('start_doctor_login', { checkId });
}

/**
* Ask run `runId` of `checkId`'s login to stop, resolving with whether it was
* found. Idempotent, and harmless when nothing is running; `false` also when a
* newer run now holds the check's slot, which is left alone. The end arrives as
* a `doctor-login-output` event with `done` and `cancelled` set — this only asks.
*/
export function cancelDoctorLogin(checkId: string, runId: string): Promise<boolean> {
return invokeCommand('cancel_doctor_login', { checkId, runId });
}

export interface DoctorLoginStatus {
running: boolean;
/** The running login's run id, which its events carry. Null when not running. */
runId: string | null;
/** The last lines the login printed, oldest first. Empty when not running. */
output: string[];
/**
* The `seq` the login's next line will carry; `output` covers the `seq`s from
* `nextSeq - output.length` up to but excluding `nextSeq`. Counted per run.
*/
nextSeq: number;
}

/**
* Whether a login is running for `checkId`, under which run id, and what it has
* printed so far — for a client that lost its record of the login (a web
* refresh, a second client, a reloaded webview). Register the
* `doctor-login-output` listener before calling this, then follow the run id it
* names and merge that run's lines by `seq`: a line below `nextSeq` is in the
* snapshot, one at or above it arrived after the snapshot.
*/
export function doctorLoginStatus(checkId: string): Promise<DoctorLoginStatus> {
return invokeCommand('doctor_login_status', { checkId });
}

/**
* Submit a line — in practice the authentication code the agent CLI asked for —
* to run `runId` of a login started by `startDoctorLogin` or by `runDoctorFix`
* with an `auth` fix type. Rejects, saying so, when that run has ended — even
* if a newer login is running for the check, which does not get the code.
*/
export function sendDoctorLoginCode(checkId: string, runId: string, code: string): Promise<void> {
return invokeCommand('send_doctor_login_code', { checkId, runId, code });
}

export interface DoctorLoginOutput {
checkId: string;
/** The run this event belongs to. Drop events from any run but the one followed. */
runId: string;
line: string | null;
/**
* Position of `line` in the login's output, from zero; on the final event,
* the number of lines it printed. See `DoctorLoginStatus.nextSeq`.
*/
seq: number;
done: boolean;
/** The login's failure. Null on a cancelled login, which is not a failure. */
error: string | null;
/** The login ended because `cancelDoctorLogin` was called on it. */
cancelled: boolean;
}

/**
* Run a source-aware update for a single readout (main CLI or ACP bridge).
*
Expand Down
146 changes: 146 additions & 0 deletions apps/staged/src/lib/features/doctor/AgentLoginPrompt.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<!--
AgentLoginPrompt.svelte — the user-facing half of an interactive agent login.

Shows the sign-in URL the CLI printed (the CLI's own browser launch can fail
silently, and a web-server client never had a browser on the host), a box for
the code the sign-in page hands back, a tail of the fix's output so a login
that is waiting on something else isn't a bare spinner, and a Cancel — the CLI
ignores a closed stdin once it is waiting on its browser callback, so without
one an abandoned login holds the check's login slot until doctor's fix timeout.

Renders nothing unless the shared record in `agentLogin.svelte.ts` belongs to
`checkId`, so it can be dropped beside any check without a guard. A host whose
own chrome already offers the cancel (the Doctor panel's fix dialog) passes
`cancellable={false}` rather than showing two.
-->
<script lang="ts">
import { openUrl } from '../../api/commands';
import { Button } from '$lib/components/ui/button';
import {
agentLogin,
agentLoginFor,
cancelAgentLogin,
submitAgentLoginCode,
} from './agentLogin.svelte';

let {
checkId,
cancellable = true,
}: { checkId: string | null | undefined; cancellable?: boolean } = $props();

const login = $derived(agentLoginFor(checkId));
</script>

{#if login}
{#if login.running}
<div class="login-prompt">
{#if login.url}
{@const url = login.url}
<p class="login-hint">Sign in, then paste the code the page gives you:</p>
<div class="login-url-row">
<code class="login-url">{url}</code>
<Button variant="outline" size="xs" onclick={() => openUrl(url)}>Open</Button>
</div>
{:else}
<p class="login-hint">Starting sign-in…</p>
{/if}
<div class="login-code-row">
<input
class="login-code-input"
aria-label="Authentication code"
placeholder="Paste authentication code"
bind:value={agentLogin.code}
onkeydown={(event) => event.key === 'Enter' && submitAgentLoginCode()}
/>
<Button
variant="outline"
size="xs"
onclick={submitAgentLoginCode}
disabled={!agentLogin.code.trim() || login.sending}
>
{login.sending ? 'Sending…' : 'Submit code'}
</Button>
{#if cancellable}
<Button variant="ghost" size="xs" onclick={cancelAgentLogin} disabled={login.cancelling}>
{login.cancelling ? 'Cancelling…' : 'Cancel'}
</Button>
{/if}
</div>
{#if login.output.length > 0}
<pre class="login-output">{login.output.join('\n')}</pre>
{/if}
</div>
{/if}
{#if login.error}
<p class="login-error">{login.error}</p>
{/if}
{/if}

<style>
.login-prompt {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}

.login-hint {
font-size: var(--size-xs);
color: var(--text-muted);
}

.login-url-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}

.login-url {
flex: 1;
min-width: 0;
font-family: monospace;
font-size: 10px;
color: var(--text-muted);
overflow-wrap: anywhere;
/* Selectable: a user reading this over a remote session copies it by hand. */
user-select: text;
}

.login-code-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}

.login-code-input {
min-width: 180px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 4px 8px;
font-size: var(--size-xs);
}

.login-output {
max-height: 120px;
overflow: auto;
margin: 0;
padding: 6px 8px;
border-radius: 6px;
background: var(--bg-primary);
font-family: monospace;
font-size: 10px;
color: var(--text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}

.login-error {
font-size: var(--size-xs);
color: var(--color-danger, #f85149);
}
</style>
Loading
Loading