a spawned agent is a link, not a rumour

The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.

Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.

Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.

Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.

Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 04:54:54 +00:00
co-authored by Claude Opus 5
parent c10dae3e6e
commit 6bfc1d38f7
2 changed files with 100 additions and 2 deletions
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/sonner';
@@ -12,7 +12,12 @@ import { useChat, EmbeddableChat } from '../Chat';
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
import type { ChatMessage } from '../Chat/types';
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
import {
useCommandHqCategories,
useCommandHqChildren,
useCommandHqCommander,
useCommandHqCommanderActions,
} from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
@@ -390,6 +395,62 @@ function NewCommander({
);
}
/**
* The agents this commander spawned, each a link to its own transcript.
*
* A spawned agent has always been fully readable — it writes an ordinary transcript under the project
* directory for its cwd, and `/chat/<id>` renders it live like any other session. What was missing was
* anybody being told the id existed. The job panel showed its output as a text stream in a small box with
* no link, so the only way to find the session was to know to go looking.
*
* Renders nothing when there are none, rather than an empty state. A commander that has not fired yet is
* the normal case and does not need a box explaining that.
*/
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
if (!children.length) return null;
return (
<div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Spawned</span>
<span>{children.length}</span>
{/*
Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's
line reading $0.28 for a night that spent several dollars is the specific understatement this
exists to stop.
*/}
{totalUsd > 0 && (
<span>
· ${totalUsd.toFixed(2)}
{unknownCost ? '+' : ''}
</span>
)}
{unknownCost > 0 && (
<span title={`${unknownCost} child${unknownCost === 1 ? '' : 'ren'} did not report a cost`}>
· {unknownCost} unknown
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{children.map((c) => (
<Link
key={c.spawnedSessionId}
to={`/chat/${c.spawnedSessionId}`}
title={`spawned ${new Date(c.firstSeenAt).toLocaleString()}${c.finishedAt ? `, finished ${new Date(c.finishedAt).toLocaleString()}` : ' — still running'}`}
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 hover:border-primary/50 hover:text-foreground"
>
{c.spawnedSessionId.slice(0, 8)}
{!c.finishedAt && (
<span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-duck-orange align-middle" />
)}
</Link>
))}
</div>
</div>
);
};
type MetaBarProps = {
sessionId: string;
title: string | undefined;
@@ -576,6 +637,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<SpawnedChildren sessionId={sessionId} />
<EmbeddableChat
chat={chat}
sessionId={undefined}
@@ -102,3 +102,39 @@ export function useCommandHqCommanderActions(sessionId: string) {
return { setCategory, rename, disarmWatcher, deleteCommander };
}
export type SpawnedChild = {
spawnedSessionId: string;
firstSeenAt: string;
finishedAt: string | null;
totalUsd: number | null;
outcome: string | null;
};
/**
* The agents this commander spawned.
*
* The backend ingests the loop's record file on every read, so calling this often is free and there is no
* timer to stop working quietly. Which is the reason this hook exists at all: the endpoint and the table
* were built, and nothing ever called them — so the table sat empty and a spawned agent stayed invisible
* even though its transcript was sitting there, readable, the whole time.
*/
export function useCommandHqChildren(sessionId: string | undefined) {
const client = useClient();
const { data, isLoading } = useQuery<{ children: SpawnedChild[]; totalUsd: number; unknownCost: number }>({
queryKey: ['command-hq', 'children', sessionId ?? ''],
queryFn: () => client.get(`/command-hq/commanders/${sessionId}/children`),
enabled: !!sessionId,
// A spawn appears while the panel is open, so a stale window here means watching a stale list during
// exactly the minutes somebody is watching.
staleTime: 0,
refetchInterval: 15_000,
retry: false,
});
return {
children: data?.children ?? [],
totalUsd: data?.totalUsd ?? 0,
unknownCost: data?.unknownCost ?? 0,
isLoading,
};
}