pi-mono/apps/p2/plugins/terminals/server.ts
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

209 lines
7.9 KiB
TypeScript

import {
type Ctx,
contribute,
defineFacet,
forbidden,
isOwner,
isWorker,
nativeOf,
type Ref,
serveNative,
signalOf,
until,
type WorkerPrincipal,
} from "@p2/app/v1";
import { Hosts, Server, Upgrades, type WorkerState } from "@p2/app/v1/server";
import { Catalog, SessionArchived } from "../sessions/contract.ts";
import { ChildStore, conversationLocation } from "../subagents/contract.ts";
import { AgentTerminals, TerminalServiceObject, TerminalServices, Terminals } from "./contract.ts";
import type { HostCall, HostResult, TerminalRequest } from "./protocol.ts";
import { TerminalService } from "./service.ts";
/** The size of a terminal the agent opens; the owner's panel resizes it to fit once it shows it. */
const AGENT_COLS = 120;
const AGENT_ROWS = 30;
export default defineFacet((host) => {
serveNative(host, TerminalServices);
// Archiving a session ends its programs, whether or not anyone holds its terminals now (the service connects to
// the host only when it runs); archiving goes on when the host cannot be reached.
contribute(host, "p2.terminals-archived", (self) => {
self.get(TerminalServiceObject, {});
const services = nativeOf(self, TerminalServices, "The terminal service");
self.contribute(SessionArchived, {
terminals: (session) =>
services()
.service.closeSession(session)
.catch((error: unknown) =>
console.error(`p2 terminals: closing session ${session}'s terminals:`, error),
),
});
});
host.serve(TerminalServiceObject, (_params, self) => {
const server = self.get(Server, {});
const serviceResource = self.open(
async () => new TerminalService(server.info.value.dataDir, server.info.value.app),
(service) => service.close(),
);
self.contribute(TerminalServices, {
get service() {
return serviceResource.value;
},
});
self.contribute(Upgrades, {
"/terminal": (request, socket, head, principal) => {
if (!isOwner(principal)) {
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
serviceResource.value.upgrade(request, socket, head);
},
});
return {};
});
host.serve(Terminals, ({ session: id }, principal, self) => {
if (!isOwner(principal)) throw forbidden("terminals are owner-only");
self.get(TerminalServiceObject, {});
const services = nativeOf(self, TerminalServices, "The terminal service");
const service = () => services().service;
const catalog = self.get(Catalog, {});
const children = self.get(ChildStore, {});
const viewResource = self.open(
async (context) => {
// An unknown session refuses the view.
await catalog.get({ id }, context);
return service().open(id);
},
(view) => service().release(view),
);
/** Each call is one request of the terminal host's protocol, for this session's view. */
const command = async (request: TerminalRequest): Promise<null> => {
await viewResource.value.command(request);
return null;
};
return {
variant: "owner",
members: {
channel: async () => service().channel(viewResource.value),
list: () => command({ type: "list", sessionId: id }),
open: async ({ cols, rows, conversation }, ctx) => {
const session = await catalog.get({ id }, ctx);
if (session.archivedAt !== null) throw new Error("Session is archived");
// Where the conversation works: a subagent's own location stays where it is when the session moves.
const child = children.children.value[id]?.find((each) => each.conversationId === conversation);
if (conversation !== 1 && conversation !== session.main && !child)
throw new Error("Unknown conversation");
const { cwd, env } = conversationLocation(session, child)!;
return command({ type: "open", sessionId: id, cwd, env, cols, rows });
},
attach: ({ terminalId }) => command({ type: "attach", terminalId }),
close: ({ force }) => command({ type: "close", force }),
resize: ({ cols, rows }) => command({ type: "resize", cols, rows }),
theme: (theme) => command({ type: "theme", ...theme }),
ack: ({ bytes }) => command({ type: "ack", bytes }),
restartHost: () => command({ type: "shutdown" }),
},
};
});
host.serve(AgentTerminals, (_params, principal, self) => {
if (!isWorker(principal)) throw forbidden("agent terminals are for session workers");
self.get(TerminalServiceObject, {});
const services = nativeOf(self, TerminalServices, "The terminal service");
const catalog = self.get(Catalog, {});
const children = self.get(ChildStore, {});
const current = currentWorker(self.get(Hosts, {}), principal);
const sessionId = principal.session;
/** The session's live, current worker only, never in an archived session. */
const admitted = async (ctx: Ctx) => {
await current(ctx);
const session = await catalog.get({ id: sessionId }, ctx);
if (session.archivedAt !== null) throw forbidden("Session is archived");
return session;
};
const call = async <K extends HostResult["kind"]>(
kind: K,
request: HostCall,
ctx: Ctx,
): Promise<Omit<Extract<HostResult, { kind: K }>, "kind">> => {
await admitted(ctx);
const result = await services().service.call(request, signalOf(ctx));
if (result.kind !== kind) throw new Error(`The terminal host answered ${result.kind} to ${request.op}`);
const { kind: _kind, ...rest } = result as Extract<HostResult, { kind: K }>;
return rest;
};
return {
variant: "worker",
members: {
list: (_args, ctx) => call("list", { op: "list", sessionId }, ctx),
open: async ({ key, location, name, command }, ctx) => {
const session = await admitted(ctx);
// Only where the session's conversations work: the session's environment or a subagent's own.
const own = children.children.value[sessionId] ?? [];
const envs = new Set([
session.env ?? null,
...own.map((child) => conversationLocation(session, child)?.env),
]);
if (!envs.has(location.env))
throw forbidden(
`This session does not work on ${location.env ? `"${location.env}"` : "this device"}`,
);
const opened = await call(
"terminal",
{
op: "open",
sessionId,
key,
cwd: location.cwd,
env: location.env,
...(name ? { name } : {}),
...(command ? { command } : {}),
cols: AGENT_COLS,
rows: AGENT_ROWS,
},
ctx,
);
return opened.terminal;
},
send: async ({ key, target, keys, literal }, ctx) =>
(await call("terminal", { op: "send", sessionId, key, target, keys, literal }, ctx)).terminal,
cursor: ({ target }, ctx) => call("cursor", { op: "cursor", sessionId, target }, ctx),
read: ({ target, from, wait }, ctx) =>
call(
"read",
{
op: "read",
sessionId,
target,
...(from === undefined ? {} : { from }),
...(wait === undefined ? {} : { wait }),
},
ctx,
),
capture: ({ target, scrollback, escapes }, ctx) =>
call("capture", { op: "capture", sessionId, target, scrollback, ...(escapes ? { escapes } : {}) }, ctx),
close: ({ key, target }, ctx) => call("closed", { op: "close", sessionId, key, target }, ctx),
},
};
});
});
/**
* Refuses all but the session's running current generation, as MCP's worker view does. A worker may ask before the
* server published its start: the check waits for that (the wait ends with the caller's call).
*/
function currentWorker(
hosts: Ref<typeof Hosts>,
{ session, generation }: WorkerPrincipal,
): (ctx: Ctx) => Promise<void> {
const known = (state: WorkerState | undefined) =>
state !== undefined && (state.generation > generation || (state.generation === generation && state.running));
return async (ctx) => {
const published = hosts.workers.value[session];
const state = known(published)
? published
: (await until(hosts.workers, (workers) => known(workers[session]), ctx))[session];
if (!state?.running || state.generation !== generation) throw forbidden("stale worker");
};
}