Merge remote-tracking branch 'origin/main' into brennanb2025/create-pr-workspace-ui

# Conflicts:
#	src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx
This commit is contained in:
brennanb2025
2026-05-15 13:25:19 -07:00
48 changed files with 4338 additions and 256 deletions
+184 -2
View File
@@ -48,6 +48,7 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { TextInputModal } from '../../../../src/components/TextInputModal'
@@ -205,6 +206,28 @@ const STATUS_LABELS: Record<ConnectionState, string> = {
'auth-failed': 'Auth failed'
}
const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
const TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND = 120
const TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS = 16
const TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES = 32
const TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS = 250
type TerminalGestureInputBucket = {
tokens: number
lastRefillMs: number
}
type TerminalGestureInputQueue = {
bytes: string
sequenceCount: number
timer: ReturnType<typeof setTimeout> | null
lastUpdatedMs: number
}
function isWheelMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean {
return mode === 'vt200' || mode === 'drag' || mode === 'any'
}
function TerminalPaneView({
handle,
active,
@@ -216,7 +239,8 @@ function TerminalPaneView({
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
}: {
handle: string
active: boolean
@@ -229,6 +253,7 @@ function TerminalPaneView({
onModesChanged: (handle: string, modes: TerminalModes) => void
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
}) {
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
@@ -256,6 +281,7 @@ function TerminalPaneView({
onModesChanged={(m) => onModesChanged(handle, m)}
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
/>
</View>
)
@@ -466,9 +492,13 @@ export default function SessionScreen() {
// Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
// change so paste reads a synchronous snapshot — no round-trip required.
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const connStateRef = useRef<ConnectionState>(connState)
// Why: measured once from TerminalWebView on mount, then passed with every
// subscribe call so the server can auto-fit the PTY to phone dimensions.
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
@@ -1295,6 +1325,16 @@ export default function SessionScreen() {
clientRef.current = client
}, [client])
useEffect(() => {
connStateRef.current = connState
if (connState === 'connected') return
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
}, [connState])
// Why: only clear terminal cache on actual unmount. Running it whenever
// `client` changes — including the initial null → real-client transition
// from useHostClient's async open path — would unsubscribe terminals and
@@ -1432,6 +1472,11 @@ export default function SessionScreen() {
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
setActiveHandle(null)
setTerminals([])
terminalsRef.current = []
@@ -1654,6 +1699,13 @@ export default function SessionScreen() {
terminalRefs.current.set(handle, ref)
} else {
terminalRefs.current.delete(handle)
terminalGestureInputBucketsRef.current.delete(handle)
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (queued?.timer) {
clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.delete(handle)
terminalGestureInputInFlightRef.current.delete(handle)
}
}, [])
@@ -1754,6 +1806,132 @@ export default function SessionScreen() {
}
}
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
const now = Date.now()
const current = terminalGestureInputBucketsRef.current.get(handle) ?? {
tokens: TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
lastRefillMs: now
}
const elapsedSeconds = Math.max(0, now - current.lastRefillMs) / 1000
const tokens = Math.min(
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
)
// Why: tokens represent terminal control sequences, not WebView messages;
// one legitimate gesture message may batch up to 32 wheel/key reports.
if (tokens < sequenceCount) {
terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now })
return false
}
terminalGestureInputBucketsRef.current.set(handle, {
tokens: tokens - sequenceCount,
lastRefillMs: now
})
return true
},
[]
)
const flushTerminalGestureInput = useCallback(async (handle: string) => {
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (!queued) return
if (queued.timer) {
clearTimeout(queued.timer)
queued.timer = null
}
if (terminalGestureInputInFlightRef.current.has(handle)) return
terminalGestureInputQueuesRef.current.delete(handle)
const isActive =
handle === activeHandleRef.current && activeSessionTabTypeRef.current === 'terminal'
const isFresh = Date.now() - queued.lastUpdatedMs <= TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS
const rpc = clientRef.current
if (!rpc || connStateRef.current !== 'connected' || !isActive || !isFresh) return
terminalGestureInputInFlightRef.current.add(handle)
try {
await rpc.sendRequest('terminal.send', {
terminal: handle,
text: queued.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
} catch {
// Transient failure
} finally {
terminalGestureInputInFlightRef.current.delete(handle)
const next = terminalGestureInputQueuesRef.current.get(handle)
if (next) {
if (Date.now() - next.lastUpdatedMs > TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS) {
if (next.timer) clearTimeout(next.timer)
terminalGestureInputQueuesRef.current.delete(handle)
} else {
void flushTerminalGestureInput(handle)
}
}
}
}, [])
const enqueueTerminalGestureInput = useCallback(
(handle: string, bytes: string, sequenceCount: number) => {
const now = Date.now()
const current = terminalGestureInputQueuesRef.current.get(handle)
if (
current &&
current.sequenceCount + sequenceCount <= TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES
) {
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
return
}
if (current) {
if (current.timer) clearTimeout(current.timer)
if (!terminalGestureInputInFlightRef.current.has(handle)) {
void flushTerminalGestureInput(handle)
} else {
terminalGestureInputQueuesRef.current.delete(handle)
}
}
const queued: TerminalGestureInputQueue = {
bytes,
sequenceCount,
timer: null,
lastUpdatedMs: now
}
queued.timer = setTimeout(() => {
queued.timer = null
void flushTerminalGestureInput(handle)
}, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS)
terminalGestureInputQueuesRef.current.set(handle, queued)
},
[flushTerminalGestureInput]
)
const handleTerminalInput = useCallback(
async (handle: string, bytes: string) => {
if (!client || connState !== 'connected' || bytes.length === 0) return
if (handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal')
return
const modes = ptyModesRef.current.get(handle)
// Why: WebView messages can become PTY input here. Only TUI scroll paths
// generate gesture input, and the bridge is rate-limited for SSH safety.
if (!modes?.altScreen && !isWheelMouseTrackingMode(modes?.mouseTrackingMode)) return
const sequenceCount = countTerminalGestureInputSequences(bytes)
if (sequenceCount == null) return
if (!allowTerminalGestureInput(handle, sequenceCount)) return
enqueueTerminalGestureInput(handle, bytes, sequenceCount)
},
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
async function handleClearTerminal(target: Terminal) {
if (!client) return
getTerminalRef(target.handle)?.clear()
@@ -1890,7 +2068,10 @@ export default function SessionScreen() {
if (text.length === 0) return
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
// Why: strip embedded bracketed-paste markers from clipboard text so a
@@ -2335,6 +2516,7 @@ export default function SessionScreen() {
onModesChanged={handleModesChanged}
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
/>
))}
{toastMessage && (
+388 -116
View File
@@ -4,9 +4,14 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import { colors } from '../theme/mobile-theme'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
@@ -22,6 +27,7 @@ export type TerminalSelectionEvents = {
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
}
export type TerminalWebViewHandle = {
@@ -178,6 +184,8 @@ const XTERM_HTML = `<!DOCTYPE html>
(function() {
var surface = document.getElementById('terminal-surface');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var writeQueue = [];
var writesDraining = false;
@@ -190,6 +198,10 @@ const XTERM_HTML = `<!DOCTYPE html>
var initRows = 24;
var terminalGeneration = 0;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
var sgrMousePixelsMode = false;
var mouseModeScanTail = '';
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
@@ -406,6 +418,58 @@ const XTERM_HTML = `<!DOCTYPE html>
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = writeQueue.shift();
@@ -439,7 +503,21 @@ const XTERM_HTML = `<!DOCTYPE html>
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
var replayData = normalizeInitialData(initialData);
// Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
var oldTerm = term;
var oldSurface = surface;
@@ -453,6 +531,7 @@ const XTERM_HTML = `<!DOCTYPE html>
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
@@ -522,6 +601,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}
function write(data) {
updateMouseModeFromData(data);
writeQueue.push(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
@@ -623,7 +703,12 @@ const XTERM_HTML = `<!DOCTYPE html>
writeQueue = [];
afterDrainCallbacks = [];
writesDraining = false;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
@@ -715,13 +800,39 @@ const XTERM_HTML = `<!DOCTYPE html>
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (bp !== lastEmittedModes.bracketedPasteMode || alt !== lastEmittedModes.altScreen) {
lastEmittedModes = { bracketedPasteMode: bp, altScreen: alt };
notify({ type: 'modes', bracketedPasteMode: bp, altScreen: alt });
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = { bracketedPasteMode: false, altScreen: false };
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
function emitKeyboardAvoidanceMetrics() {
if (!term) return;
@@ -772,6 +883,147 @@ const XTERM_HTML = `<!DOCTYPE html>
return { col: col, row: viewportRow + viewportY };
}
function viewportToMouseReportCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var maxX = Math.max(0, term.cols * cellW - 1);
var maxY = Math.max(0, term.rows * cellH - 1);
if (sx < 0) sx = 0;
if (sx > maxX) sx = maxX;
if (sy < 0) sy = 0;
if (sy > maxY) sy = maxY;
var col = Math.floor(sx / cellW);
var row = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (row < 0) row = 0;
if (row > term.rows - 1) row = term.rows - 1;
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
}
function isAlternateBufferActive() {
try {
return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate');
} catch (e) {
return false;
}
}
function getMouseTrackingMode() {
try {
if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') {
var mode = term.modes.mouseTrackingMode;
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode;
return 'none';
}
} catch (e) {}
if (
trackedMouseTrackingMode === 'x10' ||
trackedMouseTrackingMode === 'vt200' ||
trackedMouseTrackingMode === 'drag' ||
trackedMouseTrackingMode === 'any'
) {
return trackedMouseTrackingMode;
}
return 'none';
}
function repeatSequence(sequence, count) {
var out = '';
for (var i = 0; i < count; i++) out += sequence;
return out;
}
function buildArrowScrollSequence(lines) {
var prefix = '[';
try {
if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O';
} catch (e) {}
return ESC + prefix + (lines < 0 ? 'A' : 'B');
}
function buildMouseWheelSequence(lines, clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var eventCode = lines < 0 ? 64 : 65;
if (sgrMousePixelsMode) {
return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
return ESC + '[<' + eventCode + ';' + (cell.col + 1) + ';' + (cell.row + 1) + 'M';
}
// Why: xterm increments zero-based mouse cells before encoding reports.
var button = eventCode + 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
@@ -1134,12 +1386,6 @@ const XTERM_HTML = `<!DOCTYPE html>
} catch (err) {}
});
// Why: event listeners are registered once here (not inside init()) so
// they don't accumulate on re-init. They close over the mutable 'term'
// variable, so they always reference the current terminal instance.
surface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
surface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
var ts = {
lastX: 0, lastY: 0, lastTime: 0, velY: 0,
accumDelta: 0, momentumId: null, isPinching: false,
@@ -1151,121 +1397,132 @@ const XTERM_HTML = `<!DOCTYPE html>
return Math.sqrt(dx * dx + dy * dy);
}
surface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
if (e.touches.length === 2) {
ts.isPinching = true;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
}, { capture: true, passive: true });
function attachSurfaceEventHandlers(targetSurface) {
if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return;
targetSurface.__orcaSurfaceHandlersAttached = true;
// Why: init() swaps in a new hidden surface to avoid flicker; each
// replacement needs gesture handlers or tab-switch replays stop scrolling.
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
surface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX;
var y = e.touches[0].clientY;
var now = Date.now();
var dt = now - ts.lastTime;
if (userScale > 1.05) {
panX += x - ts.lastX;
panY += y - ts.lastY;
clampPan();
updateTransform();
} else {
var deltaY = ts.lastY - y;
if (dt > 0) ts.velY = deltaY / dt;
ts.lastTime = now;
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
term.scrollLines(lines);
}
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
surface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
if (userScale < 1.15) {
userScale = 1; panX = 0; panY = 0;
updateTransform();
}
if (e.touches.length === 1) {
if (e.touches.length === 2) {
ts.isPinching = true;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
}, { capture: true, passive: true });
if (e.touches.length === 0 && userScale <= 1.05) {
var vel = ts.velY;
var FRICTION = 0.95;
var MIN_VEL = 0.02;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += vel * 16;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
term.scrollLines(lines);
targetSurface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX;
var y = e.touches[0].clientY;
var now = Date.now();
var dt = now - ts.lastTime;
if (userScale > 1.05) {
panX += x - ts.lastX;
panY += y - ts.lastY;
clampPan();
updateTransform();
} else {
var deltaY = ts.lastY - y;
if (dt > 0) ts.velY = deltaY / dt;
ts.lastTime = now;
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
ts.lastX = x;
ts.lastY = y;
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
if (userScale < 1.15) {
userScale = 1; panX = 0; panY = 0;
updateTransform();
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
}
}, { capture: true, passive: true });
if (e.touches.length === 0 && userScale <= 1.05) {
var vel = ts.velY;
var FRICTION = 0.95;
var MIN_VEL = 0.02;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var effectiveCellH = getCellHeight() * currentScale;
ts.accumDelta += vel * 16;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
window.addEventListener('message', function(e) {
try {
@@ -1310,7 +1567,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
},
ref
) {
@@ -1395,10 +1653,23 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
} else if (msg.type === 'selection-evicted') {
onSelectionEvicted?.()
} else if (msg.type === 'modes') {
const mouseTrackingMode =
msg.mouseTrackingMode === 'x10' ||
msg.mouseTrackingMode === 'vt200' ||
msg.mouseTrackingMode === 'drag' ||
msg.mouseTrackingMode === 'any'
? msg.mouseTrackingMode
: 'none'
onModesChanged?.({
bracketedPasteMode: !!msg.bracketedPasteMode,
altScreen: !!msg.altScreen
altScreen: !!msg.altScreen,
mouseTrackingMode,
sgrMouseMode: !!msg.sgrMouseMode,
sgrMousePixelsMode: !!msg.sgrMousePixelsMode
})
} else if (msg.type === 'terminal-input') {
const bytes = typeof msg.bytes === 'string' ? msg.bytes : ''
if (bytes.length > 0) onTerminalInput?.(bytes)
} else if (msg.type === 'keyboard-avoidance-metrics') {
const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0
const rows = typeof msg.rows === 'number' ? msg.rows : 0
@@ -1430,7 +1701,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
onHaptic,
onTerminalInput
]
)
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
countTerminalGestureInputSequences,
isTerminalGestureInput
} from './terminal-gesture-input'
const ESC = '\x1b'
describe('isTerminalGestureInput', () => {
it('accepts repeated arrow scroll sequences', () => {
expect(isTerminalGestureInput(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(true)
expect(isTerminalGestureInput(`${ESC}[A`.repeat(32))).toBe(true)
expect(countTerminalGestureInputSequences(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(4)
expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(32))).toBe(32)
})
it('accepts repeated SGR wheel sequences', () => {
expect(isTerminalGestureInput(`${ESC}[<64;1;1M${ESC}[<65;120;40M`)).toBe(true)
expect(isTerminalGestureInput(`${ESC}[<64;0;0M`)).toBe(true)
})
it('accepts repeated default mouse wheel sequences', () => {
expect(
isTerminalGestureInput(
`${ESC}[M${String.fromCharCode(96, 97, 33)}${ESC}[M${String.fromCharCode(97, 126, 126)}`
)
).toBe(true)
})
it('rejects shell text and other terminal input', () => {
expect(isTerminalGestureInput('rm -rf .\r')).toBe(false)
expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false)
})
it('rejects malformed or oversized sequences', () => {
expect(isTerminalGestureInput(`${ESC}[<63;0;1M`)).toBe(false)
expect(isTerminalGestureInput(`${ESC}[M` + String.fromCharCode(97, 32, 33))).toBe(false)
expect(isTerminalGestureInput(`${ESC}[A`.repeat(33))).toBe(false)
expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(33))).toBeNull()
expect(isTerminalGestureInput(`${ESC}[A`.repeat(700))).toBe(false)
})
})
@@ -0,0 +1,71 @@
const ESC = '\x1b'
const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048
const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32
const SGR_MOUSE_WHEEL_SEQUENCE_RE = new RegExp(`^${ESC}\\[<(64|65);[0-9]{1,4};[0-9]{1,4}M$`)
function isDefaultMouseWheelSequence(bytes: string, offset: number): number | null {
if (!bytes.startsWith(`${ESC}[M`, offset) || offset + 6 > bytes.length) {
return null
}
const button = bytes.charCodeAt(offset + 3)
const col = bytes.charCodeAt(offset + 4)
const row = bytes.charCodeAt(offset + 5)
if ((button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && row <= 126) {
return offset + 6
}
return null
}
function isSgrMouseWheelSequence(bytes: string, offset: number): number | null {
if (!bytes.startsWith(`${ESC}[<`, offset)) {
return null
}
const end = bytes.indexOf('M', offset)
if (end === -1) {
return null
}
const sequence = bytes.slice(offset, end + 1)
return SGR_MOUSE_WHEEL_SEQUENCE_RE.test(sequence) ? end + 1 : null
}
function isArrowScrollSequence(bytes: string, offset: number): number | null {
const sequence = bytes.slice(offset, offset + 3)
if (
sequence === `${ESC}[A` ||
sequence === `${ESC}[B` ||
sequence === `${ESC}OA` ||
sequence === `${ESC}OB`
) {
return offset + 3
}
return null
}
export function countTerminalGestureInputSequences(bytes: string): number | null {
if (bytes.length === 0 || bytes.length > MAX_TERMINAL_GESTURE_INPUT_LENGTH) {
return null
}
let offset = 0
let sequenceCount = 0
while (offset < bytes.length) {
const next =
isArrowScrollSequence(bytes, offset) ??
isSgrMouseWheelSequence(bytes, offset) ??
isDefaultMouseWheelSequence(bytes, offset)
if (next == null) {
return null
}
sequenceCount += 1
if (sequenceCount > MAX_TERMINAL_GESTURE_INPUT_SEQUENCES) {
return null
}
offset = next
}
return sequenceCount
}
export function isTerminalGestureInput(bytes: string): boolean {
return countTerminalGestureInputSequences(bytes) != null
}
@@ -0,0 +1,164 @@
import type { ExternalAutomationJob } from '../../shared/automations-types'
type ExternalJobRecord = Record<string, unknown>
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function formatDurationMs(value: unknown): string {
const ms = asNumber(value)
if (!ms) {
return '?'
}
if (ms % 86_400_000 === 0) {
return `${ms / 86_400_000}d`
}
if (ms % 3_600_000 === 0) {
return `${ms / 3_600_000}h`
}
if (ms % 60_000 === 0) {
return `${ms / 60_000}m`
}
if (ms % 1000 === 0) {
return `${ms / 1000}s`
}
return `${ms}ms`
}
function isoFromMs(value: unknown): string | null {
const ms = asNumber(value)
if (!ms) {
return null
}
const date = new Date(ms)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function hermesScheduleDisplay(job: ExternalJobRecord): string {
const direct = asString(job.schedule_display)
if (direct) {
return direct
}
const schedule = job.schedule
if (isRecord(schedule)) {
return (
asString(schedule.display) ??
asString(schedule.value) ??
asString(schedule.expr) ??
asString(schedule.run_at) ??
'?'
)
}
return asString(schedule) ?? '?'
}
function hermesPromptPreview(job: ExternalJobRecord): string {
const prompt = asString(job.prompt) ?? ''
if (prompt) {
return prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt
}
const script = asString(job.script)
if (script) {
return job.no_agent ? `Script: ${script}` : `Prompt uses script context: ${script}`
}
const skills = Array.isArray(job.skills) ? job.skills.map((skill) => String(skill)) : []
return skills.length > 0 ? `Skills: ${skills.join(', ')}` : ''
}
function openClawScheduleDisplay(job: ExternalJobRecord): string {
const schedule = job.schedule
if (!isRecord(schedule)) {
return asString(schedule) ?? '?'
}
const kind = asString(schedule.kind)
if (kind === 'at') {
return `at ${asString(schedule.at) ?? '?'}`
}
if (kind === 'every') {
return `every ${formatDurationMs(schedule.everyMs)}`
}
if (kind === 'cron') {
const expr = asString(schedule.expr) ?? asString(schedule.cron) ?? '?'
const tz = asString(schedule.tz)
return tz ? `cron ${expr} @ ${tz}` : `cron ${expr}`
}
return kind ?? '?'
}
function openClawPromptPreview(job: ExternalJobRecord): string {
const payload = job.payload
if (!isRecord(payload)) {
return ''
}
const text = asString(payload.message) ?? asString(payload.text) ?? ''
return text.length > 120 ? `${text.slice(0, 117)}...` : text
}
export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
if (!Array.isArray(rawJobs)) {
return []
}
return rawJobs.filter(isRecord).map((job) => {
const id = asString(job.id) ?? 'unknown'
const enabled = job.enabled !== false && job.state !== 'paused'
const preview = hermesPromptPreview(job)
return {
id,
managerId,
provider: 'hermes',
name: (asString(job.name) ?? preview) || id,
schedule: hermesScheduleDisplay(job),
enabled,
state: asString(job.state) ?? (enabled ? 'scheduled' : 'paused'),
promptPreview: preview,
nextRunAt: asString(job.next_run_at),
lastRunAt: asString(job.last_run_at),
lastStatus: asString(job.last_status),
lastError: asString(job.last_error) ?? asString(job.last_delivery_error),
workdir: asString(job.workdir)
}
})
}
export function mapOpenClawJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] {
const jobs = Array.isArray(rawJobs)
? rawJobs
: isRecord(rawJobs) && Array.isArray(rawJobs.jobs)
? rawJobs.jobs
: []
return jobs.filter(isRecord).map((job) => {
const id = asString(job.id) ?? 'unknown'
const enabled = job.enabled !== false
const state = isRecord(job.state) ? job.state : {}
const preview = openClawPromptPreview(job)
const lastStatus = asString(state.lastRunStatus) ?? asString(state.lastStatus)
return {
id,
managerId,
provider: 'openclaw',
name: (asString(job.name) ?? preview) || id,
schedule: openClawScheduleDisplay(job),
enabled,
state: !enabled
? 'disabled'
: asNumber(state.runningAtMs)
? 'running'
: (lastStatus ?? 'idle'),
promptPreview: preview,
nextRunAt: isoFromMs(state.nextRunAtMs),
lastRunAt: isoFromMs(state.lastRunAtMs),
lastStatus,
lastError: asString(state.lastError) ?? asString(state.lastDeliveryError),
workdir: null
}
})
}
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { runExternalAutomationAction } from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
const execFileMock = vi.hoisted(() =>
vi.fn((...args: unknown[]) => {
const callback = args.at(-1)
if (typeof callback === 'function') {
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
execCallback(null, '', '')
}
})
)
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('../ipc/ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
beforeEach(() => {
execFileMock.mockClear()
})
describe('mapHermesJobs', () => {
it('normalizes Hermes cron jobs into external automation rows', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-1',
name: 'Nightly audit',
prompt: 'Audit the repo for risky dependency changes',
schedule_display: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
next_run_at: '2026-05-16T09:00:00Z',
last_run_at: '2026-05-15T09:00:00Z',
last_status: 'ok',
workdir: '/repo'
}
])
expect(jobs).toEqual([
{
id: 'job-1',
managerId: 'hermes:local',
provider: 'hermes',
name: 'Nightly audit',
schedule: '0 9 * * 1-5',
enabled: true,
state: 'scheduled',
promptPreview: 'Audit the repo for risky dependency changes',
nextRunAt: '2026-05-16T09:00:00Z',
lastRunAt: '2026-05-15T09:00:00Z',
lastStatus: 'ok',
lastError: null,
workdir: '/repo'
}
])
})
it('falls back to script and schedule fields for older Hermes records', () => {
const jobs = mapHermesJobs('hermes:local', [
{
id: 'job-2',
script: 'disk-check.sh',
no_agent: true,
schedule: { display: 'every 30m' },
enabled: false,
state: 'paused',
last_delivery_error: 'home channel missing'
}
])
expect(jobs[0]).toMatchObject({
id: 'job-2',
name: 'Script: disk-check.sh',
schedule: 'every 30m',
enabled: false,
state: 'paused',
promptPreview: 'Script: disk-check.sh',
lastError: 'home channel missing'
})
})
})
describe('runExternalAutomationAction', () => {
it('runs local Hermes lifecycle actions through the CLI', async () => {
await runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: 'job-1',
action: 'run'
})
expect(execFileMock).toHaveBeenCalledWith(
'hermes',
['cron', 'run', 'job-1'],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
it('rejects job IDs that could be parsed as CLI options', async () => {
await expect(
runExternalAutomationAction({
managerId: 'hermes:local',
provider: 'hermes',
target: { type: 'local' },
jobId: '-help',
action: 'run'
})
).rejects.toThrow('Invalid external automation job ID.')
expect(execFileMock).not.toHaveBeenCalled()
})
it('maps OpenClaw lifecycle actions through its cron CLI names', async () => {
await runExternalAutomationAction({
managerId: 'openclaw:local',
provider: 'openclaw',
target: { type: 'local' },
jobId: 'job-1',
action: 'pause'
})
expect(execFileMock).toHaveBeenCalledWith(
'openclaw',
['cron', 'disable', 'job-1'],
{ encoding: 'utf-8' },
expect.any(Function)
)
})
})
describe('mapOpenClawJobs', () => {
it('normalizes OpenClaw cron jobs into external automation rows', () => {
const jobs = mapOpenClawJobs('openclaw:local', {
version: 1,
jobs: [
{
id: 'claw-1',
name: 'Morning report',
enabled: true,
schedule: { kind: 'cron', expr: '0 9 * * *', tz: 'America/Phoenix' },
payload: { kind: 'agentTurn', message: 'Summarize overnight alerts' },
state: {
nextRunAtMs: Date.parse('2026-05-16T16:00:00Z'),
lastRunAtMs: Date.parse('2026-05-15T16:00:00Z'),
lastRunStatus: 'ok'
}
}
]
})
expect(jobs[0]).toMatchObject({
id: 'claw-1',
managerId: 'openclaw:local',
provider: 'openclaw',
name: 'Morning report',
schedule: 'cron 0 9 * * * @ America/Phoenix',
enabled: true,
state: 'ok',
promptPreview: 'Summarize overnight alerts',
nextRunAt: '2026-05-16T16:00:00.000Z',
lastRunAt: '2026-05-15T16:00:00.000Z',
lastStatus: 'ok'
})
})
})
+250
View File
@@ -0,0 +1,250 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { promisify } from 'util'
import type {
ExternalAutomationAction,
ExternalAutomationActionInput,
ExternalAutomationManager,
ExternalAutomationProvider
} from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { Store } from '../persistence'
import { getActiveMultiplexer } from '../ipc/ssh'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
const execFileAsync = promisify(execFile)
const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
await execFileAsync(finder, [command], { encoding: 'utf-8' })
return true
} catch {
return false
}
}
async function readLocalHermesJobs(): Promise<unknown[]> {
if (!existsSync(HERMES_JOBS_FILE)) {
return []
}
const content = await readFile(HERMES_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return Array.isArray(parsed) ? parsed : []
}
async function readLocalOpenClawJobs(): Promise<unknown[]> {
if (!existsSync(OPENCLAW_JOBS_FILE)) {
return []
}
const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8')
const parsed = JSON.parse(content) as unknown
return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : []
}
async function listLocalHermesManager(): Promise<ExternalAutomationManager | null> {
const [hermesAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('hermes'),
readLocalHermesJobs()
])
const hermesAvailable =
hermesAvailableResult.status === 'fulfilled' && hermesAvailableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!hermesAvailable && jobs.length === 0 && !readError) {
return null
}
const managerId = 'hermes:local'
return {
id: managerId,
provider: 'hermes',
label: 'Hermes on this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(hermesAvailable ? null : 'Hermes jobs were found, but the hermes CLI is not on PATH.'),
canManage: !readError && hermesAvailable,
jobs: mapHermesJobs(managerId, jobs)
}
}
async function listLocalOpenClawManager(): Promise<ExternalAutomationManager | null> {
const [openClawAvailableResult, jobsResult] = await Promise.allSettled([
isCommandOnPath('openclaw'),
readLocalOpenClawJobs()
])
const openClawAvailable =
openClawAvailableResult.status === 'fulfilled' && openClawAvailableResult.value
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
if (!openClawAvailable && jobs.length === 0 && !readError) {
return null
}
const managerId = 'openclaw:local'
return {
id: managerId,
provider: 'openclaw',
label: 'OpenClaw on this computer',
target: { type: 'local' },
status: readError ? 'unavailable' : 'available',
error:
readError ??
(openClawAvailable ? null : 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'),
canManage: !readError && openClawAvailable,
jobs: mapOpenClawJobs(managerId, jobs)
}
}
function remoteRelayErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('-32601') || /method not found/i.test(message)) {
return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.'
}
return message
}
async function listRemoteHermesManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'hermes')
}
async function listRemoteOpenClawManager(target: SshTarget): Promise<ExternalAutomationManager> {
return listRemoteManager(target, 'openclaw')
}
async function listRemoteManager(
target: SshTarget,
provider: ExternalAutomationProvider
): Promise<ExternalAutomationManager> {
const providerLabel = provider === 'hermes' ? 'Hermes' : 'OpenClaw'
const managerProviderId = `${provider}:ssh:${target.id}`
const mux = getActiveMultiplexer(target.id)
if (!mux || mux.isDisposed()) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: 'SSH target is not connected.',
canManage: false,
jobs: []
}
}
try {
const result = (await mux.request('externalAutomations.list', { provider })) as {
jobs?: unknown[]
hermesAvailable?: boolean
openclawAvailable?: boolean
error?: string | null
}
const commandAvailable =
provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true
const readError = result.error ?? null
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: readError ? 'unavailable' : 'available',
error:
readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`),
canManage: !readError && commandAvailable,
jobs:
provider === 'hermes'
? mapHermesJobs(managerProviderId, result.jobs ?? [])
: mapOpenClawJobs(managerProviderId, result.jobs ?? [])
}
} catch (error) {
return {
id: managerProviderId,
provider,
label: `${providerLabel} on ${target.label}`,
target: { type: 'ssh', connectionId: target.id },
status: 'unavailable',
error: remoteRelayErrorMessage(error),
canManage: false,
jobs: []
}
}
}
export async function listExternalAutomationManagers(
store: Store
): Promise<ExternalAutomationManager[]> {
const [localHermes, localOpenClaw, remote] = await Promise.all([
listLocalHermesManager(),
listLocalOpenClawManager(),
Promise.all(
store
.getSshTargets()
.flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)])
)
])
return [
...(localHermes ? [localHermes] : []),
...(localOpenClaw ? [localOpenClaw] : []),
...remote
]
}
function hermesCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'pause'
case 'resume':
return 'resume'
case 'run':
return 'run'
case 'delete':
return 'remove'
}
}
function openClawCommandForAction(action: ExternalAutomationAction): string {
switch (action) {
case 'pause':
return 'disable'
case 'resume':
return 'enable'
case 'run':
return 'run'
case 'delete':
return 'rm'
}
}
export async function runExternalAutomationAction(
input: ExternalAutomationActionInput
): Promise<void> {
if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) {
throw new Error('Invalid external automation job ID.')
}
const command =
input.provider === 'hermes'
? hermesCommandForAction(input.action)
: openClawCommandForAction(input.action)
if (input.target.type === 'local') {
await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' })
return
}
const mux = getActiveMultiplexer(input.target.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`SSH target "${input.target.connectionId}" is not connected.`)
}
await mux.request('externalAutomations.act', {
provider: input.provider,
action: input.action,
jobId: input.jobId
})
}
+133 -3
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as Os from 'os'
import type * as FsPromises from 'fs/promises'
const tempRoots: string[] = []
@@ -10,11 +11,13 @@ async function makeClaudeProjectsRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-usage-'))
tempRoots.push(root)
await mkdir(join(root, '.claude', 'projects', 'project-a'), { recursive: true })
await mkdir(join(root, '.claude', 'transcripts'), { recursive: true })
return root
}
afterEach(async () => {
vi.doUnmock('os')
vi.doUnmock('fs/promises')
vi.resetModules()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
@@ -23,8 +26,10 @@ describe('scanClaudeUsageFiles', () => {
it('scans transcript files from the configured Claude projects directory', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptsDir = join(root, '.claude', 'transcripts')
const firstFile = join(projectDir, 'a.jsonl')
const secondFile = join(projectDir, 'b.jsonl')
const transcriptFile = join(transcriptsDir, 'ses_123.jsonl')
await writeFile(
firstFile,
@@ -63,6 +68,22 @@ describe('scanClaudeUsageFiles', () => {
}
})
)
await writeFile(
transcriptFile,
JSON.stringify({
type: 'assistant',
timestamp: '2026-04-11T10:00:00.000Z',
cwd: '/workspace/repo-a/packages/app',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 70,
output_tokens: 15,
cache_read_input_tokens: 20
}
}
})
)
vi.resetModules()
vi.doMock('os', async () => ({
@@ -82,10 +103,119 @@ describe('scanClaudeUsageFiles', () => {
expect(result.processedFiles.map((file) => [file.path, file.lineCount])).toEqual([
[firstFile, 2],
[secondFile, 1]
[secondFile, 1],
[transcriptFile, 1]
])
expect(result.sessions.map((session) => session.sessionId)).toEqual(['session-2', 'session-1'])
expect(result.dailyAggregates).toHaveLength(2)
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'ses_123',
'session-2',
'session-1'
])
expect(result.dailyAggregates).toHaveLength(3)
expect(result.dailyAggregates[0]?.projectLabel).toBe('Repo A')
expect(result.dailyAggregates[2]?.projectLabel).toBe('Repo A')
})
it('reuses unchanged transcript projections from the previous scan', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptFile = join(projectDir, 'session-1.jsonl')
await writeFile(
transcriptFile,
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
cwd: '/workspace/repo-a',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 20
}
}
})
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const worktrees = [
{
repoId: 'repo-1',
worktreeId: 'worktree-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
]
const first = await scanClaudeUsageFiles(worktrees)
const cachedFile = structuredClone(first.processedFiles[0]!)
cachedFile.sessions[0]!.totalInputTokens = 999
cachedFile.sessions[0]!.locationBreakdown[0]!.inputTokens = 999
cachedFile.dailyAggregates[0]!.inputTokens = 999
const second = await scanClaudeUsageFiles(worktrees, [cachedFile])
expect(second.processedFiles[0]?.sessions[0]?.totalInputTokens).toBe(999)
expect(second.dailyAggregates[0]?.inputTokens).toBe(999)
})
it('canonicalizes repeated cwd paths once per scan file', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const transcriptFile = join(projectDir, 'session-1.jsonl')
const repeatedCwd = '/workspace/repo-a/packages/app'
await writeFile(
transcriptFile,
[1, 2, 3]
.map((index) =>
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: `2026-04-09T10:0${index}:00.000Z`,
cwd: repeatedCwd,
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 20
}
}
})
)
.join('\n')
)
const realpathCalls: string[] = []
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
vi.doMock('fs/promises', async () => ({
...(await vi.importActual<typeof FsPromises>('fs/promises')),
realpath: vi.fn(async (pathValue: string) => {
realpathCalls.push(pathValue)
return pathValue
})
}))
const { scanClaudeUsageFiles } = await import('./scanner')
await scanClaudeUsageFiles([
{
repoId: 'repo-1',
worktreeId: 'worktree-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
])
expect(realpathCalls.filter((pathValue) => pathValue === repeatedCwd)).toHaveLength(1)
})
})
+101 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
import { aggregateClaudeUsage, attributeClaudeUsageTurns, parseClaudeUsageRecord } from './scanner'
import {
aggregateClaudeUsage,
attributeClaudeUsageTurns,
parseClaudeUsageFile,
parseClaudeUsageRecord
} from './scanner'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
describe('parseClaudeUsageRecord', () => {
it('extracts token usage from assistant transcript lines', () => {
@@ -34,6 +42,66 @@ describe('parseClaudeUsageRecord', () => {
cacheWriteTokens: 5
})
})
it('merges duplicate streamed assistant usage by message and request id', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-'))
const filePath = join(root, 'session.jsonl')
try {
await writeFile(
filePath,
[
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
requestId: 'req-1',
message: {
id: 'msg-1',
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 25,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 5
}
}
}),
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:01.000Z',
requestId: 'req-1',
message: {
id: 'msg-1',
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 120,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation_input_tokens: 7
}
}
})
].join('\n')
)
await expect(parseClaudeUsageFile(filePath)).resolves.toEqual([
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
model: 'claude-sonnet-4-6',
cwd: null,
gitBranch: null,
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: 12,
cacheWriteTokens: 7
}
])
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe('Claude usage aggregation', () => {
@@ -104,4 +172,36 @@ describe('Claude usage aggregation', () => {
}
])
})
it('attributes nested cwd paths to the containing worktree', async () => {
const attributed = await attributeClaudeUsageTurns(
[
{
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
model: 'claude-sonnet-4-6',
cwd: '/workspace/repo-a/packages/app',
gitBranch: 'feature/a',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 10,
cacheWriteTokens: 5
}
],
new Map([
[
'/workspace/repo-a',
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
]
])
)
expect(attributed[0]?.projectKey).toBe('worktree:repo-1::/workspace/repo-a')
expect(attributed[0]?.projectLabel).toBe('Repo A')
})
})
+290 -41
View File
@@ -10,6 +10,7 @@ import type {
ClaudeUsageDailyAggregate,
ClaudeUsageLocationBreakdown,
ClaudeUsageParsedTurn,
ClaudeUsagePersistedFile,
ClaudeUsageProcessedFile,
ClaudeUsageSession
} from './types'
@@ -27,7 +28,11 @@ type ClaudeUsageSourceRecord = {
timestamp?: string
cwd?: string
gitBranch?: string
requestId?: string
isSidechain?: boolean
agentId?: string
message?: {
id?: string
model?: string
usage?: {
input_tokens?: number
@@ -39,8 +44,20 @@ type ClaudeUsageSourceRecord = {
}
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts')
const FILE_SCAN_BATCH_SIZE = 4
type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & {
dedupeKey: string | null
}
type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef]
const sortedWorktreeEntriesByLookup = new WeakMap<
Map<string, ClaudeUsageWorktreeRef>,
ClaudeUsageWorktreeEntry[]
>()
function getDefaultProjectLabel(cwd: string | null): string {
if (!cwd) {
return 'Unknown location'
@@ -66,6 +83,45 @@ function normalizeComparablePath(pathValue: string): string {
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function isContainedPath(parentPath: string, childPath: string): boolean {
const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '')
const child = normalizeComparablePath(childPath).replace(/\/+$/, '')
return child === parent || child.startsWith(`${parent}/`)
}
function findContainingWorktree(
cwd: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeRef | null {
const normalizedCwd = normalizeComparablePath(cwd)
const exact = worktreeLookup.get(normalizedCwd)
if (exact) {
return exact
}
for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) {
if (isContainedPath(worktreePath, normalizedCwd)) {
return worktree
}
}
return null
}
function getSortedWorktreeEntries(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeEntry[] {
const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup)
if (cached) {
return cached
}
const sorted = [...worktreeLookup.entries()].sort(
([leftPath], [rightPath]) => rightPath.length - leftPath.length
)
sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted)
return sorted
}
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
@@ -89,11 +145,17 @@ async function walkJsonlFiles(dirPath: string): Promise<string[]> {
}
export async function listClaudeTranscriptFiles(): Promise<string[]> {
try {
return (await walkJsonlFiles(CLAUDE_PROJECTS_DIR)).sort()
} catch {
return []
}
const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR]
const files = await Promise.all(
roots.map(async (root) => {
try {
return await walkJsonlFiles(root)
} catch {
return []
}
})
)
return [...new Set(files.flat())].sort()
}
export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsageProcessedFile> {
@@ -109,11 +171,69 @@ export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsag
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
}
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
async function getProcessedFileStat(
filePath: string
): Promise<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
const fileStat = await stat(filePath)
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size
}
}
function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsageParsedTurn {
return {
sessionId: turn.sessionId,
timestamp: turn.timestamp,
model: turn.model,
cwd: turn.cwd,
gitBranch: turn.gitBranch,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
}
}
function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] {
const dedupeIndexByKey = new Map<string, number>()
const deduped: ClaudeUsageParsedTurn[] = []
for (const turn of turns) {
if (turn.dedupeKey) {
const existingIndex = dedupeIndexByKey.get(turn.dedupeKey)
if (existingIndex !== undefined) {
const existing = deduped[existingIndex]
// Why: Claude Code streams repeated assistant rows with the same
// message/request IDs; later rows can contain more complete usage.
existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens)
existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens)
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens)
existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens)
continue
}
}
const stripped = stripClaudeSourceMetadata(turn)
deduped.push(stripped)
if (turn.dedupeKey) {
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
}
}
return deduped
}
function parseClaudeUsageSourceRecord(
line: string,
fallbackSessionId: string | null = null
): ClaudeUsageParsedSourceTurn | null {
let parsed: ClaudeUsageSourceRecord
try {
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
@@ -124,7 +244,8 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
if (parsed.type !== 'assistant') {
return null
}
if (!parsed.sessionId || !parsed.timestamp) {
const sessionId = parsed.sessionId ?? fallbackSessionId
if (!sessionId || !parsed.timestamp) {
return null
}
@@ -139,11 +260,13 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
}
return {
sessionId: parsed.sessionId,
sessionId,
timestamp: parsed.timestamp,
model: parsed.message?.model ?? null,
cwd: parsed.cwd ?? null,
gitBranch: parsed.gitBranch ?? null,
dedupeKey:
parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null,
inputTokens,
outputTokens,
cacheReadTokens,
@@ -151,21 +274,27 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu
}
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
const parsed = parseClaudeUsageSourceRecord(line)
return parsed ? stripClaudeSourceMetadata(parsed) : null
}
export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsageParsedTurn[]> {
const turns: ClaudeUsageParsedTurn[] = []
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const parsed = parseClaudeUsageRecord(line)
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return turns
return dedupeClaudeUsageTurns(turns)
}
async function readClaudeUsageScanFile(filePath: string): Promise<{
@@ -174,7 +303,8 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
}> {
const fileStat = await stat(filePath)
let lineCount = 0
const turns: ClaudeUsageParsedTurn[] = []
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
@@ -182,7 +312,7 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
for await (const line of lines) {
lineCount++
const parsed = parseClaudeUsageRecord(line)
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
@@ -192,9 +322,10 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
},
turns
turns: dedupeClaudeUsageTurns(turns)
}
}
@@ -224,6 +355,7 @@ export async function attributeClaudeUsageTurns(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsageAttributedTurn[]> {
const attributed: ClaudeUsageAttributedTurn[] = []
const canonicalCwdByPath = new Map<string, string>()
for (const turn of turns) {
const day = localDayFromTimestamp(turn.timestamp)
@@ -237,7 +369,14 @@ export async function attributeClaudeUsageTurns(
let projectLabel = getDefaultProjectLabel(turn.cwd)
if (turn.cwd) {
const worktree = worktreeLookup.get(await canonicalizePath(turn.cwd))
let canonicalCwd = canonicalCwdByPath.get(turn.cwd)
if (canonicalCwd === undefined) {
// Why: Claude transcripts repeat the same cwd for many consecutive
// turns. Cache realpath work so attribution scales with unique paths.
canonicalCwd = await canonicalizePath(turn.cwd)
canonicalCwdByPath.set(turn.cwd, canonicalCwd)
}
const worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
@@ -261,6 +400,90 @@ export async function attributeClaudeUsageTurns(
return attributed
}
function mergeClaudeSessions(
target: Map<string, ClaudeUsageSession>,
sessions: ClaudeUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
continue
}
if (session.firstTimestamp < existing.firstTimestamp) {
existing.firstTimestamp = session.firstTimestamp
}
if (session.lastTimestamp > existing.lastTimestamp) {
existing.lastTimestamp = session.lastTimestamp
existing.lastCwd = session.lastCwd
existing.lastGitBranch = session.lastGitBranch
}
existing.model = session.model ?? existing.model
existing.turnCount += session.turnCount
existing.totalInputTokens += session.totalInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalCacheReadTokens += session.totalCacheReadTokens
existing.totalCacheWriteTokens += session.totalCacheWriteTokens
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.turnCount += location.turnCount
existingLocation.inputTokens += location.inputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.cacheReadTokens += location.cacheReadTokens
existingLocation.cacheWriteTokens += location.cacheWriteTokens
} else {
existing.locationBreakdown.push({ ...location })
}
}
}
}
function mergeClaudeDailyAggregates(
target: Map<string, ClaudeUsageDailyAggregate>,
dailyAggregates: ClaudeUsageDailyAggregate[]
): void {
for (const aggregate of dailyAggregates) {
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
const existing = target.get(key)
if (!existing) {
target.set(key, { ...aggregate })
continue
}
existing.turnCount += aggregate.turnCount
existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount
existing.inputTokens += aggregate.inputTokens
existing.outputTokens += aggregate.outputTokens
existing.cacheReadTokens += aggregate.cacheReadTokens
existing.cacheWriteTokens += aggregate.cacheWriteTokens
}
}
function finalizeClaudeSessions(
sessionsById: Map<string, ClaudeUsageSession>
): ClaudeUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
@@ -356,23 +579,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
}
}
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return {
sessions: [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
),
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
@@ -381,22 +589,55 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
}
}
export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]): Promise<{
processedFiles: ClaudeUsageProcessedFile[]
async function parseClaudeUsagePersistedFile(
filePath: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsagePersistedFile> {
const { processedFile, turns } = await readClaudeUsageScanFile(filePath)
const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup)
return {
...processedFile,
...aggregateClaudeUsage(attributed)
}
}
export async function scanClaudeUsageFiles(
worktrees: ClaudeUsageWorktreeRef[],
previousProcessedFiles: ClaudeUsagePersistedFile[] = []
): Promise<{
processedFiles: ClaudeUsagePersistedFile[]
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}> {
const files = await listClaudeTranscriptFiles()
const processedFiles: ClaudeUsageProcessedFile[] = []
const allTurns: ClaudeUsageParsedTurn[] = []
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const processedFiles: ClaudeUsagePersistedFile[] = []
const worktreeLookup = await buildWorktreeLookup(worktrees)
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) {
const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE)
const results = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath)))
for (const result of results) {
processedFiles.push(result.processedFile)
allTurns.push(...result.turns)
const results = await Promise.all(
batch.map(async (filePath) => {
const fileInfo = await getProcessedFileStat(filePath)
const previous = previousByPath.get(filePath)
// Why: Claude histories can be gigabytes. Unchanged files should pay
// only stat cost on refresh while preserving exactly the old projection.
const canReuse =
previous &&
previous.mtimeMs === fileInfo.mtimeMs &&
previous.size === fileInfo.size &&
Array.isArray(previous.sessions) &&
Array.isArray(previous.dailyAggregates)
return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup)
})
)
for (const processed of results) {
processedFiles.push(processed)
mergeClaudeSessions(sessionsById, processed.sessions)
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
}
// Why: transcript scans run in Electron's main process. Small parallel
// batches cut independent file I/O without letting Settings stay blocked.
@@ -405,10 +646,14 @@ export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]):
}
}
const attributed = await attributeClaudeUsageTurns(allTurns, worktreeLookup)
return {
processedFiles,
...aggregateClaudeUsage(attributed)
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
@@ -416,6 +661,10 @@ export function getClaudeProjectsDirectory(): string {
return CLAUDE_PROJECTS_DIR
}
export function getClaudeTranscriptsDirectory(): string {
return CLAUDE_TRANSCRIPTS_DIR
}
export function createWorktreeRefs(
repos: Repo[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
+26
View File
@@ -17,6 +17,7 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
schemaVersion: 1,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
@@ -231,4 +232,29 @@ describe('ClaudeUsageStore', () => {
expect(summary.estimatedCostUsd).toBeCloseTo(110.25)
})
it('prices Sonnet long-context usage with threshold rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-sonnet-4-6',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
cacheWriteTokens: 300_000
}
]
})
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(8.07)
})
})
+146 -18
View File
@@ -13,38 +13,87 @@ import type {
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
const SCHEMA_VERSION = 1
const SCHEMA_VERSION = 3
const STALE_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
// mutates Electron's derived userData location, matching the persistence/store pattern.
let _claudeUsageFile: string | null = null
const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
cacheWrite: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
const SONNET_LONG_CONTEXT_PRICING = {
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
cacheWriteAboveThreshold: 7.5
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
}
const MODEL_ALIASES: Record<string, string> = {
model_placeholder_m26: 'claude-opus-4-6',
model_placeholder_m35: 'claude-sonnet-4-6',
'claude-opus-4.6': 'claude-opus-4-6',
'claude-sonnet-4.6': 'claude-sonnet-4-6',
'claude-opus-4.6-thinking': 'claude-opus-4-6',
'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6',
'claude-opus-4-6-thinking': 'claude-opus-4-6',
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6'
}
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
@@ -72,7 +121,14 @@ function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = model.toLowerCase()
const lower = model
.toLowerCase()
.trim()
.replace(/^anthropic[/:]/, '')
const alias = MODEL_ALIASES[lower]
if (alias) {
return alias
}
if (lower.includes('opus-4-7')) {
return 'claude-opus-4-7'
}
@@ -103,8 +159,13 @@ function normalizeModelForPricing(model: string | null): string | null {
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
// present in historical Claude Code/SDK logs read off disk. Match them so
// their cost is not silently dropped from the breakdown.
if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) {
return 'claude-sonnet-3-7'
if (
lower.includes('sonnet-3-5') ||
lower.includes('sonnet-3.5') ||
lower.includes('3-5-sonnet') ||
lower.includes('3.5-sonnet')
) {
return 'claude-sonnet-3-5'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
@@ -121,6 +182,20 @@ function normalizeModelForPricing(model: string | null): string | null {
return null
}
function calculateTieredCost(
tokens: number,
basePrice: number,
abovePrice?: number,
threshold?: number
): number {
if (threshold === undefined || abovePrice === undefined) {
return tokens * basePrice
}
const belowTokens = Math.min(tokens, threshold)
const aboveTokens = Math.max(tokens - threshold, 0)
return belowTokens * basePrice + aboveTokens * abovePrice
}
function estimateCostUsd(
model: string | null,
inputTokens: number,
@@ -134,10 +209,30 @@ function estimateCostUsd(
}
const pricing = MODEL_PRICING[normalized]
return (
(inputTokens * pricing.input +
outputTokens * pricing.output +
cacheReadTokens * pricing.cacheRead +
cacheWriteTokens * pricing.cacheWrite) /
(calculateTieredCost(
inputTokens,
pricing.input,
pricing.inputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
outputTokens,
pricing.output,
pricing.outputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheReadTokens,
pricing.cacheRead,
pricing.cacheReadAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheWriteTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
pricing.thresholdTokens
)) /
1_000_000
)
}
@@ -167,6 +262,22 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
JSON.stringify({
repoId,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
)
)
.sort()
return JSON.stringify(rows)
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
@@ -184,6 +295,11 @@ export class ClaudeUsageStore {
return getDefaultState()
}
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
if (parsed.schemaVersion !== SCHEMA_VERSION) {
// Why: scanner semantics affect persisted totals, so old Claude caches
// must be rebuilt after parser/source changes instead of reused briefly.
return getDefaultState()
}
return {
...getDefaultState(),
...parsed,
@@ -233,9 +349,10 @@ export class ClaudeUsageStore {
if (!this.state.scanState.enabled) {
return this.getScanState()
}
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS) {
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
@@ -257,10 +374,15 @@ export class ClaudeUsageStore {
try {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo))
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanClaudeUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
)
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
@@ -532,4 +654,10 @@ export class ClaudeUsageStore {
return true
})
}
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+8 -1
View File
@@ -1,6 +1,7 @@
export type ClaudeUsageProcessedFile = {
path: string
mtimeMs: number
size: number
lineCount: number
}
@@ -50,7 +51,8 @@ export type ClaudeUsageDailyAggregate = {
export type ClaudeUsagePersistedState = {
schemaVersion: number
processedFiles: ClaudeUsageProcessedFile[]
worktreeFingerprint: string | null
processedFiles: ClaudeUsagePersistedFile[]
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
scanState: {
@@ -61,6 +63,11 @@ export type ClaudeUsagePersistedState = {
}
}
export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}
export type ClaudeUsageParsedTurn = {
sessionId: string
timestamp: string
+48 -3
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner'
describe('parseCodexUsageRecord', () => {
it('uses cumulative totals to avoid double-counting repeated token snapshots', () => {
it('uses token totals only as a duplicate baseline', () => {
const context = {
sessionId: 'session-1',
sessionCwd: null,
@@ -100,7 +100,7 @@ describe('parseCodexUsageRecord', () => {
expect(duplicate).toBeNull()
})
it('falls back to inferred gpt-5 pricing when model metadata is missing', () => {
it('preserves unknown model metadata instead of assigning fallback pricing', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
@@ -129,9 +129,54 @@ describe('parseCodexUsageRecord', () => {
context
)
expect(parsed?.model).toBe('gpt-5')
expect(parsed?.model).toBeNull()
expect(parsed?.hasInferredPricing).toBe(true)
})
it('uses last token usage for the first resumed-session event', () => {
const context = {
sessionId: 'session-1',
sessionCwd: '/workspace/repo',
currentCwd: '/workspace/repo',
currentModel: 'gpt-5.5',
previousTotals: null
}
const parsed = parseCodexUsageRecord(
JSON.stringify({
timestamp: '2026-04-09T10:00:00.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 50_000,
cached_input_tokens: 40_000,
output_tokens: 5_000,
reasoning_output_tokens: 1_000,
total_tokens: 55_000
},
last_token_usage: {
input_tokens: 2_000,
cached_input_tokens: 1_500,
output_tokens: 500,
reasoning_output_tokens: 100,
total_tokens: 2_500
}
}
}
}),
context
)
expect(parsed).toMatchObject({
inputTokens: 2_000,
cachedInputTokens: 1_500,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 2_500
})
})
})
describe('attributeCodexUsageEvent', () => {
+119 -10
View File
@@ -47,8 +47,11 @@ type CodexUsageParseContext = {
previousTotals: CodexUsageRawUsage | null
}
type CodexUsageDeltaResolution =
| { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null }
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
const DEFAULT_CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
const LEGACY_FALLBACK_MODEL = 'gpt-5'
const YIELD_EVERY_FILES = 10
function ensureNumber(value: unknown): number {
@@ -171,6 +174,107 @@ function subtractRawUsage(
}
}
function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage {
return {
inputTokens: left.inputTokens + right.inputTokens,
cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens,
outputTokens: left.outputTokens + right.outputTokens,
reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens,
totalTokens: left.totalTokens + right.totalTokens
}
}
function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean {
return (
left.inputTokens === right.inputTokens &&
left.cachedInputTokens === right.cachedInputTokens &&
left.outputTokens === right.outputTokens &&
left.reasoningOutputTokens === right.reasoningOutputTokens
)
}
function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean {
return (
current.inputTokens >= previous.inputTokens &&
current.cachedInputTokens >= previous.cachedInputTokens &&
current.outputTokens >= previous.outputTokens &&
current.reasoningOutputTokens >= previous.reasoningOutputTokens
)
}
function rawUsageMagnitude(usage: CodexUsageRawUsage): number {
return (
usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens
)
}
function looksLikeStaleRegression(
current: CodexUsageRawUsage,
previous: CodexUsageRawUsage,
last: CodexUsageRawUsage
): boolean {
const previousTotal = rawUsageMagnitude(previous)
const currentTotal = rawUsageMagnitude(current)
const lastTotal = rawUsageMagnitude(last)
if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) {
return false
}
return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal
}
function resolveCodexUsageDelta(
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null,
previousTotals: CodexUsageRawUsage | null
): CodexUsageDeltaResolution | null {
if (totalUsage && lastUsage && previousTotals) {
if (rawUsageEquals(totalUsage, previousTotals)) {
return null
}
if (
!rawUsageIsMonotonic(totalUsage, previousTotals) &&
looksLikeStaleRegression(totalUsage, previousTotals, lastUsage)
) {
return null
}
// Why: Codex totals are mutable snapshots after compaction/resume. The
// last_token_usage payload is the billable increment; totals are the baseline.
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
}
if (totalUsage && lastUsage) {
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
}
if (totalUsage && previousTotals) {
if (rawUsageEquals(totalUsage, previousTotals)) {
return null
}
if (!rawUsageIsMonotonic(totalUsage, previousTotals)) {
return { kind: 'baseline', nextTotals: totalUsage }
}
return {
kind: 'event',
delta: subtractRawUsage(totalUsage, previousTotals),
nextTotals: totalUsage
}
}
if (totalUsage) {
return { kind: 'event', delta: totalUsage, nextTotals: totalUsage }
}
if (lastUsage && previousTotals) {
return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) }
}
if (lastUsage) {
return { kind: 'event', delta: lastUsage, nextTotals: null }
}
return null
}
function extractString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
@@ -688,17 +792,21 @@ export function parseCodexUsageRecord(
const record = info as Record<string, unknown>
const totalUsage = normalizeRawUsage(record.total_token_usage)
const lastUsage = normalizeRawUsage(record.last_token_usage)
let delta = totalUsage ? subtractRawUsage(totalUsage, context.previousTotals) : lastUsage
if (totalUsage) {
context.previousTotals = totalUsage
const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals)
if (!resolvedUsage) {
return null
}
if (!delta) {
if (resolvedUsage.kind === 'baseline') {
context.previousTotals = resolvedUsage.nextTotals
return null
}
delta = {
...delta,
cachedInputTokens: Math.min(delta.cachedInputTokens, delta.inputTokens)
let delta = {
...resolvedUsage.delta,
cachedInputTokens: Math.min(
resolvedUsage.delta.cachedInputTokens,
resolvedUsage.delta.inputTokens
)
}
if (
@@ -711,15 +819,16 @@ export function parseCodexUsageRecord(
return null
}
context.previousTotals = resolvedUsage.nextTotals
const resolvedModel = extractModel(parsed.payload) ?? context.currentModel
const model = resolvedModel ?? LEGACY_FALLBACK_MODEL
const hasInferredPricing = resolvedModel === null
return {
sessionId: context.sessionId,
timestamp: parsed.timestamp,
cwd: context.currentCwd ?? context.sessionCwd,
model,
model: resolvedModel,
hasInferredPricing,
inputTokens: delta.inputTokens,
cachedInputTokens: delta.cachedInputTokens,
+69 -5
View File
@@ -233,15 +233,79 @@ describe('CodexUsageStore', () => {
const summary = await store.getSummary('orca', '30d')
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(summary.estimatedCostUsd).toBeCloseTo(85.1)
expect(summary.estimatedCostUsd).toBeCloseTo(107.486)
expect(breakdown.find((row) => row.key === 'gpt-5.2-codex')?.estimatedCostUsd).toBeCloseTo(
15.925
)
expect(breakdown.find((row) => row.key === 'gpt-5.3-codex')?.estimatedCostUsd).toBeCloseTo(
15.925
)
expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(17.75)
expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(35.5)
expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(25.212)
expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(50.424)
})
it('normalizes Codex model variants and reasoning suffixes before pricing', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'gpt-5.4-mini-high',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 1_000_000,
cachedInputTokens: 500_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 2_000_000,
hasInferredPricing: false
},
{
day: '2026-04-09',
model: 'gpt-5.3-codex-spark-xhigh',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
},
{
day: '2026-04-09',
model: 'gpt-5.5(xhigh)',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 100_000,
cachedInputTokens: 50_000,
outputTokens: 25_000,
reasoningOutputTokens: 5_000,
totalTokens: 125_000,
hasInferredPricing: false
}
]
})
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(breakdown.find((row) => row.key === 'gpt-5.4-mini-high')?.estimatedCostUsd).toBeCloseTo(
4.9125
)
expect(
breakdown.find((row) => row.key === 'gpt-5.3-codex-spark-xhigh')?.estimatedCostUsd
).toBeCloseTo(15.925)
expect(breakdown.find((row) => row.key === 'gpt-5.5(xhigh)')?.estimatedCostUsd).toBeCloseTo(
1.025
)
})
it('keeps cached input out of the full-price input bucket for GPT-5.5 totals', async () => {
@@ -267,7 +331,7 @@ describe('CodexUsageStore', () => {
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(446.840002)
expect(summary.estimatedCostUsd).toBeCloseTo(858.929724)
})
it('counts mixed-model sessions once for each real model row in the breakdown', async () => {
@@ -584,7 +648,7 @@ describe('CodexUsageStore', () => {
} as unknown as CodexUsagePersistedState)
expect(normalized).toEqual({
schemaVersion: 2,
schemaVersion: 3,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
+136 -14
View File
@@ -17,20 +17,71 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
const SCHEMA_VERSION = 2
const SCHEMA_VERSION = 3
const STALE_MS = 5 * 60_000
let _codexUsageFile: string | null = null
const MODEL_PRICING: Record<string, { input: number; cachedInput: number; output: number }> = {
type TieredPrice = { threshold: number; price: number }
type CodexModelPricing = {
input: number
cachedInput: number
output: number
inputTiers?: TieredPrice[]
cachedInputTiers?: TieredPrice[]
outputTiers?: TieredPrice[]
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
const MODEL_PRICING: Record<string, CodexModelPricing> = {
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.4': { input: 2.5, cachedInput: 0.25, output: 15 },
'gpt-5.5': { input: 5, cachedInput: 0.5, output: 30 }
'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 },
'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 },
'gpt-5.4-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.4': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.5-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.5': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
}
}
const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']
function getDefaultState(): CodexUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -75,33 +126,104 @@ function getCodexUsageFile(): string {
return _codexUsageFile
}
function stripParenthesizedReasoningTier(model: string): string | null {
const match = model.match(/^(.*)\(([^()]*)\)$/)
if (!match) {
return model
}
const tier = match[2].trim().toLowerCase()
if (!REASONING_TIER_SUFFIXES.includes(tier)) {
return null
}
return match[1]
}
function stripDashReasoningTiers(model: string): string {
let current = model
for (let index = 0; index < 4; index++) {
const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`))
if (!suffix) {
return current
}
current = current.slice(0, -suffix.length - 1)
}
return current
}
function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = model.toLowerCase()
if (lower === 'gpt-5' || lower === 'gpt-5-codex') {
const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim())
if (!lower) {
return null
}
const normalized = stripDashReasoningTiers(lower)
if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') {
return 'gpt-5'
}
if (lower.startsWith('gpt-5.1')) {
if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) {
return 'gpt-5.1-codex-max'
}
if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) {
return 'gpt-5.1-codex'
}
if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) {
return 'gpt-5.1'
}
if (lower.startsWith('gpt-5.2')) {
if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) {
return 'gpt-5.2-codex'
}
if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) {
return 'gpt-5.2'
}
if (lower.startsWith('gpt-5.3-codex')) {
if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) {
return 'gpt-5.3-codex-spark'
}
if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) {
return 'gpt-5.3-codex'
}
if (lower.startsWith('gpt-5.4')) {
if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) {
return 'gpt-5.3'
}
if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) {
return 'gpt-5.4-mini'
}
if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) {
return 'gpt-5.4-nano'
}
if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) {
return 'gpt-5.4-pro'
}
if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) {
return 'gpt-5.4'
}
if (lower.startsWith('gpt-5.5')) {
if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) {
return 'gpt-5.5-pro'
}
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
return 'gpt-5.5'
}
return null
}
function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number {
let cost = 0
let lowerBound = 0
let activePrice = basePrice
for (const tier of tiers) {
if (tokens <= tier.threshold) {
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
cost += (tier.threshold - lowerBound) * activePrice
lowerBound = tier.threshold
activePrice = tier.price
}
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
function estimateCostUsd(
model: string | null,
inputTokens: number,
@@ -119,9 +241,9 @@ function estimateCostUsd(
// price and again at cache-read price.
const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0)
return (
(nonCachedInputTokens * pricing.input +
clampedCached * pricing.cachedInput +
outputTokens * pricing.output) /
(calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) +
calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) +
calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) /
1_000_000
)
}
+191
View File
@@ -160,6 +160,162 @@ describe('HeadlessEmulator', () => {
await emulator.write('\x1b[?1049l')
expect(emulator.getSnapshot().modes.alternateScreen).toBe(false)
})
it('tracks mouse reporting mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().modes.mouseTracking).toBe(false)
await emulator.write('\x1b[?1002;1006h')
expect(emulator.getSnapshot().modes.mouseTracking).toBe(true)
expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true)
expect(emulator.getSnapshot().modes.sgrMousePixelsMode).toBe(false)
await emulator.write('\x1b[?1002;1006l')
expect(emulator.getSnapshot().modes.mouseTracking).toBe(false)
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
})
it('tracks split SGR mouse reporting sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;100')
await emulator.write('6h')
expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true)
await emulator.write('\x1b[?100')
await emulator.write('6l')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
})
it('tracks long split private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const fillerModes = Array.from({ length: 40 }, (_, i) => String(3000 + i)).join(';')
await emulator.write(`\x1b[?1002;${fillerModes};100`)
await emulator.write('6h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks private mouse modes with leading zero params', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?01002;01006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks C1 CSI private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks split C1 CSI private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b?1002;100')
await emulator.write('6h')
await emulator.write('\x9b?1002l')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks C1 CSI split before private marker', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b')
await emulator.write('?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('does not retain complete private CSI queries as scan tail', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?2004$p')
await emulator.write('1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
it('does not expose mouse modes before xterm applies the same write', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const writePromise = emulator.write('\x1b[?1049h\x1b[?1002;1006h')
const snapshot = emulator.getSnapshot()
await writePromise
expect(snapshot.modes.alternateScreen).toBe(false)
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toBe('')
const after = emulator.getSnapshot()
expect(after.modes.alternateScreen).toBe(true)
expect(after.modes.mouseTrackingMode).toBe('drag')
expect(after.modes.sgrMouseMode).toBe(true)
expect(after.rehydrateSequences).toContain('\x1b[?1049h')
expect(after.rehydrateSequences).toContain('\x1b[?1002h')
expect(after.rehydrateSequences).toContain('\x1b[?1006h')
})
it('clears SGR mouse reporting on full terminal reset', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1bc')
await emulator.write('\x1b[?1002h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1b[?1016h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.modes.sgrMousePixelsMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1016h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
})
describe('rehydration sequences', () => {
@@ -178,6 +334,41 @@ describe('HeadlessEmulator', () => {
const snapshot = emulator.getSnapshot()
expect(snapshot.rehydrateSequences).toBe('')
})
it('rehydrates mouse reporting after alternate screen activation', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1049h\x1b[?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.rehydrateSequences).toContain('\x1b[?1049h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
expect(snapshot.rehydrateSequences.indexOf('\x1b[?1049h')).toBeLessThan(
snapshot.rehydrateSequences.indexOf('\x1b[?1002h')
)
})
it('preserves mouse modes after mobile normalizes an alternate-screen replay', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('normal buffer\r\n\x1b[?1049h\x1b[?2004h\x1b[?1002;1006halternate')
const snapshot = emulator.getSnapshot()
const payload = snapshot.rehydrateSequences + snapshot.snapshotAnsi
expect(payload.split('\x1b[?1049h')).toHaveLength(2)
expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1002h')
expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1006h')
})
it('rehydrates mouse encoding independently from reporting mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1b[?1002l')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
})
})
describe('dispose', () => {
+136 -5
View File
@@ -14,6 +14,10 @@ export type HeadlessSnapshotOptions = {
}
const DEFAULT_SCROLLBACK = 5000
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
function parseFileUriPath(uri: string): string | null {
try {
@@ -47,6 +51,10 @@ export class HeadlessEmulator {
private terminal: Terminal
private serializer: SerializeAddon
private cwd: string | null = null
private privateModeScanTail = ''
private mouseTrackingMode: MouseTrackingMode = 'none'
private sgrMouseMode = false
private sgrMousePixelsMode = false
private disposed = false
constructor(opts: HeadlessEmulatorOptions) {
@@ -80,7 +88,12 @@ export class HeadlessEmulator {
this.scanOsc7(data)
return new Promise<void>((resolve) => {
this.terminal.write(data, resolve)
this.terminal.write(data, () => {
// Why: snapshots combine serialized xterm state with mirrored mouse
// modes. Commit the mirror only after xterm has parsed the same bytes.
this.scanPrivateModes(data)
resolve()
})
})
}
@@ -93,8 +106,12 @@ export class HeadlessEmulator {
getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot {
const modes = this.getModes()
const snapshotAnsi = this.normalizeSnapshotAnsiForModes(
this.serializer.serialize({ scrollback: opts.scrollbackRows }),
modes
)
return {
snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }),
snapshotAnsi,
scrollbackAnsi: '',
rehydrateSequences: this.buildRehydrateSequences(modes),
cwd: this.cwd,
@@ -133,6 +150,93 @@ export class HeadlessEmulator {
}
}
private scanPrivateModes(data: string): void {
const input = this.privateModeScanTail + data
this.privateModeScanTail = this.extractPrivateModeScanTail(input)
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
let match: RegExpExecArray | null
while ((match = privateModeRe.exec(input)) !== null) {
if (match[0] === '\x1bc') {
this.mouseTrackingMode = 'none'
this.sgrMouseMode = false
this.sgrMousePixelsMode = false
continue
}
const params = match[1] ?? match[3]
const enabled = (match[2] ?? match[4]) === 'h'
for (const rawParam of params.split(';')) {
if (rawParam === '') {
continue
}
const param = Number(rawParam)
if (!Number.isInteger(param)) {
continue
}
if (param === 9) {
this.mouseTrackingMode = enabled ? 'x10' : 'none'
}
if (param === 1000) {
this.mouseTrackingMode = enabled ? 'vt200' : 'none'
}
if (param === 1002) {
this.mouseTrackingMode = enabled ? 'drag' : 'none'
}
if (param === 1003) {
this.mouseTrackingMode = enabled ? 'any' : 'none'
}
if (param === 1006) {
this.sgrMouseMode = enabled
this.sgrMousePixelsMode = false
}
if (param === 1016) {
this.sgrMouseMode = false
this.sgrMousePixelsMode = enabled
}
}
}
}
private extractPrivateModeScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
return ''
}
const tail = input.slice(start)
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
return ''
}
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
return tail
}
if (tail.startsWith('\x1b[?')) {
return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : ''
}
if (tail.startsWith('\x9b?')) {
return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : ''
}
return ''
}
private isIncompletePrivateModeParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
if (!modes.alternateScreen) {
return snapshotAnsi
}
const alternateScreenMarker = '\x1b[?1049h'
const start = snapshotAnsi.lastIndexOf(alternateScreenMarker)
if (start === -1) {
return snapshotAnsi
}
// Why: rehydrateSequences already enters the alternate screen and restores
// mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's
// "slice from last alt-screen marker" replay from discarding those modes.
return snapshotAnsi.slice(start + alternateScreenMarker.length)
}
private parseOsc7Uri(uri: string): void {
const parsed = parseFileUriPath(uri)
if (parsed) {
@@ -142,9 +246,13 @@ export class HeadlessEmulator {
private getModes(): TerminalModes {
const buffer = this.terminal.buffer.active
const mouseTrackingMode = this.mouseTrackingMode
return {
bracketedPaste: this.terminal.modes.bracketedPasteMode,
mouseTracking: this.terminal.modes.mouseTrackingMode !== 'none',
mouseTracking: mouseTrackingMode !== 'none',
mouseTrackingMode,
sgrMouseMode: this.sgrMouseMode,
sgrMousePixelsMode: this.sgrMousePixelsMode,
applicationCursor:
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate'
@@ -153,14 +261,37 @@ export class HeadlessEmulator {
private buildRehydrateSequences(modes: TerminalModes): string {
const seqs: string[] = []
if (modes.alternateScreen) {
seqs.push('\x1b[?1049h')
}
if (modes.bracketedPaste) {
seqs.push('\x1b[?2004h')
}
if (modes.applicationCursor) {
seqs.push('\x1b[?1h')
}
if (modes.alternateScreen) {
seqs.push('\x1b[?1049h')
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
case 'x10':
seqs.push('\x1b[?9h')
break
case 'vt200':
seqs.push('\x1b[?1000h')
break
case 'drag':
seqs.push('\x1b[?1002h')
break
case 'any':
seqs.push('\x1b[?1003h')
break
}
// Why: xterm tracks the mouse protocol and SGR encoding as independent
// modes, so snapshots must preserve the encoding even when reporting is off.
if (modes.sgrMousePixelsMode) {
seqs.push('\x1b[?1016h')
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
}
return seqs.join('')
}
+7 -4
View File
@@ -1,10 +1,10 @@
// ─── Protocol Version ────────────────────────────────────────────────
// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bumped from 5 -> 6 for OSC 133 shell wrapper changes layered on top of
// OpenCode/Pi overlay restoration, both of which are baked into daemon spawns.
export const PROTOCOL_VERSION = 6
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5] as const
// Why: bumped from 6 -> 7 so existing daemons restart with the headless
// emulator's mouse-mode snapshot tracking for mobile alternate-screen TUIs.
export const PROTOCOL_VERSION = 7
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6] as const
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
@@ -28,6 +28,9 @@ export type TerminalSnapshot = {
export type TerminalModes = {
bracketedPaste: boolean
mouseTracking: boolean
mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
sgrMouseMode?: boolean
sgrMousePixelsMode?: boolean
applicationCursor: boolean
alternateScreen: boolean
}
+15
View File
@@ -5,9 +5,15 @@ import type {
Automation,
AutomationCreateInput,
AutomationDispatchResult,
ExternalAutomationActionInput,
ExternalAutomationManager,
AutomationRun,
AutomationUpdateInput
} from '../../shared/automations-types'
import {
listExternalAutomationManagers,
runExternalAutomationAction
} from '../automations/external-manager'
export function registerAutomationHandlers(store: Store, service: AutomationService): void {
ipcMain.handle('automations:list', (): Automation[] => store.listAutomations())
@@ -16,6 +22,15 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ
(_event, args?: { automationId?: string }): AutomationRun[] =>
store.listAutomationRuns(args?.automationId)
)
ipcMain.handle('automations:listExternalManagers', (): Promise<ExternalAutomationManager[]> => {
return listExternalAutomationManagers(store)
})
ipcMain.handle(
'automations:runExternalAction',
(_event, input: ExternalAutomationActionInput) => {
return runExternalAutomationAction(input)
}
)
ipcMain.handle(
'automations:create',
(_event, input: AutomationCreateInput): Automation => store.createAutomation(input)
+4
View File
@@ -224,6 +224,8 @@ import type {
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
ExternalAutomationActionInput,
ExternalAutomationManager,
AutomationRun,
AutomationUpdateInput
} from '../shared/automations-types'
@@ -1610,6 +1612,8 @@ export type PreloadApi = {
automations: {
list: () => Promise<Automation[]>
listRuns: (args?: { automationId?: string }) => Promise<AutomationRun[]>
listExternalManagers: () => Promise<ExternalAutomationManager[]>
runExternalAction: (input: ExternalAutomationActionInput) => Promise<void>
create: (input: AutomationCreateInput) => Promise<Automation>
update: (args: { id: string; updates: AutomationUpdateInput }) => Promise<Automation>
delete: (args: { id: string }) => Promise<void>
+6
View File
@@ -100,6 +100,8 @@ import type {
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
ExternalAutomationActionInput,
ExternalAutomationManager,
AutomationRun,
AutomationUpdateInput
} from '../shared/automations-types'
@@ -2644,6 +2646,10 @@ const api = {
list: (): Promise<Automation[]> => ipcRenderer.invoke('automations:list'),
listRuns: (args?: { automationId?: string }): Promise<AutomationRun[]> =>
ipcRenderer.invoke('automations:listRuns', args),
listExternalManagers: (): Promise<ExternalAutomationManager[]> =>
ipcRenderer.invoke('automations:listExternalManagers'),
runExternalAction: (input: ExternalAutomationActionInput): Promise<void> =>
ipcRenderer.invoke('automations:runExternalAction', input),
create: (input: AutomationCreateInput): Promise<Automation> =>
ipcRenderer.invoke('automations:create', input),
update: (args: { id: string; updates: AutomationUpdateInput }): Promise<Automation> =>
+118
View File
@@ -0,0 +1,118 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { promisify } from 'util'
import type { RelayDispatcher } from './dispatcher'
const execFileAsync = promisify(execFile)
const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json')
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
type ExternalProvider = 'hermes' | 'openclaw'
type HermesAction = 'pause' | 'resume' | 'run' | 'delete'
export class ExternalAutomationsHandler {
constructor(private readonly dispatcher: RelayDispatcher) {
this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params))
this.dispatcher.onRequest('externalAutomations.act', (params) => this.runAction(params))
}
private async isCommandAvailable(command: string): Promise<boolean> {
try {
await execFileAsync('/bin/sh', ['-lc', `which ${command}`], {
encoding: 'utf-8',
timeout: 5000
})
return true
} catch {
return false
}
}
private async readJobs(provider: ExternalProvider): Promise<unknown[]> {
const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE
if (!existsSync(jobsFile)) {
return []
}
const content = await readFile(jobsFile, 'utf-8')
const parsed = JSON.parse(content) as unknown
if (Array.isArray(parsed)) {
return parsed
}
return typeof parsed === 'object' &&
parsed !== null &&
!Array.isArray(parsed) &&
Array.isArray((parsed as { jobs?: unknown }).jobs)
? (parsed as { jobs: unknown[] }).jobs
: []
}
private async listJobs(params?: Record<string, unknown>): Promise<{
jobs: unknown[]
hermesAvailable: boolean
openclawAvailable: boolean
error: string | null
}> {
const provider = params?.provider === 'openclaw' ? 'openclaw' : 'hermes'
const [commandAvailable, jobsResult] = await Promise.allSettled([
this.isCommandAvailable(provider),
this.readJobs(provider)
])
const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : []
const available = commandAvailable.status === 'fulfilled' && commandAvailable.value
return {
jobs,
hermesAvailable: provider === 'hermes' && available,
openclawAvailable: provider === 'openclaw' && available,
error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null
}
}
private hermesCommand(action: HermesAction): string {
switch (action) {
case 'pause':
return 'pause'
case 'resume':
return 'resume'
case 'run':
return 'run'
case 'delete':
return 'remove'
}
}
private openClawCommand(action: HermesAction): string {
switch (action) {
case 'pause':
return 'disable'
case 'resume':
return 'enable'
case 'run':
return 'run'
case 'delete':
return 'rm'
}
}
private async runAction(params: Record<string, unknown> = {}): Promise<{ ok: true }> {
const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes'
const action = params.action
const jobId = params.jobId
if (action !== 'pause' && action !== 'resume' && action !== 'run' && action !== 'delete') {
throw new Error('Unsupported external automation action.')
}
if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) {
throw new Error('Invalid external automation job ID.')
}
const command =
provider === 'hermes' ? this.hermesCommand(action) : this.openClawCommand(action)
await execFileAsync('/bin/sh', ['-lc', `${provider} cron ${command} ${jobId}`], {
encoding: 'utf-8',
timeout: 30_000
})
return { ok: true }
}
}
+3
View File
@@ -31,6 +31,7 @@ import { PtyHandler } from './pty-handler'
import { FsHandler } from './fs-handler'
import { GitHandler } from './git-handler'
import { PreflightHandler } from './preflight-handler'
import { ExternalAutomationsHandler } from './external-automations-handler'
import { PortScanHandler } from './port-scan-handler'
import { AgentExecHandler } from './agent-exec-handler'
import { WorkspaceSessionHandler } from './workspace-session-handler'
@@ -235,7 +236,9 @@ async function main(): Promise<void> {
void _gitHandler
const _preflightHandler = new PreflightHandler(dispatcher)
const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
void _preflightHandler
void _externalAutomationsHandler
const _portScanHandler = new PortScanHandler(dispatcher)
void _portScanHandler
@@ -28,6 +28,9 @@ import { useRepoMap, useWorktreeMap } from '@/store/selectors'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type {
Automation,
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager,
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
@@ -36,6 +39,7 @@ import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/a
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { AutomationDetail } from './AutomationDetail'
import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog'
import { ExternalAutomationManagers } from './ExternalAutomationManagers'
const AGENTS = AGENT_CATALOG.map((agent) => agent.id)
const DEFAULT_TIME = '09:00'
@@ -72,6 +76,8 @@ export default function AutomationsPage(): React.JSX.Element {
const [automations, setAutomations] = useState<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
const [externalManagers, setExternalManagers] = useState<ExternalAutomationManager[]>([])
const [externalActionKey, setExternalActionKey] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
@@ -79,6 +85,10 @@ export default function AutomationsPage(): React.JSX.Element {
const [relativeNow, setRelativeNow] = useState(Date.now())
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null)
const [externalDeleteTarget, setExternalDeleteTarget] = useState<{
manager: ExternalAutomationManager
job: ExternalAutomationJob
} | null>(null)
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
@@ -129,12 +139,14 @@ export default function AutomationsPage(): React.JSX.Element {
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const [nextAutomations, nextRuns] = await Promise.all([
const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([
window.api.automations.list(),
window.api.automations.listRuns()
window.api.automations.listRuns(),
window.api.automations.listExternalManagers()
])
setAutomations(nextAutomations)
setRuns(nextRuns)
setExternalManagers(nextExternalManagers)
const currentSelectedId = useAppStore.getState().selectedAutomationId
const hasCurrentSelection = nextAutomations.some(
(automation) => automation.id === currentSelectedId
@@ -464,6 +476,59 @@ export default function AutomationsPage(): React.JSX.Element {
toast.message('Automation run queued.')
}
const runExternalAction = async (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction
): Promise<void> => {
const key = `${manager.id}:${job.id}:${action}`
setExternalActionKey(key)
try {
await window.api.automations.runExternalAction({
managerId: manager.id,
provider: manager.provider,
target: manager.target,
jobId: job.id,
action
})
await refresh()
toast.success(
action === 'delete'
? 'External automation deleted.'
: action === 'run'
? 'External automation queued.'
: action === 'pause'
? 'External automation paused.'
: 'External automation resumed.'
)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'External automation action failed.')
} finally {
setExternalActionKey(null)
}
}
const requestExternalAction = (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction
): void => {
if (action === 'delete') {
setExternalDeleteTarget({ manager, job })
return
}
void runExternalAction(manager, job, action)
}
const confirmDeleteExternalAutomation = async (): Promise<void> => {
if (!externalDeleteTarget) {
return
}
const target = externalDeleteTarget
setExternalDeleteTarget(null)
await runExternalAction(target.manager, target.job, 'delete')
}
const openRunWorkspace = (run: AutomationRun): void => {
if (!run.workspaceId || !activateAndRevealWorktree(run.workspaceId)) {
toast.error('Workspace is not available.')
@@ -613,6 +678,55 @@ export default function AutomationsPage(): React.JSX.Element {
</DialogContent>
</Dialog>
<Dialog
open={externalDeleteTarget !== null}
onOpenChange={(open) => {
if (!open) {
setExternalDeleteTarget(null)
}
}}
>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(event) => {
event.preventDefault()
deleteConfirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">Delete External Automation</DialogTitle>
<DialogDescription className="text-xs">
Delete{' '}
<span className="break-all font-medium text-foreground">
{externalDeleteTarget?.job.name}
</span>{' '}
from {externalDeleteTarget?.manager.label}.
</DialogDescription>
</DialogHeader>
{externalDeleteTarget ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">
{externalDeleteTarget.job.name}
</div>
<div className="mt-1 text-muted-foreground">{externalDeleteTarget.job.schedule}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setExternalDeleteTarget(null)}>
Cancel
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={() => void confirmDeleteExternalAutomation()}
>
<Trash2 className="size-4" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
<div className="min-h-0 flex-1 overflow-auto p-2">
@@ -716,6 +830,12 @@ export default function AutomationsPage(): React.JSX.Element {
onToggle={(automation) => void toggleAutomation(automation)}
onDelete={requestDeleteAutomation}
/>
<ExternalAutomationManagers
managers={externalManagers}
now={relativeNow}
runningActionKey={externalActionKey}
onAction={requestExternalAction}
/>
</section>
</div>
</main>
@@ -0,0 +1,197 @@
import React from 'react'
import { Pause, Play, RefreshCw, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
type ExternalAutomationManagersProps = {
managers: ExternalAutomationManager[]
now: number
runningActionKey: string | null
onAction: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction
) => void
}
function formatExternalDate(value: string | null, now: number): string {
if (!value) {
return 'Never'
}
const parsed = Date.parse(value)
if (!Number.isFinite(parsed)) {
return value
}
return formatAutomationDateTimeWithRelative(parsed, now)
}
function actionKey(
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction
): string {
return `${manager.id}:${job.id}:${action}`
}
function ExternalActionButton({
label,
disabled,
className,
onClick,
children
}: {
label: string
disabled: boolean
className?: string
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={label}
className={className}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
export function ExternalAutomationManagers({
managers,
now,
runningActionKey,
onAction
}: ExternalAutomationManagersProps): React.JSX.Element {
return (
<div className="mt-6 rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div>
<div className="text-sm font-medium">External managers</div>
</div>
<Badge variant="outline">
{managers.reduce((sum, manager) => sum + manager.jobs.length, 0)} jobs
</Badge>
</div>
<div className="divide-y divide-border/50">
{managers.map((manager) => (
<div key={manager.id} className="px-3 py-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{manager.label}</div>
<div className="text-xs text-muted-foreground">
{manager.status === 'available'
? manager.canManage
? 'Manageable'
: 'Read-only'
: 'Unavailable'}
{manager.error ? ` - ${manager.error}` : null}
</div>
</div>
<Badge variant={manager.status === 'available' ? 'secondary' : 'outline'}>
{manager.provider}
</Badge>
</div>
<div className="divide-y divide-border/40">
{manager.jobs.map((job) => (
<div
key={job.id}
className="grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{job.name}</span>
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
{job.enabled ? 'Active' : 'Paused'}
</Badge>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)}
</div>
{job.promptPreview || job.lastError ? (
<div className="mt-1 truncate text-xs text-muted-foreground">
{job.lastError ?? job.promptPreview}
</div>
) : null}
</div>
<div className="hidden min-w-0 text-xs text-muted-foreground md:block">
Last {formatExternalDate(job.lastRunAt, now)}
{job.lastStatus ? ` · ${job.lastStatus}` : null}
</div>
<div className="flex items-center justify-end gap-1">
<ExternalActionButton
label="Run external automation"
disabled={!manager.canManage || runningActionKey !== null}
onClick={() => onAction(manager, job, 'run')}
>
{runningActionKey === actionKey(manager, job, 'run') ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<Play className="size-3.5" />
)}
</ExternalActionButton>
<ExternalActionButton
label={
job.enabled ? 'Pause external automation' : 'Resume external automation'
}
disabled={!manager.canManage || runningActionKey !== null}
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
>
{runningActionKey ===
actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? (
<RefreshCw className="size-3.5 animate-spin" />
) : job.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
</ExternalActionButton>
<ExternalActionButton
label="Delete external automation"
className="text-destructive hover:text-destructive"
disabled={!manager.canManage || runningActionKey !== null}
onClick={() => onAction(manager, job, 'delete')}
>
{runningActionKey === actionKey(manager, job, 'delete') ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</ExternalActionButton>
</div>
</div>
))}
{manager.jobs.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No {manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'} jobs found.
</div>
) : null}
</div>
</div>
))}
{managers.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No external automation managers found.
</div>
) : null}
</div>
</div>
)
}
@@ -559,7 +559,7 @@ function Settings(): React.JSX.Element {
{
id: 'stats',
title: 'Stats & Usage',
description: 'Orca stats and Claude usage analytics.',
description: 'Orca stats plus Claude and Codex usage analytics.',
icon: BarChart3,
searchEntries: STATS_PANE_SEARCH_ENTRIES
},
@@ -29,6 +29,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'qwen',
'rovo',
'hermes',
'openclaw',
'copilot',
'github',
'github copilot',
@@ -30,10 +30,12 @@ import { track } from '@/lib/telemetry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
type GroupHeaderRow,
type RepoGroupOrdering,
type Row,
type WorktreeGroupBy,
PINNED_GROUP_KEY,
buildRows,
getRepoGroupOrdering,
getGroupKeyForWorktree
} from './worktree-list-groups'
import {
@@ -93,6 +95,7 @@ type VirtualizedWorktreeViewportProps = {
rows: Row[]
activeWorktreeId: string | null
groupBy: WorktreeGroupBy
repoGroupOrdering: RepoGroupOrdering
toggleGroup: (key: string) => void
collapsedGroups: Set<string>
handleCreateForRepo: (repoId: string) => void
@@ -132,6 +135,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
rows,
activeWorktreeId,
groupBy,
repoGroupOrdering,
toggleGroup,
collapsedGroups,
handleCreateForRepo,
@@ -156,10 +160,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const scrollRef = useRef<HTMLDivElement>(null)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
// Drag is only meaningful when the user is grouping by repo. When inert
// (groupBy !== 'repo'), the controller is still constructed for hook order
// stability but the handle is never rendered.
// Drag is only meaningful when repo headers are using manual order. The
// controller is still constructed for hook order stability when inert.
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: allRepoIds,
onCommit: reorderRepos,
@@ -321,7 +325,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
prCache,
new Set<string>(),
repoOrder,
workspaceStatuses
workspaceStatuses,
repoGroupOrdering
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
if (worktreeRows.length === 0) {
return
@@ -359,6 +364,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
virtualizer,
groupBy,
repoGroupOrdering,
worktrees,
repoMap,
prCache,
@@ -511,7 +517,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? (
{canReorderRepoHeaders &&
repoDrag.state.draggingRepoId !== null &&
repoDrag.state.dropIndicatorY !== null ? (
<div
role="presentation"
className="pointer-events-none absolute left-2 right-2 z-10 border-t border-dashed border-muted-foreground/70"
@@ -525,6 +533,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
const isDraggingThis =
canReorderRepoHeaders &&
repoDrag.state.draggingRepoId !== null &&
repoDrag.state.draggingRepoId === repoIdForHeader
const headerWorkspaceStatus =
@@ -592,7 +601,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.icon ? (
<div
onPointerDown={
isRepoHeader && repoIdForHeader
canReorderRepoHeaders && isRepoHeader && repoIdForHeader
? (e) => repoDrag.onHandlePointerDown(e, repoIdForHeader)
: undefined
}
@@ -1013,9 +1022,8 @@ const WorktreeList = React.memo(function WorktreeList() {
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
// Why: header order in groupBy='repo' is bound to state.repos array order so
// manual reorder is the single source of truth. The Map lets buildRows do an
// O(1) rank lookup per group without depending on Repo identity.
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const repoOrder = useMemo(() => {
const map = new Map<string, number>()
@@ -1024,6 +1032,7 @@ const WorktreeList = React.memo(function WorktreeList() {
}, [repos])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
const reorderReposAction = useAppStore((s) => s.reorderRepos)
const repoGroupOrdering = getRepoGroupOrdering(groupBy, sortBy)
// Build flat row list for rendering
const rows: Row[] = useMemo(
@@ -1035,9 +1044,19 @@ const WorktreeList = React.memo(function WorktreeList() {
prCache,
collapsedGroups,
repoOrder,
workspaceStatuses
workspaceStatuses,
repoGroupOrdering
),
[groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder, workspaceStatuses]
[
groupBy,
worktrees,
repoMap,
prCache,
collapsedGroups,
repoOrder,
workspaceStatuses,
repoGroupOrdering
]
)
// Why: rows.length alone can stay the same when items migrate between
// groups (e.g., PR cache loads on restart and a collapsed group absorbs
@@ -1240,6 +1259,7 @@ const WorktreeList = React.memo(function WorktreeList() {
rows={rows}
activeWorktreeId={selectedSidebarWorktreeId}
groupBy={groupBy}
repoGroupOrdering={repoGroupOrdering}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
handleCreateForRepo={handleCreateForRepo}
@@ -6,7 +6,7 @@ import type {
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention'
type SortBy = 'name' | 'smart' | 'recent' | 'repo'
export type SortBy = 'name' | 'smart' | 'recent' | 'repo'
// Why: a newly-created worktree's lastActivityAt is stamped at the moment
// createLocalWorktree finishes git + setup-runner prep (often several seconds
@@ -5,6 +5,8 @@ import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/types'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
type MockState = {
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, { id: string }[]>
@@ -72,7 +74,7 @@ describe('useWorktreeActivityStatus', () => {
it('keeps a restored offscreen working agent yellow from the hook snapshot', () => {
const worktreeId = 'repo1::/path/wt1'
const paneKey = makePaneKey('tab-1', '00000000-0000-4000-8000-000000000001')
const paneKey = makePaneKey('tab-1', LEAF_ID)
mockState = {
...mockState,
tabsByWorktree: {
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { buildRows, getPRGroupKey } from './worktree-list-groups'
import { buildRows, getPRGroupKey, getRepoGroupOrdering } from './worktree-list-groups'
import type { Repo, Worktree } from '../../../../shared/types'
const repo: Repo = {
@@ -209,6 +209,7 @@ describe('buildRows repo grouping order', () => {
[repoC.id, repoC]
])
const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' }
const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
@@ -231,6 +232,82 @@ describe('buildRows repo grouping order', () => {
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
it('orders repo headers by first encounter when caller uses visible worktree order', () => {
// Caller already sorted worktrees by recency: C is freshest, then A, then B.
// Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest
// worktree out of each repo so a just-active worktree's parent group
// bubbles to the top of the sidebar.
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
[repoC.id, 2]
])
const rows = buildRows(
'repo',
[wC, wA, wB],
map,
null,
new Set(),
repoOrder,
undefined,
'visible-worktree-order'
)
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b'])
})
it('orders repo headers by each repo highest-ranked visible child', () => {
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
[repoC.id, 2]
])
const rows = buildRows(
'repo',
[wA, wB, wAStale, wC],
map,
null,
new Set(),
repoOrder,
undefined,
'visible-worktree-order'
)
expect(rows).toMatchObject([
{ type: 'header', key: 'repo:repo-a' },
{ type: 'item', worktree: { id: 'wt-a' } },
{ type: 'item', worktree: { id: 'wt-a-stale' } },
{ type: 'header', key: 'repo:repo-b' },
{ type: 'item', worktree: { id: 'wt-b' } },
{ type: 'header', key: 'repo:repo-c' },
{ type: 'item', worktree: { id: 'wt-c' } }
])
})
it('keeps repoOrder for manual repo group ordering', () => {
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
[repoC.id, 2]
])
const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
})
describe('getRepoGroupOrdering', () => {
it.each([
['repo', 'recent', 'visible-worktree-order'],
['repo', 'smart', 'visible-worktree-order'],
['repo', 'name', 'manual'],
['repo', 'repo', 'manual'],
['none', 'recent', 'manual'],
['pr-status', 'recent', 'manual']
] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
expect(getRepoGroupOrdering(groupBy, sortBy)).toBe(expected)
})
})
describe('WorktreeList header styles', () => {
@@ -9,10 +9,18 @@ import {
getWorkspaceStatusVisualMeta
} from './workspace-status'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type { SortBy } from './smart-sort'
export { branchName }
export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status'
export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
? 'visible-worktree-order'
: 'manual'
}
export type GroupHeaderRow = {
type: 'header'
@@ -146,7 +154,8 @@ export function buildRows(
prCache: Record<string, unknown> | null,
collapsedGroups: Set<string>,
repoOrder?: Map<string, number>,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
repoGroupOrdering: RepoGroupOrdering = 'manual'
): Row[] {
const result: Row[] = []
@@ -199,13 +208,11 @@ export function buildRows(
}
}
} else {
// Why: header order must follow the canonical state.repos array order, not
// first-encounter from the smart-sorted worktree stream — otherwise sorting
// or filtering side effects could shuffle which repo header appears first,
// and manual reorder would have nothing to bind to. Unknown ids (no entry
// in repoOrder) sort last by label so they remain deterministic.
// Why: dynamic sorts need repo headers to follow their highest-ranked
// visible child. Manual ordering still uses the canonical state.repos
// order so repo-header drag has a stable source of truth.
const entries = Array.from(grouped.entries())
if (repoOrder) {
if (repoGroupOrdering === 'manual' && repoOrder) {
const rankFor = (key: string): number => {
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
const rank = repoOrder.get(repoId)
@@ -4,6 +4,7 @@ import { useAppStore } from '../../store'
import { StatCard } from './StatCard'
import { ClaudeUsagePane } from './ClaudeUsagePane'
import { CodexUsagePane } from './CodexUsagePane'
import { UsageOverviewPane } from './UsageOverviewPane'
import type { SettingsSearchEntry } from '../settings/settings-search'
import { cn } from '@/lib/utils'
@@ -11,7 +12,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Stats & Usage',
description:
'Orca stats plus Claude and Codex usage analytics, tokens, cache, models, and sessions.',
'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.',
keywords: [
'stats',
'usage',
@@ -59,7 +60,7 @@ function formatTrackingSince(timestamp: number | null): string {
export function StatsPane(): React.JSX.Element {
const summary = useAppStore((s) => s.statsSummary)
const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary)
const [activeUsageTab, setActiveUsageTab] = useState<'claude' | 'codex'>('claude')
const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview')
useEffect(() => {
void fetchStatsSummary()
@@ -110,7 +111,7 @@ export function StatsPane(): React.JSX.Element {
aria-label="Usage analytics provider"
className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
>
{(['claude', 'codex'] as const).map((tab) => (
{(['overview', 'claude', 'codex'] as const).map((tab) => (
<button
key={tab}
type="button"
@@ -123,7 +124,7 @@ export function StatsPane(): React.JSX.Element {
: 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
)}
>
{tab === 'claude' ? 'Claude' : 'Codex'}
{tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
</button>
))}
</div>
@@ -132,7 +133,15 @@ export function StatsPane(): React.JSX.Element {
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the
active panel mounted avoids hidden tab-content layout/focus churn that produced a visible
vertical jitter below the usage card when switching disabled providers. */}
<div>{activeUsageTab === 'claude' ? <ClaudeUsagePane /> : <CodexUsagePane />}</div>
<div>
{activeUsageTab === 'overview' ? (
<UsageOverviewPane />
) : activeUsageTab === 'claude' ? (
<ClaudeUsagePane />
) : (
<CodexUsagePane />
)}
</div>
</div>
</div>
)
@@ -0,0 +1,412 @@
import { useEffect, useMemo } from 'react'
import {
Activity,
AlertCircle,
CalendarDays,
Coins,
DatabaseZap,
RefreshCw,
Sparkles
} from 'lucide-react'
import { useAppStore } from '../../store'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { StatCard } from './StatCard'
import {
buildUsageOverview,
formatUsageCost,
formatUsageTokens,
getRecentUsageDays,
type UsageOverviewDailyPoint,
type UsageOverviewModel,
type UsageProviderOverview
} from './usage-overview-model'
const RECENT_DAY_COUNT = 42
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
0: 'border-border/60 bg-muted/40',
1: 'border-border/60 bg-muted-foreground/20',
2: 'border-border/60 bg-muted-foreground/35',
3: 'border-border/60 bg-muted-foreground/55',
4: 'border-border/60 bg-foreground/75'
}
function formatPercent(value: number | null): string {
if (value === null) {
return 'n/a'
}
return `${Math.round(value * 100)}%`
}
function formatUpdatedAt(timestamp: number | null): string {
if (!timestamp) {
return 'Not scanned yet'
}
return `Updated ${new Date(timestamp).toLocaleString()}`
}
function formatDayLabel(day: string): string {
const parsed = new Date(`${day}T12:00:00`)
if (Number.isNaN(parsed.getTime())) {
return day
}
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
const segments = [
{
key: 'new-input',
label: 'New input',
value: overview.newInputTokens,
className: 'bg-foreground'
},
{
key: 'output',
label: 'Output',
value: overview.outputTokens,
className: 'bg-muted-foreground'
},
{
key: 'cache',
label: 'Cache',
value: overview.cacheTokens,
className: 'bg-border'
}
]
// Why: Codex cached input is a subset of input. The overview model normalizes
// that into new/cache buckets so the visual mix does not double-count it.
const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
<p className="text-xs text-muted-foreground">
Combined input, output, and cache tokens across enabled providers.
</p>
</div>
{overview.reasoningTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
{formatUsageTokens(overview.reasoningTokens)} reasoning
</Badge>
) : null}
</div>
{mixTotal > 0 ? (
<div
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
aria-label="Combined token mix"
>
{segments.map((segment) =>
segment.value > 0 ? (
<div
key={segment.key}
className={segment.className}
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
/>
) : null
)}
</div>
) : (
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
)}
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
{segments.map((segment) => (
<div key={segment.key} className="flex min-w-0 items-center gap-2">
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
<span className="min-w-0 truncate">
{segment.label}: {formatUsageTokens(segment.value)}
</span>
</div>
))}
</div>
</section>
)
}
function DailyIntensityGrid({
days,
bestDay
}: {
days: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
<p className="text-xs text-muted-foreground">
Recent combined Claude and Codex token activity.
</p>
</div>
{bestDay && bestDay.totalTokens > 0 ? (
<Badge variant="outline" className="shrink-0">
Best: {formatDayLabel(bestDay.day)}
</Badge>
) : null}
</div>
<div
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
aria-label="Recent token activity heatmap"
>
{days.map((day) => (
<div
key={day.day}
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
/>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
<span>Less</span>
<div className="flex items-center gap-1" aria-hidden>
{[0, 1, 2, 3, 4].map((intensity) => (
<span
key={intensity}
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
/>
))}
</div>
<span>More</span>
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
</div>
</section>
)
}
function ProviderRow({
provider,
totalTokens,
onEnable
}: {
provider: UsageProviderOverview
totalTokens: number
onEnable: () => void
}): React.JSX.Element {
const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
const statusVariant = provider.enabled ? 'secondary' : 'outline'
return (
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
<Badge variant={statusVariant}>{status}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{provider.topModel ?? 'No model yet'}
{provider.topProject ? ` - ${provider.topProject}` : ''}
</p>
</div>
{!provider.enabled ? (
<Button variant="outline" size="xs" onClick={onEnable}>
Enable
</Button>
) : null}
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
<span>
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
{provider.activityLabel}
</span>
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-foreground/75"
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
/>
</div>
{provider.lastScanError ? (
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3" />
{provider.lastScanError}
</p>
) : null}
</div>
)
}
export function UsageOverviewPane(): React.JSX.Element {
const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
const claudeDaily = useAppStore((state) => state.claudeUsageDaily)
const codexScanState = useAppStore((state) => state.codexUsageScanState)
const codexSummary = useAppStore((state) => state.codexUsageSummary)
const codexDaily = useAppStore((state) => state.codexUsageDaily)
const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage)
const enableCodexUsage = useAppStore((state) => state.enableCodexUsage)
useEffect(() => {
void fetchClaudeUsage()
void fetchCodexUsage()
}, [fetchClaudeUsage, fetchCodexUsage])
const overview = useMemo(
() =>
buildUsageOverview({
claude: {
scanState: claudeScanState,
summary: claudeSummary,
daily: claudeDaily
},
codex: {
scanState: codexScanState,
summary: codexSummary,
daily: codexDaily
}
}),
[claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary]
)
const recentDays = useMemo(
() => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT),
[overview.daily]
)
const isScanning = overview.providers.some((provider) => provider.isScanning)
const handleRefresh = (): void => {
void Promise.all([
claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(),
codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve()
])
}
return (
<div className="space-y-4" data-testid="usage-overview-pane">
<section className="rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">Usage Overview</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(overview.lastUpdatedAt)}
{overview.hasPartialCost ? ' - some model prices are unavailable' : ''}
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={handleRefresh}
disabled={!overview.hasAnyEnabledProvider || isScanning}
aria-label="Refresh usage overview"
>
<RefreshCw className={`size-3.5 ${isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh
</TooltipContent>
</Tooltip>
</div>
{!overview.hasAnyEnabledProvider ? (
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5">
<div className="max-w-xl space-y-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Start tracking tokens</h4>
<p className="mt-1 text-sm text-muted-foreground">
Enable a provider to scan local agent logs and build the combined token ledger.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void enableClaudeUsage()}>
Enable Claude
</Button>
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
Enable Codex
</Button>
</div>
</div>
</div>
) : (
<>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Total tokens"
value={formatUsageTokens(overview.totalTokens)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label="Est. cost"
value={formatUsageCost(overview.estimatedCostUsd)}
icon={<Coins className="size-4" />}
/>
<StatCard
label="Active days"
value={overview.activeDays.toLocaleString()}
icon={<CalendarDays className="size-4" />}
/>
<StatCard
label="Cache share"
value={formatPercent(overview.cacheShare)}
icon={<DatabaseZap className="size-4" />}
/>
</div>
{!overview.hasAnyData ? (
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
No local Claude or Codex usage found yet. The overview will populate after the next
agent session writes token logs.
</div>
) : (
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<DailyIntensityGrid days={recentDays} bestDay={overview.bestDay} />
<TokenMixBar overview={overview} />
</div>
)}
</>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">Providers</h4>
<p className="text-xs text-muted-foreground">
{overview.enabledProviderCount} enabled - {overview.dataProviderCount} with data
</p>
</div>
<Badge variant="outline" className="gap-1">
<Activity className="size-3" />
{overview.sessions.toLocaleString()} sessions
</Badge>
</div>
<div className="grid gap-3 xl:grid-cols-2">
{overview.providers.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
totalTokens={overview.totalTokens}
onEnable={() => {
if (provider.id === 'claude') {
void enableClaudeUsage()
} else {
void enableCodexUsage()
}
}}
/>
))}
</div>
</section>
</div>
)
}
@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'
import type {
ClaudeUsageDailyPoint,
ClaudeUsageScanState,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import type {
CodexUsageDailyPoint,
CodexUsageScanState,
CodexUsageSummary
} from '../../../../shared/codex-usage-types'
import {
buildUsageOverview,
formatUsageCost,
formatUsageTokens,
getRecentUsageDays
} from './usage-overview-model'
function enabledClaudeScanState(): ClaudeUsageScanState {
return {
enabled: true,
isScanning: false,
lastScanStartedAt: 100,
lastScanCompletedAt: 200,
lastScanError: null,
hasAnyClaudeData: true
}
}
function enabledCodexScanState(): CodexUsageScanState {
return {
enabled: true,
isScanning: false,
lastScanStartedAt: 300,
lastScanCompletedAt: 400,
lastScanError: null,
hasAnyCodexData: true
}
}
describe('usage overview model', () => {
it('combines Claude and Codex totals without double-counting Codex cached input', () => {
const claudeSummary: ClaudeUsageSummary = {
scope: 'orca',
range: '30d',
sessions: 2,
turns: 4,
zeroCacheReadTurns: 1,
inputTokens: 1_000,
outputTokens: 500,
cacheReadTokens: 4_000,
cacheWriteTokens: 500,
cacheReuseRate: 0.8,
estimatedCostUsd: 0.04,
topModel: 'claude-sonnet-4-5',
topProject: 'orca-main',
hasAnyClaudeData: true
}
const codexSummary: CodexUsageSummary = {
scope: 'orca',
range: '30d',
sessions: 1,
events: 3,
inputTokens: 2_000,
cachedInputTokens: 800,
outputTokens: 1_200,
reasoningOutputTokens: 300,
totalTokens: 3_200,
estimatedCostUsd: 0.02,
topModel: 'gpt-5.4',
topProject: 'orca-secondary',
hasAnyCodexData: true
}
const claudeDaily: ClaudeUsageDailyPoint[] = [
{
day: '2026-05-13',
inputTokens: 500,
outputTokens: 500,
cacheReadTokens: 2_500,
cacheWriteTokens: 0
},
{
day: '2026-05-14',
inputTokens: 500,
outputTokens: 0,
cacheReadTokens: 1_500,
cacheWriteTokens: 500
}
]
const codexDaily: CodexUsageDailyPoint[] = [
{
day: '2026-05-14',
inputTokens: 1_200,
cachedInputTokens: 400,
outputTokens: 800,
reasoningOutputTokens: 100,
totalTokens: 2_000
},
{
day: '2026-05-15',
inputTokens: 800,
cachedInputTokens: 400,
outputTokens: 400,
reasoningOutputTokens: 200,
totalTokens: 1_200
}
]
const overview = buildUsageOverview({
claude: {
scanState: enabledClaudeScanState(),
summary: claudeSummary,
daily: claudeDaily
},
codex: {
scanState: enabledCodexScanState(),
summary: codexSummary,
daily: codexDaily
}
})
expect(overview.totalTokens).toBe(9_200)
expect(overview.newInputTokens).toBe(2_200)
expect(overview.cacheTokens).toBe(5_300)
expect(overview.outputTokens).toBe(1_700)
expect(overview.reasoningTokens).toBe(300)
expect(overview.sessions).toBe(3)
expect(overview.activityCount).toBe(7)
expect(overview.activeDays).toBe(3)
expect(overview.estimatedCostUsd).toBeCloseTo(0.06)
expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500)
expect(overview.bestDay).toMatchObject({
day: '2026-05-14',
totalTokens: 4_500,
claudeTokens: 2_500,
codexTokens: 2_000,
intensity: 4
})
expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({
newInputTokens: 1_200,
cacheTokens: 800,
totalTokens: 3_200
})
})
it('pads recent usage days with zero-token cells', () => {
const recent = getRecentUsageDays(
[
{
day: '2026-05-14',
totalTokens: 4_500,
claudeTokens: 2_500,
codexTokens: 2_000,
intensity: 4
}
],
3,
new Date('2026-05-15T12:00:00')
)
expect(recent).toEqual([
{
day: '2026-05-13',
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
intensity: 0
},
{
day: '2026-05-14',
totalTokens: 4_500,
claudeTokens: 2_500,
codexTokens: 2_000,
intensity: 4
},
{
day: '2026-05-15',
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
intensity: 0
}
])
})
it('reports disabled providers as an empty overview', () => {
const overview = buildUsageOverview({
claude: { scanState: null, summary: null, daily: [] },
codex: { scanState: null, summary: null, daily: [] }
})
expect(overview.hasAnyEnabledProvider).toBe(false)
expect(overview.hasAnyData).toBe(false)
expect(overview.totalTokens).toBe(0)
expect(overview.estimatedCostUsd).toBeNull()
expect(overview.cacheShare).toBeNull()
})
it('formats token and cost values for compact UI labels', () => {
expect(formatUsageTokens(999)).toBe('999')
expect(formatUsageTokens(1_200)).toBe('1.2k')
expect(formatUsageTokens(2_500_000)).toBe('2.5M')
expect(formatUsageCost(null)).toBe('n/a')
expect(formatUsageCost(0.0042)).toBe('$0.0042')
expect(formatUsageCost(1.234)).toBe('$1.23')
})
})
@@ -0,0 +1,319 @@
import type {
ClaudeUsageDailyPoint,
ClaudeUsageScanState,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import type {
CodexUsageDailyPoint,
CodexUsageScanState,
CodexUsageSummary
} from '../../../../shared/codex-usage-types'
export type UsageProviderId = 'claude' | 'codex'
export type UsageProviderOverview = {
id: UsageProviderId
label: string
enabled: boolean
isScanning: boolean
hasData: boolean
lastScanCompletedAt: number | null
lastScanError: string | null
sessions: number
activityLabel: 'turns' | 'events'
activityCount: number
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
estimatedCostUsd: number | null
topModel: string | null
topProject: string | null
activeDays: number
}
export type UsageOverviewDailyPoint = {
day: string
totalTokens: number
claudeTokens: number
codexTokens: number
intensity: 0 | 1 | 2 | 3 | 4
}
export type UsageOverviewModel = {
providers: UsageProviderOverview[]
enabledProviderCount: number
dataProviderCount: number
hasAnyEnabledProvider: boolean
hasAnyData: boolean
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
sessions: number
activityCount: number
activeDays: number
estimatedCostUsd: number | null
hasPartialCost: boolean
cacheShare: number | null
daily: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
lastUpdatedAt: number | null
}
export type UsageOverviewInput = {
claude: {
scanState: ClaudeUsageScanState | null
summary: ClaudeUsageSummary | null
daily: ClaudeUsageDailyPoint[]
}
codex: {
scanState: CodexUsageScanState | null
summary: CodexUsageSummary | null
daily: CodexUsageDailyPoint[]
}
}
function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number {
return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
}
function getCodexNewInputTokens(summary: CodexUsageSummary | null): number {
if (!summary) {
return 0
}
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
}
function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 {
if (totalTokens <= 0 || maxTokens <= 0) {
return 0
}
const ratio = totalTokens / maxTokens
if (ratio <= 0.25) {
return 1
}
if (ratio <= 0.5) {
return 2
}
if (ratio <= 0.75) {
return 3
}
return 4
}
function countActiveDays(days: string[]): number {
return new Set(days).size
}
function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => getClaudeDailyTotal(entry) > 0)
.map((entry) => entry.day)
return {
id: 'claude',
label: 'Claude',
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'turns',
activityCount: summary?.turns ?? 0,
totalTokens: summary
? summary.inputTokens +
summary.outputTokens +
summary.cacheReadTokens +
summary.cacheWriteTokens
: 0,
newInputTokens: summary?.inputTokens ?? 0,
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0,
reasoningTokens: 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => entry.totalTokens > 0)
.map((entry) => entry.day)
return {
id: 'codex',
label: 'Codex',
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'events',
activityCount: summary?.events ?? 0,
totalTokens: summary?.totalTokens ?? 0,
newInputTokens: getCodexNewInputTokens(summary),
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary?.cachedInputTokens ?? 0,
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] {
const byDay = new Map<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
for (const entry of input.claude.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0
}
const total = getClaudeDailyTotal(entry)
current.totalTokens += total
current.claudeTokens += total
byDay.set(entry.day, current)
}
for (const entry of input.codex.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0
}
current.totalTokens += entry.totalTokens
current.codexTokens += entry.totalTokens
byDay.set(entry.day, current)
}
const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens))
return [...byDay.values()]
.sort((left, right) => left.day.localeCompare(right.day))
.map((entry) => ({
...entry,
intensity: getIntensity(entry.totalTokens, maxTokens)
}))
}
function formatLocalDay(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getRecentUsageDays(
daily: UsageOverviewDailyPoint[],
dayCount: number,
anchorDate = new Date()
): UsageOverviewDailyPoint[] {
const byDay = new Map(daily.map((entry) => [entry.day, entry]))
const count = Math.max(1, Math.floor(dayCount))
const end = new Date(anchorDate)
end.setHours(0, 0, 0, 0)
const result: UsageOverviewDailyPoint[] = []
for (let offset = count - 1; offset >= 0; offset--) {
const date = new Date(end)
date.setDate(end.getDate() - offset)
const day = formatLocalDay(date)
result.push(
byDay.get(day) ?? {
day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
intensity: 0
}
)
}
return result
}
export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel {
const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)]
const daily = buildDailyOverview(input)
const bestDay =
daily.length === 0
? null
: daily.reduce<UsageOverviewDailyPoint | null>(
(best, entry) => (!best || entry.totalTokens > best.totalTokens ? entry : best),
null
)
const totalTokens = providers.reduce((sum, provider) => sum + provider.totalTokens, 0)
const newInputTokens = providers.reduce((sum, provider) => sum + provider.newInputTokens, 0)
const outputTokens = providers.reduce((sum, provider) => sum + provider.outputTokens, 0)
const cacheTokens = providers.reduce((sum, provider) => sum + provider.cacheTokens, 0)
const reasoningTokens = providers.reduce((sum, provider) => sum + provider.reasoningTokens, 0)
const sessions = providers.reduce((sum, provider) => sum + provider.sessions, 0)
const activityCount = providers.reduce((sum, provider) => sum + provider.activityCount, 0)
const knownCost = providers.reduce((sum, provider) => sum + (provider.estimatedCostUsd ?? 0), 0)
const hasKnownCost = providers.some((provider) => provider.estimatedCostUsd !== null)
const hasPartialCost = providers.some(
(provider) => provider.hasData && provider.estimatedCostUsd === null
)
const lastUpdatedAt =
providers.reduce<number | null>(
(latest, provider) =>
provider.lastScanCompletedAt && (!latest || provider.lastScanCompletedAt > latest)
? provider.lastScanCompletedAt
: latest,
null
) ?? null
return {
providers,
enabledProviderCount: providers.filter((provider) => provider.enabled).length,
dataProviderCount: providers.filter((provider) => provider.hasData).length,
hasAnyEnabledProvider: providers.some((provider) => provider.enabled),
hasAnyData: providers.some((provider) => provider.hasData),
totalTokens,
newInputTokens,
outputTokens,
cacheTokens,
reasoningTokens,
sessions,
activityCount,
activeDays: countActiveDays(
daily.filter((entry) => entry.totalTokens > 0).map((entry) => entry.day)
),
estimatedCostUsd: hasKnownCost ? knownCost : null,
hasPartialCost,
cacheShare:
newInputTokens + cacheTokens > 0 ? cacheTokens / (newInputTokens + cacheTokens) : null,
daily,
bestDay,
lastUpdatedAt
}
}
export function formatUsageTokens(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}k`
}
return value.toLocaleString()
}
export function formatUsageCost(value: number | null): string {
if (value === null) {
return 'n/a'
}
return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
}
@@ -78,9 +78,9 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[
// scrollback (`SerializeAddon.serialize()`) never contain `?2031`:
// SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/
// [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences
// emits only ?2004h/?1h/?1049h. If xterm ever adds ?2031 to that
// whitelist, this guard would start suppressing legitimate
// subscribes during snapshot reattach — revisit then.
// emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever
// adds ?2031 to that whitelist, this guard would start suppressing
// legitimate subscribes during snapshot reattach — revisit then.
if (deps.isReplaying()) {
return false
}
+7
View File
@@ -182,6 +182,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'hermes',
faviconDomain: 'nousresearch.com',
homepageUrl: 'https://hermes-agent.nousresearch.com/docs/'
},
{
id: 'openclaw',
label: 'OpenClaw',
cmd: 'openclaw',
faviconDomain: 'openclaw.ai',
homepageUrl: 'https://github.com/openclaw/openclaw'
}
]
+1
View File
@@ -165,6 +165,7 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
'qwen-code': true,
rovo: true,
hermes: true,
openclaw: true,
copilot: true
}
+10 -1
View File
@@ -21,7 +21,16 @@ const GEMINI_PERMISSION = '\u270B' // ✋
// unsafe under the substring-based detector and would classify ordinary shell
// titles like "timestamp ready" as agent activity. Product telemetry uses the
// explicit launch/session facts Orca owns, not this inference path.
export const AGENT_NAMES = ['claude', 'codex', 'copilot', 'cursor', 'gemini', 'opencode', 'aider']
export const AGENT_NAMES = [
'claude',
'codex',
'copilot',
'cursor',
'gemini',
'opencode',
'openclaw',
'aider'
]
// Why: `android` contains `droid`; unlike the legacy agent names above, Droid
// must be token-matched so Android terminal titles do not become agent status.
+1
View File
@@ -37,6 +37,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
'qwen-code': 'qwen-code',
rovo: 'rovo',
hermes: 'hermes',
openclaw: 'openclaw',
copilot: 'copilot'
} satisfies Record<TuiAgent, ConcreteAgentKind>
+48
View File
@@ -103,3 +103,51 @@ export type AutomationDispatchResult = {
terminalSessionId?: string | null
error?: string | null
}
export type ExternalAutomationProvider = 'hermes' | 'openclaw'
export type ExternalAutomationManagerStatus = 'available' | 'unavailable'
export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete'
export type ExternalAutomationTarget =
| {
type: 'local'
}
| {
type: 'ssh'
connectionId: string
}
export type ExternalAutomationJob = {
id: string
managerId: string
provider: ExternalAutomationProvider
name: string
schedule: string
enabled: boolean
state: string
promptPreview: string
nextRunAt: string | null
lastRunAt: string | null
lastStatus: string | null
lastError: string | null
workdir: string | null
}
export type ExternalAutomationManager = {
id: string
provider: ExternalAutomationProvider
label: string
target: ExternalAutomationTarget
status: ExternalAutomationManagerStatus
error: string | null
canManage: boolean
jobs: ExternalAutomationJob[]
}
export type ExternalAutomationActionInput = {
managerId: string
provider: ExternalAutomationProvider
target: ExternalAutomationTarget
jobId: string
action: ExternalAutomationAction
}
+1
View File
@@ -58,6 +58,7 @@ export const AGENT_KIND_VALUES = [
'qwen-code',
'rovo',
'hermes',
'openclaw',
'copilot',
'other'
] as const
+6
View File
@@ -211,6 +211,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
expectedProcess: 'hermes',
promptInjectionMode: 'stdin-after-start'
},
openclaw: {
detectCmd: 'openclaw',
launchCmd: 'openclaw',
expectedProcess: 'openclaw',
promptInjectionMode: 'stdin-after-start'
},
copilot: {
detectCmd: 'copilot',
launchCmd: 'copilot',
+1
View File
@@ -1143,6 +1143,7 @@ export type TuiAgent =
| 'qwen-code' // Qwen Code
| 'rovo' // Rovo Dev
| 'hermes' // Hermes Agent
| 'openclaw' // OpenClaw
| 'copilot' // GitHub Copilot CLI
export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from './helpers/orca-app'
import { getStoreState, waitForSessionReady } from './helpers/store'
test.describe('usage overview', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
})
test('Stats & Usage opens on the combined overview with provider controls', async ({
orcaPage
}) => {
await orcaPage.evaluate(() => {
const state = window.__store!.getState()
state.openSettingsTarget({ pane: 'stats' })
state.openSettingsPage()
})
await expect
.poll(async () => getStoreState<string>(orcaPage, 'activeView'), { timeout: 5_000 })
.toBe('settings')
await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible()
const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' })
await expect(
providerTabs.getByRole('button', { name: 'Overview', exact: true })
).toHaveAttribute('aria-pressed', 'true')
await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible()
await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible()
await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible()
await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click()
await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible()
})
})