Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce5af88aa | ||
|
|
6bfc1d38f7 | ||
|
|
c10dae3e6e |
@@ -104,6 +104,15 @@ function isHuman(value: string | undefined): value is string {
|
||||
const stripComments = (source: string): string =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
|
||||
/**
|
||||
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
|
||||
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
|
||||
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
|
||||
* thing it documents.
|
||||
*/
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -135,7 +144,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = stripComments(readFileSync(file, 'utf8'));
|
||||
source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
|
||||
title: 'An agent spawned by a background job is invisible in the interface'
|
||||
theme: background-jobs
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 1
|
||||
@@ -27,6 +27,25 @@ Two fixes, and the small one is most of the value:
|
||||
against the commander that spawned it, which would also fix cost attribution — today the child's tokens
|
||||
land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars.
|
||||
|
||||
The second one needs a decision about what the child *is*. A commander is defined by its directory
|
||||
The second one needs a decision about what the child _is_. A commander is defined by its directory
|
||||
(`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately.
|
||||
Making it visible probably means a different relationship than "another commander".
|
||||
|
||||
---
|
||||
|
||||
**Closed 2026-08-23.** The commander's pane lists its spawned agents, each a link to `/chat/<id>`, with a
|
||||
dot on the ones still running.
|
||||
|
||||
Worth recording what the work actually turned out to be. The endpoint
|
||||
(`GET /command-hq/commanders/:id/children`), the `command_hq_spawned` table and the ingest were all built
|
||||
earlier the same night — and **nothing had ever called them**, so the table read empty and the feature looked
|
||||
unbuilt. Ingest happens on read rather than on a timer, which means the table being empty was itself the
|
||||
symptom: nobody had looked.
|
||||
|
||||
The second fix this file proposed — the loop echoing the transcript id — was not needed in the end. The ids
|
||||
are already in the loop's `spawned` record, which the endpoint ingests, so the id reaches the UI without the
|
||||
loop having to narrate it.
|
||||
|
||||
Cost attribution is partly addressed and honestly so: the pane shows a floor (`$1.20+`) with a count of
|
||||
children that reported no cost, never a confident total. The remaining gap is that a child's spend does not
|
||||
flow back to the commander's own cost line — that is cost accounting, deferred by the owner, and not this.
|
||||
|
||||
Reference in New Issue
Block a user