From c02b516101175371e79bc84bc9a9725321a9bcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs?= Date: Thu, 12 Mar 2026 20:27:44 -0300 Subject: [PATCH 1/2] fix(session): sanitize dots in tmux window names + regression tests (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(version): bump to 3.260310.5 [skip ci] * fix(session): sanitize dots in window names for tmux targeting tmux uses '.' as a pane separator in targets (session:window.pane), so folder names like 'ravi.bot' cause "can't find pane: bot" errors. Sanitize dots to dashes in deriveWindowName. * test(session): add regression tests for dot sanitization in window names Covers the fix for tmux "can't find pane" error when folder names contain dots (e.g. ravi.bot). Tests sanitizeWindowName as a pure function — no mock.module needed. * fix(test): use ESM import instead of require() in session tests Replace `const { basename } = require('node:path')` with top-level ESM import for consistency with the rest of the file. * fix(test): add complete export stubs to mock.module in idle-timeout tests Bun's mock.module replaces the entire module cache process-wide. When a mock only provides a subset of exports, unrelated test files that import the same module get "Export named 'X' not found" errors. Add stubs for all agent-registry and tmux exports to prevent cache corruption causing flaky CI failures. * fix(test): replace mock.module with dependency injection in idle-timeout Bun's mock.module corrupts the global module cache process-wide, causing "Export named 'X' not found" errors in unrelated test files. Refactored idle-timeout to accept dependencies via optional IdleDeps parameter (defaults to real implementations). Tests inject mocks directly — no mock.module needed anywhere in the codebase now. * fix(session): sanitize window name before collision lookup Dotted folder names (e.g. `foo.bar`) bypassed collision detection because `resolveWindowName` searched for the unsanitized basename while existing windows were stored with sanitized names (`foo-bar`). This caused `focusTeamWindow` to attach to an unrelated window running a different project. Move `sanitizeWindowName()` call inside `resolveWindowName` so the tmux lookup matches what's actually stored. The outer `sanitizeWindowName` in `deriveWindowName` is now a harmless no-op (idempotent). Adds regression tests for idempotency and dot-dash collision equivalence. * fix(spawn): sanitize dots in team names before tmux targeting Apply sanitizeWindowName to teamName in team-auto-spawn.ts before passing to ensureTeamWindow and constructing tmux target strings. Prevents "can't find pane" errors when team names contain dots. * fix(spawn): return sanitizeWindowName consistently in ensureTeamLead sanitizeTeamName lowercases + strips non-alnum, while the tmux window is created with sanitizeWindowName (dots only). Return the actual window name so callers target the correct tmux window. --------- Co-authored-by: github-actions[bot] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- src/genie-commands/__tests__/session.test.ts | 71 ++++++++++++++++- src/genie-commands/session.ts | 20 ++++- src/lib/idle-timeout.test.ts | 80 ++++++++++---------- src/lib/idle-timeout.ts | 41 +++++++--- src/lib/team-auto-spawn.ts | 12 +-- src/lib/version.ts | 2 +- 11 files changed, 169 insertions(+), 67 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0a0821605..8e3203bf5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260309.1", + "version": "3.260310.5", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 51d7e3db4..36747df90 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260309.1", + "version": "3.260310.5", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 32729ce25..0e786f293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260309.1", + "version": "3.260310.5", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 39451e7a0..8e0242457 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260309.1", + "version": "3.260310.5", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 787446b54..6fa287a3f 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260309.1", + "version": "3.260310.5", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/src/genie-commands/__tests__/session.test.ts b/src/genie-commands/__tests__/session.test.ts index 93956f838..705fd4687 100644 --- a/src/genie-commands/__tests__/session.test.ts +++ b/src/genie-commands/__tests__/session.test.ts @@ -9,8 +9,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { buildClaudeCommand, getAgentsSystemPrompt } from '../session.js'; +import { basename, join } from 'node:path'; +import { buildClaudeCommand, getAgentsSystemPrompt, sanitizeWindowName } from '../session.js'; // ============================================================================ // buildClaudeCommand tests @@ -115,3 +115,70 @@ describe('getAgentsSystemPrompt', () => { expect(result).toBe(content); }); }); + +// ============================================================================ +// sanitizeWindowName tests — regression for tmux dot-to-dash fix +// +// tmux uses '.' as a pane separator in targets (session:window.pane). +// Folder/team names containing dots (e.g. "ravi.bot") cause tmux errors +// like "can't find pane: bot". sanitizeWindowName replaces '.' with '-'. +// ============================================================================ + +describe('sanitizeWindowName', () => { + test('replaces dot with dash in team name (e.g. ravi.bot -> ravi-bot)', () => { + expect(sanitizeWindowName('ravi.bot')).toBe('ravi-bot'); + }); + + test('leaves names without dots unchanged', () => { + expect(sanitizeWindowName('my-team')).toBe('my-team'); + expect(sanitizeWindowName('genie')).toBe('genie'); + expect(sanitizeWindowName('api-server')).toBe('api-server'); + }); + + test('replaces multiple dots (e.g. my.cool.app -> my-cool-app)', () => { + expect(sanitizeWindowName('my.cool.app')).toBe('my-cool-app'); + }); + + test('handles leading and trailing dots', () => { + expect(sanitizeWindowName('.hidden')).toBe('-hidden'); + expect(sanitizeWindowName('trailing.')).toBe('trailing-'); + expect(sanitizeWindowName('.both.')).toBe('-both-'); + }); + + test('handles consecutive dots', () => { + expect(sanitizeWindowName('a..b')).toBe('a--b'); + }); + + test('handles directory basename with dot (simulates basename of /home/user/ravi.bot)', () => { + // In production, basename() is called before sanitizeWindowName, + // so we test the basename result directly. + const dirBasename = basename('/home/user/ravi.bot'); + expect(sanitizeWindowName(dirBasename)).toBe('ravi-bot'); + }); + + test('returns empty string for empty input', () => { + expect(sanitizeWindowName('')).toBe(''); + }); + + test('handles name that is only dots', () => { + expect(sanitizeWindowName('...')).toBe('---'); + }); + + test('sanitize is idempotent (double-sanitize is safe)', () => { + // resolveWindowName now pre-sanitizes, deriveWindowName sanitizes again. + // This must be safe (no-op on already-sanitized names). + const once = sanitizeWindowName('foo.bar'); + const twice = sanitizeWindowName(once); + expect(twice).toBe(once); + expect(twice).toBe('foo-bar'); + }); + + test('dotted folder collides with dashed folder after sanitize', () => { + // Regression: "foo.bar" and "foo-bar" must produce the same window name + // so that collision detection in resolveWindowName works correctly. + // Before the fix, resolveWindowName looked up "foo.bar" (unsanitized), + // missed the existing "foo-bar" window, and returned a name that + // sanitized back to "foo-bar" — attaching to the wrong project. + expect(sanitizeWindowName('foo.bar')).toBe(sanitizeWindowName('foo-bar')); + }); +}); diff --git a/src/genie-commands/session.ts b/src/genie-commands/session.ts index 0c45ff2f0..55c8b57d7 100644 --- a/src/genie-commands/session.ts +++ b/src/genie-commands/session.ts @@ -138,7 +138,10 @@ export function buildClaudeCommand(teamName: string, systemPrompt?: string, resu * 6. If no window exists -> use the base name */ async function resolveWindowName(sessionName: string, cwd: string): Promise { - const baseName = basename(cwd); + // Sanitize before lookup so collision detection matches what tmux actually stores. + // Without this, dotted folders (e.g. "foo.bar") bypass disambiguation because + // findWindowByName looks for "foo.bar" while the existing window is "foo-bar". + const baseName = sanitizeWindowName(basename(cwd)); const existing = await tmux.findWindowByName(sessionName, baseName); if (!existing) { @@ -235,11 +238,20 @@ async function focusTeamWindow( console.log(`Focused team window "${windowName}"`); } +/** + * Sanitize a window name for tmux targeting. + * tmux uses '.' as a pane separator in targets (session:window.pane), + * so dots in window names cause "can't find pane" errors. + */ +export function sanitizeWindowName(name: string): string { + return name.replace(/\./g, '-'); +} + async function deriveWindowName(sessionName: string, workspaceDir: string, team?: string): Promise { - if (team) return team; + if (team) return sanitizeWindowName(team); const existingSession = await tmux.findSessionByName(sessionName); - if (existingSession) return resolveWindowName(sessionName, workspaceDir); - return basename(workspaceDir); + if (existingSession) return sanitizeWindowName(await resolveWindowName(sessionName, workspaceDir)); + return sanitizeWindowName(basename(workspaceDir)); } async function handleReset(sessionName: string, windowName: string): Promise { diff --git a/src/lib/idle-timeout.test.ts b/src/lib/idle-timeout.test.ts index 793f53e4e..0fbe0dbd5 100644 --- a/src/lib/idle-timeout.test.ts +++ b/src/lib/idle-timeout.test.ts @@ -1,41 +1,39 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { Agent } from './agent-registry.js'; +import type { IdleDeps } from './idle-timeout.js'; +import { WATCHDOG_POLL_INTERVAL_MS, checkIdleWorkers, getIdleTimeoutMs, suspendWorker } from './idle-timeout.js'; // ============================================================================ -// Mock setup — must be before imports +// Mock deps — injected directly, no mock.module needed // ============================================================================ -const mockWorkers: any[] = []; -let mockUpdates: Array<{ id: string; updates: any }> = []; +let mockWorkers: Agent[] = []; +let mockUpdates: Array<{ id: string; updates: Partial }> = []; let mockExecuteTmuxCalls: string[] = []; let mockPaneAliveMap: Record = {}; -mock.module('./agent-registry.js', () => ({ - list: async () => mockWorkers, - get: async (id: string) => mockWorkers.find((w) => w.id === id) ?? null, - update: async (id: string, updates: any) => { - mockUpdates.push({ id, updates }); - const w = mockWorkers.find((w) => w.id === id); - if (w) Object.assign(w, updates); - }, -})); - -mock.module('./tmux.js', () => ({ - executeTmux: async (cmd: string) => { - mockExecuteTmuxCalls.push(cmd); - }, - isPaneAlive: async (paneId: string) => mockPaneAliveMap[paneId] ?? false, -})); - -// Import after mocks -const { suspendWorker, checkIdleWorkers, getIdleTimeoutMs, WATCHDOG_POLL_INTERVAL_MS } = await import( - './idle-timeout.js' -); +function makeDeps(): IdleDeps { + return { + registryGet: async (id: string) => mockWorkers.find((w) => w.id === id) ?? null, + registryList: async () => mockWorkers, + registryUpdate: async (id: string, updates: Partial) => { + mockUpdates.push({ id, updates }); + const w = mockWorkers.find((w) => w.id === id); + if (w) Object.assign(w, updates); + }, + executeTmux: async (cmd: string) => { + mockExecuteTmuxCalls.push(cmd); + return ''; + }, + isPaneAlive: async (paneId: string) => mockPaneAliveMap[paneId] ?? false, + }; +} // ============================================================================ // Helpers // ============================================================================ -function makeWorker(overrides: Partial = {}): any { +function makeWorker(overrides: Partial = {}): Agent { return { id: `test-worker-${Math.random().toString(36).slice(2, 8)}`, paneId: `%${Math.floor(Math.random() * 100)}`, @@ -48,7 +46,7 @@ function makeWorker(overrides: Partial = {}): any { transport: 'tmux', team: 'test-team', ...overrides, - }; + } as Agent; } // ============================================================================ @@ -57,7 +55,7 @@ function makeWorker(overrides: Partial = {}): any { describe('idle-timeout', () => { beforeEach(() => { - mockWorkers.length = 0; + mockWorkers = []; mockUpdates = []; mockExecuteTmuxCalls = []; mockPaneAliveMap = {}; @@ -107,14 +105,14 @@ describe('idle-timeout', () => { describe('suspendWorker', () => { test('returns false for non-existent worker', async () => { - const result = await suspendWorker('nonexistent'); + const result = await suspendWorker('nonexistent', makeDeps()); expect(result).toBe(false); }); test('returns true for already-suspended worker', async () => { const w = makeWorker({ state: 'suspended' }); mockWorkers.push(w); - const result = await suspendWorker(w.id); + const result = await suspendWorker(w.id, makeDeps()); expect(result).toBe(true); expect(mockExecuteTmuxCalls).toHaveLength(0); }); @@ -123,7 +121,7 @@ describe('idle-timeout', () => { const w = makeWorker({ paneId: '%42' }); mockWorkers.push(w); - const result = await suspendWorker(w.id); + const result = await suspendWorker(w.id, makeDeps()); expect(result).toBe(true); expect(mockExecuteTmuxCalls).toContain("kill-pane -t '%42'"); expect(mockUpdates).toEqual( @@ -140,7 +138,7 @@ describe('idle-timeout', () => { const w = makeWorker({ paneId: 'inline' }); mockWorkers.push(w); - const result = await suspendWorker(w.id); + const result = await suspendWorker(w.id, makeDeps()); expect(result).toBe(true); expect(mockExecuteTmuxCalls).toHaveLength(0); }); @@ -149,10 +147,10 @@ describe('idle-timeout', () => { const w = makeWorker(); mockWorkers.push(w); - await suspendWorker(w.id); + await suspendWorker(w.id, makeDeps()); const update = mockUpdates.find((u) => u.id === w.id); expect(update?.updates.suspendedAt).toBeDefined(); - const ts = new Date(update?.updates.suspendedAt).getTime(); + const ts = new Date(update?.updates.suspendedAt as string).getTime(); expect(ts).toBeGreaterThan(Date.now() - 5000); }); }); @@ -164,7 +162,7 @@ describe('idle-timeout', () => { mockWorkers.push(w); mockPaneAliveMap[w.paneId] = true; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toHaveLength(0); }); @@ -174,7 +172,7 @@ describe('idle-timeout', () => { mockWorkers.push(w); mockPaneAliveMap[w.paneId] = true; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toHaveLength(0); }); @@ -186,7 +184,7 @@ describe('idle-timeout', () => { }); mockWorkers.push(w); - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toHaveLength(0); }); @@ -198,7 +196,7 @@ describe('idle-timeout', () => { mockWorkers.push(w); mockPaneAliveMap[w.paneId] = true; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toHaveLength(0); }); @@ -210,7 +208,7 @@ describe('idle-timeout', () => { mockWorkers.push(w); mockPaneAliveMap[w.paneId] = true; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toContain(w.id); }); @@ -222,7 +220,7 @@ describe('idle-timeout', () => { mockWorkers.push(w); mockPaneAliveMap[w.paneId] = false; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toContain(w.id); // Should not have kill-pane call (pane already dead, goes direct update path) expect(mockExecuteTmuxCalls.filter((c) => c.includes('kill-pane'))).toHaveLength(0); @@ -244,7 +242,7 @@ describe('idle-timeout', () => { mockPaneAliveMap['%10'] = true; mockPaneAliveMap['%11'] = true; - const result = await checkIdleWorkers(); + const result = await checkIdleWorkers(makeDeps()); expect(result).toHaveLength(2); expect(result).toContain('worker-1'); expect(result).toContain('worker-2'); diff --git a/src/lib/idle-timeout.ts b/src/lib/idle-timeout.ts index 9b9cf30ba..15bb70c92 100644 --- a/src/lib/idle-timeout.ts +++ b/src/lib/idle-timeout.ts @@ -6,9 +6,32 @@ * workers preserve their Claude session ID for resume-on-message. */ +import type { Agent } from './agent-registry.js'; import * as registry from './agent-registry.js'; import { executeTmux, isPaneAlive } from './tmux.js'; +// ============================================================================ +// Dependency injection (testability without mock.module) +// ============================================================================ + +/** Dependencies used by idle-timeout functions. */ +export interface IdleDeps { + registryGet: (id: string) => Promise; + registryList: () => Promise; + registryUpdate: (id: string, updates: Partial) => Promise; + executeTmux: (cmd: string) => Promise; + isPaneAlive: (paneId: string) => Promise; +} + +/** Default production dependencies. */ +const defaultDeps: IdleDeps = { + registryGet: registry.get, + registryList: registry.list, + registryUpdate: registry.update, + executeTmux, + isPaneAlive, +}; + // ============================================================================ // Configuration // ============================================================================ @@ -43,22 +66,22 @@ export function getIdleTimeoutMs(): number { * * Returns true if the worker was successfully suspended. */ -export async function suspendWorker(workerId: string): Promise { - const worker = await registry.get(workerId); +export async function suspendWorker(workerId: string, deps: IdleDeps = defaultDeps): Promise { + const worker = await deps.registryGet(workerId); if (!worker) return false; if (worker.state === 'suspended') return true; // Kill tmux pane (best-effort) if (worker.paneId && worker.paneId !== 'inline') { try { - await executeTmux(`kill-pane -t '${worker.paneId}'`); + await deps.executeTmux(`kill-pane -t '${worker.paneId}'`); } catch { // Pane may already be dead } } // Update registry to suspended state - await registry.update(workerId, { + await deps.registryUpdate(workerId, { state: 'suspended', suspendedAt: new Date().toISOString(), }); @@ -74,11 +97,11 @@ export async function suspendWorker(workerId: string): Promise { * Check all workers and suspend any that have been idle too long. * Returns the list of worker IDs that were suspended. */ -export async function checkIdleWorkers(): Promise { +export async function checkIdleWorkers(deps: IdleDeps = defaultDeps): Promise { const timeoutMs = getIdleTimeoutMs(); if (timeoutMs === 0) return []; // Disabled - const workers = await registry.list(); + const workers = await deps.registryList(); const suspended: string[] = []; for (const w of workers) { @@ -89,10 +112,10 @@ export async function checkIdleWorkers(): Promise { if (idleMs < timeoutMs) continue; // Verify pane is still alive before suspending - const alive = await isPaneAlive(w.paneId); + const alive = await deps.isPaneAlive(w.paneId); if (!alive) { // Pane already dead — mark suspended directly - await registry.update(w.id, { + await deps.registryUpdate(w.id, { state: 'suspended', suspendedAt: new Date().toISOString(), }); @@ -100,7 +123,7 @@ export async function checkIdleWorkers(): Promise { continue; } - const ok = await suspendWorker(w.id); + const ok = await suspendWorker(w.id, deps); if (ok) suspended.push(w.id); } diff --git a/src/lib/team-auto-spawn.ts b/src/lib/team-auto-spawn.ts index 11d1a6ccf..ec2f08622 100644 --- a/src/lib/team-auto-spawn.ts +++ b/src/lib/team-auto-spawn.ts @@ -11,6 +11,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { sanitizeWindowName } from '../genie-commands/session.js'; import { ensureNativeTeam, loadConfig, registerNativeMember, sanitizeTeamName } from './claude-native-teams.js'; import { buildTeamLeadCommand, shellQuote } from './team-lead-command.js'; import * as tmux from './tmux.js'; @@ -88,7 +89,7 @@ async function isTeamActive(teamName: string): Promise { export async function ensureTeamLead(teamName: string, workingDir: string): Promise { // Fast path: team already active if (await isTeamActive(teamName)) { - return { created: false, session: DEFAULT_SESSION, window: sanitizeTeamName(teamName) }; + return { created: false, session: DEFAULT_SESSION, window: sanitizeWindowName(teamName) }; } // Create native team structure @@ -103,18 +104,19 @@ export async function ensureTeamLead(teamName: string, workingDir: string): Prom // Ensure tmux session exists const session = await ensureSession(); - // Create team window - const teamWindow = await tmux.ensureTeamWindow(session, teamName, workingDir); + // Create team window (sanitize dots — tmux interprets '.' as pane separator) + const windowName = sanitizeWindowName(teamName); + const teamWindow = await tmux.ensureTeamWindow(session, windowName, workingDir); if (teamWindow.created) { // Launch Claude Code in the new window const systemPrompt = getSystemPrompt(workingDir); - const target = `${session}:${teamName}`; + const target = `${session}:${windowName}`; const cdCmd = `cd ${shellQuote(workingDir)}`; await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cdCmd)} Enter`); const cmd = buildTeamLeadCommand(teamName, { systemPrompt: systemPrompt ?? undefined }); await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cmd)} Enter`); } - return { created: teamWindow.created, session, window: sanitizeTeamName(teamName) }; + return { created: teamWindow.created, session, window: windowName }; } diff --git a/src/lib/version.ts b/src/lib/version.ts index 4894e1efc..83aba1533 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Runtime version (baked in at build time) -export const VERSION = '3.260309.1'; +export const VERSION = '3.260310.5'; From ccd43f79152ad12e8fe5dbce470bb69e5b05ecaf Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 16:33:10 -0300 Subject: [PATCH 2/2] fix(spawn): pass CWD to tmux split-window via -c flag (#562) The worktree CWD override in handleWorkerSpawn set ctx.cwd correctly but tmux split-window inherited the parent pane's CWD instead. Adding -c flag ensures the spawned pane starts in the team's worktree. Co-Authored-By: Paperclip --- src/term-commands/agents.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index f0f46bbfa..e599252e5 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -502,7 +502,8 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise { let paneId: string; try { - const splitCmd = `tmux split-window -d ${splitTarget} -P -F '#{pane_id}' ${ctx.fullCommand}`; + const cwdFlag = ctx.cwd ? `-c '${ctx.cwd}'` : ''; + const splitCmd = `tmux split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' ${ctx.fullCommand}`; paneId = execSync(splitCmd, { encoding: 'utf-8' }).trim(); } catch (err) { console.error(`Failed to create tmux pane: ${err instanceof Error ? err.message : 'unknown error'}`);