feat: expand subagent status visibility

This commit is contained in:
Peter Steinberger
2026-01-18 04:44:52 +00:00
parent 1ae415e395
commit b105745299
11 changed files with 643 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
### Fixes

View File

@@ -62,6 +62,7 @@ Text + native (when enabled):
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/cost on|off` (toggle per-response usage line)

View File

@@ -9,6 +9,17 @@ read_when:
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
## Slash command
Use `/subagents` to inspect or control sub-agent runs for the **current session**:
- `/subagents list`
- `/subagents stop <id|#|all>`
- `/subagents log <id|#> [limit] [tools]`
- `/subagents info <id|#>`
- `/subagents send <id|#> <message>`
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run.
- Keep sub-agents isolated by default (session separation + optional sandboxing).

View File

@@ -284,7 +284,7 @@ export function createClawdbotCodingTools(options?: {
];
const pluginGroups = buildPluginToolGroups({
tools,
toolMeta: (tool) => getPluginToolMeta(tool),
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
});
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);

View File

@@ -348,6 +348,11 @@ export function resetSubagentRegistryForTests() {
persistSubagentRuns();
}
export function addSubagentRunForTests(entry: SubagentRunRecord) {
subagentRuns.set(entry.runId, entry);
persistSubagentRuns();
}
export function releaseSubagentRun(runId: string) {
const didDelete = subagentRuns.delete(runId);
if (didDelete) persistSubagentRuns();

View File

@@ -144,6 +144,32 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
description: "Show your sender id.",
textAlias: "/whoami",
}),
defineChatCommand({
key: "subagents",
nativeName: "subagents",
description: "List/stop/log/info subagent runs for this session.",
textAlias: "/subagents",
args: [
{
name: "action",
description: "list | stop | log | info | send",
type: "string",
choices: ["list", "stop", "log", "info", "send"],
},
{
name: "target",
description: "Run id, index, or session key",
type: "string",
},
{
name: "value",
description: "Additional input (limit/message)",
type: "string",
captureRemaining: true,
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "config",
nativeName: "config",

View File

@@ -13,6 +13,7 @@ import {
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import {
handleAbortTrigger,
handleActivationCommand,
@@ -36,6 +37,7 @@ const HANDLERS: CommandHandler[] = [
handleStatusCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,
handleConfigCommand,
handleDebugCommand,
handleStopCommand,

View File

@@ -3,12 +3,14 @@ import {
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "../../agents/tools/sessions-helpers.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
@@ -171,6 +173,30 @@ export async function buildStatusReply(params: {
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ?? sessionEntry?.queueCap ?? sessionEntry?.queueDrop,
);
let subagentsLine: string | undefined;
if (sessionKey) {
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length === 0) {
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
} else {
const active = runs.filter((entry) => !entry.endedAt);
const done = runs.length - active.length;
if (verboseEnabled) {
const labels = active
.map((entry) => entry.label?.trim() || entry.task?.trim() || "")
.filter(Boolean)
.slice(0, 3);
const labelText = labels.length ? ` (${labels.join(", ")})` : "";
subagentsLine = `🤖 Subagents: ${active.length} active${labelText} · ${done} done`;
} else if (active.length > 0) {
subagentsLine = `🤖 Subagents: ${active.length} active`;
}
}
}
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
: undefined;
@@ -206,6 +232,7 @@ export async function buildStatusReply(params: {
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
subagentsLine,
mediaDecisions: params.mediaDecisions,
includeTranscriptUsage: false,
});

View File

@@ -0,0 +1,441 @@
import crypto from "node:crypto";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js";
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
stripToolMessages,
} from "../../agents/tools/sessions-helpers.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { truncateUtf16Safe } from "../../utils.js";
import { stopSubagentsForRequester } from "./abort.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
type SubagentTargetResolution = {
entry?: SubagentRunRecord;
error?: string;
};
const COMMAND = "/subagents";
const ACTIONS = new Set(["list", "stop", "log", "send", "info", "help"]);
function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
const totalSeconds = Math.round(valueMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h${minutes}m`;
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
function formatAgeShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
const minutes = Math.round(valueMs / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
function formatTimestamp(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
return new Date(valueMs).toISOString();
}
function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
}
function formatRunStatus(entry: SubagentRunRecord) {
if (!entry.endedAt) return "running";
const status = entry.outcome?.status ?? "done";
return status === "ok" ? "done" : status;
}
function formatRunLabel(entry: SubagentRunRecord) {
const raw = entry.label?.trim() || entry.task?.trim() || "subagent";
return raw.length > 72 ? `${truncateUtf16Safe(raw, 72).trimEnd()}` : raw;
}
function sortRuns(runs: SubagentRunRecord[]) {
return [...runs].sort((a, b) => {
const aTime = a.startedAt ?? a.createdAt ?? 0;
const bTime = b.startedAt ?? b.createdAt ?? 0;
return bTime - aTime;
});
}
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
if (!raw) return undefined;
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
): SubagentTargetResolution {
const trimmed = token?.trim();
if (!trimmed) return { error: "Missing subagent id." };
if (trimmed === "last") {
const sorted = sortRuns(runs);
return { entry: sorted[0] };
}
const sorted = sortRuns(runs);
if (/^\d+$/.test(trimmed)) {
const idx = Number.parseInt(trimmed, 10);
if (!Number.isFinite(idx) || idx <= 0 || idx > sorted.length) {
return { error: `Invalid subagent index: ${trimmed}` };
}
return { entry: sorted[idx - 1] };
}
if (trimmed.includes(":")) {
const match = runs.find((entry) => entry.childSessionKey === trimmed);
return match ? { entry: match } : { error: `Unknown subagent session: ${trimmed}` };
}
const byRunId = runs.filter((entry) => entry.runId.startsWith(trimmed));
if (byRunId.length === 1) return { entry: byRunId[0] };
if (byRunId.length > 1) {
return { error: `Ambiguous run id prefix: ${trimmed}` };
}
return { error: `Unknown subagent id: ${trimmed}` };
}
function buildSubagentsHelp() {
return [
"🧭 Subagents",
"Usage:",
"- /subagents list",
"- /subagents stop <id|#|all>",
"- /subagents log <id|#> [limit] [tools]",
"- /subagents info <id|#>",
"- /subagents send <id|#> <message>",
"",
"Ids: use the list index (#), runId prefix, or full session key.",
].join("\n");
}
type ChatMessage = {
role?: unknown;
content?: unknown;
name?: unknown;
toolName?: unknown;
};
function normalizeMessageText(text: string) {
return text.replace(/\s+/g, " ").trim();
}
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : "";
const content = message.content;
if (typeof content === "string") {
const normalized = normalizeMessageText(content);
return normalized ? { role, text: normalized } : null;
}
if (!Array.isArray(content)) return null;
const chunks: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
chunks.push(text);
}
}
const joined = normalizeMessageText(chunks.join(" "));
return joined ? { role, text: joined } : null;
}
function formatLogLines(messages: ChatMessage[]) {
const lines: string[] = [];
for (const msg of messages) {
const extracted = extractMessageText(msg);
if (!extracted) continue;
const label = extracted.role === "assistant" ? "Assistant" : "User";
lines.push(`${label}: ${extracted.text}`);
}
return lines;
}
function loadSubagentSessionEntry(params: Parameters<CommandHandler>[0], childKey: string) {
const parsed = parseAgentSessionKey(childKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
const store = loadSessionStore(storePath);
return { storePath, store, entry: store[childKey] };
}
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
if (!normalized.startsWith(COMMAND)) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /subagents from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rest = normalized.slice(COMMAND.length).trim();
const [actionRaw, ...restTokens] = rest.split(/\s+/).filter(Boolean);
const action = actionRaw?.toLowerCase() || "list";
if (!ACTIONS.has(action)) {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
}
const requesterKey = resolveRequesterSessionKey(params);
if (!requesterKey) {
return { shouldContinue: false, reply: { text: "⚠️ Missing session key." } };
}
const runs = listSubagentRunsForRequester(requesterKey);
if (action === "help") {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
}
if (action === "list") {
if (runs.length === 0) {
return { shouldContinue: false, reply: { text: "🧭 Subagents: none for this session." } };
}
const sorted = sortRuns(runs);
const active = sorted.filter((entry) => !entry.endedAt);
const done = sorted.length - active.length;
const lines = [
"🧭 Subagents (current session)",
`Active: ${active.length} · Done: ${done}`,
];
sorted.forEach((entry, index) => {
const status = formatRunStatus(entry);
const label = formatRunLabel(entry);
const runtime =
entry.endedAt && entry.startedAt
? formatDurationShort(entry.endedAt - entry.startedAt)
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
const runId = entry.runId.slice(0, 8);
lines.push(
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
);
});
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (action === "stop") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: "⚙️ Usage: /subagents stop <id|#|all>" } };
}
if (target === "all" || target === "*") {
const { stopped } = stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: requesterKey,
});
const label = stopped === 1 ? "subagent" : "subagents";
return {
shouldContinue: false,
reply: { text: `⚙️ Stopped ${stopped} ${label}.` },
};
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
if (resolved.entry.endedAt) {
return {
shouldContinue: false,
reply: { text: "⚙️ Subagent already finished." },
};
}
const childKey = resolved.entry.childSessionKey;
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey);
const sessionId = entry?.sessionId;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([childKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (entry) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[childKey] = entry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[childKey] = entry;
});
}
return {
shouldContinue: false,
reply: { text: `⚙️ Stop requested for ${formatRunLabel(resolved.entry)}.` },
};
}
if (action === "info") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: " Usage: /subagents info <id|#>" } };
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const run = resolved.entry;
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
: "n/a";
const lines = [
" Subagent info",
`Status: ${formatRunStatus(run)}`,
`Label: ${formatRunLabel(run)}`,
`Task: ${run.task}`,
`Run: ${run.runId}`,
`Session: ${run.childSessionKey}`,
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
`Runtime: ${runtime}`,
`Created: ${formatTimestampWithAge(run.createdAt)}`,
`Started: ${formatTimestampWithAge(run.startedAt)}`,
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
`Cleanup: ${run.cleanup}`,
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
`Outcome: ${outcome}`,
].filter(Boolean);
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (action === "log") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: "📜 Usage: /subagents log <id|#> [limit]" } };
}
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
const lines = formatLogLines(filtered as ChatMessage[]);
const header = `📜 Subagent log: ${formatRunLabel(resolved.entry)}`;
if (lines.length === 0) {
return { shouldContinue: false, reply: { text: `${header}\n(no messages)` } };
}
return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } };
}
if (action === "send") {
const target = restTokens[0];
const message = restTokens.slice(1).join(" ").trim();
if (!target || !message) {
return {
shouldContinue: false,
reply: { text: "✉️ Usage: /subagents send <id|#> <message>" },
};
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = (await callGateway({
method: "agent",
params: {
message,
sessionKey: resolved.entry.childSessionKey,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
},
timeoutMs: 10_000,
})) as { runId?: string };
if (response?.runId) runId = response.runId;
} catch (err) {
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return { shouldContinue: false, reply: { text: `⚠️ Send failed: ${messageText}` } };
}
const waitMs = 30_000;
const wait = (await callGateway({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2000,
})) as { status?: string; error?: string };
if (wait?.status === "timeout") {
return {
shouldContinue: false,
reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` },
};
}
if (wait?.status === "error") {
return {
shouldContinue: false,
reply: {
text: `⚠️ Subagent error: ${wait.error ?? "unknown error"} (run ${runId.slice(0, 8)}).`,
},
};
}
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return {
shouldContinue: false,
reply: {
text:
replyText ??
`✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
};

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import type { MsgContext } from "../templating.js";
@@ -197,3 +198,128 @@ describe("handleCommands context", () => {
expect(result.reply?.text).toContain("Top tools (schema size):");
});
});
describe("handleCommands subagents", () => {
it("lists subagents when none exist", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents list", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents: none");
});
it("returns help for unknown subagents action", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents foo", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("/subagents");
});
it("returns usage for subagents info without target", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents info", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("/subagents info");
});
it("includes subagent count in /status when active", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/status", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("🤖 Subagents: 1 active");
});
it("includes subagent details in /status when verbose", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
});
addSubagentRunForTests({
runId: "run-2",
childSessionKey: "agent:main:subagent:def",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "finished task",
cleanup: "keep",
createdAt: 900,
startedAt: 900,
endedAt: 1200,
outcome: { status: "ok" },
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/status", cfg);
params.resolvedVerboseLevel = "on";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("🤖 Subagents: 1 active");
expect(result.reply?.text).toContain("· 1 done");
});
it("returns info for a subagent", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
endedAt: 2000,
outcome: { status: "ok" },
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/subagents info 1", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagent info");
expect(result.reply?.text).toContain("Run: run-1");
expect(result.reply?.text).toContain("Status: done");
});
});

View File

@@ -54,6 +54,7 @@ type StatusArgs = {
usageLine?: string;
queue?: QueueStatus;
mediaDecisions?: MediaUnderstandingDecision[];
subagentsLine?: string;
includeTranscriptUsage?: boolean;
now?: number;
};
@@ -367,6 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string {
mediaLine,
args.usageLine,
`🧵 ${sessionLine}`,
args.subagentsLine,
`⚙️ ${optionsLine}`,
activationLine,
]