Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion public/entry-rendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ function addEntry(e) {
const entryCwd = e.cwd || null;
if (!sessionsMap.has(sid)) {
const shortSid = sid.slice(0, 8);
sessionsMap.set(sid, { id: sid, firstTs: e.ts, firstId: entryId, lastId: entryId, count: 0, mainCount: 0, subCount: 0, model, totalCost: 0, cwd: entryCwd, title: null, titleReqTs: 0, lastAssistantText: null, agent: e.agent || 'claude', provider: e.provider || 'anthropic', latestCacheHitRatio: 0, latestCacheReadTokens: 0 });
sessionsMap.set(sid, { id: sid, firstTs: e.ts, firstId: entryId, lastId: entryId, count: 0, mainCount: 0, subCount: 0, model, totalCost: 0, cwd: entryCwd, title: null, titleReqTs: 0, lastAssistantText: null, agent: e.agent || 'claude', provider: e.provider || 'anthropic', latestCacheHitRatio: 0, latestCacheReadTokens: 0, resumeCommand: null });
// Live-update visibleProviders when a new provider appears
const settings = window.ccxraySettings;
if (!Array.isArray(settings.visibleProviders)) settings.visibleProviders = [];
Expand Down Expand Up @@ -268,6 +268,9 @@ function addEntry(e) {
}
}
const sess = sessionsMap.get(sid);
// Resume command is computed server-side (single source of truth). Sticky:
// once any turn reports a command, keep it even if later turns lack usage.
if (e.resumeCommand) sess.resumeCommand = e.resumeCommand;
// Update cwd if not yet known or was only a quota-check
if (entryCwd && (!sess.cwd || sess.cwd === '(quota-check)')) sess.cwd = entryCwd;
if (model && model !== '?') sess.model = model;
Expand Down
16 changes: 8 additions & 8 deletions public/miller-columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -1386,8 +1386,8 @@ function formatEntryDateShort(id) {
}


function copySessionContinue(sid, btn, agent) {
const cmd = agent === 'codex' ? 'codex resume ' + sid : (agent || 'claude') + ' --resume ' + sid;
function copySessionContinue(cmd, btn) {
if (!cmd) return;
navigator.clipboard.writeText(cmd).then(() => {
const orig = btn.textContent;
btn.textContent = '✓ copied!';
Expand Down Expand Up @@ -1495,12 +1495,12 @@ function renderSessionItem(sess, sid) {
const titleRow = sess.title
? '<div class="si-title">' + escapeHtml(sess.title) + '</div>'
: '';
const resumeCmd = (sess.agent || 'claude') === 'codex'
? 'codex resume ' + sid
: (sess.agent || 'claude') + ' --resume ' + sid;
const copyBtn = sid === 'direct-api' || sid === 'codex-raw'
? ''
: '<button class="launch-btn" onclick="event.stopPropagation();copySessionContinue(&quot;' + escapeHtml(sid) + '&quot;,this,&quot;' + escapeHtml(sess.agent || 'claude') + '&quot;)" title="Copy: ' + escapeHtml(resumeCmd) + '">&#10697;</button>';
// Resume command is computed server-side; null means this session can't be
// resumed (e.g. a codex session that only errored before any turn completed).
const resumeCmd = sess.resumeCommand || null;
const copyBtn = resumeCmd
? '<button class="launch-btn" onclick="event.stopPropagation();copySessionContinue(&quot;' + escapeHtml(resumeCmd) + '&quot;,this)" title="Copy: ' + escapeHtml(resumeCmd) + '">&#10697;</button>'
: '';
return '<div class="si-row1">' +
'<button class="' + sdotClasses + '"' + (sdotTitle ? ' title="' + sdotTitle + '"' : '') + (sdotOnclick ? ' onclick="' + sdotOnclick + '"' : '') + ' tabindex="-1"></button>' +
'<span class="sid" title="' + escapeHtml(tooltip) + '">' + escapeHtml(shortSid) + '</span>' +
Expand Down
4 changes: 4 additions & 0 deletions server/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,15 @@ const UPSTREAM_PROFILES = Object.freeze({
cache: 'ephemeral-ttl',
inputIncludesCached: false,
label: 'Anthropic',
resume: Object.freeze({ template: '{agent} --resume {sid}', condition: 'always' }),
}),
openai: Object.freeze({
cache: 'server-managed',
inputIncludesCached: true,
label: 'OpenAI',
// Codex only writes a rollout file (resumable session) after a successful
// API turn — sessions with only startup errors can't be resumed.
resume: Object.freeze({ template: 'codex resume {sid}', condition: 'has-usage' }),
}),
});

Expand Down
4 changes: 4 additions & 0 deletions server/restore.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ async function restoreFromLogs() {
if (meta.provider) store.sessionMeta[meta.sessionId].provider = meta.provider;
if (meta.cwd) store.sessionMeta[meta.sessionId].cwd = meta.cwd;
if (meta.receivedAt) store.sessionMeta[meta.sessionId].lastSeenAt = meta.receivedAt;
// Mark resume-eligibility before any summarizeEntry pass so every entry in
// the session reports the final (monotonic) resumable value, not the value
// as of its position in the index.
store.markSessionUsage(meta);
}
if (meta.cost?.cost != null && meta.sessionId) {
store.sessionCosts.set(meta.sessionId, (store.sessionCosts.get(meta.sessionId) || 0) + meta.cost.cost);
Expand Down
2 changes: 1 addition & 1 deletion server/routes/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function computeSettings() {
const visibleProviders = [...new Set([...fromMeta, ...fromEntries])];

const providerProfiles = Object.fromEntries(
Object.entries(UPSTREAM_PROFILES).map(([k, v]) => [k, { cache: v.cache, label: v.label }])
Object.entries(UPSTREAM_PROFILES).map(([k, v]) => [k, { cache: v.cache, label: v.label, resume: v.resume }])
);

return {
Expand Down
7 changes: 7 additions & 0 deletions server/sse-broadcast.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ const { agentForProvider } = require('./providers');
// Strip req/res from broadcast — browser only needs summary for the turn list
function summarizeEntry(entry) {
const tok = entry.tokens;
// Server owns the resume-button policy. Record this turn's usage signal first,
// then compute the per-session resume command so the client is a pure view.
// (Deliberate side-effect in a serialize function: this is the single funnel
// both SSE broadcast and the /api/entries restore batch pass through.)
store.markSessionUsage(entry);
const { resumable, resumeCommand } = store.computeSessionResume(entry.sessionId, entry.provider);
return {
id: entry.id, ts: entry.ts, sessionId: entry.sessionId,
provider: entry.provider || 'anthropic',
agent: entry.agent || agentForProvider(entry.provider),
resumable, resumeCommand,
method: entry.method, url: entry.url,
elapsed: entry.elapsed, status: entry.status, isSSE: entry.isSSE,
receivedAt: entry.receivedAt || null,
Expand Down
45 changes: 45 additions & 0 deletions server/store.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
'use strict';

const { agentForProvider, getUpstreamProfile } = require('./providers');

// Synthetic session buckets that have no resumable rollout/session file.
const NON_RESUMABLE_SESSIONS = new Set(['direct-api', 'codex-raw', 'unknown']);

// ── In-memory store & SSE clients ───────────────────────────────────
const MAX_ENTRIES = parseInt(process.env.CCXRAY_MAX_ENTRIES || '5000', 10);
const entries = [];
Expand Down Expand Up @@ -252,6 +257,43 @@ function setRestoreState(patch) {
restoreState.entryCount = entries.length;
}

// Mark a session as having produced a real completed turn. Codex only writes a
// resumable rollout file once a turn reports usage; a non-subagent entry with
// usage is our proxy-side signal for "this session can be resumed". Monotonic:
// once true it never flips back.
function markSessionUsage(entry) {
const sid = entry?.sessionId;
if (!sid || NON_RESUMABLE_SESSIONS.has(sid)) return;
if (entry.isSubagent) return;
if (!entry.usage) return;
const meta = sessionMeta[sid] || (sessionMeta[sid] = {});
meta.hasUsage = true;
}

// Single source of truth for the dashboard's resume button. Interprets the
// declarative resume profile from UPSTREAM_PROFILES ({template, condition}):
// 'always' resumes unconditionally, 'has-usage' requires a completed turn.
// Returns the resume command string (null when the session can't be resumed).
function computeSessionResume(sessionId, provider) {
if (!sessionId || NON_RESUMABLE_SESSIONS.has(sessionId)) {
return { resumable: false, resumeCommand: null };
}
// Entries without a provider predate provider tagging — they are anthropic.
// An unknown provider, however, fails closed: better no button than a
// command we can't vouch for.
const profile = provider == null ? getUpstreamProfile('anthropic') : getUpstreamProfile(provider);
if (!profile) return { resumable: false, resumeCommand: null };
const resume = profile.resume;
if (!resume) return { resumable: false, resumeCommand: null };
if (resume.condition === 'has-usage' && !sessionMeta[sessionId]?.hasUsage) {
return { resumable: false, resumeCommand: null };
}
const resumeCommand = resume.template
.replace('{agent}', agentForProvider(provider))
.replace('{sid}', sessionId);
return { resumable: true, resumeCommand };
}

module.exports = {
MAX_ENTRIES,
entries,
Expand Down Expand Up @@ -281,4 +323,7 @@ module.exports = {
getSessionTitle,
attributeTitleGen,
propagateLoadedSkills,
markSessionUsage,
computeSessionResume,
NON_RESUMABLE_SESSIONS,
};
12 changes: 12 additions & 0 deletions test/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ describe('agent provider registry', () => {
assert.equal(providers.PROVIDER_AGENT.openai, 'codex');
});

it('declares resume profiles per upstream', () => {
const { UPSTREAM_PROFILES } = providers;
assert.deepEqual(UPSTREAM_PROFILES.anthropic.resume, {
template: '{agent} --resume {sid}',
condition: 'always',
});
assert.deepEqual(UPSTREAM_PROFILES.openai.resume, {
template: 'codex resume {sid}',
condition: 'has-usage',
});
});

it('centralizes display names and unsupported-provider handling', () => {
assert.equal(providers.getDisplayName('claude', {}), 'ccxray');
assert.equal(providers.getDisplayName('codex', {}), 'ccxray');
Expand Down
92 changes: 92 additions & 0 deletions test/restore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,95 @@ describe('restoreFromLogs — maxContext re-inference for legacy entries', () =>
assert.equal(entry.maxContext, null);
});
});

// ── codex resume eligibility ────────────────────────────────────────

// Resume-eligibility must survive a restart: it is rebuilt purely from the
// index (no rollout-file probing), so a codex session is resumable iff the
// index holds a non-subagent usage turn for it.
describe('restoreFromLogs — codex resume eligibility', () => {
const config = require('../server/config');
const store = require('../server/store');
const { restoreFromLogs } = require('../server/restore');
const { summarizeEntry } = require('../server/sse-broadcast');
const tmpDir = path.join(os.tmpdir(), 'ccxray-restore-resume-' + Date.now());
let realStorage;
let realRestoreDays;

before(async () => {
realStorage = config.storage;
realRestoreDays = config.RESTORE_DAYS;
config.RESTORE_DAYS = 0;
const tmpStorage = require('../server/storage/local').createLocalStorage(tmpDir);
await tmpStorage.init();
config.storage = tmpStorage;
});

after(() => {
config.storage = realStorage;
config.RESTORE_DAYS = realRestoreDays;
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('a codex session with a completed turn is resumable after restore', async () => {
store.entries.length = 0;
const sid = 'codex-restore-ok';
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T10-00-00-000', ts: '10:00:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: { input_tokens: 42 }, isSubagent: false,
isSSE: true, status: 200, receivedAt: 1779000000000,
}) + '\n');

await restoreFromLogs();
assert.deepEqual(
store.computeSessionResume(sid, 'openai'),
{ resumable: true, resumeCommand: `codex resume ${sid}` },
);
const summary = summarizeEntry(store.entries.find(e => e.sessionId === sid));
assert.equal(summary.resumeCommand, `codex resume ${sid}`);
});

it('marks usage before serialization: a usage-less entry earlier in the index still reports the final command', async () => {
store.entries.length = 0;
const sid = 'codex-restore-late-usage';
// First indexed entry has no usage; the usage-bearing turn comes later.
// Without the restore-loop pre-marking, serializing entry 1 first would
// report resumable:false (summarizeEntry only marks as it goes).
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T12-00-00-000', ts: '12:00:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: null, isSubagent: false,
isSSE: false, status: 502, receivedAt: 1779000000000,
}) + '\n');
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T12-01-00-000', ts: '12:01:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: { input_tokens: 7 }, isSubagent: false,
isSSE: true, status: 200, receivedAt: 1779000060000,
}) + '\n');

await restoreFromLogs();
const first = store.entries.find(e => e.id === '2026-05-20T12-00-00-000');
assert.equal(summarizeEntry(first).resumeCommand, `codex resume ${sid}`);
});

it('a codex session with only a 502-style turn (no usage) is not resumable', async () => {
store.entries.length = 0;
const sid = 'codex-restore-502';
await config.storage.appendIndex(JSON.stringify({
id: '2026-05-20T11-00-00-000', ts: '11:00:00', sessionId: sid,
provider: 'openai', agent: 'codex', model: 'gpt-5',
usage: null, isSubagent: false,
isSSE: false, status: 502, receivedAt: 1779000000000,
}) + '\n');

await restoreFromLogs();
assert.deepEqual(
store.computeSessionResume(sid, 'openai'),
{ resumable: false, resumeCommand: null },
);
const summary = summarizeEntry(store.entries.find(e => e.sessionId === sid));
assert.equal(summary.resumeCommand, null);
});
});
73 changes: 73 additions & 0 deletions test/resume-button-render.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

// Render-level regression for the resume copy button: the client is a pure
// view of the server-computed sess.resumeCommand — no button when null.
function loadMillerColumnsContext() {
const publicDir = path.join(__dirname, '..', 'public');
function el() {
return {
style: {}, dataset: {}, innerHTML: '', textContent: '',
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
addEventListener() {}, appendChild() {}, insertBefore() {},
querySelector: () => el(), querySelectorAll: () => [],
};
}
const context = {
console, window: {},
document: {
getElementById: () => el(), createElement: () => el(),
querySelector: () => el(), querySelectorAll: () => [],
addEventListener() {}, body: el(),
},
localStorage: { getItem: () => null, setItem() {} },
sessionStorage: { getItem: () => null, setItem() {} },
navigator: {}, location: { search: '', hash: '' }, history: {},
URLSearchParams, setTimeout, clearTimeout,
};
vm.createContext(context);
for (const f of ['session-label.js', 'miller-columns.js']) {
vm.runInContext(fs.readFileSync(path.join(publicDir, f), 'utf8'), context);
}
return context;
}

function makeSession(overrides) {
return {
id: 'sid', agent: 'claude', provider: 'anthropic', resumeCommand: null,
count: 1, mainCount: 1, subCount: 0, totalCost: 0, model: 'gpt-5',
firstTs: '10:00:00', firstId: '2026-06-08T10-00-00-000', lastId: '2026-06-08T10-00-00-000',
latestCacheHitRatio: 0, latestCacheReadTokens: 0,
...overrides,
};
}

describe('renderSessionItem – resume copy button', () => {
it('omits the button for a codex session without a server-computed command', () => {
const ctx = loadMillerColumnsContext();
const sess = makeSession({ id: 'codex-502-sid', agent: 'codex', provider: 'openai', resumeCommand: null });
const html = ctx.renderSessionItem(sess, 'codex-502-sid');
assert.equal(html.includes('launch-btn'), false);
assert.equal(html.includes('copySessionContinue'), false);
});

it('renders the button with the exact server-computed command', () => {
const ctx = loadMillerColumnsContext();
const sess = makeSession({ id: 'codex-ok-sid', agent: 'codex', provider: 'openai', resumeCommand: 'codex resume codex-ok-sid' });
const html = ctx.renderSessionItem(sess, 'codex-ok-sid');
assert.equal(html.includes('launch-btn'), true);
assert.equal(html.includes('codex resume codex-ok-sid'), true);
});

it('renders a claude resume button from the server-computed command', () => {
const ctx = loadMillerColumnsContext();
const sess = makeSession({ id: 'claude-sid', resumeCommand: 'claude --resume claude-sid' });
const html = ctx.renderSessionItem(sess, 'claude-sid');
assert.equal(html.includes('claude --resume claude-sid'), true);
});
});
12 changes: 12 additions & 0 deletions test/settings-endpoint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,16 @@ describe('/_api/settings (computeSettings)', () => {
const s = computeSettings();
assert.equal(s.autoCompactPct, 0.835);
});

it('exposes resume profiles in providerProfiles', () => {
const s = computeSettings();
assert.deepEqual(s.providerProfiles.anthropic.resume, {
template: '{agent} --resume {sid}',
condition: 'always',
});
assert.deepEqual(s.providerProfiles.openai.resume, {
template: 'codex resume {sid}',
condition: 'has-usage',
});
});
});
Loading
Loading