a spawned session says who started it

The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.

`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.

`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 05:09:28 +00:00
co-authored by Claude Opus 5
parent 44f5246e38
commit f6b9ddc877
5 changed files with 85 additions and 0 deletions
@@ -15,6 +15,7 @@ export {
disarmCommandHqWatcher,
recordCommandHqTurn,
ingestSpawnedChildren,
parentCommanderOf,
recordSpawnedOutcome,
getCommandHqCostSummary,
listCommandHqTurns,
@@ -475,3 +475,44 @@ export async function recordSpawnedOutcome(
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
);
}
export type SpawnParent = {
/** The commander's transcript id — what `/command-hq/<id>` opens. */
sessionId: string;
/** What the owner called it, or null for a commander filed before the column existed. */
name: string | null;
firstSeenAt: string;
finishedAt: string | null;
};
/**
* Which commander spawned this session, if any.
*
* The same row `ingestSpawnedChildren` writes, read from the other end — so this needed no new table and no
* new write path. A commander's children were already recorded because deleting one has to take them with
* it; that record answers "who is my parent" for free, and nothing was asking.
*
* Null for almost every session, and that is the normal case rather than a miss: a chat somebody opened
* themselves has no parent, and a session spawned by anything other than a commander has no row here. The
* caller must treat absence as "not spawned", never as "lookup failed".
*/
export async function parentCommanderOf(spawnedSessionId: string): Promise<SpawnParent | null> {
const [row] = await db
.select({
sessionId: commandHqSessions.sessionId,
name: commandHqSessions.name,
firstSeenAt: commandHqSpawned.firstSeenAt,
finishedAt: commandHqSpawned.finishedAt,
})
.from(commandHqSpawned)
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
.limit(1);
if (!row) return null;
return {
sessionId: row.sessionId,
name: row.name,
firstSeenAt: row.firstSeenAt.toISOString(),
finishedAt: row.finishedAt ? row.finishedAt.toISOString() : null,
};
}
+10
View File
@@ -13,6 +13,7 @@ import {
getChatNotifyPreferences,
setChatNotifyPreference,
forgetChatSessions,
parentCommanderOf,
} from 'officerdb';
import type { NotifyPreference } from 'officerdb/types';
import { setNudge, getNudge, cancelNudge } from 'officerdb';
@@ -273,6 +274,15 @@ chatRouter.get('/sessions/:id', async (ctx) => {
travels with the conversation.
*/
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
/*
Which commander spawned this, if any — the row `ingestSpawnedChildren` writes, read from the other
end. A spawned agent has always been fully readable here; what it could never say was who started
it, so opening one gave no way back to the conversation that owns it.
Null for almost every session and that is the normal case, not a miss: a chat somebody opened
themselves has no parent. Never fatal — a session must render when this lookup will not.
*/
spawnedBy: await parentCommanderOf(id).catch(() => null),
});
});
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useWorkspace } from '../../components/Workspace';
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
import { useChat } from '../../hooks/useChat';
@@ -21,8 +22,30 @@ type ResumeState = {
offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean;
/** The commander that started this, when one did — see `SpawnedByBar`. */
spawnedBy?: { sessionId: string; name: string | null } | null;
};
/**
* "Started by <commander>", above a transcript that was spawned rather than opened.
*
* A spawned agent has always rendered here perfectly — it writes an ordinary transcript and this view reads
* it like any other. What it could never say was where it came from, so somebody who followed a link into
* one had no way back to the conversation that owns it, and no way to tell it apart from a chat they had
* started themselves and forgotten.
*
* Renders nothing for the overwhelming majority of sessions, which have no parent. That is the normal case
* and it does not want a bar saying so.
*/
const SpawnedByBar = ({ parent }: { parent: { sessionId: string; name: string | null } }) => (
<div className="shrink-0 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
Started by{' '}
<Link to={`/command-hq/${parent.sessionId}`} className="text-foreground/80 underline hover:text-foreground">
{parent.name ?? 'a commander'}
</Link>
</div>
);
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
// on scroll. Matches the /chat screen.
const CHAT_TAIL = 20;
@@ -105,6 +128,7 @@ const ChatPanelInner = ({
return (
<div className="flex h-full min-h-0 flex-col">
<AgentIdentityBar panel={agentPanel} />
{resume?.spawnedBy && <SpawnedByBar parent={resume.spawnedBy} />}
<EmbeddableChat
className="min-h-0 flex-1"
chat={chat}
@@ -234,6 +258,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
total: detail.total,
offset: detail.offset,
isCommander: detail.isCommander === true,
spawnedBy: detail.spawnedBy ?? null,
});
} catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -104,6 +104,14 @@ export type ClaudeSessionDetail = {
* to decide it from the route, so the same commander opened in /chat quietly lost it.
*/
isCommander?: boolean;
/**
* The commander that spawned this session, if one did.
*
* Read from the row a commander already keeps of its children — so this is the parent side of a link that
* existed for a different reason (deleting a commander has to take its children with it). Absent for
* almost every conversation, and absence means "nobody spawned this", never "the lookup failed".
*/
spawnedBy?: { sessionId: string; name: string | null; firstSeenAt: string; finishedAt: string | null } | null;
};
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };