Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3e329bb
feat(pty-daemon): standalone PTY daemon package (Phase 1, skeleton)
Kitenite Apr 30, 2026
b8784ea
feat(pty-daemon): control-plane integration suite + production build
Kitenite Apr 30, 2026
9bdbf7b
feat(host-service): DaemonClient — Unix-socket client for pty-daemon
Kitenite Apr 30, 2026
b1eb105
feat(desktop): pty-daemon coordinator + manifest + main entry
Kitenite Apr 30, 2026
401e203
feat(host-service): route terminal sessions through pty-daemon
Kitenite Apr 30, 2026
9efd5d8
Merge remote-tracking branch 'origin' into pty-daemon-host-integration
Kitenite Apr 30, 2026
b387324
fix(desktop): make pty-daemon spawn failure non-fatal for host-service
Kitenite Apr 30, 2026
2e8d216
debug(desktop): surface daemon spawn failures with log tail + child e…
Kitenite Apr 30, 2026
df81d8b
fix(desktop): allow .env / shell to provide SUPERSET_PTY_DAEMON_SOCKET
Kitenite Apr 30, 2026
05ae50c
fix(desktop): use short /tmp path for pty-daemon socket (Darwin sun_p…
Kitenite Apr 30, 2026
aae131e
fix(host-service): adopt existing daemon sessions on host-service res…
Kitenite Apr 30, 2026
2bbb084
test(pty-daemon): replay-on-exited-session edge case
Kitenite Apr 30, 2026
525d3ec
test(host-service): full E2E adoption test under Electron-as-Node
Kitenite Apr 30, 2026
a6f09d3
fix(pty-daemon) + test(host-service): three more edge cases
Kitenite Apr 30, 2026
faa76bf
docs(desktop): pty-daemon implementation report
Kitenite Apr 30, 2026
9a6c8fc
fix(host-service): close terminal WS streams on daemon disconnect
Kitenite Apr 30, 2026
0286beb
feat(desktop): pty-daemon crash supervision (3-in-60s circuit breaker)
Kitenite Apr 30, 2026
42f0bc9
feat(desktop): pty-daemon telemetry events
Kitenite Apr 30, 2026
a928c16
test(pty-daemon): real SIGKILL recovery test
Kitenite Apr 30, 2026
d4b9a4b
refactor(host-service): own pty-daemon supervision
Kitenite Apr 30, 2026
07ec14f
docs(host-service): daemon supervision reference
Kitenite Apr 30, 2026
5cb3ba7
fix(host-service): DaemonClient lifecycle hardening
Kitenite Apr 30, 2026
2b8ca2c
fix(host-service): subscribe replay assertion + daemon CLI polish
Kitenite Apr 30, 2026
2b24a38
test(host-service): supervisor + tRPC + Electron-coupling coverage
Kitenite Apr 30, 2026
743d9f3
feat(host-service): kill pty-daemon on dev-mode shutdown
Kitenite Apr 30, 2026
ab5413f
fix(desktop): restore pty-daemon bundle target for Electron
Kitenite Apr 30, 2026
7d34cad
fix(desktop): wrap V2SessionsSection in WorkspaceClientProvider
Kitenite Apr 30, 2026
285179a
fix(host-service): correct sideBySide daemon-script path resolution
Kitenite Apr 30, 2026
9697ce8
fix(pty-daemon): delete sessions on PTY exit (no accumulation)
Kitenite Apr 30, 2026
d85d3fc
feat(host-service): adopted-daemon liveness check + dev-mode log piping
Kitenite May 1, 2026
0a8f06c
fix(pty-daemon): default close to SIGHUP — interactive shells leak on…
Kitenite May 1, 2026
69d3d61
chore(host-service): bump version to 0.5.0 to force fresh respawn on …
Kitenite May 1, 2026
668afeb
docs(host-service): update daemon-supervision reference for shipped b…
Kitenite May 1, 2026
ea80743
Merge remote-tracking branch 'origin/main' into pty-daemon-host-integ…
Kitenite May 1, 2026
f9a7c81
chore(host-service): post-merge CI cleanup
Kitenite May 1, 2026
011b59d
Merge remote-tracking branch 'origin/main' into pty-daemon-host-integ…
Kitenite May 1, 2026
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
3 changes: 3 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export default defineConfig({
"git-task-worker": resolve("src/main/git-task-worker.ts"),
// Workspace service - local HTTP/tRPC server per org
"host-service": resolve("src/main/host-service/index.ts"),
// pty-daemon - long-lived per-org Unix-socket server that owns PTYs.
// Spawned by PtyDaemonCoordinator; survives host-service restarts.
"pty-daemon": resolve("src/main/pty-daemon/index.ts"),
},
output: {
dir: resolve(devPath, "main"),
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"@superset/macos-process-metrics": "workspace:*",
"@superset/panes": "workspace:*",
"@superset/port-scanner": "workspace:*",
"@superset/pty-daemon": "workspace:*",
"@superset/shared": "workspace:*",
"@superset/trpc": "workspace:*",
"@superset/ui": "workspace:*",
Expand Down Expand Up @@ -213,7 +214,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"semver": "^7.7.4",
"shell-env": "^4.0.3",
"shell-quote": "^1.8.3",
"shiki": "^3.21.0",
Expand Down
67 changes: 64 additions & 3 deletions apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,18 @@ import { HOOK_PROTOCOL_VERSION } from "./terminal/env";
* `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use
* machineId text instead of uuid surrogates.
* 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`.
*
* 0.5.0 — pty-daemon supervision migrated into host-service. New
* `terminal.daemon` tRPC namespace; older 0.4.x host-services don't
* expose it. Adopting one in place would leave the new desktop
* talking to old code: Settings → Manage daemon would silently
* fail, and the v2 PTY survival promise is broken. Bumping the
* floor forces the coordinator's `tryAdopt` (host-service-coordinator
* line ~308) to SIGTERM old host-services on first launch and
* respawn with the new bundle. One-time terminal-session loss for
* users on upgrade — accepted per release-notes guidance.
*/
const MIN_HOST_SERVICE_VERSION = "0.4.0";
const MIN_HOST_SERVICE_VERSION = "0.5.0";

export type HostServiceStatus = "starting" | "running" | "stopped";

Expand Down Expand Up @@ -82,6 +92,10 @@ export class HostServiceCoordinator extends EventEmitter {
private scriptPath = path.join(__dirname, "host-service.js");
private machineId = getHostId();
private devReloadWatcher: fs.FSWatcher | null = null;
// Note: pty-daemon supervision moved into host-service itself —
// see packages/host-service/src/daemon. Host-service spawns and adopts
// the daemon when it boots, so the desktop coordinator no longer needs
// to know about it.

async start(
organizationId: string,
Expand Down Expand Up @@ -385,6 +399,9 @@ export class HostServiceCoordinator extends EventEmitter {
this.instances.set(organizationId, instance);
this.emitStatus(organizationId, "starting", null);

// pty-daemon is supervised by host-service itself; this coordinator
// only spawns host-service and steps out. See
// packages/host-service/src/daemon for the supervisor lifecycle.
const childEnv = await this.buildEnv(organizationId, port, secret, config);
// Host-service owns v2 PTYs, so it must survive Electron restarts in
// every environment. This mirrors the terminal-host daemon: detach the
Expand All @@ -394,8 +411,16 @@ export class HostServiceCoordinator extends EventEmitter {
path.join(manifestDir(organizationId), "host-service.log"),
MAX_HOST_LOG_BYTES,
);
const stdio: childProcess.StdioOptions =
logFd >= 0 ? ["ignore", logFd, logFd] : ["ignore", "ignore", "ignore"];
// Dev: pipe child stdout/stderr through this process so log lines
// land in the developer's `bun dev` terminal. Production: hard-back
// stdio with the rotating log file so the detached child survives
// parent teardown without losing logs.
const isDev = !app.isPackaged;
const stdio: childProcess.StdioOptions = isDev
? ["ignore", "pipe", "pipe"]
: logFd >= 0
? ["ignore", logFd, logFd]
: ["ignore", "ignore", "ignore"];

let child: ReturnType<typeof childProcess.spawn>;
try {
Expand All @@ -416,6 +441,15 @@ export class HostServiceCoordinator extends EventEmitter {
}
}

// In dev, fan child output through to parent stdout/stderr with a
// prefix so it's identifiable in `bun dev`. The detached child has
// its own session, so closing pipes won't kill it on parent exit.
if (isDev && child.stdout && child.stderr) {
const tag = `[hs:${organizationId.slice(0, 8)}]`;
pipeWithPrefix(child.stdout, process.stdout, tag);
pipeWithPrefix(child.stderr, process.stderr, tag);
}

const childPid = child.pid;
if (!childPid) {
this.instances.delete(organizationId);
Expand Down Expand Up @@ -540,6 +574,33 @@ export class HostServiceCoordinator extends EventEmitter {
}
}

/**
* Forward child stdout/stderr to a parent stream with a per-line prefix.
* Plain `chunk => parent.write(`${tag} ${chunk}`)` only prefixes the first
* line in a chunk and breaks visual scanning when child output bursts.
*/
function pipeWithPrefix(
source: NodeJS.ReadableStream,
target: NodeJS.WritableStream,
tag: string,
): void {
let pending = "";
source.on("data", (chunk: Buffer) => {
const text = pending + chunk.toString("utf8");
const lines = text.split("\n");
// Last element is a partial line if input doesn't end with \n;
// stash it for the next chunk.
pending = lines.pop() ?? "";
for (const line of lines) {
target.write(`${tag} ${line}\n`);
}
});
source.on("end", () => {
if (pending) target.write(`${tag} ${pending}\n`);
pending = "";
});
}

let coordinator: HostServiceCoordinator | null = null;

export function getHostServiceCoordinator(): HostServiceCoordinator {
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src/main/pty-daemon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* pty-daemon — Desktop bundle target
*
* The supervisor (in @superset/host-service) spawns this script as the
* daemon process. We need a desktop-side entry so electron-vite emits
* `apps/desktop/dist/main/pty-daemon.js` alongside `host-service.js` —
* the supervisor's `sideBySide` script-path resolution looks for the
* daemon binary right next to its own bundle.
*
* The actual daemon implementation lives in `@superset/pty-daemon`.
* This file is a thin runtime shim: argv parsing, signal handling,
* and starting the Server. Mirrors the layout host-service uses
* (apps/desktop/src/main/host-service/index.ts).
*
* Headless deploy path: in a non-Electron build, this file is unused —
* the supervisor instead spawns the @superset/pty-daemon package's
* built-in main.ts directly.
*/

import { Server } from "@superset/pty-daemon";

interface CliArgs {
socket: string;
}

function parseArgs(argv: string[]): CliArgs {
const args: Partial<CliArgs> = {};
for (const arg of argv) {
if (arg.startsWith("--socket=")) {
args.socket = arg.slice("--socket=".length);
}
}
if (!args.socket) {
throw new Error("--socket=PATH is required");
}
return args as CliArgs;
}

async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// Source of truth for daemon version — the supervisor sets this env
// var on spawn (matching its EXPECTED_DAEMON_VERSION). Falls back to
// a hardcoded default if launched without env, so the daemon still
// reports something sane on direct invocation.
const daemonVersion = process.env.SUPERSET_PTY_DAEMON_VERSION ?? "0.1.0";
const server = new Server({
socketPath: args.socket,
daemonVersion,
});
await server.listen();
process.stderr.write(
`[pty-daemon] listening on ${args.socket} (v${daemonVersion})\n`,
);

let shuttingDown = false;
const shutdown = async (signal: NodeJS.Signals) => {
if (shuttingDown) return;
shuttingDown = true;
process.stderr.write(`[pty-daemon] received ${signal}, shutting down\n`);
try {
await server.close();
} catch (err) {
process.stderr.write(
`[pty-daemon] shutdown error: ${(err as Error).stack ?? err}\n`,
);
} finally {
process.exit(0);
}
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}

void main().catch((error) => {
process.stderr.write(
`[pty-daemon] failed to start: ${(error as Error).stack ?? error}\n`,
);
process.exit(1);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LinkBehaviorSetting } from "./components/LinkBehaviorSetting";
import { PresetsSection } from "./components/PresetsSection";
import { SessionsSection } from "./components/SessionsSection";
import { V2PresetsSection } from "./components/V2PresetsSection";
import { V2SessionsSection } from "./components/V2SessionsSection";

interface TerminalSettingsProps {
visibleItems?: SettingItemId[] | null;
Expand Down Expand Up @@ -97,7 +98,12 @@ export function TerminalSettings({
/>
))}
{showLinkBehavior && <LinkBehaviorSetting key="link-behavior" />}
{showSessions && <SessionsSection key="sessions" />}
{showSessions &&
(isV2CloudEnabled ? (
<V2SessionsSection key="sessions" />
) : (
<SessionsSection key="sessions" />
))}
</SectionList>
</div>
);
Expand Down
Loading
Loading