Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/daemon/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <dataDir>/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.
Expand Down
6 changes: 5 additions & 1 deletion apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions apps/daemon/src/pty-session-cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { WorkspaceSession } from "@citadel/contracts";
import { connectPtyDaemonClient } from "@citadel/terminal";

export async function closePtyDaemonSession(session: WorkspaceSession): Promise<void> {
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();
}
}
2 changes: 1 addition & 1 deletion docs/operations/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 73 additions & 3 deletions packages/operations/src/create-agent-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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> | void;
} = {},
) {
return new OperationService(store, {
...(dataDir ? { dataDir } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/operations/src/create-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions packages/operations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -132,6 +133,7 @@ export class OperationService {
terminal?: CitadelConfig["terminal"];
agentRuntimes?: CitadelConfig["agentRuntimes"];
agentSessions?: CitadelConfig["agentSessions"];
closePtySession?: ClosePtySession;
},
private readonly runAutoTransitionsDep: RunAutoTransitionsDep | null = null,
) {}
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/operations/src/pty-session-cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { WorkspaceSession } from "@citadel/contracts";

export type ClosePtySession = (session: WorkspaceSession) => Promise<void> | void;

export function closePtySessionBestEffort(closePtySession: ClosePtySession | undefined, session: WorkspaceSession) {
if (session.terminalBackend !== "pty-daemon" || !closePtySession) return;
void Promise.resolve(closePtySession(session)).catch(() => undefined);
}
1 change: 1 addition & 0 deletions packages/runtimes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
".": "./dist/index.js"
},
"dependencies": {
"@citadel/config": "workspace:*",
"@citadel/contracts": "workspace:*"
},
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading