sessions carry isCommander, so the phone can filter what tabs would hide

A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the
two: commanders appeared in the chat list looking like conversations somebody started and forgot.

The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an
implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs
to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew
about would fix one surface and leave the other wrong — which is the shape of half the bugs found
tonight.

Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one
directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already
reads each session's cwd. So it costs one string comparison, adds no query, and still works for a
commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript
rather than from code and records whatever directory the process was actually in.

Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists,
no transcript has that cwd, and Command HQ shows 0 commanders.

Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 18:41:43 +00:00
co-authored by Claude Opus 5
parent 7a46612f41
commit af78f0ed51
+40 -2
View File
@@ -1,5 +1,5 @@
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { dirname, join, resolve } from 'node:path';
import { DATA_PATH, getCommandersDir } from '../../data-path';
import {
appendTextAs,
findPathAs,
@@ -108,6 +108,24 @@ export type ClaudeSessionSummary = {
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
*/
partCount?: number;
/**
* This is a Command HQ commander, not an ordinary chat.
*
* ── Why it is a field and not a frontend detail ──
*
* A commander IS a Claude session, and this list reads transcripts from disk, so nothing distinguished
* the two and commanders appeared in the chat list looking like conversations somebody started and
* forgot. The web can now split them into tabs — but the reason this belongs in the PAYLOAD is the
* phone: the iPhone chat app swipes left and right between live chats, and with no tabs to hide behind
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
* would fix one surface and leave the other wrong.
*
* Derived from the CWD rather than from a database join. Every commander a user has shares one
* directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list
* already reads each session's `cwd`. So it costs a string comparison, works for a commander whose
* `command_hq_sessions` row was lost, and adds no query.
*/
isCommander?: boolean;
};
/** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */
@@ -968,11 +986,31 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
// why the flag has to reach the payload at all.
const commander = isCommandersDir(who.email, cwd);
return mergeChains(scanGroup(who, cwd))
.map(publish)
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* Is this directory the one every commander shares?
*
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
* whatever the process was actually in.
*/
function isCommandersDir(email: string, cwd: string): boolean {
try {
return resolve(cwd) === resolve(getCommandersDir(email));
} catch {
return false;
}
}
/**
* Every conversation in every directory, newest first.
*