diff --git a/src/agents-detect.js b/src/agents-detect.js
index 29c6f9f..bec455d 100644
--- a/src/agents-detect.js
+++ b/src/agents-detect.js
@@ -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' },
diff --git a/src/data.js b/src/data.js
index a3b4472..016c202 100644
--- a/src/data.js
+++ b/src/data.js
@@ -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) {
@@ -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;
@@ -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;
@@ -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 = {};
@@ -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) {
@@ -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;
@@ -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 {}
}
diff --git a/src/frontend/app.js b/src/frontend/app.js
index 7597f5e..ffeb8cb 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -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);
}
diff --git a/src/frontend/detail.js b/src/frontend/detail.js
index bed5fc9..6563e95 100644
--- a/src/frontend/detail.js
+++ b/src/frontend/detail.js
@@ -76,7 +76,11 @@ async function openDetail(s) {
} else if (activeSessions[s.id]) {
infoHtml += '';
} else {
- infoHtml += '';
+ if (s.tool === 'pi') {
+ infoHtml += '';
+ } else {
+ infoHtml += '';
+ }
if (s.tool === 'claude') {
infoHtml += '';
}
diff --git a/src/server.js b/src/server.js
index fc25aae..0bf294d 100644
--- a/src/server.js
+++ b/src/server.js
@@ -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 ──────────────────────────────────
@@ -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');
@@ -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
@@ -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
diff --git a/test/agents-detect.test.js b/test/agents-detect.test.js
index 24f76b8..19ba197 100644
--- a/test/agents-detect.test.js
+++ b/test/agents-detect.test.js
@@ -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']);
});
diff --git a/test/frontend-escaping.test.js b/test/frontend-escaping.test.js
index b9f1bc5..275129e 100644
--- a/test/frontend-escaping.test.js
+++ b/test/frontend-escaping.test.js
@@ -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\)/);
diff --git a/test/pi-session.test.js b/test/pi-session.test.js
index 16bd703..bc31295 100644
--- a/test/pi-session.test.js
+++ b/test/pi-session.test.js
@@ -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');
@@ -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');