mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
refactor: pass 2 — extract shared helpers and simplify patterns
app.js: - Add _clearTimer() helper replacing 11 inline clearTimeout patterns - Add _isStaleSelect() helper for generation check + cleanup - Replace 11 keyboard shortcut if-blocks with data-driven lookup table - Extract _cleanupPreviousSession() from selectSession() (~75 lines) - Extract _resetAllAppState() from handleInit() (~75 lines) tmux-manager: - Extract buildEnvExports() eliminating duplication in createSession/respawnPane - Extract buildPathExport() for CLI path resolution - Extract _configureOpenCode() for OpenCode setup routes: - Add readJsonConfig() to route-helpers, replacing 5 inline JSON-read patterns - Add validateSessionFilePath() to route-helpers, replacing 2 identical path traversal validation blocks in file-routes session-auto-ops: - Convert executeWhenIdle() from 8 positional params to options object - Extract validateThreshold() for shared compact/clear validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+57
-48
@@ -358,6 +358,52 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the array of environment export commands shared by createSession() and respawnPane().
|
||||||
|
* Includes locale, mux markers, session identity, and API URL.
|
||||||
|
*/
|
||||||
|
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
|
||||||
|
const exports = [
|
||||||
|
'export LANG=en_US.UTF-8',
|
||||||
|
'export LC_ALL=en_US.UTF-8',
|
||||||
|
'unset COLORTERM',
|
||||||
|
'export CODEMAN_MUX=1',
|
||||||
|
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||||
|
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||||
|
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||||||
|
];
|
||||||
|
// Only unset CLAUDECODE for Claude sessions
|
||||||
|
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the CLI binary directory and return the PATH export prefix string.
|
||||||
|
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
|
||||||
|
* In createSession(), a missing binary dir throws — the caller handles that separately.
|
||||||
|
*/
|
||||||
|
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
|
||||||
|
if (mode === 'claude') {
|
||||||
|
const dir = findClaudeDir();
|
||||||
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
|
}
|
||||||
|
if (mode === 'opencode') {
|
||||||
|
const dir = resolveOpenCodeDir();
|
||||||
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
|
}
|
||||||
|
return { pathExport: '', dir: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure OpenCode-specific environment on a tmux session.
|
||||||
|
* Sets sensitive API keys and config content via tmux setenv
|
||||||
|
* (not visible in ps output or tmux history, inherited by panes).
|
||||||
|
*/
|
||||||
|
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
|
||||||
|
setOpenCodeEnvVars(muxName);
|
||||||
|
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* In test mode: creates an in-memory session only (no real tmux session).
|
||||||
@@ -402,33 +448,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode
|
||||||
let pathExport = '';
|
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||||
if (mode === 'claude') {
|
if (mode === 'claude' && !cliDir) {
|
||||||
const claudeDir = findClaudeDir();
|
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||||
if (!claudeDir) {
|
}
|
||||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
if (mode === 'opencode' && !cliDir) {
|
||||||
}
|
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||||
pathExport = `export PATH="${claudeDir}:$PATH" && `;
|
|
||||||
} else if (mode === 'opencode') {
|
|
||||||
const openCodeDir = resolveOpenCodeDir();
|
|
||||||
if (!openCodeDir) {
|
|
||||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
|
||||||
}
|
|
||||||
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const envExports = [
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
'export LANG=en_US.UTF-8',
|
|
||||||
'export LC_ALL=en_US.UTF-8',
|
|
||||||
'unset COLORTERM',
|
|
||||||
'export CODEMAN_MUX=1',
|
|
||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
|
||||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
|
||||||
];
|
|
||||||
// Only unset CLAUDECODE for Claude sessions
|
|
||||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
|
||||||
const envExportsStr = envExports.join(' && ');
|
|
||||||
|
|
||||||
const baseCmd = buildSpawnCommand({
|
const baseCmd = buildSpawnCommand({
|
||||||
mode,
|
mode,
|
||||||
@@ -476,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// For OpenCode: set sensitive env vars and config via tmux setenv
|
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||||||
// (not visible in ps output or tmux history, inherited by panes)
|
// (not visible in ps output or tmux history, inherited by panes)
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
setOpenCodeEnvVars(muxName);
|
this._configureOpenCode(muxName, openCodeConfig);
|
||||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the shell with the actual command (no echo in terminal)
|
// Replace the shell with the actual command (no echo in terminal)
|
||||||
@@ -628,26 +655,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||||
|
|
||||||
// Resolve CLI binary directory based on mode
|
// Resolve CLI binary directory based on mode
|
||||||
let pathExport = '';
|
const { pathExport } = this.buildPathExport(mode);
|
||||||
if (mode === 'claude') {
|
|
||||||
const claudeDir = findClaudeDir();
|
|
||||||
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
|
||||||
} else if (mode === 'opencode') {
|
|
||||||
const openCodeDir = resolveOpenCodeDir();
|
|
||||||
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const envExports = [
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||||
'export LANG=en_US.UTF-8',
|
|
||||||
'export LC_ALL=en_US.UTF-8',
|
|
||||||
'unset COLORTERM',
|
|
||||||
'export CODEMAN_MUX=1',
|
|
||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
|
||||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
|
||||||
];
|
|
||||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
|
||||||
const envExportsStr = envExports.join(' && ');
|
|
||||||
|
|
||||||
const baseCmd = buildSpawnCommand({
|
const baseCmd = buildSpawnCommand({
|
||||||
mode,
|
mode,
|
||||||
@@ -665,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
try {
|
try {
|
||||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||||
if (mode === 'opencode') {
|
if (mode === 'opencode') {
|
||||||
setOpenCodeEnvVars(muxName);
|
this._configureOpenCode(muxName, openCodeConfig);
|
||||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||||
|
|||||||
+123
-139
@@ -432,6 +432,25 @@ class CodemanApp {
|
|||||||
return this._elemCache[id];
|
return this._elemCache[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; }
|
||||||
|
_clearTimer(timerName) {
|
||||||
|
if (this[timerName]) {
|
||||||
|
clearTimeout(this[timerName]);
|
||||||
|
this[timerName] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a selectSession generation is stale (a newer tab switch has started).
|
||||||
|
// If stale, cleans up buffer-loading state and returns true.
|
||||||
|
_isStaleSelect(selectGen) {
|
||||||
|
if (selectGen !== this._selectGeneration) {
|
||||||
|
if (this._isLoadingBuffer) this._finishBufferLoad();
|
||||||
|
this._restoringFlushedState = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
|
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
|
||||||
formatTokens(count) {
|
formatTokens(count) {
|
||||||
if (count >= 1000000) {
|
if (count >= 1000000) {
|
||||||
@@ -588,73 +607,43 @@ class CodemanApp {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
|
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
|
||||||
|
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
|
||||||
|
// shift? (require Shift), action }.
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||||
|
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
|
||||||
|
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||||
|
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||||
|
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
|
||||||
|
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||||
|
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||||
|
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||||
|
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||||
|
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||||
|
];
|
||||||
|
|
||||||
// Use capture to handle before terminal
|
// Use capture to handle before terminal
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Don't intercept keys during CJK IME composition
|
// Don't intercept keys during CJK IME composition
|
||||||
if (e.isComposing || e.keyCode === 229) return;
|
if (e.isComposing || e.keyCode === 229) return;
|
||||||
|
|
||||||
// Escape - close panels and modals
|
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
this.closeAllPanels();
|
this.closeAllPanels();
|
||||||
this.closeHelp();
|
this.closeHelp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd + ? - help
|
// Match against shortcut table
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) {
|
for (const s of SHORTCUTS) {
|
||||||
e.preventDefault();
|
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||||
this.showHelp();
|
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
||||||
}
|
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
||||||
|
if (keyMatch && ctrlMatch && shiftMatch) {
|
||||||
// Ctrl/Cmd + Enter - quick start
|
e.preventDefault();
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
s.action();
|
||||||
e.preventDefault();
|
return;
|
||||||
this.quickStart();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + W - close active session
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.killActiveSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + Tab - next session
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.nextSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + K - kill all
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.killAllSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + L - clear terminal
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.clearTerminal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + Shift + R - restore terminal size (after mobile squeeze)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.restoreTerminalSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + +/- - font size
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.increaseFontSize();
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.decreaseFontSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + Shift + V - toggle voice input
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'V') {
|
|
||||||
e.preventDefault();
|
|
||||||
VoiceInput.toggle();
|
|
||||||
}
|
}
|
||||||
}, true); // Use capture phase to handle before terminal
|
}, true); // Use capture phase to handle before terminal
|
||||||
|
|
||||||
@@ -682,10 +671,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending reconnect timeout to prevent duplicate connections
|
// Clear any pending reconnect timeout to prevent duplicate connections
|
||||||
if (this.sseReconnectTimeout) {
|
this._clearTimer('sseReconnectTimeout');
|
||||||
clearTimeout(this.sseReconnectTimeout);
|
|
||||||
this.sseReconnectTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
|
||||||
if (this._sseListenerCleanup) {
|
if (this._sseListenerCleanup) {
|
||||||
@@ -742,9 +728,7 @@ class CodemanApp {
|
|||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
|
||||||
if (this.sseReconnectTimeout) {
|
this._clearTimer('sseReconnectTimeout');
|
||||||
clearTimeout(this.sseReconnectTimeout);
|
|
||||||
}
|
|
||||||
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
|
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
|
||||||
// Fast first retry (200ms) for server-restart case (COM deploy),
|
// Fast first retry (200ms) for server-restart case (COM deploy),
|
||||||
// then ramp up for real network issues.
|
// then ramp up for real network issues.
|
||||||
@@ -1168,10 +1152,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
/** Close the active WebSocket connection (if any). */
|
/** Close the active WebSocket connection (if any). */
|
||||||
_disconnectWs() {
|
_disconnectWs() {
|
||||||
if (this._wsReconnectTimer) {
|
this._clearTimer('_wsReconnectTimer');
|
||||||
clearTimeout(this._wsReconnectTimer);
|
|
||||||
this._wsReconnectTimer = null;
|
|
||||||
}
|
|
||||||
this._wsReconnectAttempts = 0;
|
this._wsReconnectAttempts = 0;
|
||||||
if (this._ws) {
|
if (this._ws) {
|
||||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||||
@@ -1317,35 +1298,12 @@ class CodemanApp {
|
|||||||
if (!showCjk) window.cjkActive = false;
|
if (!showCjk) window.cjkActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInit(data) {
|
/**
|
||||||
// Clear the init fallback timer since we got data
|
* Reset all app state maps, timers, and handlers to a clean baseline.
|
||||||
if (this._initFallbackTimer) {
|
* Called by handleInit() on SSE reconnect / page reload to prevent
|
||||||
clearTimeout(this._initFallbackTimer);
|
* memory leaks and stale data.
|
||||||
this._initFallbackTimer = null;
|
*/
|
||||||
}
|
_resetAllAppState() {
|
||||||
const gen = ++this._initGeneration;
|
|
||||||
|
|
||||||
// CJK input form: controlled by user setting (with server env as override)
|
|
||||||
this._serverCjkOverride = data.inputCjkForm || false;
|
|
||||||
this._updateCjkInputState();
|
|
||||||
|
|
||||||
// Update version displays (header and toolbar)
|
|
||||||
if (data.version) {
|
|
||||||
const versionEl = this.$('versionDisplay');
|
|
||||||
const headerVersionEl = this.$('headerVersion');
|
|
||||||
if (versionEl) {
|
|
||||||
versionEl.textContent = `v${data.version}`;
|
|
||||||
versionEl.title = `Codeman v${data.version}`;
|
|
||||||
}
|
|
||||||
if (headerVersionEl) {
|
|
||||||
headerVersionEl.textContent = `v${data.version}`;
|
|
||||||
headerVersionEl.title = `Codeman v${data.version}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop any active voice recording on reconnect
|
|
||||||
VoiceInput.cleanup();
|
|
||||||
|
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.ralphStates.clear();
|
this.ralphStates.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
@@ -1359,17 +1317,11 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
this.idleTimers.clear();
|
this.idleTimers.clear();
|
||||||
// Clear flicker filter state
|
// Clear flicker filter state
|
||||||
if (this.flickerFilterTimeout) {
|
this._clearTimer('flickerFilterTimeout');
|
||||||
clearTimeout(this.flickerFilterTimeout);
|
|
||||||
this.flickerFilterTimeout = null;
|
|
||||||
}
|
|
||||||
this.flickerFilterBuffer = '';
|
this.flickerFilterBuffer = '';
|
||||||
this.flickerFilterActive = false;
|
this.flickerFilterActive = false;
|
||||||
// Clear pending terminal writes
|
// Clear pending terminal writes
|
||||||
if (this.syncWaitTimeout) {
|
this._clearTimer('syncWaitTimeout');
|
||||||
clearTimeout(this.syncWaitTimeout);
|
|
||||||
this.syncWaitTimeout = null;
|
|
||||||
}
|
|
||||||
this.pendingWrites = [];
|
this.pendingWrites = [];
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
@@ -1428,6 +1380,36 @@ class CodemanApp {
|
|||||||
clearInterval(this.runSummaryAutoRefreshTimer);
|
clearInterval(this.runSummaryAutoRefreshTimer);
|
||||||
this.runSummaryAutoRefreshTimer = null;
|
this.runSummaryAutoRefreshTimer = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInit(data) {
|
||||||
|
// Clear the init fallback timer since we got data
|
||||||
|
this._clearTimer('_initFallbackTimer');
|
||||||
|
const gen = ++this._initGeneration;
|
||||||
|
|
||||||
|
// CJK input form: controlled by user setting (with server env as override)
|
||||||
|
this._serverCjkOverride = data.inputCjkForm || false;
|
||||||
|
this._updateCjkInputState();
|
||||||
|
|
||||||
|
// Update version displays (header and toolbar)
|
||||||
|
if (data.version) {
|
||||||
|
const versionEl = this.$('versionDisplay');
|
||||||
|
const headerVersionEl = this.$('headerVersion');
|
||||||
|
if (versionEl) {
|
||||||
|
versionEl.textContent = `v${data.version}`;
|
||||||
|
versionEl.title = `Codeman v${data.version}`;
|
||||||
|
}
|
||||||
|
if (headerVersionEl) {
|
||||||
|
headerVersionEl.textContent = `v${data.version}`;
|
||||||
|
headerVersionEl.title = `Codeman v${data.version}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any active voice recording on reconnect
|
||||||
|
VoiceInput.cleanup();
|
||||||
|
|
||||||
|
this._resetAllAppState();
|
||||||
|
|
||||||
data.sessions.forEach(s => {
|
data.sessions.forEach(s => {
|
||||||
this.sessions.set(s.id, s);
|
this.sessions.set(s.id, s);
|
||||||
// Load ralph state from session data (only if not explicitly closed by user)
|
// Load ralph state from session data (only if not explicitly closed by user)
|
||||||
@@ -2001,23 +1983,13 @@ class CodemanApp {
|
|||||||
return this.getShortId(session.id);
|
return this.getShortId(session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectSession(sessionId) {
|
/**
|
||||||
if (this.activeSessionId === sessionId) return;
|
* Clean up state from the previous session before switching tabs.
|
||||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
* Handles: WebSocket teardown, CJK clear, flicker filter, tab completion,
|
||||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
* terminal write queue, IME composition, and local echo flush.
|
||||||
// After the first await the gesture context is lost and focus() is silently
|
* @param {string} newSessionId - The session being switched TO.
|
||||||
// ignored, leaving the keyboard unable to send input to the terminal.
|
*/
|
||||||
if (this.terminal) this.terminal.focus();
|
_cleanupPreviousSession(newSessionId) {
|
||||||
|
|
||||||
const _selStart = performance.now();
|
|
||||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
|
||||||
_crashDiag.log(`SELECT: ${_selName}`);
|
|
||||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
|
||||||
|
|
||||||
const selectGen = ++this._selectGeneration;
|
|
||||||
|
|
||||||
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
|
||||||
|
|
||||||
// Close WebSocket for previous session (new one opens after buffer load)
|
// Close WebSocket for previous session (new one opens after buffer load)
|
||||||
this._disconnectWs();
|
this._disconnectWs();
|
||||||
|
|
||||||
@@ -2026,10 +1998,7 @@ class CodemanApp {
|
|||||||
if (cjkEl) cjkEl.value = '';
|
if (cjkEl) cjkEl.value = '';
|
||||||
|
|
||||||
// Clean up flicker filter state when switching sessions
|
// Clean up flicker filter state when switching sessions
|
||||||
if (this.flickerFilterTimeout) {
|
this._clearTimer('flickerFilterTimeout');
|
||||||
clearTimeout(this.flickerFilterTimeout);
|
|
||||||
this.flickerFilterTimeout = null;
|
|
||||||
}
|
|
||||||
this.flickerFilterBuffer = '';
|
this.flickerFilterBuffer = '';
|
||||||
this.flickerFilterActive = false;
|
this.flickerFilterActive = false;
|
||||||
|
|
||||||
@@ -2037,14 +2006,11 @@ class CodemanApp {
|
|||||||
this._tabCompletionSessionId = null;
|
this._tabCompletionSessionId = null;
|
||||||
this._tabCompletionRetries = 0;
|
this._tabCompletionRetries = 0;
|
||||||
this._tabCompletionBaseText = null;
|
this._tabCompletionBaseText = null;
|
||||||
if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; }
|
this._clearTimer('_tabCompletionFallback');
|
||||||
if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; }
|
this._clearTimer('_clientDropRecoveryTimer');
|
||||||
|
|
||||||
// Clean up pending terminal writes to prevent old session data from appearing in new session
|
// Clean up pending terminal writes to prevent old session data from appearing in new session
|
||||||
if (this.syncWaitTimeout) {
|
this._clearTimer('syncWaitTimeout');
|
||||||
clearTimeout(this.syncWaitTimeout);
|
|
||||||
this.syncWaitTimeout = null;
|
|
||||||
}
|
|
||||||
this.pendingWrites = [];
|
this.pendingWrites = [];
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
@@ -2094,9 +2060,29 @@ class CodemanApp {
|
|||||||
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
// Only sessions with prior flushed text (from tab-switch-away) need detection.
|
||||||
// After the user's first Enter, clear() resets _bufferDetectDone = false,
|
// After the user's first Enter, clear() resets _bufferDetectDone = false,
|
||||||
// re-enabling detection for tab completion and other legitimate cases.
|
// re-enabling detection for tab completion and other legitimate cases.
|
||||||
if (this._localEchoOverlay && !this._flushedOffsets?.has(sessionId)) {
|
if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
|
||||||
this._localEchoOverlay.suppressBufferDetection();
|
this._localEchoOverlay.suppressBufferDetection();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSession(sessionId) {
|
||||||
|
if (this.activeSessionId === sessionId) return;
|
||||||
|
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||||
|
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||||
|
// After the first await the gesture context is lost and focus() is silently
|
||||||
|
// ignored, leaving the keyboard unable to send input to the terminal.
|
||||||
|
if (this.terminal) this.terminal.focus();
|
||||||
|
|
||||||
|
const _selStart = performance.now();
|
||||||
|
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||||
|
_crashDiag.log(`SELECT: ${_selName}`);
|
||||||
|
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||||
|
|
||||||
|
const selectGen = ++this._selectGeneration;
|
||||||
|
|
||||||
|
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
||||||
|
|
||||||
|
this._cleanupPreviousSession(sessionId);
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||||
this.hideWelcome();
|
this.hideWelcome();
|
||||||
@@ -2186,7 +2172,7 @@ class CodemanApp {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.reset();
|
this.terminal.reset();
|
||||||
await this.chunkedTerminalWrite(cachedBuffer);
|
await this.chunkedTerminalWrite(cachedBuffer);
|
||||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
if (this._isStaleSelect(selectGen)) return;
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
_crashDiag.log('CACHE_DONE');
|
_crashDiag.log('CACHE_DONE');
|
||||||
} else if (sessionIsBusy) {
|
} else if (sessionIsBusy) {
|
||||||
@@ -2198,7 +2184,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
_crashDiag.log('FETCH_START');
|
_crashDiag.log('FETCH_START');
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
if (this._isStaleSelect(selectGen)) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||||
|
|
||||||
@@ -2217,7 +2203,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
// Use chunked write for large buffers to avoid UI jank
|
// Use chunked write for large buffers to avoid UI jank
|
||||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||||
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
|
if (this._isStaleSelect(selectGen)) return;
|
||||||
// Ensure terminal is scrolled to bottom after buffer load
|
// Ensure terminal is scrolled to bottom after buffer load
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -2624,9 +2610,7 @@ class CodemanApp {
|
|||||||
updateTokens() {
|
updateTokens() {
|
||||||
// Debounce at 200ms — token display is non-critical and shouldn't
|
// Debounce at 200ms — token display is non-critical and shouldn't
|
||||||
// compete with input handling on the main thread
|
// compete with input handling on the main thread
|
||||||
if (this._updateTokensTimeout) {
|
this._clearTimer('_updateTokensTimeout');
|
||||||
clearTimeout(this._updateTokensTimeout);
|
|
||||||
}
|
|
||||||
this._updateTokensTimeout = setTimeout(() => {
|
this._updateTokensTimeout = setTimeout(() => {
|
||||||
this._updateTokensTimeout = null;
|
this._updateTokensTimeout = null;
|
||||||
this._updateTokensImmediate();
|
this._updateTokensImmediate();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||||
|
import { realpathSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
@@ -33,6 +35,45 @@ export function validatePathWithinBase(name: string, baseDir: string): string |
|
|||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and parses a JSON config file, returning a default value on ENOENT.
|
||||||
|
* Logs an error for any I/O failure other than a missing file.
|
||||||
|
*/
|
||||||
|
export async function readJsonConfig<T>(filePath: string, logLabel: string, defaultValue: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.error(`Failed to read ${logLabel}:`, err);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file path (possibly containing symlinks) resolves to a location
|
||||||
|
* within the given session working directory. Returns the resolved and relative paths,
|
||||||
|
* or null if the path escapes the directory or doesn't exist.
|
||||||
|
*/
|
||||||
|
export function validateSessionFilePath(
|
||||||
|
sessionWorkingDir: string,
|
||||||
|
filePath: string
|
||||||
|
): { resolvedPath: string; relativePath: string } | null {
|
||||||
|
const fullPath = resolve(sessionWorkingDir, filePath);
|
||||||
|
let resolvedPath: string;
|
||||||
|
try {
|
||||||
|
resolvedPath = realpathSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const relativePath = relative(sessionWorkingDir, resolvedPath);
|
||||||
|
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { resolvedPath, relativePath };
|
||||||
|
}
|
||||||
|
|
||||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
|
|||||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { writeHooksConfig } from '../../hooks-config.js';
|
import { writeHooksConfig } from '../../hooks-config.js';
|
||||||
import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js';
|
import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||||
|
|
||||||
@@ -22,15 +22,7 @@ const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
|||||||
|
|
||||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||||
try {
|
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||||
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
|
|
||||||
} catch (err) {
|
|
||||||
// Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON)
|
|
||||||
if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.warn('[Server] Failed to read linked cases:', err);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { realpathSync } from 'node:fs';
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
import { findSessionOrFail } from '../route-helpers.js';
|
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||||
import type { SessionPort } from '../ports/index.js';
|
import type { SessionPort } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||||
@@ -148,17 +147,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||||
const fullPath = resolve(session.workingDir, filePath);
|
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||||
let resolvedPath: string;
|
if (!validated) {
|
||||||
try {
|
|
||||||
resolvedPath = realpathSync(fullPath);
|
|
||||||
} catch {
|
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
||||||
}
|
}
|
||||||
const relativePath = relative(session.workingDir, resolvedPath);
|
const { resolvedPath } = validated;
|
||||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(resolvedPath);
|
const stat = await fs.stat(resolvedPath);
|
||||||
@@ -255,19 +248,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||||
const fullPath = resolve(session.workingDir, filePath);
|
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||||
let resolvedPath: string;
|
if (!validated) {
|
||||||
try {
|
|
||||||
resolvedPath = realpathSync(fullPath);
|
|
||||||
} catch {
|
|
||||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relativePath = relative(session.workingDir, resolvedPath);
|
const { resolvedPath } = validated;
|
||||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
||||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import { findSessionOrFail, formatUptime, parseBody, SETTINGS_PATH } from '../route-helpers.js';
|
import { findSessionOrFail, formatUptime, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
@@ -393,15 +393,7 @@ export function registerSystemRoutes(
|
|||||||
// ========== Settings ==========
|
// ========== Settings ==========
|
||||||
|
|
||||||
app.get('/api/settings', async () => {
|
app.get('/api/settings', async () => {
|
||||||
try {
|
return readJsonConfig(SETTINGS_PATH, 'settings', {});
|
||||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.error('Failed to read settings:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/settings', async (req) => {
|
app.put('/api/settings', async (req) => {
|
||||||
@@ -472,16 +464,8 @@ export function registerSystemRoutes(
|
|||||||
// ========== Model Configuration ==========
|
// ========== Model Configuration ==========
|
||||||
|
|
||||||
app.get('/api/execution/model-config', async () => {
|
app.get('/api/execution/model-config', async () => {
|
||||||
try {
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'model config', {});
|
||||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
return { success: true, data: settings.modelConfig || {} };
|
||||||
const settings = JSON.parse(content);
|
|
||||||
return { success: true, data: settings.modelConfig || {} };
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.error('Failed to read model config:', err);
|
|
||||||
}
|
|
||||||
return { success: true, data: {} };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/execution/model-config', async (req) => {
|
app.put('/api/execution/model-config', async (req) => {
|
||||||
@@ -545,15 +529,7 @@ export function registerSystemRoutes(
|
|||||||
// ========== Subagent Window State Persistence ==========
|
// ========== Subagent Window State Persistence ==========
|
||||||
|
|
||||||
app.get('/api/subagent-window-states', async () => {
|
app.get('/api/subagent-window-states', async () => {
|
||||||
try {
|
return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] });
|
||||||
const content = await fs.readFile(windowStatesPath, 'utf-8');
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.error('Failed to read subagent window states:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { minimized: {}, open: [] };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/subagent-window-states', async (req) => {
|
app.put('/api/subagent-window-states', async (req) => {
|
||||||
@@ -573,15 +549,7 @@ export function registerSystemRoutes(
|
|||||||
// ========== Subagent Parent Associations ==========
|
// ========== Subagent Parent Associations ==========
|
||||||
|
|
||||||
app.get('/api/subagent-parents', async () => {
|
app.get('/api/subagent-parents', async () => {
|
||||||
try {
|
return readJsonConfig(parentMapPath, 'subagent parent map', {});
|
||||||
const content = await fs.readFile(parentMapPath, 'utf-8');
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.error('Failed to read subagent parent map:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/subagent-parents', async (req) => {
|
app.put('/api/subagent-parents', async (req) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user