Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(agents): restore agent page actions, keep early hook reports
- agents page: row click, Open next, Copy team prompt, Install skill had lost their handlers
- first hook report detects the agent now, so the 2s watcher no longer wipes it
- agent prompt/wait/cmd/extract find a just-started agent instead of 409
- resume runs the stored executable (cursor-agent, agy), hook flags before args
- claude AskUserQuestion/ExitPlanMode count as blocked, not working
- done-unseen also when the tab is hidden or on another view; cleared on pane focus
  • Loading branch information
codejunction authored and claude committed Oct 5, 2026
commit 3e46e1ec1e3605c6c119368d40f5b5b07140edc7
4 changes: 4 additions & 0 deletions shelldeck/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
"letta": lambda i: ["letta", "--conversation", "default", "--agent", i[8:]] if i.startswith("default:") else ["letta", "--conversation", i],
}
STATES = {WORKING, IDLE, DONE, BLOCKED, SESSION} # an explicit state in the installed command (matcher-specific hooks)
# Claude tools that stop and wait for the user (no PermissionRequest fires for them)
ASKS_USER = {"AskUserQuestion": "question", "ExitPlanMode": "approval"}
SESSION_KEYS = ("session_id", "sessionId", "conversation_id", "conversationId")


Expand All @@ -82,6 +84,8 @@ def state_for(agent: str, event: dict, forced: str | None = None) -> tuple[str |
return forced, (("question" if asks else "approval") if forced == BLOCKED else None)
name = event.get("hook_event_name") or ""
state = EVENTS.get(agent, {}).get(name)
if agent == "claude" and name == "PreToolUse" and event.get("tool_name") in ASKS_USER:
return BLOCKED, ASKS_USER[event["tool_name"]] # these tools wait on you; PostToolUse sets working again
if agent == "claude" and name == "Notification":
kind = event.get("notification_type") or ""
msg = str(event.get("message") or "").lower()
Expand Down
48 changes: 32 additions & 16 deletions shelldeck/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1457,10 +1457,10 @@ def _resume_plan(sid: str, auto: bool = False) -> tuple[str | None, str | None]:
if auto and db.list_handoffs(to_sid=sid, status="open"):
return None, "open_handoff" # a hand-off is reviewed by a person, never resumed silently
try: # per-run hooks as the agent's own flag (Claude's --settings), placed before the resume args
extra = integrations.run_flags(rec["agent"])
return team.launch_line(rec["agent"], extra=extra) + " " + " ".join(argv[1:]), None
extra = [team._arg(a) for a in integrations.run_flags(rec["agent"])]
except (ValueError, OSError):
return " ".join(argv), None
extra = []
return " ".join([argv[0], *extra, *argv[1:]]), None # argv[0] is the checked executable (cursor-agent, agy)


def _auto_resume(sid: str) -> None:
Expand Down Expand Up @@ -1546,6 +1546,28 @@ def _remember_state(sid: str, state: str) -> None:
log.debug("agent session state not saved", exc_info=True)


def _detected(sid: str, key: str) -> None:
"""A new agent process in a terminal: new generation, context session, and none of the old one's reports."""
_generation[0] += 1
if sid in agent_kind: # another agent took over the terminal: close the previous run first
_capture(sid, "end_session", _ctx_session(sid))
agent_kind[sid] = (key, _generation[0])
_capture(sid, "start_session", _ctx_session(sid), key)
_emit("agent.detected", {"session_id": sid, "agent": key, "generation": _generation[0]})
reports.forget(sid)


async def _agent_key(sid: str) -> str | None:
"""The terminal's agent, detecting it now when the 2s watcher hasn't yet (a hook's first report, a fresh launch),
so early reports aren't dropped when the watcher catches up."""
if sid in agent_kind:
return agent_kind[sid][0]
key = await _agent_in(sid)
if key and sid not in agent_kind:
_detected(sid, key)
return key


async def _check_agents() -> None:
found = await asyncio.to_thread(agents.running, _shell_pids())
now = time.monotonic()
Expand All @@ -1560,14 +1582,8 @@ async def _check_agents() -> None:
agent_status.pop(sid, None)
natives = await asyncio.to_thread(agents.native, {sid: pid for sid, pid in _shell_pids().items() if sid in found})
for sid, (key, _) in found.items():
if agent_kind.get(sid, ("",))[0] != key: # a new agent process: forget the old one's reports
_generation[0] += 1
if sid in agent_kind: # another agent took over the terminal: close the previous run first
_capture(sid, "end_session", _ctx_session(sid))
agent_kind[sid] = (key, _generation[0])
_capture(sid, "start_session", _ctx_session(sid), key)
_emit("agent.detected", {"session_id": sid, "agent": key, "generation": _generation[0]})
reports.forget(sid)
if agent_kind.get(sid, ("",))[0] != key:
_detected(sid, key)
was = agent_state.get(sid, "idle")
agent_state[sid] = await asyncio.to_thread(_next_state, sid, was, now)
reports.put_heuristic(sid, lifecycle.heuristic(agent_state[sid], now))
Expand All @@ -1592,7 +1608,7 @@ async def agent_report(request: Request, payload: dict):
return err("invalid_report_token", 403)
if payload.get("session_id") not in (None, sid):
return err("session_mismatch", 403)
key = agent_kind.get(sid, (None,))[0] or await _agent_in(sid)
key = await _agent_key(sid)
if not key:
return err("no_agent_running", 409)
try:
Expand Down Expand Up @@ -1633,7 +1649,7 @@ async def agent_command_list(agent: str):
async def agent_command(session_id: str, payload: dict):
"""Type an agent's native command (e.g. Gemini's /compress for `compact`) into its terminal. Refused while the agent
is blocked (it would answer the question) or working (most agents ignore or queue commands mid-turn) unless `force`."""
key = agent_kind.get(session_id, (None,))[0] or await _agent_in(session_id)
key = await _agent_key(session_id)
if not key:
return err("no_agent_running", 409)
try:
Expand Down Expand Up @@ -1662,7 +1678,7 @@ async def agent_command(session_id: str, payload: dict):
async def extract_facts(session_id: str):
"""On demand only: one prompt asking the agent to record its own session knowledge (no model runs in shelldeck).
Refused while the agent is blocked or working, like any prompt."""
key = agent_kind.get(session_id, (None,))[0] or await _agent_in(session_id)
key = await _agent_key(session_id)
if not key:
return err("no_agent_running", 409)
state = (agent_status.get(session_id) or {}).get("state")
Expand All @@ -1682,7 +1698,7 @@ async def agent_prompt(payload: dict):
text = str(payload.get("text") or "")[:20000]
if not text.strip():
return err("text_required")
if sid not in agent_kind:
if not await _agent_key(sid):
return err("no_agent_running", 409)
if (agent_status.get(sid) or {}).get("state") == "blocked":
return err("agent_blocked", 409)
Expand Down Expand Up @@ -1775,7 +1791,7 @@ async def agent_wait(payload: dict):
timeout = min(max(float(payload.get("timeout") or 600), 0.1), 3600)
if not until:
return err("invalid_until")
if sid not in agent_kind:
if not await _agent_key(sid):
return err("no_agent_running", 409)
return await _wait_for(sid, agent_kind[sid][1], lambda st: st["state"] in until, timeout)

Expand Down
21 changes: 20 additions & 1 deletion shelldeck/static/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,29 @@ async function onClick(e, el) {
return toastError(err);
}
}
const next = e.target.closest("[data-open-next]")?.dataset.openNext;
if (next) return showSession(next);
if (e.target.closest("[data-copy-prompt]")) {
await navigator.clipboard.writeText(TEAM_PROMPT).catch(() => {});
return toast({ title: "Team prompt copied", body: "Paste it into each agent." });
}
const skill = e.target.closest("[data-skill]")?.dataset.skill;
if (skill) {
try {
const { results } = await api("/api/skills", { method: "POST", body: { agents: [skill] } });
toast({ title: "Skill installed", body: results.map((r) => r.path).join("\n") });
return renderAgents(el);
} catch (err) {
return toastError(err);
}
}
const resume = e.target.closest("[data-resume]")?.dataset.resume;
if (resume) return resumeDevin(data.devin_sessions.find((d) => d.id === resume));
const key = e.target.closest("[data-launch]")?.dataset.launch;
if (!key) return;
if (!key) {
const sid = e.target.closest("tr[data-sid]")?.dataset.sid;
return sid ? showSession(sid) : undefined;
}
const model = e.target.closest(".ag-card").querySelector("[data-model]")?.value;
// the server builds the line with the agent's own flags (model, per-run hooks such as Claude's --settings)
try {
Expand Down
4 changes: 3 additions & 1 deletion shelldeck/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,7 @@ function markFocus() {
}

export function setFocus(sid) {
if (S.doneUnseen.delete(sid)) renderSidebar(); // looked at it in place
if (isFree()) raise(sid);
if (S.focused === sid) return;
S.focused = sid;
Expand Down Expand Up @@ -1945,7 +1946,8 @@ function connectAlarms() {
S.serverAgentState[msg.session_id] = msg.state;
if (msg.status) {
S.serverStatus[msg.session_id] = msg.status;
if (msg.status.state === "done" && msg.session_id !== S.focused) S.doneUnseen.add(msg.session_id);
const watching = !document.hidden && document.hasFocus() && S.view === "terminals" && S.focused === msg.session_id;
if (msg.status.state === "done" && !watching) S.doneUnseen.add(msg.session_id);
if (msg.status.state === "exited") S.doneUnseen.delete(msg.session_id);
renderSidebar();
}
Expand Down
7 changes: 7 additions & 0 deletions tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,10 @@ def test_devin_installs_only_events_devin_accepts():
assert set(integrations.INSTALLERS["devin"].events()) <= DEVIN_EVENTS
act = hook.report("devin", {"hook_event_name": "PostToolUse", "tool_name": "exec", "tool_input": {"command": "pytest"}})
assert act["activity"] == {"command": "pytest", "ok": True}


def test_claude_tools_that_ask_you_block():
ask = {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}
assert hook.state_for("claude", ask) == ("blocked", "question")
assert hook.state_for("claude", {**ask, "tool_name": "ExitPlanMode"}) == ("blocked", "approval")
assert hook.state_for("claude", {**ask, "hook_event_name": "PostToolUse"}) == ("working", None)
49 changes: 49 additions & 0 deletions tests/test_shelldeck.py
Original file line number Diff line number Diff line change
Expand Up @@ -1191,3 +1191,52 @@ async def fake_type(sid, text, enter=True):
assert len(typed) == 1 and "sd remember" in typed[0] and "don't read memory first" in typed[0]
assert len(server.EXTRACT_PROMPT) < 420 # one short turn
server.agent_status.pop("tx", None)


def test_first_report_detects_the_agent_and_survives_the_watcher(client, monkeypatch):
"""A hook's report that beats the 2s watcher registers the agent, so the watcher doesn't wipe it."""
class Live:
pid = 1

def isalive(self):
return True

def kill(self):
pass

async def quiet(msg, host_only=False):
pass

async def agent_in(sid):
return "claude"

monkeypatch.setattr(server, "_broadcast", quiet)
monkeypatch.setattr(server, "_agent_in", agent_in)
monkeypatch.setattr(server, "_capture", lambda *a, **k: None)
monkeypatch.setitem(server.manager.procs, "te", Live())
monkeypatch.setitem(server.report_tokens, "te", "tok-te")
server.agent_kind.pop("te", None)
body = {"source": "integration:claude", "agent": "claude", "state": "working"}
assert client.post("/api/agent-reports", json=body, headers={"X-Shelldeck-Report-Token": "tok-te"}).json()["status"]["state"] == "working"
gen = server.agent_kind["te"]
monkeypatch.setattr(server.agents, "running", lambda pids: {"te": ("claude", 1)})
monkeypatch.setattr(server.agents, "native", lambda pids: {})
monkeypatch.setattr(server, "_shell_pids", lambda: {"te": 1})
asyncio.run(server._check_agents())
assert server.agent_kind["te"] == gen and server.agent_status["te"]["source"] == "integration"
for d in (server.agent_kind, server.agent_status, server.agent_state):
d.pop("te", None)
server.reports.forget("te")


def test_resume_runs_the_stored_executable(client, tmp_path, monkeypatch):
work = tmp_path / "w3"
work.mkdir()
pid = client.post("/api/projects", json={"path": str(work)}).json()["id"]
sid = client.post("/api/sessions", json={"project_id": pid, "shell": shells.default_kind()}).json()["id"]
monkeypatch.setattr(server.shutil, "which", lambda exe: f"/usr/bin/{exe}")
monkeypatch.setattr(server.integrations, "run_flags", lambda agent: ["--settings", "/tmp/s.json"] if agent == "claude" else [])
db.save_agent_session(sid, "cursor", "integration:cursor", "c1", ["cursor-agent", "--resume", "c1"], "done")
assert server._resume_plan(sid) == ("cursor-agent --resume c1", None)
db.save_agent_session(sid, "claude", "integration:claude", "a1", ["claude", "--resume", "a1"], "done")
assert server._resume_plan(sid) == ("claude --settings /tmp/s.json --resume a1", None)