An exited workspace shell agent has no Restart, and its menu still offers Stop #129

Open
opened 2026-09-05 14:29:47 +02:00 by lz · 0 comments
Owner

The workspace shell's ⋯ menu gates its one lifecycle action on the session row existing, not on the agent being up. Nothing deletes that row when claude exits, so a shell whose agent is gone still shows "Stop shell agent" and offers no way to restart it.

A regular session does not have this problem: sessionAgentItems offers Restart agent unconditionally and hides Stop agent when the probe says the agent is down.

Where it is

nexus/src/lib/menu/target-menu.tsshellItems:

ctx.shellOpen
  ? { action: { kind: 'stop-shell' }, label: 'Stop shell agent', danger: true }
  : { action: { kind: 'open-shell' }, label: 'Start shell agent' }

ctx.shellOpen comes from menuContextFor (nexus/src/lib/menu/menu-actions.ts):

shellOpen: workspaces.workspaceShellOpen(scope.workerId),

…and workspaceShellOpen is row-existence, nothing more:

workspaceShellOpen(workerId) {
  return this.workspaceShellSessionId(workerId) !== null;
}

Why the row survives

SessionEnd is a hook that records attention state; it does not delete anything. The row is deleted only by closeWorkspaceSession. And the tmux window outlives claude regardless — the launch is claude …; exec bash, so the window stays as a shell. So "the row exists" and "the agent is running" are independent facts, and the menu reads the wrong one.

What the operator sees

Not a dead end, but a bad path:

  1. The shell agent exits (/exit, logout, or a crash).
  2. Its ⋯ menu offers Stop shell agent — for something already stopped.
  3. Choosing it raises a confirm that says "The running claude is killed; conversation state is persisted to disk and can be resumed." — describing the killing of a process that is not running.
  4. That deletes the row, the menu flips to Start shell agent, and a second action starts it.

So recovery takes two actions, one of which is a destructive-sounding confirm that misdescribes what is about to happen.

Surfaced by

PR #126 added an ended mark whose tooltip reads "agent exited — restart it from the ⋯ menu". That is accurate for a session and wrong for the shell, which is what made the gap visible. The wording is not the bug; it is the first thing to point at it.

Suggested fix

The input is already computed one line above and then deliberately thrown away. menuContextFor resolves sessionId for the shell scope:

const sessionId = scope.kind === 'session'
  ? scope.sessionId
  : (workspaces.workspaceShellSessionId(scope.workerId) ?? '');

but then does:

// Workspace scope has no single agent to report on: the card's menu is
// lifecycle-only and the shell's row is Start/Stop-shell, neither of which
// reads this.
agentRunning: scope.kind === 'session' ? workspaces.agentRunning(scope.sessionId) : undefined,

That comment is the reason this was never wired, and it is no longer true — the shell's row should read it. Passing workspaces.agentRunning(sessionId) for the shell scope too, then giving shellItems the same shape as sessionAgentItems (Restart always; Stop only when the agent is up), fixes both halves. The comment needs deleting with it, or the next person will re-derive the same conclusion from it.

Worth checking while in there: whether open-shell on a row that already exists but whose agent is dead does the right thing, or whether the row has to be deleted first — openWorkspaceSession is documented as idempotent, so a Restart may be able to route through it without the Stop.

The workspace shell's ⋯ menu gates its one lifecycle action on **the session row existing**, not on the agent being up. Nothing deletes that row when claude exits, so a shell whose agent is gone still shows **"Stop shell agent"** and offers no way to restart it. A regular session does not have this problem: `sessionAgentItems` offers `Restart agent` unconditionally and hides `Stop agent` when the probe says the agent is down. ## Where it is `nexus/src/lib/menu/target-menu.ts` — `shellItems`: ```ts ctx.shellOpen ? { action: { kind: 'stop-shell' }, label: 'Stop shell agent', danger: true } : { action: { kind: 'open-shell' }, label: 'Start shell agent' } ``` `ctx.shellOpen` comes from `menuContextFor` (`nexus/src/lib/menu/menu-actions.ts`): ```ts shellOpen: workspaces.workspaceShellOpen(scope.workerId), ``` …and `workspaceShellOpen` is row-existence, nothing more: ```ts workspaceShellOpen(workerId) { return this.workspaceShellSessionId(workerId) !== null; } ``` ## Why the row survives `SessionEnd` is a hook that records attention state; it does not delete anything. The row is deleted only by `closeWorkspaceSession`. And the tmux window outlives claude regardless — the launch is `claude …; exec bash`, so the window stays as a shell. So "the row exists" and "the agent is running" are independent facts, and the menu reads the wrong one. ## What the operator sees Not a dead end, but a bad path: 1. The shell agent exits (`/exit`, logout, or a crash). 2. Its ⋯ menu offers **Stop shell agent** — for something already stopped. 3. Choosing it raises a confirm that says *"The running claude is killed; conversation state is persisted to disk and can be resumed."* — describing the killing of a process that is not running. 4. That deletes the row, the menu flips to **Start shell agent**, and a second action starts it. So recovery takes two actions, one of which is a destructive-sounding confirm that misdescribes what is about to happen. ## Surfaced by PR #126 added an `ended` mark whose tooltip reads *"agent exited — restart it from the ⋯ menu"*. That is accurate for a session and wrong for the shell, which is what made the gap visible. The wording is not the bug; it is the first thing to point at it. ## Suggested fix The input is already computed one line above and then deliberately thrown away. `menuContextFor` resolves `sessionId` for the shell scope: ```ts const sessionId = scope.kind === 'session' ? scope.sessionId : (workspaces.workspaceShellSessionId(scope.workerId) ?? ''); ``` but then does: ```ts // Workspace scope has no single agent to report on: the card's menu is // lifecycle-only and the shell's row is Start/Stop-shell, neither of which // reads this. agentRunning: scope.kind === 'session' ? workspaces.agentRunning(scope.sessionId) : undefined, ``` That comment is the reason this was never wired, and it is no longer true — the shell's row *should* read it. Passing `workspaces.agentRunning(sessionId)` for the shell scope too, then giving `shellItems` the same shape as `sessionAgentItems` (Restart always; Stop only when the agent is up), fixes both halves. The comment needs deleting with it, or the next person will re-derive the same conclusion from it. Worth checking while in there: whether `open-shell` on a row that already exists but whose agent is dead does the right thing, or whether the row has to be deleted first — `openWorkspaceSession` is documented as idempotent, so a Restart may be able to route through it without the Stop.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#129
No description provided.