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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@
All notable changes to **basou** are recorded here. The project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0.

## Unreleased

### Added

- `session.metrics.machine_active_time_ms` (additive, optional) — model compute
time: the summed duration of a source's per-turn spans (Codex
`task_complete.duration_ms`). A SUBSET of a session's active time, kept as a
separate labeled measure. Unlike `active_intervals` it is a plain sum, NOT
wall-clock-deduplicated, so two concurrent sessions can sum past their
billable (union) active wall-clock — intended, as two models working at once
did two machine-hours in one wall-clock hour. Captured only for sources that
record per-turn duration (Codex today); absent otherwise. Surfaced as a
`Model working` line in `basou stats` (and `--by-source` / `--by-day` /
`--json`), as a `machine` segment on the `basou session show` `Work:` line,
and in the `basou view` Stats tab. Re-import (`--force`, or `basou refresh`)
to backfill existing Codex sessions.

### Changed

- Codex active time is now derived from the rollout's real per-turn intervals
(`task_started` → `task_complete`) for the in-turn portion — the log's true
wall-clock span, uncapped, and crediting the session's final turn — unioned
with the gap-capped engagement series for between-turn bridging.
`active_time_method` is `turn-intervals` for these sessions. The active-time
SEMANTICS are unchanged (still human-engaged time with idle gaps over 5
minutes excluded); only the in-turn precision improves, so a re-imported Codex
session's active time typically rises (long turns are no longer truncated at
the gap cap, and the final turn is counted). Claude imports are unchanged
(their transcripts carry no explicit per-turn duration).

## 0.5.0 — 2026-06-04

### Added
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/commands/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,16 @@ type SessionFixture = {
startedAt?: string;
endedAt?: string;
workingDirectory?: string;
source?: { kind: "claude-code-adapter" | "human" | "import" | "terminal"; version: "0.1.0" };
source?: {
kind: "claude-code-adapter" | "codex-import" | "human" | "import" | "terminal";
version: "0.1.0";
};
label?: string;
exitCode?: number | null;
args?: string[];
command?: string;
relatedFiles?: string[];
metrics?: Record<string, unknown>;
events?: string; // raw events.jsonl body (concatenated lines, each with \n)
};

Expand Down Expand Up @@ -142,6 +146,7 @@ async function createSession(repo: string, fixture: SessionFixture): Promise<str
},
related_files: fixture.relatedFiles ?? [],
events_log: "events.jsonl",
...(fixture.metrics !== undefined ? { metrics: fixture.metrics } : {}),
},
};
await writeYamlFile(join(sessionDir, "session.yaml"), session);
Expand Down Expand Up @@ -473,6 +478,35 @@ describe("doRunSessionShow", () => {
// Work summary line: action counts + time proxies.
expect(stdout).toContain("Work:");
expect(stdout).toContain("dec");
// No machine metrics on this session, so no machine segment.
expect(stdout).not.toContain("machine ");
});

it("case 11b: Work line shows machine compute when captured", async () => {
const repo = await setupInitedRepo();
const id = SES("Y0M");
const events =
SESSION_STARTED_LINE(id, "M01", "2026-05-08T11:00:00+09:00") +
SESSION_ENDED_LINE(id, "M02", "2026-05-08T11:30:00+09:00");
await createSession(repo, {
id,
source: { kind: "codex-import", version: "0.1.0" },
endedAt: "2026-05-08T11:30:00+09:00",
metrics: {
active_time_ms: 30 * 60 * 1000,
active_intervals: [
{ start: "2026-05-08T11:00:00+09:00", end: "2026-05-08T11:30:00+09:00" },
],
active_time_method: "turn-intervals",
machine_active_time_ms: 12 * 60 * 1000,
},
events,
});
const out = captureStdout();
await doRunSessionShow(id, {}, { cwd: repo });
const stdout = joinCalls(out);
expect(stdout).toContain("Work:");
expect(stdout).toContain("machine 12m");
});

it("case 12: unique prefix hit resolves to the full ID", async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ function formatSessionWork(session: Session, events: Event[], now: Date): string
parts.push(`${w.commandCount} cmd / ${w.fileChangedCount} files / ${w.decisionCount} dec`);
const activeBasis = w.activeTimeBasis === "engaged-turns" ? "turns" : "events";
parts.push(`active ${formatDurationMs(w.activeTimeMs)} (${activeBasis})`);
if (w.availability.machineActive) {
parts.push(`machine ${formatDurationMs(w.machineActiveTimeMs)}`);
}
parts.push(`span ${formatDurationMs(w.sessionSpanMs)}${w.open ? " (open)" : ""}`);
parts.push(
w.availability.commandTime
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/commands/stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ describe("basou stats", () => {
expect(text).toContain("Reasoning tokens:");
expect(text).toContain("Billable active:");
expect(text).toContain("Command:");
// No machine_active_time_ms on this session, so no model-working line.
expect(text).not.toContain("Model working:");
});

it("prints a model-working line when machine compute was captured", async () => {
const repo = await setupInitedRepo();
await placeSession(repo, {
id: "ses_01HXABCDEF1234567890ABCDE1",
source: "codex-import",
endedAt: "2026-05-10T00:30:00.000Z",
metrics: {
active_time_ms: 30 * 60 * 1000,
active_intervals: [{ start: "2026-05-10T00:00:00.000Z", end: "2026-05-10T00:30:00.000Z" }],
active_time_method: "turn-intervals",
machine_active_time_ms: 12 * 60 * 1000,
},
});
const out = captureStdout();
await doRunStats({}, ctx(repo));
const text = out.join("\n");
expect(text).toContain("Model working:");
expect(text).toContain("12m");
expect(text).toContain("1 of 1 sessions");
});

it("caveats command time when a claude-code-import session is present", async () => {
Expand Down Expand Up @@ -217,6 +240,24 @@ describe("basou stats", () => {
expect(text).toMatch(/2026-05-\d{2}: /);
});

it("--by-day shows model compute on a day with machine time", async () => {
const repo = await setupInitedRepo();
await placeSession(repo, {
id: "ses_01HXABCDEF1234567890ABCDE6",
source: "codex-import",
endedAt: "2026-05-10T00:30:00.000Z",
metrics: {
active_intervals: [{ start: "2026-05-10T00:00:00.000Z", end: "2026-05-10T00:30:00.000Z" }],
active_time_method: "turn-intervals",
machine_active_time_ms: 12 * 60 * 1000,
},
});
const out = captureStdout();
await doRunStats({ byDay: true }, ctx(repo));
const text = out.join("\n");
expect(text).toMatch(/active \(model 12m/);
});

it("shows a summed line only when concurrent sessions overlap", async () => {
const repo = await setupInitedRepo();
await placeSession(repo, {
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ function printStatsText(result: WorkStatsResult, bySource: boolean, byDay: boole
` Summed: ${formatDurationMs(t.activeTimeMs)} (per-session sum; concurrent sessions double-counted)`,
);
}
if (t.machineActiveAvailable) {
const machineSessions = result.sessions.filter((s) => s.availability.machineActive).length;
console.log(
` Model working: ${formatDurationMs(t.machineActiveTimeMs)} (model compute, subset of active; Codex turn duration on ${machineSessions} of ${t.sessionCount} sessions; summed, not wall-clock-deduped)`,
);
}
const openPart = t.openSessionCount > 0 ? `; ${t.openSessionCount} open counted to now` : "";
console.log(
` Span: ${formatDurationMs(t.sessionSpanMs)} (total elapsed${openPart})`,
Expand All @@ -145,8 +151,10 @@ function printStatsText(result: WorkStatsResult, bySource: boolean, byDay: boole
console.log("");
console.log("By day (billable time x volume):");
for (const d of result.byDay) {
const machine =
d.machineActiveTimeMs > 0 ? ` (model ${formatDurationMs(d.machineActiveTimeMs)})` : "";
console.log(
` ${d.date}: ${formatDurationMs(d.billableActiveTimeMs)} active, ${formatInt(d.tokens.output)} out tok, ${d.commandCount} cmd / ${d.fileChangedCount} files / ${d.decisionCount} dec`,
` ${d.date}: ${formatDurationMs(d.billableActiveTimeMs)} active${machine}, ${formatInt(d.tokens.output)} out tok, ${d.commandCount} cmd / ${d.fileChangedCount} files / ${d.decisionCount} dec`,
);
}
}
Expand All @@ -155,7 +163,10 @@ function printStatsText(result: WorkStatsResult, bySource: boolean, byDay: boole
function describeSource(s: SourceWorkStats): string {
const cmd = s.commandTimeReliable ? formatDurationMs(s.commandTimeMs) : "n/a";
const tokens = s.tokensAvailable ? `${formatInt(s.tokens.output)} out tok` : "no tokens";
return `${s.sessionCount} sessions, ${tokens}, active ${formatDurationMs(s.activeTimeMs)}, command ${cmd}`;
const machine = s.machineActiveAvailable
? `, model ${formatDurationMs(s.machineActiveTimeMs)}`
: "";
return `${s.sessionCount} sessions, ${tokens}, active ${formatDurationMs(s.activeTimeMs)}${machine}, command ${cmd}`;
}

/** "1,234,567" — thousands-separated, fixed en-US so output is deterministic. */
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/lib/view-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,23 +279,29 @@ export const VIEW_HTML = `<!doctype html>
if (t.activeTimeMs !== t.billableActiveTimeMs) {
timeRows.push(kvrow('summed', fmtDur(t.activeTimeMs) + ' (concurrent sessions double-counted)'));
}
if (t.machineActiveAvailable) {
var machineSessions = sessions.filter(function (s) { return s.availability && s.availability.machineActive; }).length;
timeRows.push(kvrow('model working', fmtDur(t.machineActiveTimeMs) + ' (model compute, subset of active; Codex turn duration on ' + machineSessions + ' of ' + t.sessionCount + ' sessions; not wall-clock-deduped)'));
}
timeRows.push(kvrow('span', fmtDur(t.sessionSpanMs) + (t.openSessionCount > 0 ? ' (' + t.openSessionCount + ' open)' : '')));
timeRows.push(kvrow('command', fmtDur(t.commandTimeMs) + (t.commandTimeReliable ? '' : ' (some sessions report 0)')));
detail.appendChild(el('table', { class: 'kv' }, [el('tbody', {}, timeRows)]));
if (d.bySource && d.bySource.length) {
detail.appendChild(el('h3', { text: 'By source' }));
d.bySource.forEach(function (s) {
var cmd = s.commandTimeReliable ? fmtDur(s.commandTimeMs) : 'n/a';
var machine = s.machineActiveAvailable ? ', model ' + fmtDur(s.machineActiveTimeMs) : '';
detail.appendChild(el('div', { class: 'row' }, [
el('span', { text: s.sourceKind + ': ' + s.sessionCount + ' sessions, ' + numfmt(s.tokens.output) + ' out tok, active ' + fmtDur(s.activeTimeMs) + ', command ' + cmd })
el('span', { text: s.sourceKind + ': ' + s.sessionCount + ' sessions, ' + numfmt(s.tokens.output) + ' out tok, active ' + fmtDur(s.activeTimeMs) + machine + ', command ' + cmd })
]));
});
}
if (d.byDay && d.byDay.length) {
detail.appendChild(el('h3', { text: 'By day (billable time x volume)' }));
d.byDay.forEach(function (day) {
var dayMachine = day.machineActiveTimeMs > 0 ? ' (model ' + fmtDur(day.machineActiveTimeMs) + ')' : '';
detail.appendChild(el('div', { class: 'row' }, [
el('span', { text: day.date + ': ' + fmtDur(day.billableActiveTimeMs) + ' active, ' + numfmt(day.tokens.output) + ' out tok, ' + day.commandCount + ' cmd / ' + day.fileChangedCount + ' files / ' + day.decisionCount + ' dec' })
el('span', { text: day.date + ': ' + fmtDur(day.billableActiveTimeMs) + ' active' + dayMachine + ', ' + numfmt(day.tokens.output) + ' out tok, ' + day.commandCount + ' cmd / ' + day.fileChangedCount + ' files / ' + day.decisionCount + ' dec' })
]));
});
}
Expand Down
Loading