mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge master into refactor/envoverrides-tmux-export
Resolved conflict in src/web/public/session-ui.js by keeping this PR's buildEnvOverrides() helper — it already covers both CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline block is fully replaced. Also fixed test/session-manager.test.ts MockSession to add a getEnvOverridesForPersist() stub — without it, SessionManager.updateSessionState's new call breaks 19 tests with "TypeError: session.getEnvOverridesForPersist is not a function". Verified: typecheck, lint, format:check, build, and test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+41
-13
@@ -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<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
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<string, number>();
|
||||
let activeSessions = new Map<string, number>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1095,6 +1095,18 @@
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Thinking Effort</label>
|
||||
<select id="appSettingsThinkingEffort" class="form-select">
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">XHigh</option>
|
||||
<option value="max">Max</option>
|
||||
</select>
|
||||
<span class="form-hint">Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override)</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
|
||||
@@ -92,6 +92,7 @@ const KeyboardAccessoryBar = {
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user