diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2962c9ec..5d5b2fd7 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -98,6 +98,44 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/; /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; +/** + * Separator used in `tmux list-panes -F` output between session name and pid. + * + * Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution + * contexts (launchd on macOS, systemd without TTYPath) tmux can emit such + * escapes as the literal two characters `\` + letter rather than the control + * byte, breaking the parser and causing every tracked session to be classified + * as dead — which wipes state.json on restart. '|' is passed through verbatim + * in every environment and is rejected by tmux's own session-name validation, + * so it cannot appear inside `#{session_name}` and cause a false split. + */ +const PANE_LIST_SEP = '|'; + +/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */ +const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; + +/** + * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'` + * into a Map of session-name → pane pid. Exported for unit testing. + * + * - Skips empty lines and lines without the separator. + * - Skips entries with a non-numeric pid or empty name. + */ +export function parsePaneList(output: string): Map { + const result = new Map(); + for (const line of output.split('\n')) { + if (!line) continue; + const sep = line.indexOf(PANE_LIST_SEP); + if (sep === -1) continue; + const name = line.slice(0, sep); + const pid = parseInt(line.slice(sep + 1), 10); + if (name && !Number.isNaN(pid)) { + result.set(name, pid); + } + } + return result; +} + /** Characters unsafe in paths — shell metacharacters, quotes, and control chars */ const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/; @@ -944,23 +982,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const discovered: string[] = []; // Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls) - const activeSessions = new Map(); + let activeSessions = new Map(); try { - const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", { + const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); - - for (const line of output.split('\n')) { - if (!line) continue; - const sep = line.indexOf('\t'); - if (sep === -1) continue; - const name = line.slice(0, sep); - const pid = parseInt(line.slice(sep + 1), 10); - if (name && !Number.isNaN(pid)) { - activeSessions.set(name, pid); - } - } + activeSessions = parsePaneList(output); } catch (err) { console.error('[TmuxManager] Failed to list tmux panes:', err); } diff --git a/src/web/public/index.html b/src/web/public/index.html index 50daf7c7..0a744e40 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1095,6 +1095,18 @@ Use 1M token context window (model: opus[1m]) for all new sessions +
+ + + Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override) +
Nice Priority
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index e58aebba..4785eda7 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -92,6 +92,7 @@ const KeyboardAccessoryBar = { + @@ -125,7 +126,7 @@ const KeyboardAccessoryBar = { this.handleAction(action, btn); // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) - const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']); + const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']); if (refocusActions.has(action) || ((action === 'clear' || action === 'compact') && this._confirmAction)) { if (typeof app !== 'undefined' && app.terminal) { @@ -184,6 +185,9 @@ const KeyboardAccessoryBar = { case 'ctrl-o': this.sendKey('\x0f'); break; + case 'effort-max': + this.sendCommand('/effort max'); + break; case 'init': this.sendCommand('/init'); break; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 96d47a23..4e345d3f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -893,6 +893,7 @@ html.mobile-init .file-browser-panel { gap: 8px; align-items: center; overflow-x: auto; + overflow-y: hidden; -webkit-overflow-scrolling: touch; z-index: 51; transition: transform 0.15s ease-out; @@ -2097,6 +2098,7 @@ html.mobile-init .file-browser-panel { gap: 8px; align-items: center; overflow-x: auto; + overflow-y: hidden; -webkit-overflow-scrolling: touch; z-index: 51; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 3ee0ab24..a29a52be 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -334,6 +334,7 @@ Object.assign(CodemanApp.prototype, { // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; + document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; // CPU Priority settings const niceSettings = settings.nice || {}; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; @@ -1134,6 +1135,7 @@ Object.assign(CodemanApp.prototype, { // Claude Permissions settings agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, + thinkingEffort: document.getElementById('appSettingsThinkingEffort').value, // CPU Priority settings nice: { enabled: document.getElementById('appSettingsNiceEnabled').checked, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 2df8839b..ab851f25 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -268,6 +268,7 @@ export const SettingsUpdateSchema = z tunnelEnabled: z.boolean().optional(), tabTwoRows: z.boolean().optional(), agentTeamsEnabled: z.boolean().optional(), + thinkingEffort: z.string().max(20).optional(), // UI visibility showFontControls: z.boolean().optional(), showSystemStats: z.boolean().optional(), diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index c68e7764..4fe6ca6f 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -77,6 +77,10 @@ vi.mock('../src/session.js', () => { }; } + getEnvOverridesForPersist() { + return undefined; + } + getOutput() { return 'mock output'; } @@ -147,19 +151,14 @@ describe('SessionManager', () => { it('should persist session to store', async () => { const session = await manager.createSession('/tmp/test'); - expect(mockState.store.setSession).toHaveBeenCalledWith( - session.id, - expect.any(Object) - ); + expect(mockState.store.setSession).toHaveBeenCalledWith(session.id, expect.any(Object)); }); it('should throw when max sessions reached', async () => { mockState.store.state.config.maxConcurrentSessions = 1; await manager.createSession('/tmp/test1'); - await expect(manager.createSession('/tmp/test2')).rejects.toThrow( - /Maximum concurrent sessions/ - ); + await expect(manager.createSession('/tmp/test2')).rejects.toThrow(/Maximum concurrent sessions/); }); it('should forward session output events', async () => { @@ -325,9 +324,7 @@ describe('SessionManager', () => { describe('sendToSession', () => { it('should throw for non-existent session', async () => { - await expect(manager.sendToSession('non-existent', 'test')).rejects.toThrow( - /Session non-existent not found/ - ); + await expect(manager.sendToSession('non-existent', 'test')).rejects.toThrow(/Session non-existent not found/); }); it('should send input to session', async () => { diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index b93a207f..105a1a09 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { TmuxManager } from '../src/tmux-manager.js'; +import { TmuxManager, parsePaneList } from '../src/tmux-manager.js'; import { execSync } from 'node:child_process'; // ============================================================================ @@ -287,13 +287,27 @@ describe('TmuxManager (unit)', () => { }); it('should update respawn config', () => { - const config = { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true }; + const config = { + enabled: true, + idleTimeoutMs: 5000, + updatePrompt: 'test', + interStepDelayMs: 1000, + sendClear: true, + sendInit: true, + }; manager.updateRespawnConfig('meta-test', config); expect(manager.getSession('meta-test')?.respawnConfig).toEqual(config); }); it('should clear respawn config', () => { - manager.updateRespawnConfig('meta-test', { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true }); + manager.updateRespawnConfig('meta-test', { + enabled: true, + idleTimeoutMs: 5000, + updatePrompt: 'test', + interStepDelayMs: 1000, + sendClear: true, + sendInit: true, + }); manager.clearRespawnConfig('meta-test'); expect(manager.getSession('meta-test')?.respawnConfig).toBeUndefined(); }); @@ -327,8 +341,8 @@ describe('TmuxManager (unit)', () => { const sessions = manager.getSessions(); expect(sessions).toHaveLength(2); - expect(sessions.map(s => s.sessionId)).toContain('s1'); - expect(sessions.map(s => s.sessionId)).toContain('s2'); + expect(sessions.map((s) => s.sessionId)).toContain('s1'); + expect(sessions.map((s) => s.sessionId)).toContain('s2'); }); }); @@ -342,3 +356,68 @@ describe('TmuxManager (unit)', () => { }); }); +// ============================================================================ +// Parser Tests — locks in the '|' separator contract for `tmux list-panes -F` +// output, guarding against regressions in non-tty execution contexts where +// `\t` in tmux FORMAT strings can be emitted as the literal two characters +// `\` + `t` instead of a tab byte (launchd, systemd without TTYPath, docker +// exec without TTY). See PR #71. +// ============================================================================ + +describe('parsePaneList', () => { + it('parses well-formed output into name → pid', () => { + const out = 'codeman-aaaa|1234\ncodeman-bbbb|5678\nclaudeman-cccc|9999'; + const result = parsePaneList(out); + expect(result.size).toBe(3); + expect(result.get('codeman-aaaa')).toBe(1234); + expect(result.get('codeman-bbbb')).toBe(5678); + expect(result.get('claudeman-cccc')).toBe(9999); + }); + + it('returns an empty map for empty output', () => { + expect(parsePaneList('').size).toBe(0); + }); + + it('skips blank lines', () => { + const result = parsePaneList('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n'); + expect(result.size).toBe(2); + expect(result.get('codeman-aaaa')).toBe(100); + expect(result.get('codeman-bbbb')).toBe(200); + }); + + it('skips lines without the separator', () => { + const result = parsePaneList('codeman-aaaa 1234\ncodeman-bbbb|5678'); + expect(result.size).toBe(1); + expect(result.get('codeman-bbbb')).toBe(5678); + }); + + it('skips lines with a non-numeric pid', () => { + const result = parsePaneList('codeman-aaaa|notapid\ncodeman-bbbb|5678'); + expect(result.size).toBe(1); + expect(result.get('codeman-bbbb')).toBe(5678); + }); + + it('skips lines with an empty session name', () => { + const result = parsePaneList('|1234\ncodeman-bbbb|5678'); + expect(result.size).toBe(1); + expect(result.get('codeman-bbbb')).toBe(5678); + }); + + it('treats a literal backslash-t in input as part of the session name, not a delimiter', () => { + // Reproduces the launchd/systemd regression: under non-tty contexts tmux + // was emitting FORMAT '\t' as the two characters `\` + `t` rather than a + // tab byte. With the '|' separator, such literals must not be silently + // treated as a delimiter — the line is discarded because there is no '|'. + const literalBackslashT = 'codeman-aaaa\\t1234'; + const result = parsePaneList(literalBackslashT); + expect(result.size).toBe(0); + }); + + it('splits on the first separator only', () => { + // Numeric trailing junk after the pid is tolerated by parseInt — proves + // that splitting on the first '|' leaves the pid extractable even if a + // future tmux ever appended extra fields. + const result = parsePaneList('codeman-aaaa|1234|extra-field'); + expect(result.get('codeman-aaaa')).toBe(1234); + }); +});