Compare commits
3
Commits
41fffbebe9
...
7ce5af88aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce5af88aa | ||
|
|
6bfc1d38f7 | ||
|
|
c10dae3e6e |
@@ -104,6 +104,15 @@ function isHuman(value: string | undefined): value is string {
|
|||||||
const stripComments = (source: string): string =>
|
const stripComments = (source: string): string =>
|
||||||
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
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> {
|
function* walk(dir: string): Generator<string> {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
@@ -135,7 +144,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
|||||||
files++;
|
files++;
|
||||||
let source: string;
|
let source: string;
|
||||||
try {
|
try {
|
||||||
source = stripComments(readFileSync(file, 'utf8'));
|
source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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 { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
@@ -12,7 +12,12 @@ import { useChat, EmbeddableChat } from '../Chat';
|
|||||||
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
|
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
import type { ChatMessage } from '../Chat/types';
|
||||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
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 { formatCommanderCost, formatTokens } from './format';
|
||||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
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 = {
|
type MetaBarProps = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
@@ -576,6 +637,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
|
|||||||
isGenerating={chat.isGenerating}
|
isGenerating={chat.isGenerating}
|
||||||
onDisconnect={chat.disconnectSession}
|
onDisconnect={chat.disconnectSession}
|
||||||
/>
|
/>
|
||||||
|
<SpawnedChildren sessionId={sessionId} />
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={chat}
|
chat={chat}
|
||||||
sessionId={undefined}
|
sessionId={undefined}
|
||||||
|
|||||||
@@ -102,3 +102,39 @@ export function useCommandHqCommanderActions(sessionId: string) {
|
|||||||
|
|
||||||
return { setCategory, rename, disarmWatcher, deleteCommander };
|
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
|
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
|
||||||
title: 'An agent spawned by a background job is invisible in the interface'
|
title: 'An agent spawned by a background job is invisible in the interface'
|
||||||
theme: background-jobs
|
theme: background-jobs
|
||||||
status: open
|
status: done
|
||||||
created: 2026-08-21
|
created: 2026-08-21
|
||||||
date_source: stated
|
date_source: stated
|
||||||
order: 1
|
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
|
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.
|
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.
|
(`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".
|
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