Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- **The Capacity Dock card and the popover quota rows no longer print a pace verdict under each window.** The "On pace" / "Lasts until reset" caption from #1314 added a third line to every window cell and made the card read as clutter; the cells are back to percent, label and countdown, up to three windows share one row again with a fourth wrapping to a second, and the pace math still feeds the tooltip and the agent-tab card.

### Fixed
- **Codex sub-agent rollouts no longer double-count their parent's usage.** A thread spawned as a sub-agent replays its parent's history, including every `token_count` event with its original timestamp, and names the parent under `source.subagent.thread_spawn.parent_thread_id` rather than `forked_from_id`, so the fork guard never saw it and the replayed prefix was counted again per child. The parent id is now read from either field, and the replayed events drop like a fork's. On a corpus with 381 spawned children this removed 405 duplicate calls and 403,712 duplicate cache tokens; rollouts without sub-agents are unchanged. (#1380)
- **The menubar refreshes usage within five minutes while a session is running, instead of up to thirty.** The background change guard only stats directories, so a session appending to its transcript looked unchanged and the app skipped every refresh for half an hour; the skip now lasts at most five minutes, and the Kimi Code session store joined the watched roots.
- **The Capacity Dock card lists running sessions for providers whose CLI id differs from the dock id.** The card matched live rows against the dock's own identifier ("kimi"), while the CLI reports "kimicode", so a running Kimi Code session read "none running"; the card now matches the provider's payload ids, which also lets the Cursor card count cursor-agent sessions.
- **Shared and MCP-redacted payloads no longer carry git branch names or the working directory of a session.** Two identities were riding along in `current`: the `topSessions` rows added in #1316 carry `projectKey`, a dash-encoded absolute path such as `-Users-<user>-Projects-<repo>`, which `redactProjectNames` left verbatim even when an MCP client asked not to see project names, and the per-branch rows added in #788 carry the raw branch, which both `sanitizeForSharing` and the MCP pass let through, so a branch such as `exp/extraction-arms` reached a shared payload as typed. The MCP pass now hashes `projectKey` with the same salted pseudonym it already applies to `topProjects[].id`, hashes the drill-through `sessionId` on both `topSessions` and `topProjects[].sessionDetails` (a Codex id embeds the rollout timestamp the pass blanks out of `date`), and gives each distinct branch a stable `branch-xxxxxx` pseudonym, keeping the cost, calls and session counts untouched; a `null` branch stays null, because unbranched spend is not a name. Sharing drops the per-branch block outright, the way it already drops the project and session rows, since a pseudonymized branch tells a peer device nothing anyway. Nothing changes for the local menubar, the desktop app or the web dashboard, which read the unsanitized payload, and nothing changes for an MCP caller that passes `include_project_names: true` — it still gets every name as recorded. (#1325, #1321)
Expand Down
15 changes: 14 additions & 1 deletion src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ type CodexEntry = {
originator?: string
session_id?: string
forked_from_id?: string
/// Parent of a spawned sub-agent thread; its rollout replays the parent's
/// history like a fork does, but names the parent under `source`, not
/// `forked_from_id`.
parent_thread_id?: string
source?: { subagent?: { thread_spawn?: { parent_thread_id?: string } } }
model?: string
name?: string
invocation?: { server?: string; tool?: string }
Expand Down Expand Up @@ -494,6 +499,9 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
originator: payloadString('originator'),
session_id: payloadString('session_id'),
forked_from_id: payloadString('forked_from_id'),
parent_thread_id: type === 'session_meta'
? getRawJsonStringField(getRawPayloadFieldWindow(line, 'source') ?? '', 'parent_thread_id')
: undefined,
model: compactModel,
name: payloadString('name'),
invocation,
Expand Down Expand Up @@ -829,7 +837,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
// string methods on it.
const rawSessionCwd: unknown = entry.payload?.cwd
if (typeof rawSessionCwd === 'string' && rawSessionCwd) sessionCwd = rawSessionCwd
forkedFromId = entry.payload?.forked_from_id ?? ''
// Small lines arrive fully parsed (nested `source`), oversized ones
// through the compact head decoder (flattened `parent_thread_id`).
forkedFromId = entry.payload?.forked_from_id
|| entry.payload?.parent_thread_id
|| entry.payload?.source?.subagent?.thread_spawn?.parent_thread_id
|| ''
if (forkedFromId && entry.timestamp) {
// An unparseable timestamp (a garbage string, or a non-string from
// the unchecked JSON.parse cast) makes `new Date(NaN).toISOString()`
Expand Down
27 changes: 26 additions & 1 deletion tests/providers/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})

function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: string; model?: string; forked_from_id?: string; timestamp?: string } = {}) {
function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: string; model?: string; forked_from_id?: string; source?: unknown; timestamp?: string } = {}) {
return JSON.stringify({
type: 'session_meta',
timestamp: opts.timestamp ?? '2026-04-14T10:00:00Z',
Expand All @@ -28,6 +28,7 @@ function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: str
session_id: opts.session_id ?? 'sess-001',
model: opts.model ?? 'gpt-5.3-codex',
...(opts.forked_from_id ? { forked_from_id: opts.forked_from_id } : {}),
...(opts.source ? { source: opts.source } : {}),
},
})
}
Expand Down Expand Up @@ -1266,6 +1267,30 @@ describe('codex provider - forked session dedupe', () => {
expect(tokens).toBe(1500)
})

it('does not double-count a spawned sub-agent that replays its parent with the original timestamps', async () => {
// A MultiAgent sub-agent rollout names its parent under
// source.subagent.thread_spawn.parent_thread_id, never forked_from_id, and
// replays the parent's token_count events with their ORIGINAL timestamps,
// then adds its own work (+400). Parent 1100 + child 400 = 1500, not 2600.
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
sessionMeta({ session_id: 'sess-parent' }),
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
])
await writeSession(tmpDir, '2026-04-14', 'rollout-2-subagent.jsonl', [
sessionMeta({
session_id: 'sess-child',
timestamp: '2026-04-14T10:00:30Z',
source: { subagent: { thread_spawn: { parent_thread_id: 'sess-parent', agent_role: 'explorer' } } },
}),
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
tokenCount({ timestamp: '2026-04-14T10:00:40Z', last: { input: 400 }, total: { total: 400 } }),
])
const { tokens } = await aggregateTokens(tmpDir)
expect(tokens).toBe(1500)
})

it('keeps a genuine divergent fork event that shares a cumulative total with the parent', async () => {
// Parent reaches cumulative 1600 via input (last input 500). The fork replays
// 700 and 1100, then does genuinely different work that also reaches
Expand Down
Loading