diff --git a/backend/conversations.py b/backend/conversations.py
index 5582353..d78ea2e 100644
--- a/backend/conversations.py
+++ b/backend/conversations.py
@@ -210,6 +210,16 @@ _MEMORY_RE = re.compile(
# action happened, and stamp it with that turn's timestamp. These derived values
# are merged non-destructively under `lifecycleAuto` (the live sidecar wins).
#
+# The actual deploy commands (as opposed to the `conv-meta ... deployed` stamp
+# below, which only *records* one after the fact) — reused to detect a deploy
+# in flight, see `_is_deploy_call`.
+_DEPLOY_CMD_RE = re.compile(
+ r"\bdeploy-html\b"
+ r"|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?deploy\b"
+ r"|\bzipgo\s+deploy\b"
+ r"|\bmake\s+deploy[\w.]*\b"
+ r"|\bdeploy[\w.-]*\.sh\b")
+
# Each pattern matches a command/skill-name string; a hit lights its stage(s).
_LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [
# commit-homelab / commit-project both commit *and* push to Gitea.
@@ -219,12 +229,7 @@ _LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [
("pushed", re.compile(r"\bgit\b[^\n|&;]*\bpush\b")),
("merged", re.compile(r"\bgit\s+merge\b")),
# deploy paths: skill/alias, npm/pnpm/yarn deploy, zipgo, make deploy, *.sh.
- ("deployed", re.compile(
- r"\bdeploy-html\b"
- r"|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?deploy\b"
- r"|\bzipgo\s+deploy\b"
- r"|\bmake\s+deploy[\w.]*\b"
- r"|\bdeploy[\w.-]*\.sh\b")),
+ ("deployed", _DEPLOY_CMD_RE),
("notified", re.compile(r"\b(?:notify-done|notify-ask|notify\.sh)\b")),
# explicit conv-meta stamps (e.g. `conv-meta.sh committed`).
("committed", re.compile(r"\bconv-meta(?:\.sh)?\b[^\n|&;]*\bcommitted\b")),
@@ -280,6 +285,16 @@ def _lifecycle_stages(name: str | None, inp: dict) -> set[str]:
return stages
+def _is_deploy_call(name: str | None, inp: dict) -> bool:
+ """True if this tool call itself performs (or wraps) a deploy — used to
+ detect one in flight, unlike `_lifecycle_stages`'s "deployed" hit, which
+ also fires on the post-hoc `conv-meta ... deployed` stamp."""
+ if name == "Skill" and inp.get("skill") == "deploy-html":
+ return True
+ cmd = inp.get("command")
+ return isinstance(cmd, str) and bool(_DEPLOY_CMD_RE.search(cmd))
+
+
def _bucket(tool: str | None) -> str:
return TOOL_BUCKETS.get(tool or "", "tool")
@@ -672,6 +687,10 @@ class ParserState:
self._turn_item: dict | None = None
# Open Task/Agent calls: an entry here *is* a running subagent.
self.agent_calls: dict[str, dict] = {}
+ # Set while a deploy command (Bash or the deploy-html skill) has been
+ # issued and hasn't returned yet — cleared on its tool_result.
+ self.deploying_since: str | None = None
+ self._deploying_tool_use_id: str | None = None
# Skill card awaiting its injected SKILL.md body.
self.pending_skill: dict | None = None
# Timestamp of the previous record (assistant latency baseline).
@@ -850,6 +869,9 @@ class ParserState:
for stage in _lifecycle_stages(b.get("name"), inp):
if ts and (stage not in self.lifecycle or ts > self.lifecycle[stage]):
self.lifecycle[stage] = ts
+ if b.get("id") and _is_deploy_call(b.get("name"), inp):
+ self.deploying_since = ts
+ self._deploying_tool_use_id = b["id"]
if b.get("name") == "Skill":
sk = (b.get("input") or {}).get("skill")
if sk:
@@ -1101,6 +1123,9 @@ class ParserState:
self.agent_calls.pop(b["tool_use_id"], None)
elif call is not None:
call["background"] = True
+ if b.get("tool_use_id") == self._deploying_tool_use_id:
+ self.deploying_since = None
+ self._deploying_tool_use_id = None
if "node_type: memory" in rtext:
self.memories_read.update(_MEMORY_RE.findall(rtext))
if self.full:
@@ -1235,6 +1260,7 @@ class ParserState:
"worktreesAuto": list(self.worktrees.values()),
"tasks": list(self.tasks.values()),
"runningAgents": list(self.agent_calls.values()),
+ "deploying": bool(self.deploying_since),
"lifecycleAuto": self.lifecycle,
"memoriesRead": sorted(self.memories_read),
"doneMarker": bool(_DONE_RE.search(self.last_assistant_text)),
diff --git a/backend/main.py b/backend/main.py
index bc19bc0..a969a31 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -988,6 +988,16 @@ def _running_agents(summary: dict, state: str | None = None) -> list[dict]:
return ra if st == "running" else []
+def _is_deploying(summary: dict, state: str | None = None) -> bool:
+ """True while this conversation has a deploy command in flight (issued, no
+ tool_result yet) — gated the same way as `_running_agents`: a dangling call
+ left open by an interrupted/dead run isn't actually deploying anymore."""
+ if not summary.get("deploying"):
+ return False
+ st = state if state is not None else _conv_meta(summary).get("state")
+ return st == "running"
+
+
def _sidechain_state(ap: pathlib.Path) -> str | None:
"""A subagent's own lifecycle state: it runs exactly as long as the parent's
Task call is still open. `None` if `ap` isn't a subagent transcript.
@@ -1262,6 +1272,7 @@ def _conversation_cards(full: bool) -> list[dict]:
out.append({"id": cid, **{k: s.get(k) for k in keys},
"title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")),
+ "deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": agents_by_conv.get(cid, []),
"meta": m})
@@ -1367,6 +1378,7 @@ def conversation_summary(id: str):
return {"id": id, **{k: s.get(k) for k in _CARD_KEYS},
"title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")),
+ "deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": _agents_by_conversation().get(id, []),
"meta": m}
@@ -1632,6 +1644,7 @@ def conversation_detail(id: str):
data["meta"]["state"] = sub_state
# Resolve the state first: which Task calls count as running depends on it.
data["runningAgents"] = _running_agents(data, data["meta"].get("state"))
+ data["deploying"] = _is_deploying(data, data["meta"].get("state"))
_attach_subagents(ap, data)
# Cross-link the memories this conversation read (recalled) and created.
read_slugs = set(data.get("memoriesRead") or [])
diff --git a/backend/schemas.py b/backend/schemas.py
index 2b3c4f4..8820333 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -384,6 +384,10 @@ class ConversationSummary(Schema):
# Subagents still in flight (their Task call never came back). The list card
# shows one "running" chip per entry while the conversation itself is running.
runningAgents: Optional[list[RunningAgent]] = None
+ # A deploy command this conversation issued hasn't returned yet. The list
+ # card shows a "Deploying…" chip while the conversation itself is running,
+ # same gating as `runningAgents`.
+ deploying: Optional[bool] = None
# Current context size: the last assistant API call's full prompt+reply
# (input + cache read/write + output) — what the next request replays.
# `contextModel` is the model that made that call, so the UI can pick the
diff --git a/frontend/openapi.json b/frontend/openapi.json
index 80133f0..dbd10b9 100644
--- a/frontend/openapi.json
+++ b/frontend/openapi.json
@@ -5257,6 +5257,17 @@
],
"title": "Runningagents"
},
+ "deploying": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Deploying"
+ },
"contextTokens": {
"anyOf": [
{
@@ -5696,6 +5707,17 @@
],
"title": "Runningagents"
},
+ "deploying": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Deploying"
+ },
"contextTokens": {
"anyOf": [
{
diff --git a/frontend/src/business/conversations/components/ConvMetaBits.tsx b/frontend/src/business/conversations/components/ConvMetaBits.tsx
index 6905db7..6a53d28 100644
--- a/frontend/src/business/conversations/components/ConvMetaBits.tsx
+++ b/frontend/src/business/conversations/components/ConvMetaBits.tsx
@@ -591,6 +591,21 @@ export function RunningAgentTags({ agents }: { agents?: RunningAgent[] | null })
);
}
+/**
+ * A deploy command this conversation issued hasn't returned yet. Shown next to
+ * `RunningAgentTags` on the card — same "still in flight" shape, but for the
+ * conversation's own deploy call rather than a spawned subagent.
+ */
+export function DeployingBadge({ deploying }: { deploying?: boolean | null }) {
+ if (!deploying) return null;
+ return (
+
+