Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 7 additions & 1 deletion packages/webui/server/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { shutdownMcodeAcpSingleton } from './lib/acp-client.js'
import { installGracefulShutdown } from './lib/graceful-shutdown.js'
import { init as initSettings, getPersistPath, getTokenEnabled } from './lib/settings.js'
import { setTokenAuthEnabled as setAuthTokenEnabled } from './lib/auth.js'
import { pushTokenFirstRun } from './lib/state-bus.js'
import { pushTokenFirstRun, startSubagentStatusPolling } from './lib/state-bus.js'

installGlobalErrorHandlers()

Expand Down Expand Up @@ -126,6 +126,12 @@ listenWithPortFallback(server, {
onListening: (boundPort) => {
setServingPort(boundPort)
stopTranscriptSync = startTranscriptSync()
// Slice 06 (Agent Team): start polling `local_runtime_background_tasks`
// so the parent's `→ task` tool line carries a live running badge
// while a subagent is busy. The poller is a no-op when no runtime db
// is present (e.g. a freshly-installed machine that hasn't run mcode
// yet), so we always call it; it returns early on its own.
startSubagentStatusPolling()
console.log(`[webui] listening on http://${HOST}:${boundPort}`)
console.log(`[webui] http layer: ${SERVER_IMPL}`)
console.log(`[webui] LAN url: http://${LAN_IP}:${boundPort}`)
Expand Down
101 changes: 101 additions & 0 deletions packages/webui/server/lib/agent-team-detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// webui/server/lib/agent-team-detect.js
// Subagent detection — parse `<task_result session_id="...">` out of a
// tool output body and match it against the runtime db's subagent task
// rows so the parent's tool line can carry a jumpable subagent reference
// and the running badge.
//
// The runtime's parent stream does not emit a dedicated subagent event;
// per .tickets/webui-parity/06-agent-team-panel.md the only authoritative
// signal the webui has is the `<task_result ... session_id="mvs_…">`
// literal that lands inside the body of a `→ task` tool result. The
// session row in `local_runtime_sessions` and the task row in
// `local_runtime_background_tasks` both exist by the time the result
// arrives; this module is the bridge that records the toolCallId ↔
// childSessionId pair on the cid and surfaces the live task status for
// the running badge.
//
// Every function in this module is pure (no I/O) or read-only — the
// runtime db is open `readonly: true` and the cid state is mutated
// through the public helpers in lib/state-bus.js, never directly.

import { AGENT_TEAM_STATUS, projectTaskStatus } from "./agent-team-status.js";
import {
findSubagentTaskByToolCallId,
hasSubagentTaskByToolCallId,
} from "./agent-team-tasks.js";

// The literal the engine writes inside the parent stream's tool body.
// Captured by the ticket's R8 investigation; non-greedy match keeps the
// attribute parser from gobbling a second sibling `<task_result ...>`
// tag. `session_id` is the only attribute we care about today; if the
// engine grows more (parent_turn_id, agent_name, …) we extend the regex
// without rewriting the parser.
const TASK_RESULT_TAG = /<task_result\b[^>]*?\bsession_id=["']([^"']+)["'][^>]*>/i;

// Agent-name attribute, if the engine ever inlines it. Optional — the
// live task row is the primary source for `agentName`.
const TASK_RESULT_AGENT = /<task_result\b[^>]*?\bagent=["']([^"']+)["']/i;

/**
* Pull the child session id (and optional agent hint) out of a tool
* result body. Returns null when the body does not look like a
* `<task_result>` payload.
*
* @param {string|undefined|null} body
* @returns {{ sessionId: string, agentName: string|null } | null}
*/
export function parseTaskResult(body) {
if (typeof body !== "string" || body.length === 0) return null;
const sidMatch = TASK_RESULT_TAG.exec(body);
if (!sidMatch) return null;
const sessionId = (sidMatch[1] || "").trim();
if (!sessionId) return null;
const agentMatch = TASK_RESULT_AGENT.exec(body);
return {
sessionId,
agentName: agentMatch ? (agentMatch[1] || "").trim() || null : null,
};
}

/**
* True when the tool name (or the body's `<task_result>` tag) indicates
* the engine just spawned a subagent. The header name is the cheapest
* signal we have — every `task` tool call the engine dispatches in the
* parent stream becomes a subagent row, so the header name alone is
* enough to start polling the runtime db for the live status.
*
* Tool-name variants observed in R8:
* - "task" — the canonical name
* - "Task" — capitalised by some renderers
* - "delegate" / "delegatetask" — used by older builds
* The match is case-insensitive and tolerant of underscores.
*/
export function isSubagentDispatch(toolName, body) {
if (typeof toolName === "string" && toolName.trim()) {
const n = toolName.trim().toLowerCase().replace(/[^a-z]/g, "");
if (n === "task" || n === "delegate" || n === "delegatetask") return true;
}
// Header may not be present (e.g. the webui attached mid-stream and
// the very first frame is the body). The body's tag is enough.
if (typeof body === "string" && TASK_RESULT_TAG.test(body)) return true;
return false;
}

/**
* Resolve the live subagent task for a toolCallId WITHOUT recording
* anything. Returns the projected status so the running badge can
* render without a per-render db hit on the wire.
*
* Read-only: callers MUST NOT mutate the returned object.
*/
export function readSubagentStatusForToolCall(toolCallId) {
return findSubagentTaskByToolCallId(toolCallId);
}

/** Detect the moment a subagent is born — read-only, no side effects. */
export function detectSubagentBirth(toolCallId) {
return hasSubagentTaskByToolCallId(toolCallId);
}

/** Re-export the status vocabulary so consumers can `import { AGENT_TEAM_STATUS }` here. */
export { AGENT_TEAM_STATUS, projectTaskStatus };
145 changes: 145 additions & 0 deletions packages/webui/server/lib/agent-team-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// webui/server/lib/agent-team-status.js
// DB → UI status projection for the Agent Team panel.
//
// Why this module exists. The runtime db is the source of truth for the
// parent/child session graph, but its column vocabulary is **not** what the
// UI should render:
//
// • `local_runtime_sessions.status` is intentionally narrow: it only records
// `idle | interrupted | aborted | error`. The runtime does not currently
// flip this column while a session is actively running a turn — it stays
// `idle`. Treating that as "not running" is correct; treating it as the
// whole picture is wrong.
//
// • `local_runtime_background_tasks.status` records the *actual* run state
// of a delegated subagent (`running | succeeded | failed | canceled`).
// The session row does not, and projecting only the session column is
// how a sidebar would render a busy subagent as "idle".
//
// The TUI exposes a richer vocabulary (`failed | waiting | running | queued |
// done | stopped`) on its own projection layer; we do not import that, but we
// adopt the same shape so the contract stays greppable. Every UI consumer
// (the agent-team section of the sidebar, the running badge on a parent's
// task tool line, the task-view modal) reads the projected vocabulary below
// rather than raw db strings.
//
// This module is the ONLY place that decides the mapping. Raw values from
// the db are never passed through to the wire. New status values the
// runtime might grow land here as a single guard clause, and tests pin the
// current behavior so a future contributor cannot silently change it.

/**
* The shape the UI renders. Ordered to match the TUI vocabulary for
* greppability; the order is NOT load-bearing for the UI but it does help
* the test reader see the mapping at a glance.
*/
export const AGENT_TEAM_STATUS = Object.freeze({
IDLE: "idle",
QUEUED: "queued",
RUNNING: "running",
WAITING: "waiting",
DONE: "done",
STOPPED: "stopped",
FAILED: "failed",
});

const UI_STATUSES = new Set(Object.values(AGENT_TEAM_STATUS));

/**
* Map a `local_runtime_sessions.status` value to the UI vocabulary.
*
* Real measured values from this machine's db (ticket 06 R8 复核):
* • `idle` — the only state the engine writes for an active session
* • `interrupted` — the engine aborted a turn mid-flight (user stop, crash)
* • `aborted` — the engine aborted a turn cleanly (cancel)
* • `error` — the turn ended on an unrecoverable error
*
* The session row does NOT carry `running` / `done` / `failed` / `queued`.
* Those come from `local_runtime_background_tasks.status` (see
* `projectTaskStatus`). When the runtime grows a richer vocabulary the new
* values land here AND in the matching test.
*/
export function projectSessionStatus(rawStatus) {
const s = typeof rawStatus === "string" ? rawStatus.trim() : "";
if (s === "error") return AGENT_TEAM_STATUS.FAILED;
if (s === "aborted") return AGENT_TEAM_STATUS.FAILED;
if (s === "interrupted") return AGENT_TEAM_STATUS.STOPPED;
// Default: idle covers both an actual `idle` row and an unknown value
// the runtime has not grown yet (we prefer "no claim" over "loud
// failure" for an unrecognised string).
return AGENT_TEAM_STATUS.IDLE;
}

/**
* Map a `local_runtime_background_tasks.status` (when `kind = 'subagent'`)
* to the UI vocabulary.
*
* Measured values from this machine's db (ticket 06 R8 复核):
* • `running` — the subagent is mid-turn (the only value that
* claims "live" on the parent's tool line)
* • `succeeded` — the subagent finished cleanly
* • `failed` — the subagent ended on an error
* • `canceled` — the subagent was stopped or interrupted
*
* `canceled` lands on `stopped` (matches TUI vocabulary), not on `failed`:
* cancellation is a deliberate user action, not a fault.
*/
export function projectTaskStatus(rawStatus) {
const s = typeof rawStatus === "string" ? rawStatus.trim() : "";
if (s === "running") return AGENT_TEAM_STATUS.RUNNING;
if (s === "succeeded") return AGENT_TEAM_STATUS.DONE;
if (s === "failed") return AGENT_TEAM_STATUS.FAILED;
if (s === "canceled") return AGENT_TEAM_STATUS.STOPPED;
// Unknown / empty — render as idle rather than risk a false "running"
// claim on an unrecognised future status.
return AGENT_TEAM_STATUS.IDLE;
}

/**
* Compose the two projections for a session row that has a live task.
*
* The task row's `running` wins — that is the only way to display
* "running" at all, because the session column is intentionally narrow.
* When no task is supplied (a subagent row that has not been picked up by
* the runtime yet, or whose task row has been cleaned up), the session
* column's projection is the answer.
*
* @param {string|undefined|null} sessionRaw
* @param {string|undefined|null} taskRaw
* @returns {string} one of `AGENT_TEAM_STATUS`
*/
export function projectAgentStatus(sessionRaw, taskRaw) {
const fromTask = projectTaskStatus(taskRaw);
if (fromTask === AGENT_TEAM_STATUS.RUNNING) return fromTask;
// If the task is in a terminal state, prefer the task projection — a
// session column stuck at `idle` would otherwise re-paint a `done` /
// `failed` subagent as "running again".
if (taskRaw && String(taskRaw).trim() !== "") {
if (
fromTask === AGENT_TEAM_STATUS.DONE ||
fromTask === AGENT_TEAM_STATUS.FAILED ||
fromTask === AGENT_TEAM_STATUS.STOPPED
) {
return fromTask;
}
}
return projectSessionStatus(sessionRaw);
}

/**
* True when a status is one the UI should treat as "live" (still being
* driven by the engine, not yet terminal). Used by the running badge on
* the parent's task tool line and by the live-render hook in the sidebar
* session tree.
*/
export function isLiveStatus(uiStatus) {
return (
uiStatus === AGENT_TEAM_STATUS.RUNNING ||
uiStatus === AGENT_TEAM_STATUS.WAITING
);
}

/** Defensive read for callers that trust nothing. */
export function isUiStatus(value) {
return typeof value === "string" && UI_STATUSES.has(value);
}
Loading
Loading