pi-mono/apps/p2/plugins/sessions/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

771 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The session catalog and its views (server): the sidebar's rows, creating and renaming sessions, and the lifecycle
// operations (relocate, clone, fork, archive), each a method that answers once its visible effect committed and a job
// for its long part, shown in `Sessions.operations` while it runs and when it failed.
import { randomUUID } from "node:crypto";
import { mkdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { arr, bool, nul, obj, str, union, unknown } from "@earendil-works/pi-schema";
import {
type AcceptsTable,
type Context,
type Ctx,
defineFacet,
defineJob,
defineState,
deriveTyped,
forbidden,
isGuest,
isOwner,
isWorker,
type JobError,
type Location,
literal,
nativeOf,
type Ref,
type SessionInit,
serveKeyed,
type Tx,
until,
WorkerBoundary,
withAbortSignal,
} from "@p2/app/v1";
import { Server, serverOf, WorkerInitValues } from "@p2/app/v1/server";
import { EnvManagers, EnvService } from "../envs/contract.ts";
import { ModelConfig, ModelResources } from "../models/contract.ts";
import { ChildStore } from "../subagents/contract.ts";
import { CatalogDb } from "./catalog.ts";
import {
ArchivedStarts,
Catalog,
isThinkingLevel,
MAX_TITLE,
type Operation,
OperationSchema,
type Session,
SessionArchived,
SessionMoves,
SessionOperations,
Sessions,
SessionTransfer,
} from "./contract.ts";
import { copySessionDirectories, copySessionStore, removeSessionCopy, removeSessionDirectories } from "./copy.ts";
/** A row as `Sessions` v4 and `Catalog` v3 consumers read it: a null branch label is the session title. */
function sessionV4(row: Session) {
return {
...row,
branches: row.branches.map(({ conversation, label }) => ({ conversation, label: label ?? row.title })),
};
}
/** A row as `Sessions` v3 and `Catalog` v2 consumers read it: before `/tree`, without `main` and `branches`. */
function sessionV3({ main: _main, branches: _branches, ...row }: Session) {
return row;
}
/** Catalog consumers built before its current version (a server plugin not reloaded yet) read rows mapped back. */
const CATALOG_ACCEPTS: AcceptsTable<typeof Catalog> = {
2: {
state: { sessions: (rows) => rows.map(sessionV3) },
methods: {
get: { result: sessionV3 },
create: { result: sessionV3 },
// Older arguments are a subset of the current ones.
update: { args: (old) => old, result: sessionV3 },
},
},
3: {
state: { sessions: (rows) => rows.map(sessionV4) },
methods: {
get: { result: sessionV4 },
create: { result: sessionV4 },
update: { args: (old) => old, result: sessionV4 },
},
},
};
/** Older arguments of `create`, `clone` and `fork` are a subset of the current ones (no chosen IDs). */
const SAME_ARGS = { args: <T>(old: T) => old };
/**
* Older `Sessions` consumers (a page or TUI loaded before a deploy, a worker not restarted yet) read rows mapped
* back; v4's `treeSelf` labels are text, which the current version takes as it is. None of them sees `operations`.
*/
const SESSIONS_ACCEPTS: AcceptsTable<typeof Sessions> = {
3: {
state: {
owner: { sessions: (rows) => rows.map(sessionV3) },
guest: { sessions: (rows) => rows.map(sessionV3) },
worker: { session: (row) => (row === null ? null : sessionV3(row)) },
},
methods: {
create: { ...SAME_ARGS, result: sessionV3 },
clone: { ...SAME_ARGS, result: sessionV3 },
fork: { ...SAME_ARGS, result: sessionV3 },
},
},
4: {
state: {
owner: { sessions: (rows) => rows.map(sessionV4) },
guest: { sessions: (rows) => rows.map(sessionV4) },
worker: { session: (row) => (row === null ? null : sessionV4(row)) },
},
methods: {
create: { ...SAME_ARGS, result: sessionV4 },
clone: { ...SAME_ARGS, result: sessionV4 },
fork: { ...SAME_ARGS, result: sessionV4 },
treeSelf: SAME_ARGS,
},
},
5: { methods: { create: SAME_ARGS, clone: SAME_ARGS, fork: SAME_ARGS } },
};
const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim();
/** `~` and `~/…` on this machine. */
const expandHome = (cwd: string): string =>
cwd === "~" ? homedir() : cwd.startsWith("~/") ? resolve(homedir(), cwd.slice(2)) : cwd;
/**
* Fences the session's running worker for `doing` ("changing directory"): undefined once fenced, else why it refused
* (it has work, or `to` is a move its `RelocationChecks` refuse; nothing fenced). `ctx` acts as the server (a local
* object's job or method): the worker's `WorkerBoundary` answers only the server.
*/
async function fenceWorker(
catalog: Ref<typeof Catalog>,
children: Ref<typeof ChildStore>,
id: string,
doing: string,
ctx: Ctx,
to?: Location,
): Promise<string | undefined> {
const boundary = await ctx.get(WorkerBoundary, { session: id });
const result = await boundary.prepare(to ? { to } : null, ctx);
if (result.accepted) return undefined;
if (result.refusal !== undefined) return result.refusal;
const row = await catalog.get({ id }, ctx);
// A `/tree` branch left while running goes on in the background (docs/tree.md); conversation 1 is top-level before
// the worker first reports `branches`.
const branchOf = (conversation: number) => row.branches.find((branch) => branch.conversation === conversation);
const topLevel = (conversation: number) => conversation === 1 || branchOf(conversation) !== undefined;
const branches = result.busy
.filter((conversation) => topLevel(conversation) && conversation !== row.main)
.map((conversation) => `"${branchOf(conversation)?.label ?? row.title}"`);
const agents = result.busy
.filter((conversation) => !topLevel(conversation))
.map(
(conversation) =>
children.children.value[id]?.find((child) => child.conversationId === conversation)?.name ??
`#${conversation}`,
);
return result.busy.includes(row.main)
? `Stop the current run before ${doing}`
: branches.length
? `Branch ${branches.join(", ")} is still running: switch to it and stop it before ${doing}`
: agents.length
? `Subagent ${agents.join(", ")} is still running: stop it before ${doing}`
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
}
/** Lifecycle jobs persist and run again after a crash: each run is idempotent (a copy starts over, a move repeats). */
const RERUN = { resume: "rerun" } as const;
const RelocateJob = defineJob("p2.sessions.relocate", {
version: 1,
args: obj({
id: str(),
cwd: str(),
env: union([str(), nul()]),
/** The owner's move (a refusal is a failed operation) or the agent's (`SessionMoves`: its tool reports it). */
by: union([literal("owner"), literal("agent")]),
}),
result: obj({ refused: union([str(), nul()]) }),
persist: RERUN,
});
const CloneJob = defineJob("p2.sessions.clone", {
version: 1,
args: obj({ id: str(), copy: str() }),
/** Why a busy worker refused the copy (rolled back: a failed operation), else null. */
result: obj({ refused: union([str(), nul()]) }),
persist: RERUN,
});
/** A fork's progress: copying its files (its worker waits), then seeding its history (through its worker). */
const ForkProgress = defineState("p2.sessions.fork.progress", {
schema: obj({ copying: bool() }),
version: 1,
initial: () => ({ copying: true }),
persist: true,
checkpointWhen: () => true,
});
const ForkJob = defineJob("p2.sessions.fork", {
version: 1,
/** `entries`: the history the fork holds, read from the source when the fork was asked for. */
args: obj({ id: str(), copy: str(), entries: arr(unknown()) }),
result: nul(),
state: ForkProgress,
persist: RERUN,
});
const ArchiveJob = defineJob("p2.sessions.archive", {
version: 1,
args: obj({ id: str() }),
result: nul(),
persist: RERUN,
});
/** Failed operations, until dismissed (the latest `MAX_FAILED`). */
const Failed = defineState("p2.sessions.failed", {
schema: obj({ list: arr(OperationSchema) }),
version: 1,
initial: () => ({ list: [] }),
});
const MAX_FAILED = 20;
/** Why a job failed, in words. */
function failureText(error: JobError): string {
if (error.kind === "threw") return error.error.message;
if (error.kind === "aborted") return "The operation was interrupted";
return `The operation was stopped after ${error.restarts} restarts`;
}
/** How long a worker start waits for its session's row (a client-chosen ID held before `create` answered). */
const ROW_WAIT_MS = 10_000;
export default defineFacet((host) => {
serveKeyed(host, SessionArchived);
host.serve(
Catalog,
(_params, self) => {
const server = self.get(Server, {});
// The catalog's sessions: its database is this object's native, and every write sets the rows in the same
// method, before it answers.
const sessions = self.writable<Session[]>([]);
const db = self.open(
async () => {
const opened = new CatalogDb(server.info.value.dataDir);
sessions.set(opened.all());
return opened;
},
(opened) => opened.close(),
);
return {
sessions: deriveTyped(self, [sessions], ([value]) => value),
get: async ({ id }) => db.value.get(id),
create: async (input) => {
const session = db.value.create(input);
sessions.set(db.value.all());
return session;
},
activity: async ({ id, timestamp, firstUserText }) => {
db.value.activity(id, timestamp, firstUserText);
sessions.set(db.value.all());
return null;
},
update: async ({ id, ...change }) => {
const session = db.value.update(id, change);
sessions.set(db.value.all());
return session;
},
};
},
{ accepts: CATALOG_ACCEPTS },
);
host.serve(ArchivedStarts, (_params, self) => {
const allowed = self.writable<string[]>([]);
return {
allowed: self.derive([allowed], ([value]) => value),
allow: async ({ session }) => {
if (!allowed.value.includes(session)) allowed.set([...allowed.value, session]);
return null;
},
end: async ({ session }) => {
allowed.set(allowed.value.filter((id) => id !== session));
return null;
},
};
});
host.serve(SessionOperations, (_params, self) => {
const catalog = self.get(Catalog, {});
const server = self.get(Server, {});
const natives = serverOf(self);
const envService = self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const children = self.get(ChildStore, {});
const archivedStarts = self.get(ArchivedStarts, {});
const archived = self.registry(SessionArchived);
const failed = self.state(Failed);
/** Copies whose row is being made: their worker waits like one whose files are being copied. */
const preparing = self.writable<readonly string[]>([]);
/** A finished run that failed (a refused owner's move too) stays in `operations` until dismissed. */
const recordFailure = async (tx: Tx, operation: Omit<Extract<Operation, { phase: "failed" }>, "phase">) => {
const list = (await tx.draft(failed)).list;
list.push({ phase: "failed", ...operation });
if (list.length > MAX_FAILED) list.splice(0, list.length - MAX_FAILED);
};
/** A failed copy: its row archived first (so its worker cannot start), its worker stopped, its files gone. */
const failCopy = async (copy: string, ctx: Ctx) => {
if (catalog.sessions.value.some((session) => session.id === copy))
await catalog.update({ id: copy, archived: true }, ctx);
await natives().workers.suspend(copy);
await removeSessionCopy(server.info.value.dataDir, copy);
};
/**
* Moves session `id` to `location`: a stopped worker only gets the catalog update; a running one is fenced, the
* catalog moves and the worker follows in place (restarted from the catalog when it cannot). Returns why a busy
* worker refused (nothing moved), else null.
*/
const relocate = async (id: string, location: Location, ctx: Ctx): Promise<string | null> => {
let refused: string | null = null;
const boundary = () => ctx.get(WorkerBoundary, { session: id });
try {
await natives().workers.relocate(id, {
prepare: async () => {
const refusal = await fenceWorker(catalog, children, id, "changing directory", ctx, location);
refused = refusal ?? null;
return refusal;
},
update: async () => {
await catalog.update({ id, ...location }, ctx);
},
move: async () => {
await (await boundary()).relocate(location, ctx);
},
cancel: async () => {
await (await boundary()).cancel(ctx);
},
});
} catch (error) {
// A busy worker (or one starting or stopping): nothing moved.
if (refused !== null) return refused;
if (error instanceof Error && error.message.startsWith("Background work is still running"))
return error.message;
throw error;
}
return null;
};
const relocations = self.job(RelocateJob, {
run: async ({ id, cwd, env }, ctx) => ({ refused: await relocate(id, { cwd, env }, ctx) }),
finish: async (outcome, tx) => {
const error =
outcome.kind === "failed"
? failureText(outcome.error)
: outcome.args.by === "owner"
? outcome.result.refused
: null;
if (error !== null)
await recordFailure(tx, {
id: outcome.id,
kind: "relocate",
session: outcome.args.id,
target: null,
error,
});
},
});
const clones = self.job(CloneJob, {
run: async ({ id, copy }, ctx) => {
const dataDir = server.info.value.dataDir;
let refused: string | null = null;
try {
// The store is copied while the worker is stopped: SQLite's file and WAL belong together then.
await natives().workers.pause(
id,
async () => {
const refusal = await fenceWorker(catalog, children, id, "cloning", ctx);
refused = refusal ?? null;
return refusal;
},
async () => {
await removeSessionCopy(dataDir, copy);
await copySessionStore(dataDir, id, copy);
await copySessionDirectories(dataDir, id, copy);
},
"cloning",
);
// A move while the clone waited for the pause belongs to the copy too.
const [source, row] = await Promise.all([catalog.get({ id }, ctx), catalog.get({ id: copy }, ctx)]);
if (source.cwd !== row.cwd || (source.env ?? null) !== (row.env ?? null))
await catalog.update({ id: copy, cwd: source.cwd, env: source.env ?? null }, ctx);
return { refused: null };
} catch (error) {
await failCopy(copy, ctx);
// A busy worker (or one starting or stopping) is an answer, not a failure of the job.
if (refused !== null) return { refused };
if (error instanceof Error && error.message.startsWith("Background work is still running"))
return { refused: error.message };
throw error;
}
},
finish: async (outcome, tx) => {
const error = outcome.kind === "failed" ? failureText(outcome.error) : outcome.result.refused;
if (error !== null)
await recordFailure(tx, {
id: outcome.id,
kind: "clone",
session: outcome.args.id,
target: outcome.args.copy,
error,
});
},
});
const forks = self.job(ForkJob, {
run: async ({ id, copy, entries }, ctx) => {
const dataDir = server.info.value.dataDir;
try {
if (ctx.state.value.copying) {
await removeSessionDirectories(dataDir, copy);
await copySessionDirectories(dataDir, id, copy);
await ctx.state.change((progress) => {
progress.copying = false;
}, ctx);
}
// The copy's worker starts now (this hold, or a page that navigated to it) and writes the history.
await (await ctx.get(SessionTransfer, { session: copy })).seed({ entries }, ctx);
} catch (error) {
await failCopy(copy, ctx);
throw error;
}
return null;
},
finish: async (outcome, tx) => {
if (outcome.kind === "failed")
await recordFailure(tx, {
id: outcome.id,
kind: "fork",
session: outcome.args.id,
target: outcome.args.copy,
error: failureText(outcome.error),
});
},
});
const archives = self.job(ArchiveJob, {
run: async ({ id }, ctx) => {
// Archive stops the worker whatever it is doing; its storage and history stay. Its holders (a page's
// lingering conversation) wait for a restore rather than start a worker the archive refuses.
for (const close of archived.value.values()) await close(id, ctx);
await natives().workers.suspend(id);
return null;
},
finish: async (outcome, tx) => {
if (outcome.kind === "failed")
await recordFailure(tx, {
id: outcome.id,
kind: "archive",
session: outcome.args.id,
target: null,
error: failureText(outcome.error),
});
},
});
const operations = deriveTyped(
self,
[relocations.running, clones.running, forks.running, archives.running, failed],
([moving, cloning, forking, archiving, { list }]): Operation[] => [
...moving.flatMap((run) =>
run.phase === "blocked" || run.args.by === "agent"
? []
: [running(run.id, "relocate", run.args.id, null, false)],
),
...cloning.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "clone", run.args.id, run.args.copy, true)],
),
...forking.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "fork", run.args.id, run.args.copy, run.state.copying)],
),
...archiving.flatMap((run) =>
run.phase === "blocked" ? [] : [running(run.id, "archive", run.args.id, null, false)],
),
...list,
],
);
/** Sessions whose worker must not start yet: a copy's row being made, or its files being copied. */
const copying = deriveTyped(self, [preparing, operations], ([made, list]) => [
...made,
...list.flatMap((operation) =>
operation.phase === "running" && operation.copying && operation.target !== null ? [operation.target] : [],
),
]);
/** A destination as the move stores it: `~` expanded and checked to be a directory (on its environment). */
const destination = async ({ cwd, env }: Location): Promise<Location> => {
if (env) return { cwd: await envs().manager.workingDirectory(env, cwd, false), env };
const local = resolve(expandHome(cwd));
if (!(await stat(local)).isDirectory()) throw new Error("Working directory is not a directory");
return { cwd: local, env: null };
};
const liveSession = async (id: string, ctx: Ctx): Promise<Session> => {
const session = await catalog.get({ id }, ctx);
if (session.archivedAt !== null) throw new Error("Session is archived");
return session;
};
/** A copy's catalog entry: the source's place, model and level under a marked title. */
const copyOf = (source: Session, id: string, mark: string, ctx: Ctx) =>
catalog.create(
{
id,
cwd: source.cwd,
env: source.env ?? null,
title: cleanTitle(`${source.title} (${mark})`).slice(0, MAX_TITLE),
titleEdited: true,
model: source.model,
thinkingLevel: source.thinkingLevel,
},
ctx,
);
/**
* Makes a copy's row and starts the job filling it, its worker held back meanwhile. A copy that exists already is
* a retry of the same request: its row is the answer.
*/
const startCopy = async (
source: Session,
copy: string,
mark: string,
start: () => Promise<unknown>,
ctx: Ctx,
) => {
const existing = catalog.sessions.value.find((session) => session.id === copy);
if (existing) return existing;
preparing.set([...preparing.value, copy]);
try {
const row = await copyOf(source, copy, mark, ctx);
await start();
return row;
} catch (error) {
await failCopy(copy, ctx);
throw error;
} finally {
preparing.set(preparing.value.filter((id) => id !== copy));
}
};
// The start data of a session's worker; refuses archived and unknown sessions. A copy's worker waits until its
// files are there; a client-chosen ID held before its row exists waits for the row.
self.contribute(WorkerInitValues, {
session: async (id, context): Promise<SessionInit> => {
const waiting = withAbortSignal(AbortSignal.timeout(ROW_WAIT_MS), context);
await until(catalog.sessions, (rows) => rows.some((row) => row.id === id), waiting).catch(() => {});
await until(copying, (ids) => !ids.includes(id), context);
return startData(id, context);
},
});
const startData = async (id: string, context: Context): Promise<SessionInit> => {
const session = await catalog.get({ id }, context);
// An archived session starts only to rebuild its index, and then without resuming its work.
const indexing = session.archivedAt !== null && archivedStarts.allowed.value.includes(id);
if (session.archivedAt !== null && !indexing) throw forbidden("Session is archived");
if (session.env !== null && session.env !== undefined && !Object.hasOwn(envService.configs.value, session.env))
throw new Error(`Environment "${session.env}" is not configured; refusing local execution`);
return {
id: session.id,
cwd: session.cwd,
env: session.env ?? null,
model: session.model,
thinkingLevel: session.thinkingLevel as SessionInit["thinkingLevel"],
...(indexing ? { resume: false } : {}),
};
};
return {
operations,
move: async ({ id, ...target }, ctx) => {
await liveSession(id, ctx);
const location = await destination(target);
const run = await relocations.start({ id, ...location, by: "agent" }, ctx);
const outcome = await run.outcome(ctx);
if (outcome.kind === "failed") throw new Error(failureText(outcome.error));
return outcome.result;
},
relocate: async ({ id, ...target }, ctx) => {
await liveSession(id, ctx);
const location = await destination(target);
await relocations.start({ id, ...location, by: "owner" }, ctx);
return null;
},
clone: async ({ id, copy }, ctx) => {
const source = await liveSession(id, ctx);
return startCopy(source, copy, "copy", () => clones.start({ id, copy }, ctx, { key: copy }), ctx);
},
fork: async ({ id, copy, conversation, before }, ctx) => {
const source = await liveSession(id, ctx);
// Read now, so a fork of a subagent or of an unknown message is refused before anything is made. This local
// object's `ctx.get` acts as the server, whom `SessionTransfer` serves.
const { entries } = await (await ctx.get(SessionTransfer, { session: id })).history(
{ conversation, before },
ctx,
);
return startCopy(source, copy, "fork", () => forks.start({ id, copy, entries }, ctx, { key: copy }), ctx);
},
archive: async ({ id }, ctx) => {
await catalog.update({ id, archived: true }, ctx);
await archives.start({ id }, ctx, { key: id });
return null;
},
restore: async ({ id }, ctx) => {
// After a running archive of it: its worker stop must not land after the restore.
const archiving = archives.running.value.find((run) => run.phase !== "blocked" && run.key === id);
if (archiving) await archives.outcome(archiving.id, ctx).catch(() => {});
await catalog.update({ id, archived: false }, ctx);
// A start refused while archived left the worker failed or suspended: its holders start it again.
await natives().workers.resume(id);
return null;
},
dismiss: async ({ operation }, ctx) => {
await failed.change((draft) => {
draft.list = draft.list.filter((each) => each.id !== operation);
}, ctx);
return null;
},
};
});
host.serve(SessionMoves, (_params, self) => {
const operations = self.get(SessionOperations, {});
return { relocate: (target, ctx) => operations.move(target, ctx) };
});
host.serve(
Sessions,
(_params, principal, self) => {
const catalog = self.get(Catalog, {});
if (isWorker(principal)) {
const id = principal.session;
const sessionView = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((s) => s.id === id) ?? null,
);
return {
variant: "worker",
members: {
session: sessionView,
renameSelf: async ({ title }, ctx) => {
const previous = (await catalog.get({ id }, ctx)).title;
await catalog.update({ id, title: cleanTitle(title) }, ctx);
return { previous };
},
// Either field alone updates the entry; the other keeps its value (Pi Mobile's configure).
configureSelf: async ({ model, thinkingLevel }, ctx) => {
await catalog.update(
{ id, ...(model ? { model } : {}), ...(thinkingLevel ? { thinkingLevel } : {}) },
ctx,
);
return null;
},
treeSelf: async ({ main, branches }, ctx) => {
const current = await catalog.get({ id }, ctx);
if (current.main !== main || JSON.stringify(current.branches) !== JSON.stringify(branches))
await catalog.update({ id, main, branches }, ctx);
return null;
},
},
};
}
if (isGuest(principal)) {
const shared = principal.sessions;
const sessionsView = deriveTyped(self, [catalog.sessions], ([sessions]) =>
sessions.filter((s) => Object.hasOwn(shared, s.id) && s.archivedAt === null),
);
return { variant: "guest", members: { sessions: sessionsView } };
}
if (!isOwner(principal)) throw forbidden("sessions are owner-only");
const models = self.get(ModelConfig, {});
const resources = nativeOf(self, ModelResources, "The model configuration");
self.get(EnvService, {});
const envs = nativeOf(self, EnvManagers, "The SSH manager");
const lifecycle = self.get(SessionOperations, {});
const defaultCwd = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((session) => session.archivedAt === null)?.cwd ?? homedir(),
);
const defaultEnv = deriveTyped(
self,
[catalog.sessions],
([sessions]) => sessions.find((session) => session.archivedAt === null)?.env ?? null,
);
const defaultModel = deriveTyped(self, [models.summary], ([summary]) => summary.defaultModel);
return {
variant: "owner",
members: {
sessions: catalog.sessions,
defaultCwd,
defaultEnv,
operations: lifecycle.operations,
create: async ({ id, env, cwd, createDirectory, title, model, thinkingLevel }, ctx) => {
// A chosen ID already in the catalog is a retry of the same request (its answer was lost).
const existing = id === undefined ? undefined : catalog.sessions.value.find((row) => row.id === id);
if (existing) return existing;
if (env) cwd = await envs().manager.workingDirectory(env, cwd, createDirectory);
else {
cwd = expandHome(cwd);
if (!isAbsolute(cwd)) throw new Error("The working directory must be an absolute path");
if (createDirectory) await mkdir(cwd, { recursive: true });
if (!(await stat(cwd)).isDirectory()) throw new Error("Working directory is not a directory");
}
const fallback = defaultModel.value;
const chosen = model ?? fallback;
if (!chosen) throw new Error("Select a model or configure P2_PROVIDER and P2_MODEL");
// As Pi Mobile and setDefault: a model the configuration does not know never reaches a worker.
if (!resources().resources.models.getModel(chosen.provider, chosen.modelId))
throw new Error("Unknown model");
const level = thinkingLevel ?? fallback?.thinkingLevel;
return catalog.create(
{
...(id === undefined ? {} : { id }),
cwd,
env: env ?? null,
title: cleanTitle(title ?? "").slice(0, 100) || "New session",
titleEdited: Boolean(cleanTitle(title ?? "")),
model: { provider: chosen.provider, modelId: chosen.modelId },
thinkingLevel: isThinkingLevel(level) ? level : "high",
},
ctx,
);
},
relocate: (target, ctx) => lifecycle.relocate(target, ctx),
clone: ({ id, copy }, ctx) => lifecycle.clone({ id, copy: copy ?? randomUUID() }, ctx),
fork: ({ copy, ...from }, ctx) => lifecycle.fork({ ...from, copy: copy ?? randomUUID() }, ctx),
rename: async ({ id, title }, ctx) => {
const clean = cleanTitle(title);
if (!clean || clean.length > MAX_TITLE)
throw new Error(`The title must be 1–${MAX_TITLE} characters`);
await catalog.update({ id, title: clean }, ctx);
return null;
},
pin: async ({ id, pinned }, ctx) => {
await catalog.update({ id, pinned }, ctx);
return null;
},
archive: (args, ctx) => lifecycle.archive(args, ctx),
restore: (args, ctx) => lifecycle.restore(args, ctx),
dismissOperation: (args, ctx) => lifecycle.dismiss(args, ctx),
},
};
},
{ accepts: SESSIONS_ACCEPTS },
);
});
/** A running operation's entry. */
function running(
id: string,
kind: Operation["kind"],
session: string,
target: string | null,
copying: boolean,
): Operation {
return { phase: "running", id, kind, session, target, copying };
}