From 58335f355f0dae1b10fbd5d72a69b756b5c6c787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Sat, 6 Jun 2026 05:49:37 +0000 Subject: [PATCH 1/2] Default shell terminals to PTY daemon --- apps/daemon/src/app.ts | 9 ++- apps/daemon/src/index.ts | 6 +- apps/daemon/src/pty-session-cleanup.ts | 14 ++++ docs/operations/config-reference.md | 2 +- .../src/create-agent-session.test.ts | 76 ++++++++++++++++++- .../operations/src/create-agent-session.ts | 2 +- packages/operations/src/index.ts | 6 +- .../operations/src/pty-session-cleanup.ts | 8 ++ 8 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 apps/daemon/src/pty-session-cleanup.ts create mode 100644 packages/operations/src/pty-session-cleanup.ts diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index bb997c28..d659fa58 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -71,6 +71,7 @@ import { registerPrRoutes } from "./pr-routes.js"; import { createProviderCache } from "./provider-cache.js"; import { startProviderRefreshJob } from "./provider-refresh-job.js"; import { ensurePtyDaemon } from "./pty-daemon-supervisor.js"; +import { closePtyDaemonSession } from "./pty-session-cleanup.js"; import { wireRateLimitBackgroundResume } from "./rate-limit-background-resume.js"; import { workspaceAppHookSample } from "./readiness.js"; import { registerRepoDiscoveryRoutes } from "./repo-discovery-routes.js"; @@ -198,7 +199,13 @@ export async function createDaemonApp(input: { emit: (type, payload) => emit(type, payload), providerCache, }); - const operations = input.operations ?? new OperationService(store, config, runAutoTransitions); + const operations = + input.operations ?? + new OperationService( + store, + { ...config, closePtySession: (session) => closePtyDaemonSession(session) }, + runAutoTransitions, + ); // Always-on structured diagnostics. Writes JSONL to /diagnostics.jsonl // (rotated at 50 MB) and keeps the last 1000 events in memory for the // Settings → Debug panel + the /api/diagnostics/bundle.tar.gz download. diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 8ba4e876..7dd7c093 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -7,6 +7,7 @@ import { createDaemonApp } from "./app.js"; import { runBootRestore } from "./boot-restore.js"; import { shouldReapTmuxOrphans } from "./orphan-reaper-safety.js"; import { reapOrphans } from "./orphan-reaper.js"; +import { closePtyDaemonSession } from "./pty-session-cleanup.js"; // Resolve the worktree root before loading config. When running inside a // Citadel checkout, env always wins over dev.json; dev.json wins over an @@ -79,7 +80,10 @@ if ( } const store = new SqliteStore(config.databasePath); store.migrate(); -const operations = new OperationService(store, config); +const operations = new OperationService(store, { + ...config, + closePtySession: (session) => closePtyDaemonSession(session), +}); const layoutMigration = operations.runWorkspaceLayoutMigrations(); if (layoutMigration.operationId && (layoutMigration.migrated > 0 || layoutMigration.skipped.length > 0)) { console.log( diff --git a/apps/daemon/src/pty-session-cleanup.ts b/apps/daemon/src/pty-session-cleanup.ts new file mode 100644 index 00000000..b96d994d --- /dev/null +++ b/apps/daemon/src/pty-session-cleanup.ts @@ -0,0 +1,14 @@ +import type { WorkspaceSession } from "@citadel/contracts"; +import { connectPtyDaemonClient } from "@citadel/terminal"; + +export async function closePtyDaemonSession(session: WorkspaceSession): Promise { + if (session.terminalBackend !== "pty-daemon") return; + if (!session.ptySessionId) return; + if (!session.ptyOwnerSocket) return; + const client = await connectPtyDaemonClient({ socketPath: session.ptyOwnerSocket, timeoutMs: 1000 }); + try { + client.closeSession(session.ptySessionId); + } finally { + client.dispose(); + } +} diff --git a/docs/operations/config-reference.md b/docs/operations/config-reference.md index 4c598aab..d5b96b55 100644 --- a/docs/operations/config-reference.md +++ b/docs/operations/config-reference.md @@ -52,7 +52,7 @@ Agent runtimes are prompt-driven command adapters launched through tmux: } ``` -Built-in agent defaults include `claude-code`, `codex`, `cursor-agent`, and `pi`. Plain shell is the singular `terminal` profile, not an agent runtime. Codex defaults to `--yolo` so interactive launches use the CLI's no-approval/no-sandbox mode; edit or clear the runtime args in Settings to change that. Citadel keeps `--enable goals` on the Codex runtime so all Citadel-launched Codex sessions use the experimental goals feature. Agent runtime health is derived from command availability. Workspace sessions persist tmux session name/id for reconnect. +Built-in agent defaults include `claude-code`, `codex`, `cursor-agent`, and `pi`. Plain shell is the singular `terminal` profile, not an agent runtime. New plain shell terminal sessions use the low-latency PTY daemon backend by default; set `CITADEL_TERMINAL_BACKEND=tmux` only as a legacy rollback for new shell terminals. Existing tmux-backed workspace sessions keep their persisted tmux session name/id until closed. Codex defaults to `--yolo` so interactive launches use the CLI's no-approval/no-sandbox mode; edit or clear the runtime args in Settings to change that. Citadel keeps `--enable goals` on the Codex runtime so all Citadel-launched Codex sessions use the experimental goals feature. Agent runtime health is derived from command availability. `agentSessions.baseSystemPrompt` is a single global prompt prefix configured from Settings -> Agents. Freestyle agent sessions use it as their system prompt. Specialized role sessions receive the global base prompt first and the Agents-tab role template system prompt second. Public API/MCP callers may provide supplemental system-prompt text for their own launch, but they cannot suppress the global base prompt or provide trusted role/source metadata. diff --git a/packages/operations/src/create-agent-session.test.ts b/packages/operations/src/create-agent-session.test.ts index c5eb6330..6c993b89 100644 --- a/packages/operations/src/create-agent-session.test.ts +++ b/packages/operations/src/create-agent-session.test.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { WorkspaceSession } from "@citadel/contracts"; import { SqliteStore } from "@citadel/db"; import { codexSqliteHomeForWorkspace } from "@citadel/runtimes"; import { afterEach, describe, expect, it } from "vitest"; @@ -133,6 +134,7 @@ describe("createAgentSession session-id wiring", () => { expect(store.listWorkspaceSessions(created.workspaceId).find((s) => s.id === session.id)).toMatchObject({ kind: "terminal", runtimeId: null, + terminalBackend: "pty-daemon", }); expect(store.listActivity(created.workspaceId).find((event) => event.type === "terminal.started")).toBeDefined(); expect(store.listActivity(created.workspaceId).find((event) => event.type === "agent.started")).toBeUndefined(); @@ -141,9 +143,9 @@ describe("createAgentSession session-id wiring", () => { } }, 15_000); - it("creates feature-flagged PTY-daemon terminal rows without spawning tmux", async () => { + it("creates PTY-daemon terminal rows by default without spawning tmux", async () => { const previous = process.env.CITADEL_TERMINAL_BACKEND; - process.env.CITADEL_TERMINAL_BACKEND = "pty-daemon"; + Reflect.deleteProperty(process.env, "CITADEL_TERMINAL_BACKEND"); try { const fixture = createGitFixture(); const store = new SqliteStore(path.join(fixture.dir, "citadel.sqlite")); @@ -172,6 +174,71 @@ describe("createAgentSession session-id wiring", () => { } }); + it("keeps legacy tmux available only through the explicit rollback env", async () => { + const previous = process.env.CITADEL_TERMINAL_BACKEND; + process.env.CITADEL_TERMINAL_BACKEND = "tmux"; + try { + const fixture = createGitFixture(); + const store = new SqliteStore(path.join(fixture.dir, "citadel.sqlite")); + store.migrate(); + const service = makeService(store); + const repo = service.registerRepo({ rootPath: fixture.repoPath }); + const created = await service.createWorkspace({ repoId: repo.id, name: "terminal-tmux", source: "scratch" }); + + const session = await service.createTerminalSession({ workspaceId: created.workspaceId }); + try { + expect(session).toMatchObject({ + kind: "terminal", + runtimeId: null, + terminalBackend: "tmux", + tmuxSessionName: expect.any(String), + ptySessionId: null, + }); + } finally { + service.stopWorkspaceSession({ sessionId: session.id }); + } + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, "CITADEL_TERMINAL_BACKEND"); + else process.env.CITADEL_TERMINAL_BACKEND = previous; + } + }, 15_000); + + it("asks the PTY owner to close a PTY-daemon session before clearing row ownership", async () => { + const fixture = createGitFixture(); + const store = new SqliteStore(path.join(fixture.dir, "citadel.sqlite")); + store.migrate(); + const closed: WorkspaceSession[] = []; + const service = makeService(store, undefined, { + closePtySession: (session) => { + closed.push(session); + }, + }); + const repo = service.registerRepo({ rootPath: fixture.repoPath }); + const created = await service.createWorkspace({ repoId: repo.id, name: "terminal-close-pty", source: "scratch" }); + const session = await service.createTerminalSession({ workspaceId: created.workspaceId }); + store.updateWorkspaceSessionTerminalOwner(session.id, { + ptySessionId: "pty-owned", + ptyOwnerSocket: "/tmp/citadel-test-pty.sock", + ptyOwnerPid: 123, + ptyLastSeenAt: new Date().toISOString(), + }); + + service.stopWorkspaceSession({ sessionId: session.id }); + + expect(closed).toHaveLength(1); + expect(closed[0]).toMatchObject({ + id: session.id, + terminalBackend: "pty-daemon", + ptySessionId: "pty-owned", + ptyOwnerSocket: "/tmp/citadel-test-pty.sock", + }); + expect(store.listWorkspaceSessions(created.workspaceId).find((s) => s.id === session.id)).toMatchObject({ + status: "stopped", + ptySessionId: null, + ptyOwnerSocket: null, + }); + }); + it("passes Codex initial prompts as positional argv instead of pasting into the TUI", async () => { const fixture = createGitFixture(); const store = new SqliteStore(path.join(fixture.dir, "citadel.sqlite")); @@ -421,7 +488,10 @@ describe("createAgentSession session-id wiring", () => { function makeService( store: SqliteStore, dataDir?: string, - overrides: { agentSessions?: { baseSystemPrompt: string } } = {}, + overrides: { + agentSessions?: { baseSystemPrompt: string }; + closePtySession?: (session: WorkspaceSession) => Promise | void; + } = {}, ) { return new OperationService(store, { ...(dataDir ? { dataDir } : {}), diff --git a/packages/operations/src/create-agent-session.ts b/packages/operations/src/create-agent-session.ts index a876a0b5..9033a801 100644 --- a/packages/operations/src/create-agent-session.ts +++ b/packages/operations/src/create-agent-session.ts @@ -443,7 +443,7 @@ export async function createTerminalSession( } function configuredTerminalBackend(): WorkspaceSession["terminalBackend"] { - return process.env.CITADEL_TERMINAL_BACKEND === "pty-daemon" ? "pty-daemon" : "tmux"; + return process.env.CITADEL_TERMINAL_BACKEND === "tmux" ? "tmux" : "pty-daemon"; } async function launchRuntimeOnce( diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts index bc9c5f45..01b8efdc 100644 --- a/packages/operations/src/index.ts +++ b/packages/operations/src/index.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { CitadelConfig, HookConfig } from "@citadel/config"; // biome-ignore format: keep on one line to stay inside the 800-line file-size budget -import type { ActivityEvent, AgentSession, CheckoutContextInput, CreateAgentSessionInput, CreateNamespaceInput, CreateTerminalSessionInput, CreateWorkspaceCheckoutInput, CreateWorkspaceInput, HookAction, HookEvent, HookOutput, JiraAutoTransitionEvent, LaunchAgentInput, MarkCheckoutReadyForReviewInput, Namespace, Operation, PlanDeviationReport, RegisterCheckoutReviewArtifactInput, RegisterWorkspacePlanInput, Repo, UpdateNamespaceInput, UpdateTicketStatusInput, Workspace, WorkspaceManagerControlInput, WorktreeCheckout } from "@citadel/contracts"; +import type { ActivityEvent, AgentSession, CheckoutContextInput, CreateAgentSessionInput, CreateNamespaceInput, CreateTerminalSessionInput, CreateWorkspaceCheckoutInput, CreateWorkspaceInput, HookAction, HookEvent, HookOutput, JiraAutoTransitionEvent, LaunchAgentInput, MarkCheckoutReadyForReviewInput, Namespace, Operation, PlanDeviationReport, RegisterCheckoutReviewArtifactInput, RegisterWorkspacePlanInput, Repo, UpdateNamespaceInput, UpdateTicketStatusInput, Workspace, WorkspaceManagerControlInput, WorkspaceSession, WorktreeCheckout } from "@citadel/contracts"; import { createId, nowIso } from "@citadel/core"; import type { SqliteStore } from "@citadel/db"; import { killTmuxSession } from "@citadel/terminal"; @@ -16,6 +16,7 @@ import { import { type CreateWorkspaceOptions, type WorkspaceOpsDeps, createWorkspaceImpl } from "./create-workspace.js"; import { launchAgent as launchAgentImpl } from "./launch-agent.js"; import * as namespaceOps from "./namespaces.js"; +import { type ClosePtySession, closePtySessionBestEffort } from "./pty-session-cleanup.js"; import { registerRepo as registerRepoImpl } from "./register-repo.js"; import { checkWorkspaceRemovalImpl, removeWorkspaceCheckoutImpl, removeWorkspaceImpl } from "./remove-workspace.js"; import type { CreateAgentSessionOperationInput } from "./system-prompt-launch.js"; @@ -132,6 +133,7 @@ export class OperationService { terminal?: CitadelConfig["terminal"]; agentRuntimes?: CitadelConfig["agentRuntimes"]; agentSessions?: CitadelConfig["agentSessions"]; + closePtySession?: ClosePtySession; }, private readonly runAutoTransitionsDep: RunAutoTransitionsDep | null = null, ) {} @@ -328,6 +330,7 @@ export class OperationService { const session = this.store.listWorkspaceSessions().find((candidate) => candidate.id === input.sessionId); if (!session) return { stopped: false, reason: "session_not_found" as const }; if (session.tmuxSessionName) killTmuxSession(session.tmuxSessionName, session.tmuxSocketName ?? null); + closePtySessionBestEffort(this.config?.closePtySession, session); this.store.closeWorkspaceSession(session.id); const workspace = this.store.listWorkspaces().find((candidate) => candidate.id === session.workspaceId); const activityType = session.kind === "agent" ? "agent.stopped" : "terminal.stopped"; @@ -790,7 +793,6 @@ export class OperationService { }; } } - // biome-ignore format: keep on one line to stay inside the 800-line file-size budget export { runDoctorChecks } from "./doctor.js"; // biome-ignore format: keep on one line to stay inside the 800-line file-size budget diff --git a/packages/operations/src/pty-session-cleanup.ts b/packages/operations/src/pty-session-cleanup.ts new file mode 100644 index 00000000..7c459e6c --- /dev/null +++ b/packages/operations/src/pty-session-cleanup.ts @@ -0,0 +1,8 @@ +import type { WorkspaceSession } from "@citadel/contracts"; + +export type ClosePtySession = (session: WorkspaceSession) => Promise | void; + +export function closePtySessionBestEffort(closePtySession: ClosePtySession | undefined, session: WorkspaceSession) { + if (session.terminalBackend !== "pty-daemon" || !closePtySession) return; + void Promise.resolve(closePtySession(session)).catch(() => undefined); +} From 9c6cb0e258c5c6cbc52dbabb7307d4ca94312081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Sun, 7 Jun 2026 20:15:03 +0000 Subject: [PATCH 2/2] Declare runtimes config dependency --- packages/runtimes/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/runtimes/package.json b/packages/runtimes/package.json index 2af9a58e..2d6be4c4 100644 --- a/packages/runtimes/package.json +++ b/packages/runtimes/package.json @@ -9,6 +9,7 @@ ".": "./dist/index.js" }, "dependencies": { + "@citadel/config": "workspace:*", "@citadel/contracts": "workspace:*" }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7240f99a..d3ddde3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: packages/runtimes: dependencies: + '@citadel/config': + specifier: workspace:* + version: link:../config '@citadel/contracts': specifier: workspace:* version: link:../contracts