mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
chore: bump version to 0.1531
This commit is contained in:
@@ -35,7 +35,7 @@ When user says "COM":
|
|||||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||||
|
|
||||||
**Version**: 0.1530 (must match `package.json` for npm publish)
|
**Version**: 0.1531 (must match `package.json` for npm publish)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
|||||||
| **SSE events** | Search `broadcast(` in `server.ts` |
|
| **SSE events** | Search `broadcast(` in `server.ts` |
|
||||||
| **CLI commands** | `claudeman --help` |
|
| **CLI commands** | `claudeman --help` |
|
||||||
| **Frontend patterns** | `src/web/public/app.js` (subagent windows, notifications) |
|
| **Frontend patterns** | `src/web/public/app.js` (subagent windows, notifications) |
|
||||||
| **Session modes** | `SessionMode` type in `src/types.ts` |
|
| **Session statuses** | `SessionStatus` type in `src/types.ts` |
|
||||||
| **Error codes** | `createErrorResponse()` in `src/types.ts` |
|
| **Error codes** | `createErrorResponse()` in `src/types.ts` |
|
||||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||||
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
|
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1530",
|
"version": "0.1531",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, ChildProcess } from 'node:child_process';
|
import { spawn, ChildProcess } from 'node:child_process';
|
||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync, realpathSync } from 'node:fs';
|
||||||
import { resolve, relative, isAbsolute } from 'node:path';
|
import { resolve, relative, isAbsolute } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
@@ -158,7 +158,7 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
return { success: false, error: validationResult.error };
|
return { success: false, error: validationResult.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolutePath = validationResult.absolutePath!;
|
let absolutePath = validationResult.absolutePath!;
|
||||||
|
|
||||||
// Check file exists and size
|
// Check file exists and size
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +175,22 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
return { success: false, error: 'File not found or not accessible' };
|
return { success: false, error: 'File not found or not accessible' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-resolve symlinks right before spawn to minimize TOCTOU window.
|
||||||
|
// A symlink could have been swapped between validatePath() and here.
|
||||||
|
try {
|
||||||
|
const resolvedPath = realpathSync(absolutePath);
|
||||||
|
if (resolvedPath !== absolutePath) {
|
||||||
|
// Symlink target changed — re-validate against allowed paths
|
||||||
|
const recheck = this.validatePath(resolvedPath, workingDir);
|
||||||
|
if (!recheck.valid) {
|
||||||
|
return { success: false, error: recheck.error };
|
||||||
|
}
|
||||||
|
absolutePath = resolvedPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: 'File not found or not accessible' };
|
||||||
|
}
|
||||||
|
|
||||||
// Generate stream ID
|
// Generate stream ID
|
||||||
const streamId = `${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const streamId = `${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
@@ -363,19 +379,27 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve to absolute path
|
// Resolve to absolute path
|
||||||
const absolutePath = isAbsolute(expandedPath)
|
let absolutePath = isAbsolute(expandedPath)
|
||||||
? resolve(expandedPath)
|
? resolve(expandedPath)
|
||||||
: resolve(workingDir, expandedPath);
|
: resolve(workingDir, expandedPath);
|
||||||
|
|
||||||
|
// Resolve symlinks to prevent symlink attacks — validate the real target,
|
||||||
|
// not the symlink itself. Fall back to resolved path if file doesn't exist yet.
|
||||||
|
try {
|
||||||
|
absolutePath = realpathSync(absolutePath);
|
||||||
|
} catch {
|
||||||
|
// File may not exist yet (tail -f can wait); keep the resolved path
|
||||||
|
// which will be caught by the existsSync check below
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize the working directory
|
// Normalize the working directory
|
||||||
const normalizedWorkingDir = resolve(workingDir);
|
const normalizedWorkingDir = resolve(workingDir);
|
||||||
|
|
||||||
// Check if the resolved path is within the working directory
|
// Check if the resolved path is within the working directory
|
||||||
// or common log directories
|
// or common log directories (/tmp intentionally excluded — world-writable)
|
||||||
const allowedPaths = [
|
const allowedPaths = [
|
||||||
normalizedWorkingDir,
|
normalizedWorkingDir,
|
||||||
'/var/log',
|
'/var/log',
|
||||||
'/tmp',
|
|
||||||
resolve(homedir(), '.local/share'),
|
resolve(homedir(), '.local/share'),
|
||||||
resolve(homedir(), '.cache'),
|
resolve(homedir(), '.cache'),
|
||||||
resolve(homedir(), 'logs'),
|
resolve(homedir(), 'logs'),
|
||||||
@@ -393,10 +417,8 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal attempts
|
// Note: No need to check for '..' — resolve() already normalizes the path,
|
||||||
if (absolutePath.includes('..')) {
|
// and realpathSync() resolves symlinks. Both eliminate traversal sequences.
|
||||||
return { valid: false, error: 'Path traversal not allowed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file exists
|
// Check file exists
|
||||||
if (!existsSync(absolutePath)) {
|
if (!existsSync(absolutePath)) {
|
||||||
|
|||||||
+84
-4
@@ -136,6 +136,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
readonly backend = 'tmux' as const;
|
readonly backend = 'tmux' as const;
|
||||||
private sessions: Map<string, MuxSession> = new Map();
|
private sessions: Map<string, MuxSession> = new Map();
|
||||||
private statsInterval: NodeJS.Timeout | null = null;
|
private statsInterval: NodeJS.Timeout | null = null;
|
||||||
|
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||||||
|
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||||
|
private lastPaneCount: Map<string, number> = new Map();
|
||||||
|
|
||||||
private trueColorConfigured = false;
|
private trueColorConfigured = false;
|
||||||
|
|
||||||
@@ -298,14 +301,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
|
|
||||||
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
||||||
// These configure UX niceties (no status bar, mouse mode, true color).
|
// These configure UX niceties (no status bar, true color).
|
||||||
|
// Mouse mode is OFF by default so xterm.js handles text selection natively.
|
||||||
|
// It gets enabled dynamically when panes are split (agent teams).
|
||||||
const configPromises: Promise<void>[] = [
|
const configPromises: Promise<void>[] = [
|
||||||
// Disable tmux status bar — Claudeman's web UI provides session info
|
// Disable tmux status bar — Claudeman's web UI provides session info
|
||||||
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||||
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }),
|
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }),
|
||||||
// Enable mouse mode — allows clicking to select tmux panes
|
|
||||||
execAsync(`tmux set-option -t "${muxName}" mouse on`, { timeout: EXEC_TIMEOUT_MS })
|
|
||||||
.then(() => {}).catch(() => { /* Non-critical — pane clicking won't work but keyboard input still does */ }),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||||
@@ -535,6 +537,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lastPaneCount.delete(session.muxName);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
this.saveSessions();
|
this.saveSessions();
|
||||||
this.emit('sessionKilled', { sessionId });
|
this.emit('sessionKilled', { sessionId });
|
||||||
@@ -813,8 +816,47 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic mouse mode sync for all tracked sessions.
|
||||||
|
* Polls pane counts every 5s and toggles mouse on/off as needed.
|
||||||
|
* Only calls tmux set-option when the pane count actually changes.
|
||||||
|
*/
|
||||||
|
startMouseModeSync(intervalMs: number = 5000): void {
|
||||||
|
if (this.mouseSyncInterval) {
|
||||||
|
clearInterval(this.mouseSyncInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mouseSyncInterval = setInterval(() => {
|
||||||
|
if (IS_TEST_MODE) return;
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
const panes = this.listPanes(session.muxName);
|
||||||
|
const count = panes.length;
|
||||||
|
const prev = this.lastPaneCount.get(session.muxName);
|
||||||
|
|
||||||
|
// Only toggle when pane count crosses the 1↔N boundary
|
||||||
|
if (prev !== count && count > 0) {
|
||||||
|
this.lastPaneCount.set(session.muxName, count);
|
||||||
|
if (count > 1) {
|
||||||
|
this.enableMouseMode(session.muxName);
|
||||||
|
} else {
|
||||||
|
this.disableMouseMode(session.muxName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopMouseModeSync(): void {
|
||||||
|
if (this.mouseSyncInterval) {
|
||||||
|
clearInterval(this.mouseSyncInterval);
|
||||||
|
this.mouseSyncInterval = null;
|
||||||
|
}
|
||||||
|
this.lastPaneCount.clear();
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.stopStatsCollection();
|
this.stopStatsCollection();
|
||||||
|
this.stopMouseModeSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSession(session: MuxSession): void {
|
registerSession(session: MuxSession): void {
|
||||||
@@ -918,6 +960,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
/**
|
/**
|
||||||
* Enable mouse mode for an existing tmux session.
|
* Enable mouse mode for an existing tmux session.
|
||||||
* Allows clicking to select panes in agent team split-pane layouts.
|
* Allows clicking to select panes in agent team split-pane layouts.
|
||||||
|
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
|
||||||
*/
|
*/
|
||||||
enableMouseMode(muxName: string): boolean {
|
enableMouseMode(muxName: string): boolean {
|
||||||
if (IS_TEST_MODE) return true;
|
if (IS_TEST_MODE) return true;
|
||||||
@@ -937,6 +980,43 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable mouse mode for an existing tmux session.
|
||||||
|
* Restores native xterm.js text selection and browser clipboard copy.
|
||||||
|
*/
|
||||||
|
disableMouseMode(muxName: string): boolean {
|
||||||
|
if (IS_TEST_MODE) return true;
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`tmux set-option -t "${muxName}" mouse off`,
|
||||||
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync mouse mode based on pane count: enable if split (>1 pane), disable if single.
|
||||||
|
* Called by TeamWatcher when teammates spawn/despawn panes.
|
||||||
|
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
||||||
|
*/
|
||||||
|
syncMouseMode(muxName: string): boolean {
|
||||||
|
if (IS_TEST_MODE) return true;
|
||||||
|
const panes = this.listPanes(muxName);
|
||||||
|
if (panes.length > 1) {
|
||||||
|
return this.enableMouseMode(muxName);
|
||||||
|
} else {
|
||||||
|
return this.disableMouseMode(muxName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all panes in a tmux session.
|
* List all panes in a tmux session.
|
||||||
* Returns structured info for each pane.
|
* Returns structured info for each pane.
|
||||||
|
|||||||
+39
-22
@@ -1545,6 +1545,7 @@ class ClaudemanApp {
|
|||||||
this.initTerminal();
|
this.initTerminal();
|
||||||
this.loadFontSize();
|
this.loadFontSize();
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
// Remove mobile-init class now that JS has applied visibility settings.
|
// Remove mobile-init class now that JS has applied visibility settings.
|
||||||
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
||||||
@@ -1569,6 +1570,7 @@ class ClaudemanApp {
|
|||||||
// Load server-stored settings (async, re-applies visibility after load)
|
// Load server-stored settings (async, re-applies visibility after load)
|
||||||
this.loadAppSettingsFromServer().then(() => {
|
this.loadAppSettingsFromServer().then(() => {
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5034,10 +5036,11 @@ class ClaudemanApp {
|
|||||||
prompt += '## If Stuck\n';
|
prompt += '## If Stuck\n';
|
||||||
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
|
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
|
||||||
|
|
||||||
// Show preview with highlighting
|
// Show preview with highlighting (escape first, then apply formatting)
|
||||||
const highlightedPrompt = prompt
|
const escapedPrompt = this.escapeHtml(prompt);
|
||||||
.replace(/<promise>/g, '<span class="preview-highlight"><promise>')
|
const highlightedPrompt = escapedPrompt
|
||||||
.replace(/<\/promise>/g, '</promise></span>')
|
.replace(/<promise>/g, '<span class="preview-highlight"><promise>')
|
||||||
|
.replace(/<\/promise>/g, '</promise></span>')
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
preview.innerHTML = highlightedPrompt;
|
preview.innerHTML = highlightedPrompt;
|
||||||
@@ -5411,7 +5414,7 @@ class ClaudemanApp {
|
|||||||
if (metadata.synthesisStats?.sourceBreakdown) {
|
if (metadata.synthesisStats?.sourceBreakdown) {
|
||||||
const sources = metadata.synthesisStats.sourceBreakdown;
|
const sources = metadata.synthesisStats.sourceBreakdown;
|
||||||
const sourceList = Object.entries(sources)
|
const sourceList = Object.entries(sources)
|
||||||
.map(([src, count]) => `${src}: ${count}`)
|
.map(([src, count]) => `${this.escapeHtml(src)}: ${count}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
statsText += ` · Sources: ${sourceList}`;
|
statsText += ` · Sources: ${sourceList}`;
|
||||||
}
|
}
|
||||||
@@ -5426,10 +5429,10 @@ class ClaudemanApp {
|
|||||||
if (metadata?.verificationWarnings?.length > 0 || metadata?.verificationGaps?.length > 0) {
|
if (metadata?.verificationWarnings?.length > 0 || metadata?.verificationGaps?.length > 0) {
|
||||||
let warningsHtml = '';
|
let warningsHtml = '';
|
||||||
if (metadata.verificationGaps?.length > 0) {
|
if (metadata.verificationGaps?.length > 0) {
|
||||||
warningsHtml += `<div class="plan-gaps"><strong>Gaps identified:</strong> ${metadata.verificationGaps.join('; ')}</div>`;
|
warningsHtml += `<div class="plan-gaps"><strong>Gaps identified:</strong> ${metadata.verificationGaps.map(g => this.escapeHtml(g)).join('; ')}</div>`;
|
||||||
}
|
}
|
||||||
if (metadata.verificationWarnings?.length > 0) {
|
if (metadata.verificationWarnings?.length > 0) {
|
||||||
warningsHtml += `<div class="plan-warnings"><strong>Warnings:</strong> ${metadata.verificationWarnings.join('; ')}</div>`;
|
warningsHtml += `<div class="plan-warnings"><strong>Warnings:</strong> ${metadata.verificationWarnings.map(w => this.escapeHtml(w)).join('; ')}</div>`;
|
||||||
}
|
}
|
||||||
warningsEl.innerHTML = warningsHtml;
|
warningsEl.innerHTML = warningsHtml;
|
||||||
warningsEl.classList.remove('hidden');
|
warningsEl.classList.remove('hidden');
|
||||||
@@ -6005,9 +6008,9 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
win.innerHTML = `
|
win.innerHTML = `
|
||||||
<div class="plan-subagent-header">
|
<div class="plan-subagent-header">
|
||||||
<span class="plan-subagent-prompt-link" data-agent-id="${agentId}" data-agent-type="${agentType}" title="Click to view prompt">
|
<span class="plan-subagent-prompt-link" data-agent-id="${this.escapeHtml(agentId)}" data-agent-type="${this.escapeHtml(agentType)}" title="Click to view prompt">
|
||||||
<span class="plan-subagent-icon">${typeIcons[agentType] || '🤖'}</span>
|
<span class="plan-subagent-icon">${typeIcons[agentType] || '🤖'}</span>
|
||||||
<span class="plan-subagent-title">${typeLabels[agentType] || agentType}</span>
|
<span class="plan-subagent-title">${typeLabels[agentType] || this.escapeHtml(agentType)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="plan-subagent-model">${model}</span>
|
<span class="plan-subagent-model">${model}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -8086,8 +8089,8 @@ class ClaudemanApp {
|
|||||||
// Shorter timer name display
|
// Shorter timer name display
|
||||||
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
|
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
|
||||||
|
|
||||||
html += `<div class="respawn-countdown-timer" title="${timer.reason || ''}">
|
html += `<div class="respawn-countdown-timer" title="${this.escapeHtml(timer.reason || '')}">
|
||||||
<span class="timer-name">${displayName}</span>
|
<span class="timer-name">${this.escapeHtml(displayName)}</span>
|
||||||
<span class="timer-value">${remainingSec}s</span>
|
<span class="timer-value">${remainingSec}s</span>
|
||||||
<div class="respawn-timer-bar">
|
<div class="respawn-timer-bar">
|
||||||
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
|
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
|
||||||
@@ -8123,7 +8126,7 @@ class ClaudemanApp {
|
|||||||
html += `<div class="respawn-action-entry${extraClass}">
|
html += `<div class="respawn-action-entry${extraClass}">
|
||||||
<span class="action-time">${time}</span>
|
<span class="action-time">${time}</span>
|
||||||
<span class="action-type">[${action.type}]</span>
|
<span class="action-type">[${action.type}]</span>
|
||||||
<span class="action-detail">${action.detail}</span>
|
<span class="action-detail">${this.escapeHtml(action.detail)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9405,6 +9408,7 @@ class ClaudemanApp {
|
|||||||
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
||||||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
||||||
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
||||||
|
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? true;
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||||||
@@ -9530,6 +9534,7 @@ class ClaudemanApp {
|
|||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||||
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
@@ -9616,6 +9621,7 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
// Apply header visibility immediately
|
// Apply header visibility immediately
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applyTabWrapSettings();
|
||||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||||
@@ -9740,6 +9746,7 @@ class ClaudemanApp {
|
|||||||
subagentActiveTabOnly: true, // Only show subagents for active tab
|
subagentActiveTabOnly: true, // Only show subagents for active tab
|
||||||
imageWatcherEnabled: false,
|
imageWatcherEnabled: false,
|
||||||
ralphTrackerEnabled: false,
|
ralphTrackerEnabled: false,
|
||||||
|
tabTwoRows: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Desktop defaults - rely on ?? operators in apply functions
|
// Desktop defaults - rely on ?? operators in apply functions
|
||||||
@@ -9811,6 +9818,16 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyTabWrapSettings() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const twoRows = settings.tabTwoRows ?? defaults.tabTwoRows ?? true;
|
||||||
|
const tabsEl = document.getElementById('sessionTabs');
|
||||||
|
if (tabsEl) {
|
||||||
|
tabsEl.classList.toggle('tabs-single-row', !twoRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyMonitorVisibility() {
|
applyMonitorVisibility() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
@@ -9951,7 +9968,7 @@ class ClaudemanApp {
|
|||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentTrackingEnabled', 'subagentActiveTabOnly',
|
'subagentTrackingEnabled', 'subagentActiveTabOnly',
|
||||||
'imageWatcherEnabled', 'ralphTrackerEnabled',
|
'imageWatcherEnabled', 'ralphTrackerEnabled', 'tabTwoRows',
|
||||||
]);
|
]);
|
||||||
const filteredAppSettings = {};
|
const filteredAppSettings = {};
|
||||||
for (const [key, value] of Object.entries(appSettings)) {
|
for (const [key, value] of Object.entries(appSettings)) {
|
||||||
@@ -11349,19 +11366,19 @@ class ClaudemanApp {
|
|||||||
let html = `
|
let html = `
|
||||||
<div class="ralph-status-block-header">
|
<div class="ralph-status-block-header">
|
||||||
<span>RALPH_STATUS</span>
|
<span>RALPH_STATUS</span>
|
||||||
<span class="ralph-status-block-status ${statusClass}">${statusBlock.status}</span>
|
<span class="ralph-status-block-status ${statusClass}">${this.escapeHtml(statusBlock.status)}</span>
|
||||||
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
|
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="ralph-status-block-stats">
|
<div class="ralph-status-block-stats">
|
||||||
<span>${workIcon} ${statusBlock.workType}</span>
|
<span>${workIcon} ${this.escapeHtml(statusBlock.workType)}</span>
|
||||||
<span>📁 ${statusBlock.filesModified} files</span>
|
<span>📁 ${statusBlock.filesModified} files</span>
|
||||||
<span>✓ ${statusBlock.tasksCompletedThisLoop} tasks</span>
|
<span>✓ ${this.escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks</span>
|
||||||
<span>${testsIcon} Tests: ${statusBlock.testsStatus}</span>
|
<span>${testsIcon} Tests: ${this.escapeHtml(statusBlock.testsStatus)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (statusBlock.recommendation) {
|
if (statusBlock.recommendation) {
|
||||||
html += `<div class="ralph-status-block-recommendation">${statusBlock.recommendation}</div>`;
|
html += `<div class="ralph-status-block-recommendation">${this.escapeHtml(statusBlock.recommendation)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
@@ -11765,7 +11782,7 @@ class ClaudemanApp {
|
|||||||
const hasWindow = this.subagentWindows.has(agent.agentId);
|
const hasWindow = this.subagentWindows.has(agent.agentId);
|
||||||
const canKill = agent.status === 'active' || agent.status === 'idle';
|
const canKill = agent.status === 'active' || agent.status === 'idle';
|
||||||
const modelBadge = agent.modelShort
|
const modelBadge = agent.modelShort
|
||||||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
? `<span class="subagent-model-badge ${this.escapeHtml(agent.modelShort)}">${this.escapeHtml(agent.modelShort)}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const teammateInfo = this.getTeammateInfo(agent);
|
const teammateInfo = this.getTeammateInfo(agent);
|
||||||
@@ -11869,7 +11886,7 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
const detailTitle = agent.description || `Agent ${agent.agentId}`;
|
const detailTitle = agent.description || `Agent ${agent.agentId}`;
|
||||||
const modelBadge = agent.modelShort
|
const modelBadge = agent.modelShort
|
||||||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
? `<span class="subagent-model-badge ${this.escapeHtml(agent.modelShort)}">${this.escapeHtml(agent.modelShort)}</span>`
|
||||||
: '';
|
: '';
|
||||||
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
|
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
|
||||||
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
|
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
|
||||||
@@ -12001,14 +12018,14 @@ class ClaudemanApp {
|
|||||||
win.document.write(`
|
win.document.write(`
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Subagent ${agentId} Transcript</title>
|
<title>Subagent ${this.escapeHtml(agentId)} Transcript</title>
|
||||||
<style>
|
<style>
|
||||||
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
|
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
|
||||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>Subagent ${agentId} Transcript (${data.data.entryCount} entries)</h2>
|
<h2>Subagent ${this.escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)</h2>
|
||||||
<pre>${this.escapeHtml(content)}</pre>
|
<pre>${this.escapeHtml(content)}</pre>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -790,6 +790,16 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar Section -->
|
||||||
|
<div class="settings-section-header">Tab Bar</div>
|
||||||
|
<div class="settings-item" title="Allow tabs to wrap into two rows when there are many sessions">
|
||||||
|
<span class="settings-item-label">Two-Row Tabs</span>
|
||||||
|
<label class="switch switch-sm">
|
||||||
|
<input type="checkbox" id="appSettingsTabTwoRows" checked>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Panels Section -->
|
<!-- Panels Section -->
|
||||||
<div class="settings-section-header">Panels</div>
|
<div class="settings-section-header">Panels</div>
|
||||||
<div class="settings-item" title="Show Monitor panel at bottom right">
|
<div class="settings-item" title="Show Monitor panel at bottom right">
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ body {
|
|||||||
contain: layout;
|
contain: layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tabs.tabs-single-row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.session-tabs::-webkit-scrollbar {
|
.session-tabs::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-4
@@ -9,6 +9,56 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ========== Path Validation ==========
|
||||||
|
|
||||||
|
/** Regex to validate working directory paths (no shell metacharacters) — matches tmux-manager.ts */
|
||||||
|
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
|
||||||
|
|
||||||
|
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
|
||||||
|
export function isValidWorkingDir(p: string): boolean {
|
||||||
|
if (!p || !p.startsWith('/')) return false;
|
||||||
|
if (p.includes(';') || p.includes('&') || p.includes('|') ||
|
||||||
|
p.includes('$') || p.includes('`') || p.includes('(') ||
|
||||||
|
p.includes(')') || p.includes('{') || p.includes('}') ||
|
||||||
|
p.includes('<') || p.includes('>') || p.includes("'") ||
|
||||||
|
p.includes('"') || p.includes('\n') || p.includes('\r')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (p.includes('..')) return false;
|
||||||
|
return SAFE_PATH_PATTERN.test(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zod refinement for safe absolute path */
|
||||||
|
const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||||
|
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
|
/** Allowlisted env var key prefixes */
|
||||||
|
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_'];
|
||||||
|
|
||||||
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
|
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
|
||||||
|
'CLAUDEMAN_SCREEN_NAME', 'CLAUDEMAN_TMUX',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Validate that an env var key is allowed */
|
||||||
|
function isAllowedEnvKey(key: string): boolean {
|
||||||
|
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
||||||
|
return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zod schema for env overrides with allowlist enforcement */
|
||||||
|
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
return Object.keys(val).every(isAllowedEnvKey);
|
||||||
|
},
|
||||||
|
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* keys are allowed.' },
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Session Routes ==========
|
// ========== Session Routes ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,10 +66,10 @@ import { z } from 'zod';
|
|||||||
* Creates a new session with optional working directory, mode, and name.
|
* Creates a new session with optional working directory, mode, and name.
|
||||||
*/
|
*/
|
||||||
export const CreateSessionSchema = z.object({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: z.string().optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
mode: z.enum(['claude', 'shell']).optional(),
|
mode: z.enum(['claude', 'shell']).optional(),
|
||||||
name: z.string().max(100).optional(),
|
name: z.string().max(100).optional(),
|
||||||
envOverrides: z.record(z.string(), z.string()).optional(),
|
envOverrides: safeEnvOverridesSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,13 +253,13 @@ export const FlickerFilterSchema = z.object({
|
|||||||
/** POST /api/run */
|
/** POST /api/run */
|
||||||
export const QuickRunSchema = z.object({
|
export const QuickRunSchema = z.object({
|
||||||
prompt: z.string().min(1).max(100000),
|
prompt: z.string().min(1).max(100000),
|
||||||
workingDir: z.string().max(1000).optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** POST /api/scheduled */
|
/** POST /api/scheduled */
|
||||||
export const ScheduledRunSchema = z.object({
|
export const ScheduledRunSchema = z.object({
|
||||||
prompt: z.string().min(1).max(100000),
|
prompt: z.string().min(1).max(100000),
|
||||||
workingDir: z.string().max(1000).optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
durationMinutes: z.number().int().min(1).max(14400).optional(),
|
durationMinutes: z.number().int().min(1).max(14400).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+70
-3
@@ -15,7 +15,7 @@ import fastifyCompress from '@fastify/compress';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||||
@@ -558,13 +558,38 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security headers on every response
|
// Security headers + CORS on every response
|
||||||
this.app.addHook('onRequest', (_req, reply, done) => {
|
this.app.addHook('onRequest', (req, reply, done) => {
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
reply.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'");
|
||||||
if (this.https) {
|
if (this.https) {
|
||||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CORS: restrict to same-origin (localhost) only
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin) {
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
|
||||||
|
reply.header('Access-Control-Allow-Origin', origin);
|
||||||
|
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
reply.header('Access-Control-Max-Age', '86400');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid origin header — do not set CORS headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
reply.code(204).send();
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -742,6 +767,18 @@ export class WebServer extends EventEmitter {
|
|||||||
const body = result.data;
|
const body = result.data;
|
||||||
const workingDir = body.workingDir || process.cwd();
|
const workingDir = body.workingDir || process.cwd();
|
||||||
|
|
||||||
|
// Validate workingDir exists and is a directory
|
||||||
|
if (body.workingDir) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(workingDir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write env overrides to .claude/settings.local.json if provided
|
// Write env overrides to .claude/settings.local.json if provided
|
||||||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
||||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||||
@@ -2117,6 +2154,18 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const dir = workingDir || process.cwd();
|
const dir = workingDir || process.cwd();
|
||||||
|
|
||||||
|
// Validate workingDir exists and is a directory
|
||||||
|
if (workingDir) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(dir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = new Session({ workingDir: dir });
|
const session = new Session({ workingDir: dir });
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
this.store.incrementSessionsCreated();
|
this.store.incrementSessionsCreated();
|
||||||
@@ -2149,6 +2198,18 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const { prompt, workingDir, durationMinutes } = srResult.data;
|
const { prompt, workingDir, durationMinutes } = srResult.data;
|
||||||
|
|
||||||
|
// Validate workingDir exists and is a directory
|
||||||
|
if (workingDir) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(workingDir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||||||
return { success: true, run };
|
return { success: true, run };
|
||||||
});
|
});
|
||||||
@@ -5241,6 +5302,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
|
|
||||||
// Start stats collection to show screen info
|
// Start stats collection to show screen info
|
||||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count.
|
||||||
|
// Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts).
|
||||||
|
if (this.mux.backend === 'tmux' && 'startMouseModeSync' in this.mux) {
|
||||||
|
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dead.length > 0) {
|
if (dead.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user