diff --git a/src/session.ts b/src/session.ts
index 38328dd8..97f54059 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -989,6 +989,7 @@ export class Session extends EventEmitter {
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
this._promptCheckTimeout = setTimeout(() => {
this._promptCheckTimeout = null;
+ if (this._isStopped) return;
this._status = 'idle';
this.emit('needsRefresh');
}, 3000);
@@ -1035,6 +1036,10 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if mux is not used
if (!this.ptyProcess) {
+ // OpenCode sessions require tmux for env var injection (API keys via setenv)
+ if (this.mode === 'opencode') {
+ throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
+ }
try {
// Pass --session-id to use the SAME ID as the Claudeman session
// This ensures subagents can be directly matched to the correct tab
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 7b2627d9..94731a77 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -258,7 +258,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
* const manager = new TmuxManager();
*
* // Create a tmux session for Claude
- * const session = await manager.createSession(sessionId, '/project', 'claude');
+ * const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
*
* // Send input (single command, no delay!)
* manager.sendInput(sessionId, '/clear\r');
diff --git a/src/web/public/index.html b/src/web/public/index.html
index d9818b26..9d1cf6af 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -510,7 +510,7 @@
-
+
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index 8ef87ba6..78082c12 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -70,7 +70,10 @@ const OpenCodeConfigSchema = z.object({
autoAllowTools: z.boolean().optional(),
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
forkSession: z.boolean().optional(),
- configContent: z.string().max(10000).optional(),
+ configContent: z.string().max(10000).refine(
+ (val) => { try { JSON.parse(val); return true; } catch { return false; } },
+ { message: 'configContent must be valid JSON' },
+ ).optional(),
}).optional();
export const CreateSessionSchema = z.object({
diff --git a/src/web/server.ts b/src/web/server.ts
index 16c78cbd..08077521 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -2026,10 +2026,14 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
+ // Respawn is not supported for opencode sessions
+ if (session.mode === 'opencode') {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
+ }
+
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
- // Ralph tracker is not supported for opencode sessions
- if (session.mode !== 'opencode' && this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
+ if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
autoConfigureRalph(session, session.workingDir, () => {});
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
@@ -2084,6 +2088,11 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
+ // Respawn is not supported for opencode sessions
+ if (session.mode === 'opencode') {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
+ }
+
// Check if session is running (has a PID)
if (!session.pid) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts
new file mode 100644
index 00000000..af330777
--- /dev/null
+++ b/test/opencode-resize.test.ts
@@ -0,0 +1,345 @@
+/**
+ * OpenCode session UI tests
+ *
+ * Tests OpenCode-specific UI behavior:
+ * - Initial terminal resize (not stuck at 120x40)
+ * - Close modal shows "Kill Tmux & OpenCode" (not "Claude Code")
+ * - needsRefresh handler sends resize
+ *
+ * Port: 3211 (opencode UI tests)
+ *
+ * Run: npx vitest run test/opencode-resize.test.ts
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const PORT = 3211;
+const BASE_URL = `http://localhost:${PORT}`;
+
+let server: WebServer;
+let browser: Browser;
+
+async function freshPage(): Promise<{ context: BrowserContext; page: Page }> {
+ const context = await browser.newContext({
+ viewport: { width: 1280, height: 800 },
+ });
+ const page = await context.newPage();
+ return { context, page };
+}
+
+async function navigateAndWait(page: Page): Promise {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => document.body.classList.contains('app-loaded'), {
+ timeout: 5000,
+ });
+}
+
+beforeAll(async () => {
+ server = new WebServer(PORT, false, true); // testMode
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+}, 30_000);
+
+afterAll(async () => {
+ await browser?.close();
+ await server?.stop();
+}, 30_000);
+
+describe('OpenCode session initial resize', () => {
+ let context: BrowserContext;
+ let page: Page;
+
+ afterAll(async () => {
+ await context?.close();
+ });
+
+ it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => {
+ // This test verifies at the code level that runOpenCode does NOT
+ // pre-set activeSessionId before calling selectSession.
+ // If it did, selectSession would early-return and skip sendResize.
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Read the runOpenCode source from the live app and verify
+ // it doesn't assign activeSessionId before selectSession
+ const hasPreAssignment = await page.evaluate(() => {
+ const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app;
+ const source = app.runOpenCode.toString();
+
+ // Check: the source should NOT have activeSessionId = ... before selectSession
+ // Find positions of both patterns
+ const assignIdx = source.indexOf('this.activeSessionId = data.sessionId');
+ const selectIdx = source.indexOf('this.selectSession(data.sessionId)');
+
+ // If assign doesn't exist at all, that's the correct fix
+ if (assignIdx === -1) return false;
+
+ // If assign comes before select, that's the bug
+ return assignIdx < selectIdx;
+ });
+
+ expect(hasPreAssignment).toBe(false);
+ });
+
+ it('sends resize to server after creating a session via quick-start', async () => {
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Intercept resize API calls to track when they happen
+ const resizeCalls: Array<{ url: string; cols: number; rows: number }> = [];
+ await page.route('**/api/sessions/*/resize', async (route) => {
+ const request = route.request();
+ const body = request.postDataJSON();
+ resizeCalls.push({
+ url: request.url(),
+ cols: body.cols,
+ rows: body.rows,
+ });
+ // Let the request through to the server
+ await route.continue();
+ });
+
+ // Create a session via API (simulating what quick-start does)
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }),
+ });
+ const data = await res.json();
+ return data.id ?? data.session?.id;
+ });
+
+ expect(sessionId).toBeTruthy();
+
+ // Call selectSession (which is what runOpenCode does after fix)
+ await page.evaluate(async (sid: string) => {
+ const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app;
+ await app.selectSession(sid);
+ }, sessionId);
+
+ // Wait for the resize to be sent (it's fire-and-forget in selectSession)
+ await page.waitForTimeout(500);
+
+ // Verify resize was called with reasonable dimensions (not 120x40 default)
+ expect(resizeCalls.length).toBeGreaterThanOrEqual(1);
+ const lastResize = resizeCalls[resizeCalls.length - 1];
+ expect(lastResize.url).toContain(sessionId);
+ // Browser viewport is 1280x800 — terminal cols/rows should be substantially
+ // different from the hardcoded 120x40 default. xterm.js calculates these
+ // from container dimensions and cell size, but in headless mode with a
+ // 1280x800 viewport, we should get something reasonable (>= 40 cols).
+ expect(lastResize.cols).toBeGreaterThanOrEqual(40);
+ expect(lastResize.rows).toBeGreaterThanOrEqual(10);
+
+ console.log(`[opencode-resize] resize sent: ${lastResize.cols}x${lastResize.rows}`);
+
+ // Cleanup
+ await page.evaluate(async (sid: string) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+
+ it('selectSession does NOT early-return for a new session', async () => {
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Create a session
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }),
+ });
+ const data = await res.json();
+ return data.id ?? data.session?.id;
+ });
+
+ expect(sessionId).toBeTruthy();
+
+ // Verify activeSessionId is NOT the new session before selectSession
+ const activeBeforeSelect = await page.evaluate(() => {
+ const app = (window as unknown as { app: { activeSessionId: string | null } }).app;
+ return app.activeSessionId;
+ });
+
+ // activeSessionId should be null or empty (welcome screen) — not our session
+ expect(activeBeforeSelect).not.toBe(sessionId);
+
+ // Now call selectSession and verify it actually runs (sets activeSessionId)
+ await page.evaluate(async (sid: string) => {
+ const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app;
+ await app.selectSession(sid);
+ }, sessionId);
+
+ const activeAfterSelect = await page.evaluate(() => {
+ const app = (window as unknown as { app: { activeSessionId: string | null } }).app;
+ return app.activeSessionId;
+ });
+
+ expect(activeAfterSelect).toBe(sessionId);
+
+ // Cleanup
+ await page.evaluate(async (sid: string) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+
+ it('needsRefresh handler includes sendResize call', async () => {
+ // The needsRefresh handler is registered inside a closure (connectSSE),
+ // so we can't directly invoke it from tests. Instead, verify that the
+ // handler source code dispatched to the EventSource includes sendResize.
+ // This is a structural test — if the handler code changes, this test
+ // ensures the resize call is preserved.
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Dispatch a needsRefresh event on the EventSource and intercept
+ // the resulting resize API call
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }),
+ });
+ const data = await res.json();
+ return data.id ?? data.session?.id;
+ });
+
+ expect(sessionId).toBeTruthy();
+
+ // Select the session first so activeSessionId is set
+ await page.evaluate(async (sid: string) => {
+ const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app;
+ await app.selectSession(sid);
+ }, sessionId);
+
+ await page.waitForTimeout(300);
+
+ // Intercept resize calls
+ const resizeCalls: Array<{ url: string }> = [];
+ await page.route('**/api/sessions/*/resize', async (route) => {
+ resizeCalls.push({ url: route.request().url() });
+ await route.continue();
+ });
+
+ // Dispatch the needsRefresh event directly on the EventSource
+ // (this is how the server sends SSE events — as named events)
+ await page.evaluate((sid: string) => {
+ const app = (window as unknown as { app: { eventSource: EventSource } }).app;
+ if (app.eventSource) {
+ const event = new MessageEvent('session:needsRefresh', {
+ data: JSON.stringify({ id: sid }),
+ });
+ app.eventSource.dispatchEvent(event);
+ }
+ }, sessionId);
+
+ // Wait for the async handler (fetches /terminal buffer + sends resize)
+ await page.waitForTimeout(1500);
+
+ // Verify resize was called
+ expect(resizeCalls.length).toBeGreaterThanOrEqual(1);
+ console.log(`[opencode-resize] needsRefresh triggered ${resizeCalls.length} resize call(s)`);
+
+ // Cleanup
+ await page.route('**/api/sessions/*/resize', (route) => route.continue());
+ await page.evaluate(async (sid: string) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+});
+
+describe('OpenCode close modal text', () => {
+ let context: BrowserContext;
+ let page: Page;
+
+ afterAll(async () => {
+ await context?.close();
+ });
+
+ it('shows "Kill Tmux & OpenCode" for opencode sessions', async () => {
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Create a session and mark it as opencode mode
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }),
+ });
+ const data = await res.json();
+ return data.id ?? data.session?.id;
+ });
+
+ expect(sessionId).toBeTruthy();
+
+ // Wait for SSE to propagate the session
+ await page.waitForTimeout(500);
+
+ // Open the close confirmation modal
+ await page.evaluate((sid: string) => {
+ const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app;
+ app.requestCloseSession(sid);
+ }, sessionId);
+
+ // Check the kill button text
+ const killTitle = await page.locator('#closeConfirmKillTitle').textContent();
+ expect(killTitle).toBe('Kill Tmux & OpenCode');
+
+ // Close the modal
+ await page.evaluate(() => {
+ const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app;
+ app.cancelCloseSession();
+ });
+
+ // Cleanup
+ await page.evaluate(async (sid: string) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+
+ it('shows "Kill Tmux & Claude Code" for claude sessions', async () => {
+ ({ context, page } = await freshPage());
+ await navigateAndWait(page);
+
+ // Create a standard claude session
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }),
+ });
+ const data = await res.json();
+ return data.id ?? data.session?.id;
+ });
+
+ expect(sessionId).toBeTruthy();
+
+ await page.waitForTimeout(500);
+
+ // Open the close confirmation modal
+ await page.evaluate((sid: string) => {
+ const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app;
+ app.requestCloseSession(sid);
+ }, sessionId);
+
+ // Check the kill button text
+ const killTitle = await page.locator('#closeConfirmKillTitle').textContent();
+ expect(killTitle).toBe('Kill Tmux & Claude Code');
+
+ // Close the modal
+ await page.evaluate(() => {
+ const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app;
+ app.cancelCloseSession();
+ });
+
+ // Cleanup
+ await page.evaluate(async (sid: string) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+});