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
2 changes: 1 addition & 1 deletion src/agents-detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const AGENT_DEFS = Object.freeze([
{ id: 'codex', label: 'Codex', bin: 'codex' },
{ id: 'cursor', label: 'Cursor', bin: 'cursor-agent', appBundle: 'Cursor.app' },
{ id: 'qwen', label: 'Qwen Code', bin: 'qwen' },
{ id: 'pi', label: 'OhMyPi', customCheck: 'piPath' },
{ id: 'pi', label: 'Pi/OhMyPi', customCheck: 'piPath' },
{ id: 'kilo', label: 'Kilo', bin: 'kilo' },
{ id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli' },
{ id: 'opencode', label: 'OpenCode', bin: 'opencode' },
Expand Down
25 changes: 16 additions & 9 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ function listPiSessionFiles(agentDir) {
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
files.push(fullPath);
} else if (entry.isDirectory() && depth < 3) {
Expand Down Expand Up @@ -705,6 +706,9 @@ function normalizePiUsage(usage) {
};
}

const SAFE_PI_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;


function parsePiSessionFile(sessionFile) {
if (!fs.existsSync(sessionFile)) return null;

Expand All @@ -723,6 +727,7 @@ function parsePiSessionFile(sessionFile) {
if (!header || header.type !== 'session' || !header.id) return null;

let sessionId = String(header.id);
if (!SAFE_PI_SESSION_ID.test(sessionId)) return null;
let projectPath = typeof header.cwd === 'string' ? header.cwd : '';
let title = typeof header.title === 'string' ? header.title.trim().slice(0, 200) : '';
let msgCount = 0;
Expand Down Expand Up @@ -2832,8 +2837,8 @@ let _codexSessionsDirMtimes = {}; // { dayDirPath: mtimeMs } — shallow leaf di
// check. Reused by _updateScanMarkers() to avoid a second filesystem walk
// (which would race against the first and yield inconsistent snapshots).
let _codexDayDirMtimesPending = null;
let _ompSessionDirMtimes = {};
let _ompSessionDirMtimesPending = null;
let _piOmpSessionDirMtimes = {};
let _piOmpSessionDirMtimesPending = null;

function _piSessionDirMtimes(agentDirs) {
const out = {};
Expand All @@ -2843,15 +2848,17 @@ function _piSessionDirMtimes(agentDirs) {
function walk(dir, depth) {
let entries;
try {
const st = fs.statSync(dir);
const st = fs.lstatSync(dir);
if (st.isSymbolicLink()) return;
out[dir] = st.mtimeMs;
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch { return; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
try {
const st = fs.statSync(fullPath);
const st = fs.lstatSync(fullPath);
out[fullPath] = st.mtimeMs + ':' + st.size;
} catch {}
} else if (entry.isDirectory() && depth < 3) {
Expand Down Expand Up @@ -2950,12 +2957,12 @@ function _sessionsNeedRescan() {
if (dayMtimes[k] !== _codexSessionsDirMtimes[k]) return true;
}
const piDirMtimes = _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS));
_ompSessionDirMtimesPending = piDirMtimes;
const prevPiKeys = Object.keys(_ompSessionDirMtimes);
_piOmpSessionDirMtimesPending = piDirMtimes;
const prevPiKeys = Object.keys(_piOmpSessionDirMtimes);
const curPiKeys = Object.keys(piDirMtimes);
if (prevPiKeys.length !== curPiKeys.length) return true;
for (const k of curPiKeys) {
if (piDirMtimes[k] !== _ompSessionDirMtimes[k]) return true;
if (piDirMtimes[k] !== _piOmpSessionDirMtimes[k]) return true;
}
} catch {}
return false;
Expand Down Expand Up @@ -3011,8 +3018,8 @@ function _updateScanMarkers() {
// otherwise (first call / direct invocation) walk now.
_codexSessionsDirMtimes = _codexDayDirMtimesPending || _codexDayDirMtimes();
_codexDayDirMtimesPending = null;
_ompSessionDirMtimes = _ompSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS));
_ompSessionDirMtimesPending = null;
_piOmpSessionDirMtimes = _piOmpSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS));
_piOmpSessionDirMtimesPending = null;
} catch {}
}

Expand Down
2 changes: 1 addition & 1 deletion src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ function getResumeCommand(tool, sessionId, project, session) {
if (tool === 'qwen') return 'qwen -r ' + sessionId;
if (tool === 'pi') {
var target = session && session.resume_target ? session.resume_target : sessionId;
return getPiCommand() === 'omp'
return session && session.agent_variant === 'ohmypi'
? 'omp --resume ' + quoteShellArg(target)
: 'pi --session ' + quoteShellArg(target);
}
Expand Down
6 changes: 5 additions & 1 deletion src/frontend/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ async function openDetail(s) {
} else if (activeSessions[s.id]) {
infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + jsId + '\')">Focus Terminal</button>';
} else {
infoHtml += '<button class="launch-btn" onclick="launchPiSession(\'' + jsId + '\',\'' + jsTool + '\',\'' + jsProject + '\')">Resume</button>';
if (s.tool === 'pi') {
infoHtml += '<button class="launch-btn" onclick="launchPiSession(\'' + jsId + '\',\'' + jsTool + '\',\'' + jsProject + '\')">Resume</button>';
} else {
infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + jsId + '\',\'' + jsTool + '\',\'' + jsProject + '\')">Resume</button>';
}
if (s.tool === 'claude') {
infoHtml += '<button class="launch-btn" style="background:var(--accent-orange);color:#000" onclick="launchDangerous(\'' + jsId + '\',\'' + jsProject + '\')" title="--dangerously-skip-permissions">Resume (skip perms)</button>';
}
Expand Down
35 changes: 22 additions & 13 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@ const pathLib = require('path');
const { repoRefreshManager } = require('./repo-refresh');
const { handleRepoRefreshRoute } = require('./repo-refresh-routes');

function isValidPiResumeTarget(sessionId, resumeTarget) {
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return false;
if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return false;
if (/['`$\\\n\r\0]/.test(resumeTarget)) return false;
const resolvedTarget = pathLib.resolve(resumeTarget);
const found = dataApi.findSessionFile(sessionId);
if (!found || found.format !== 'pi' || !found.file) return false;
return pathLib.resolve(found.file) === resolvedTarget;
const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;

function getValidatedPiResumeTarget(sessionId, resumeTarget, project) {
if (typeof sessionId !== 'string' || !SAFE_SESSION_ID.test(sessionId)) return '';
if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return '';
if (/['`$\\\n\r\0]/.test(resumeTarget)) return '';
const found = dataApi.findSessionFile(sessionId, project);
if (!found || found.format !== 'pi' || !found.file) return '';
try {
if (fs.lstatSync(found.file).isSymbolicLink()) return '';
} catch {
return '';
}
const resolvedFound = pathLib.resolve(found.file);
if (pathLib.resolve(resumeTarget) !== resolvedFound) return '';
return resolvedFound;
}

// ── Logging ──────────────────────────────────
Expand Down Expand Up @@ -178,11 +186,12 @@ function startServer(host, port, openBrowser = true) {
const parsed = JSON.parse(body);
const { sessionId, resumeTarget, tool, flags, project, terminal, mode, autoRegister } = parsed;
const fresh = mode === 'fresh';
let piResumeTarget = '';
if (!fresh) {
const isSafeId = /^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || ''));
const isSafeId = SAFE_SESSION_ID.test(String(sessionId || ''));
const hasResumeTarget = resumeTarget !== undefined && resumeTarget !== null && resumeTarget !== '';
const isSafePiTarget = tool === 'pi' && hasResumeTarget && isValidPiResumeTarget(sessionId, resumeTarget);
if (!isSafeId || (hasResumeTarget && !isSafePiTarget)) throw new Error('invalid sessionId');
piResumeTarget = tool === 'pi' && hasResumeTarget ? getValidatedPiResumeTarget(sessionId, resumeTarget, project) : '';
if (!isSafeId || (hasResumeTarget && !piResumeTarget)) throw new Error('invalid sessionId');
}
if (fresh && !project) {
throw new Error('project path required for fresh session');
Expand All @@ -199,7 +208,7 @@ function startServer(host, port, openBrowser = true) {
const knownTool = settingsApi.isKnownAgent(tool);
const detectedAgent = detection.agents.find(a => a.id === tool);
if (knownTool && !detectedAgent) {
throw new Error('agent not installed: ' + tool);
throw new Error('agent not installed');
}
const resolvedTool = knownTool ? tool : 'claude';
// Explicit allowlist for flags — element-level. Defense-in-depth in
Expand All @@ -212,7 +221,7 @@ function startServer(host, port, openBrowser = true) {
? detectedAgent.command
: undefined;
log('LAUNCH', `mode=${fresh ? 'fresh' : 'resume'} session=${sessionId || '(none)'} tool=${resolvedTool} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${safeFlags.join(',') || '(none)'}`);
openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume', launchCommand, fresh ? '' : (resumeTarget || ''));
openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume', launchCommand, fresh ? '' : piResumeTarget);

// Auto-register: when a fresh launch fires for a path under $HOME
// that is either a git repo or has been launched ≥2 times, add it
Expand Down
1 change: 1 addition & 0 deletions test/agents-detect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ test('detect prefers pi and falls back to omp for Pi', async () => {
assert.ok(fallbackPi, 'Pi should be detected by omp fallback binary');
assert.equal(fallbackPi.binPath, '/usr/local/bin/omp');
assert.equal(fallbackPi.command, 'omp');
assert.equal(fallbackPi.label, 'Pi/OhMyPi');
assert.deepEqual(fallbackPi.commands, ['omp']);
});

Expand Down
7 changes: 5 additions & 2 deletions test/frontend-escaping.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,23 @@ test('detail resume button uses JS-string escaping for project paths', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8');
assert.match(source, /var jsProject = escJsString\(s\.project \|\| ''\);/);
assert.ok(
source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"),
source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"),
'Resume onclick should pass jsProject, not raw escHtml(project)'
);
});

test('detail Pi resume button routes through launchPiSession for resume_target support', () => {
test('detail only Pi resume button routes through launchPiSession for resume_target support', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8');
assert.ok(source.includes("if (s.tool === 'pi')"));
assert.ok(source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"));
assert.ok(source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"));
assert.match(source, /resumeTarget: resumeTarget \|\| ''/);
});

test('frontend Pi resume commands use variant-specific shell-safe syntax', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8');
assert.match(source, /function quoteShellArg\(value\)/);
assert.match(source, /session && session\.agent_variant === 'ohmypi'/);
assert.match(source, /'omp --resume ' \+ quoteShellArg\(target\)/);
assert.match(source, /'pi --session ' \+ quoteShellArg\(target\)/);
assert.doesNotMatch(source, /JSON\.stringify\(target\)/);
Expand Down
26 changes: 26 additions & 0 deletions test/pi-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ test('parsePiSessionFile reads OMP header and message summary', () => {
assert.equal(summary.lastTs, Date.parse('2026-05-24T10:00:04.000Z'));
});

test('parsePiSessionFile rejects unsafe header ids', () => {
const dir = tmpDir();
const file = path.join(dir, 'sessions', '--tmp--project--', 'bad.jsonl');
writeJsonl(file, [
{ type: 'session', id: '../../claude-session', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' },
{ type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'bad id' } },
]);

assert.equal(parsePiSessionFile(file), null);
});


test('loadPiDetail returns role-compatible display messages with tokens', () => {
const dir = tmpDir();
const file = path.join(dir, 'sessions', '--tmp--project--', '2026_pi-session-2.jsonl');
Expand Down Expand Up @@ -125,6 +137,20 @@ test('scanPiSessions ignores malformed and non-OMP files', () => {
assert.equal(sessions[0].agent_variant, 'pi');
});

test('scanPiSessions ignores symlinked session files', () => {
const agentDir = tmpDir();
const outside = path.join(tmpDir(), 'outside.jsonl');
writeJsonl(outside, [
{ type: 'session', id: 'pi-symlink', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' },
{ type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'outside' } },
]);
const link = path.join(agentDir, 'sessions', '--tmp--project--', 'link.jsonl');
fs.mkdirSync(path.dirname(link), { recursive: true });
fs.symlinkSync(outside, link);

assert.deepEqual(scanPiSessions(agentDir), []);
});

test('scanPiSessions marks OhMyPi variant when scanning omp directory', () => {
const agentDir = tmpDir();
const file = path.join(agentDir, 'sessions', '--tmp--project--', '2026_omp-session-1.jsonl');
Expand Down
Loading