From af78f0ed51964dee45d828a6d18f37f705ec59de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 18:41:43 +0000 Subject: [PATCH] sessions carry isCommander, so the phone can filter what tabs would hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/claude-sessions.ts | 42 +++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index bc0c395e..5b06b431 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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. *