Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.15.93",
"version": "0.15.98",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
88 changes: 87 additions & 1 deletion apps/desktop/scripts/agent-monitor-client/Sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format
import { effectiveSessionStatus, isSessionAwaitingInput } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";

// CLOSEDLOOP FEA-1434: subscription-covered billing modes. Mirrors the
// canonical enum in src/shared/billing-mode.ts on the desktop side. Inlined
// here because the agent-monitor client is bundled separately from the
// desktop main TypeScript tree.
const SUBSCRIPTION_BILLING_MODES = new Set<string>([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hand copy of SUBSCRIPTION_MODES in billing-mode.ts with nothing tying them together. Add a 9th subscription mode there and the importers/detector will stamp it but this UI silently buckets it as uncounted, and no build gate catches the drift. The separate-bundle reason is fair, but worth at least a test that asserts the two sets match.

"claude_pro",
"claude_max",
"codex_chatgpt_pro",
"cursor_pro",
"copilot_seat",
"opencode",
]);

function isApiMetered(billingMode: string | null | undefined): boolean {
return billingMode === "api";
}

function isSubscriptionCovered(billingMode: string | null | undefined): boolean {
return !!billingMode && SUBSCRIPTION_BILLING_MODES.has(billingMode);
}

const PAGE_SIZE = 10;
export function Sessions() {
const navigate = useNavigate();
Expand Down Expand Up @@ -169,6 +190,23 @@ export function Sessions() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);

// CLOSEDLOOP FEA-1434: split the visible page of sessions into two ledgers.
// The page is paginated server-side so these totals are page-local; full
// workspace rollups belong on the Dashboard. Keeping the math local avoids
// a separate API round-trip just for the totals.
const ledgerTotals = sessions.reduce(
(acc, s) => {
const cost = s.cost ?? 0;
if (isApiMetered(s.billing_mode)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rollup drops anything that isn't 'api' or a subscription mode, so an 'unknown' session lands in neither card. But down at line 462 that same row still renders its cost as a bare dollar amount, identical to an api row. 'unknown' is the schema default for every pre-migration row and for any spawn where detection fell through (no API key env, no creds file), so you'll routinely have rows showing '$3.00' in the column that aren't in the Metered API spend total. The two cards won't add up to what the user sees in the table. Either fold unknown into the api bucket or render it muted so it's clear it's uncounted.

acc.api += cost;
} else if (isSubscriptionCovered(s.billing_mode)) {
acc.subscription += cost;
}
return acc;
},
{ api: 0, subscription: 0 },
);

return (
<div className="animate-fade-in">
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
Expand Down Expand Up @@ -300,6 +338,33 @@ export function Sessions() {
/>
) : (
<>
{/* FEA-1434: two-ledger rollup. Subscription-covered cost never
sums into the headline — it's surfaced as an "equivalent" for
transparency only. */}
<div className="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="card px-4 py-3">
<div className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
Metered API spend
</div>
<div className="mt-1 text-lg font-mono text-gray-100">
{fmtCost(ledgerTotals.api)}
</div>
<div className="text-[11px] text-gray-500">
Sessions billed per-token via API keys (this page)
</div>
</div>
<div className="card px-4 py-3">
<div className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
Subscription-covered
</div>
<div className="mt-1 text-lg font-mono text-gray-300">
{fmtCost(ledgerTotals.subscription)} equiv.
</div>
<div className="text-[11px] text-gray-500">
Pro / Max / per-seat — token-cost equivalent only (this page)
</div>
</div>
</div>
<div className="card overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead>
Expand Down Expand Up @@ -374,7 +439,28 @@ export function Sessions() {
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-gray-400 font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
{/* FEA-1434: render API-metered cost as a dollar amount;
subscription-covered cost is muted and prefixed
"Covered ·" so the user can see at a glance which
ledger a row contributes to. */}
{(() => {
const cost = session.cost;
const billing = session.billing_mode;
if (cost == null || cost <= 0) {
return "-";
}
if (isApiMetered(billing)) {
return fmtCost(cost);
}
if (isSubscriptionCovered(billing)) {
return (
<span className="text-gray-500">
Covered · {fmtCost(cost)} equiv.
</span>
);
}
return fmtCost(cost);
})()}
</td>
<td
className="px-5 py-4 text-[11px] text-gray-500 font-mono"
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/scripts/agent-monitor-codex/codex-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ function importCodexSession(dbModule, session) {
} catch {
/* non-fatal — column/stmt guaranteed by db.js Patch #4 */
}
// FEA-1434: Codex CLI sessions land here via rollout-file parsing — the
// desktop spawn path stamps billing_mode in launch-metadata, but most Codex
// sessions are user-driven (CLI invocations outside the desktop). Without
// a runtime signal we treat them as ChatGPT Pro subscription by default;
// sessions backed by an explicit API key get their mode rewritten when the
// sync service merges in the launch-metadata.billingMode value.
//
// FEA-1434 (round-3 review follow-up): pass the protected-mode exclusion
// list — the prepared statement is `... NOT IN (?, ?, ?, ?)` so the row
// is never demoted from a deliberate non-default value ('api',
// 'claude_max', 'claude_pro') back to 'codex_chatgpt_pro'. The new mode
// is repeated as the third bound parameter so the statement is still a
// no-op when the row already carries that value.
try {
dbModule.stmts.setSessionBillingMode.run(
"codex_chatgpt_pro",
session.sessionId,
"codex_chatgpt_pro",
"api",
"claude_max",
"claude_pro",
);
} catch {
/* non-fatal — column/stmt guaranteed by db.js FEA-1434 patch */
}
const reactivated = reactivateImportedSession(dbModule, session);
return { sessionId: session.sessionId, result, reactivated };
}
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/scripts/agent-monitor-copilot/copilot-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ function importCopilotSession(dbModule, session) {
try {
dbModule.stmts.setSessionHarness.run("copilot", session.sessionId, "copilot");
} catch { /* non-fatal */ }
// FEA-1434: GitHub Copilot is always covered by a per-seat subscription —
// no per-token API surface exists. Stamp the mode so the UI ledger split
// counts these as subscription-covered.
//
// FEA-1434 (round-3 review follow-up): pass the protected-mode exclusion
// list ('api', 'claude_max', 'claude_pro') so the importer can never
// demote a row that the desktop main process has deliberately marked.
// See the prepared-statement comment in `build-agent-monitor.mjs`.
try {
dbModule.stmts.setSessionBillingMode.run(
"copilot_seat",
session.sessionId,
"copilot_seat",
"api",
"claude_max",
"claude_pro",
);
} catch { /* non-fatal */ }
const reactivated = reactivateImportedSession(dbModule, session);
return { sessionId: session.sessionId, result, reactivated };
}
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/scripts/agent-monitor-cursor/cursor-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ function importCursorSession(dbModule, session) {
try {
dbModule.stmts.setSessionHarness.run("cursor", session.sessionId, "cursor");
} catch { /* non-fatal */ }
// FEA-1434: Cursor sessions are always covered by a Cursor Pro subscription
// (no per-token billing surface exposed by the Cursor CLI). Stamp the mode
// so the UI can group them under the subscription ledger.
//
// FEA-1434 (round-3 review follow-up): pass the protected-mode exclusion
// list ('api', 'claude_max', 'claude_pro') so the importer can never
// demote a row that the desktop main process has deliberately marked.
// See the prepared-statement comment in `build-agent-monitor.mjs`.
try {
dbModule.stmts.setSessionBillingMode.run(
"cursor_pro",
session.sessionId,
"cursor_pro",
"api",
"claude_max",
"claude_pro",
);
} catch { /* non-fatal */ }
const reactivated = reactivateImportedSession(dbModule, session);
return { sessionId: session.sessionId, result, reactivated };
}
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/scripts/agent-monitor-opencode/opencode-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ function importOpenCodeSession(dbModule, session) {
try {
dbModule.stmts.setSessionHarness.run("opencode", session.sessionId, "opencode");
} catch { /* non-fatal */ }
// FEA-1434: OpenCode is hosted on a flat subscription model — no per-token
// API cost surface. Stamp the mode so the UI ledger split counts these as
// subscription-covered.
//
// FEA-1434 (round-3 review follow-up): pass the protected-mode exclusion
// list ('api', 'claude_max', 'claude_pro') so the importer can never
// demote a row that the desktop main process has deliberately marked.
// See the prepared-statement comment in `build-agent-monitor.mjs`.
try {
dbModule.stmts.setSessionBillingMode.run(
"opencode",
session.sessionId,
"opencode",
"api",
"claude_max",
"claude_pro",
);
} catch { /* non-fatal */ }
const reactivated = reactivateImportedSession(dbModule, session);
return { sessionId: session.sessionId, result, reactivated };
}
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/scripts/build-agent-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,90 @@ function patchDbFile(file) {
source = source.replace(stmtsNeedle, `\n${replacement}`);
}

// CLOSEDLOOP FEA-1434: add `billing_mode` so the UI can split sessions into
// an API-metered ledger and a subscription-covered ledger. Additive +
// DEFAULT 'unknown' so existing rows keep working unchanged. The desktop
// spawn sites stamp the mode via launch-metadata; the importers stamp a
// fixed mode per harness (cursor_pro / copilot_seat / opencode). Read paths
// need no change.
//
// FEA-1434 (round-3 review follow-up): the importer-side
// `setSessionBillingMode` UPDATE must NOT clobber a deliberate non-default
// value that the desktop writeback (or launch-metadata, or a future manual
// user correction) has set. Concrete race scenario from review:
//
// 1. user has OPENAI_API_KEY set; desktop spawn writes 'api'
// 2. agent-session-sync-service writeback persists 'api' on the row
// 3. sidecar restarts and codex-import.js calls
// setSessionBillingMode.run("codex_chatgpt_pro", id, "codex_chatgpt_pro")
// 4. without this guard the COALESCE('api','') != 'codex_chatgpt_pro'
// predicate is TRUE → the importer overwrites 'api' →
// 'codex_chatgpt_pro'. The next sync cycle restores it, but for a
// ~5s window the Sessions UI mis-buckets the row.
//
// The fix: extend the WHERE clause so the importer can promote a default
// (`unknown` → `codex_chatgpt_pro`) but can never demote a deliberate
// non-default value (`api`, `claude_max`, `claude_pro`) back to a default.
// The exclusion list is sourced from BILLING_MODE_PROTECTED_VALUES so the
// SQL stays a single source of truth — adding a new "protected" mode is a
// one-line array edit, not a string-patch surgery.
//
// The exclusion set mirrors the read-side guard in
// `agent-session-sync-service.ts` writeback path and stays in sync with
// `src/shared/billing-mode.ts`.
if (!source.includes("ADD COLUMN billing_mode")) {
const setSessionHarnessNeedle =
" setSessionHarness: db.prepare(\"UPDATE sessions SET harness = ? WHERE id = ? AND COALESCE(harness, '') != ?\"),";
if (!source.includes(setSessionHarnessNeedle)) {
throw new Error(
`Unable to patch ${file}: expected the setSessionHarness statement (billing_mode).`,
);
}
// Modes the importer is forbidden to overwrite. These represent a
// deliberate signal: either the desktop main process detected an API
// key (`api`) or a user's OAuth credentials indicated a subscription
// tier (`claude_max`, `claude_pro`). The importer's job is to promote
// 'unknown' to a fixed-per-harness default — never to demote.
const protectedModes = ["api", "claude_max", "claude_pro"];
const protectedPlaceholders = protectedModes.map(() => "?").join(", ");
// Render the prepared statement with the exclusion list inline. The
// statement takes one extra `?` for the new value plus the protected
// mode placeholders. Callers must bind:
// (newMode, sessionId, newMode, ...protectedModes)
// — the third `?` matches the existing "do nothing if row already has
// newMode" guard; the trailing placeholders are the exclusion set.
const setSessionBillingModeStmt =
" setSessionBillingMode: db.prepare(\"UPDATE sessions SET billing_mode = ? WHERE id = ? AND COALESCE(billing_mode, '') NOT IN (?, " +
protectedPlaceholders +
")\"),";
const replacement = [
setSessionHarnessNeedle,
setSessionBillingModeStmt,
].join("\n");
source = source.replace(setSessionHarnessNeedle, replacement);

// Run the migration before the `const stmts` block so the prepared
// statements above can rely on the column existing.
const stmtsNeedle = "\nconst stmts = {";
if (!source.includes(stmtsNeedle)) {
throw new Error(
`Unable to patch ${file}: expected the prepared-statements block (billing_mode migration).`,
);
}
const migration = [
"",
"try {",
' db.prepare("SELECT billing_mode FROM sessions LIMIT 1").get();',
"} catch {",
" db.prepare(\"ALTER TABLE sessions ADD COLUMN billing_mode TEXT NOT NULL DEFAULT 'unknown'\").run();",
"}",
'db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_billing_mode ON sessions(billing_mode)");',
"",
"const stmts = {",
].join("\n");
source = source.replace(stmtsNeedle, `\n${migration}`);
}

const sessionTotalsNeedle = [
" sessionTokenTotals: db.prepare(`",
" SELECT",
Expand Down Expand Up @@ -2800,6 +2884,14 @@ function patchClientSource() {
find: " cost?: number;",
replace: " cost?: number;\n harness?: string | null;",
},
{
// FEA-1434: surface billing_mode on the Session type so the UI can
// split sessions into API-metered vs subscription-covered ledgers.
rel: "src/lib/types.ts",
guard: "billing_mode?: string | null",
find: " harness?: string | null;",
replace: " harness?: string | null;\n billing_mode?: string | null;",
},
{
rel: "src/lib/api.ts",
guard: " harness?: string;",
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/src/main/agent-session-sync-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
export const AGENT_SESSION_SYNC_SCHEMA_VERSION = 1 as const;
// CLOSEDLOOP CONTRACT — consumed by the cloud relay, ships separately.
// Schema-version bumps follow the CLAUDE.md "Breaking Changes" rule: the
// previous shape must remain decodable at the boundary.
//
// v2 (FEA-1434): adds optional `billingMode` to SyncedAgentSession.
// - Outbound: desktop sends `billingMode` when known; `undefined`/`null`
// means "no signal" (the relay should treat it as `unknown`).
// - Inbound: the cloud relay MUST accept payloads with the v1 schema
// version that omit `billingMode` and treat them as `billingMode:
// "unknown"`. See the legacy-migration note in cloud-protocol.ts.
// - Removal: once the relay enforces v2 across all clients, the v1
// decode path can be deleted. Tracking ticket: FEA-1439 ("Remove v1
// schema acceptance from agent-session sync relay (post FEA-1434
// rollout)"). Trigger: telemetry shows zero v1 payloads for 14d AND
// all installs past the FEA-1434 auto-update threshold.
export const AGENT_SESSION_SYNC_SCHEMA_VERSION = 2 as const;

export type AgentSessionSyncMode = "backfill" | "incremental";

Expand Down Expand Up @@ -60,6 +75,17 @@ export type SyncedAgentSession = {
name?: string | null;
status: string;
harness?: string | null;
/**
* FEA-1434: billing mode used to distinguish API-metered cost from
* subscription-covered usage. Encoded as a string at the wire boundary
* (the canonical enum lives in `src/shared/billing-mode.ts`) so the cloud
* relay can accept forward-compatible values without a contract bump.
*
* `undefined` or `null` means "no signal" and is equivalent to the
* `unknown` billing mode in the typed enum. Older clients (v1 schema)
* never set this field — the relay treats missing as `unknown`.
*/
billingMode?: string | null;
cwd?: string | null;
model?: string | null;
startedAt: string;
Expand Down
Loading
Loading