diff --git a/src/frontend/app.js b/src/frontend/app.js
index 850c2f9..290b710 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -184,6 +184,17 @@ function escHtml(s) {
.replace(/'/g, ''');
}
+function escJsString(s) {
+ if (s === null || s === undefined) return '';
+ return escHtml(String(s)
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "\\'")
+ .replace(/\r/g, '\\r')
+ .replace(/\n/g, '\\n')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029'));
+}
+
function showToast(msg) {
const el = document.getElementById('toast');
if (!el) return;
@@ -849,8 +860,8 @@ function renderCard(s, idx) {
if (s.has_detail) {
var btnTitle = sessionTitles[s.id] ? 'Regenerate AI title' : 'Generate AI title';
var btnIcon = sessionTitles[s.id] ? '↻' : '⚛';
- html += '';
- html += '';
+ html += '';
+ html += '';
}
html += '';
// MCP/Skills footer
@@ -2042,7 +2053,7 @@ function renderRunningCard(a, s) {
html += '';
if (s) {
html += '';
- html += '';
+ html += '';
}
html += '';
html += '';
@@ -2065,7 +2076,7 @@ function renderDoneCard(s) {
if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
';
html += '';
html += '';
- html += '';
+ html += '';
html += '
';
html += '';
return html;
diff --git a/src/frontend/detail.js b/src/frontend/detail.js
index 3ea57d4..40a96ed 100644
--- a/src/frontend/detail.js
+++ b/src/frontend/detail.js
@@ -20,11 +20,13 @@ async function openDetail(s) {
var aiTitle = sessionTitles[s.id];
var sessionName = s.session_name || '';
var displayName = aiTitle || (sessionName ? sessionName.slice(0, 55) : '');
- var escProject = escHtml(s.project || '').replace(/'/g, "\\'");
+ var jsId = escJsString(s.id);
+ var jsTool = escJsString(s.tool);
+ var jsProject = escJsString(s.project || '');
if (displayName) {
- infoHtml += 'Name' + escHtml(displayName) + '
';
+ infoHtml += 'Name' + escHtml(displayName) + '
';
} else if (s.has_detail) {
- infoHtml += 'Name
';
+ infoHtml += 'Name
';
}
var detailToolLabel = getToolLabel(s.tool);
infoHtml += 'Tool' + escHtml(detailToolLabel) + '
';
@@ -68,28 +70,28 @@ async function openDetail(s) {
infoHtml += '';
// Tool-specific launch buttons
if (s.tool === 'cursor') {
- infoHtml += '';
+ infoHtml += '';
} else if (s.tool === 'copilot-chat') {
- infoHtml += '';
+ infoHtml += '';
} else if (activeSessions[s.id]) {
- infoHtml += '';
+ infoHtml += '';
} else {
- infoHtml += '';
+ infoHtml += '';
if (s.tool === 'claude') {
- infoHtml += '';
+ infoHtml += '';
}
}
- infoHtml += '';
+ infoHtml += '';
if (s.has_detail) {
- infoHtml += '';
- infoHtml += '';
+ infoHtml += '';
+ infoHtml += '';
getConvertTargets(s.tool).forEach(function(target) {
- infoHtml += '';
+ infoHtml += '';
});
- infoHtml += '';
+ infoHtml += '';
}
- infoHtml += '';
- infoHtml += '';
+ infoHtml += '';
+ infoHtml += '';
infoHtml += '
';
body.innerHTML = infoHtml + '';
diff --git a/src/terminals.js b/src/terminals.js
index d18f779..97a549c 100644
--- a/src/terminals.js
+++ b/src/terminals.js
@@ -192,41 +192,70 @@ function termLog(tag, msg) {
console.log(` ${color}${ts} [${tag}]\x1b[0m ${msg}`);
}
-function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
- const skipPerms = flags.includes('skip-permissions');
+function buildAgentCommand(sessionId, tool, flags, mode) {
+ const skipPerms = (flags || []).includes('skip-permissions');
const fresh = mode === 'fresh';
- let cmd;
if (fresh) {
// Start a brand new session in projectDir (no --resume). We map known
// tools to their CLI entry point; unrecognised tools fall back to claude
// so a misconfigured UI doesn't silently open the wrong agent.
switch (tool) {
- case 'codex': cmd = 'codex'; break;
- case 'qwen': cmd = 'qwen'; break;
- case 'kilo': cmd = 'kilo'; break;
- case 'kiro': cmd = 'kiro-cli'; break;
- case 'opencode': cmd = 'opencode'; break;
- case 'cursor': cmd = 'cursor-agent'; break;
+ case 'codex': return 'codex';
+ case 'qwen': return 'qwen';
+ case 'kilo': return 'kilo';
+ case 'kiro': return 'kiro-cli';
+ case 'opencode': return 'opencode';
+ case 'cursor': return 'cursor-agent';
case 'copilot':
- case 'copilot-chat': cmd = 'gh copilot suggest'; break;
+ case 'copilot-chat': return 'gh copilot suggest';
default:
- cmd = 'claude';
- if (skipPerms) cmd += ' --dangerously-skip-permissions';
+ return 'claude' + (skipPerms ? ' --dangerously-skip-permissions' : '');
}
- } else if (tool === 'codex') {
- cmd = `codex resume ${sessionId}`;
- } else if (tool === 'qwen') {
- cmd = `qwen -r ${sessionId}`;
- } else if (tool === 'kilo') {
- cmd = `kilo resume ${sessionId}`;
- } else {
- cmd = `claude --resume ${sessionId}`;
- if (skipPerms) cmd += ' --dangerously-skip-permissions';
}
+ if (tool === 'codex') return `codex resume ${sessionId}`;
+ if (tool === 'qwen') return `qwen -r ${sessionId}`;
+ if (tool === 'kilo') return `kilo resume ${sessionId}`;
+ return `claude --resume ${sessionId}` + (skipPerms ? ' --dangerously-skip-permissions' : '');
+}
+
+function buildPosixShellCommand(cmd, projectDir) {
const cdPart = projectDir ? `cd ${JSON.stringify(projectDir)} && ` : '';
- const fullCmd = cdPart + cmd;
+ return cdPart + cmd;
+}
+
+function quotePowerShellSingle(value) {
+ return "'" + String(value).replace(/'/g, "''") + "'";
+}
+
+function buildStartProcessArgs(filePath, argList, workingDirectory) {
+ const script = [
+ `Start-Process -FilePath ${quotePowerShellSingle(filePath)}`,
+ workingDirectory ? `-WorkingDirectory ${quotePowerShellSingle(workingDirectory)}` : '',
+ `-ArgumentList @(${argList.map(quotePowerShellSingle).join(',')})`,
+ ].filter(Boolean).join(' ');
+ return ['-NoProfile', '-Command', script];
+}
+
+function buildWindowsCmdStartArgs(cmd, projectDir) {
+ return buildStartProcessArgs('cmd.exe', ['/k', cmd], projectDir);
+}
+
+function buildWindowsPowerShellStartArgs(cmd, projectDir) {
+ return buildStartProcessArgs('powershell.exe', ['-NoExit', '-NoProfile', '-Command', cmd], projectDir);
+}
+
+function buildWindowsTerminalArgs(cmd, projectDir) {
+ const args = ['new-tab'];
+ if (projectDir) args.push('--startingDirectory', projectDir);
+ args.push('cmd.exe', '/k', cmd);
+ return args;
+}
+
+function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
+ const cmd = buildAgentCommand(sessionId, tool, flags, mode);
+ const fullCmd = buildPosixShellCommand(cmd, projectDir);
const escapedCmd = fullCmd.replace(/"/g, '\\"');
termLog('TERM', `openInTerminal: terminal=${terminalId || 'default'} tool=${tool} cmd="${fullCmd}"`);
@@ -407,13 +436,13 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
} else {
switch (terminalId) {
case 'powershell':
- exec(`start powershell -NoExit -Command "${fullCmd}"`);
+ execFileSafe('powershell.exe', buildWindowsPowerShellStartArgs(cmd, projectDir));
break;
case 'windows-terminal':
- exec(`wt new-tab cmd /k "${fullCmd}"`);
+ execFileSafe('wt.exe', buildWindowsTerminalArgs(cmd, projectDir));
break;
default:
- exec(`start cmd /k "${fullCmd}"`);
+ execFileSafe('powershell.exe', buildWindowsCmdStartArgs(cmd, projectDir));
break;
}
}
@@ -1019,4 +1048,17 @@ function focusTerminalByPid(pid, sessionId) {
return { ok: false };
}
-module.exports = { detectTerminals, openInTerminal, focusTerminalByPid, isWSL };
+module.exports = {
+ detectTerminals,
+ openInTerminal,
+ focusTerminalByPid,
+ isWSL,
+ __test: {
+ buildAgentCommand,
+ buildPosixShellCommand,
+ buildWindowsCmdStartArgs,
+ buildWindowsPowerShellStartArgs,
+ buildWindowsTerminalArgs,
+ quotePowerShellSingle,
+ },
+};
diff --git a/test/frontend-escaping.test.js b/test/frontend-escaping.test.js
new file mode 100644
index 0000000..ea62d16
--- /dev/null
+++ b/test/frontend-escaping.test.js
@@ -0,0 +1,30 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+function loadEscapers() {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8');
+ const match = source.match(/function escHtml[\s\S]*?\nfunction showToast/);
+ assert.ok(match, 'escaper block should be present');
+ const block = match[0].replace(/\nfunction showToast[\s\S]*$/, '');
+ const ctx = {};
+ vm.runInNewContext(block + '\nthis.escJsString = escJsString;', ctx);
+ return ctx;
+}
+
+test('escJsString preserves Windows backslashes for inline onclick arguments', () => {
+ const { escJsString } = loadEscapers();
+ assert.equal(escJsString('C:\\projects'), 'C:\\\\projects');
+ assert.equal(escJsString("C:\\Users\\O'Neil\\repo"), 'C:\\\\Users\\\\O\\'Neil\\\\repo');
+});
+
+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("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"),
+ 'Resume onclick should pass jsProject, not raw escHtml(project)'
+ );
+});
diff --git a/test/terminals-windows-launch.test.js b/test/terminals-windows-launch.test.js
new file mode 100644
index 0000000..be7525e
--- /dev/null
+++ b/test/terminals-windows-launch.test.js
@@ -0,0 +1,35 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { __test } = require('../src/terminals');
+
+test('Windows cmd launcher uses WorkingDirectory instead of nested cd quoting', () => {
+ const cmd = __test.buildAgentCommand('019e2039-7e0d-7582-9116-39d0d2713e36', 'codex', [], 'resume');
+ const args = __test.buildWindowsCmdStartArgs(cmd, 'C:\\projects');
+ const script = args[2];
+
+ assert.equal(cmd, 'codex resume 019e2039-7e0d-7582-9116-39d0d2713e36');
+ assert.deepEqual(args.slice(0, 2), ['-NoProfile', '-Command']);
+ assert.match(script, /Start-Process -FilePath 'cmd\.exe'/);
+ assert.match(script, /-WorkingDirectory 'C:\\projects'/);
+ assert.match(script, /-ArgumentList @\('\/k','codex resume 019e2039-7e0d-7582-9116-39d0d2713e36'\)/);
+ assert.equal(script.includes('cd "C:\\projects" &&'), false);
+});
+
+test('Windows Terminal launcher passes cwd as startingDirectory', () => {
+ const cmd = __test.buildAgentCommand('abc123', 'codex', [], 'resume');
+ assert.deepEqual(
+ __test.buildWindowsTerminalArgs(cmd, 'C:\\projects\\codbash'),
+ ['new-tab', '--startingDirectory', 'C:\\projects\\codbash', 'cmd.exe', '/k', 'codex resume abc123']
+ );
+});
+
+test('Windows PowerShell launcher also uses WorkingDirectory', () => {
+ const cmd = __test.buildAgentCommand('abc123', 'claude', ['skip-permissions'], 'resume');
+ const script = __test.buildWindowsPowerShellStartArgs(cmd, 'C:\\projects\\codbash')[2];
+
+ assert.match(script, /Start-Process -FilePath 'powershell\.exe'/);
+ assert.match(script, /-WorkingDirectory 'C:\\projects\\codbash'/);
+ assert.match(script, /'-NoExit','-NoProfile','-Command','claude --resume abc123 --dangerously-skip-permissions'/);
+ assert.equal(script.includes('&&'), false);
+});