@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
1,010 lines • 64.9 kB
JavaScript
import { saveContinuationIntent } from "./continuation-launch.js";
import { redactPeerHandoff } from "./peer-handoff.js";
// Moving a live session to another agent: the agent in the pane writes a
// hand-off, exits the way it normally does, and the target harness starts in
// the same pane by default, or a new chat that keeps the old agent running.
//
// The hand-off is asked for through the same durable prompt path `hand_off`
// uses, as a reply between two marker lines; the Hook reads it from the
// transcript and saves it. A reply never waits on a permission prompt, which
// a write outside the checkout would in a supervised session. When the agent
// does not answer in time, the hand-off is built from the transcript's tail,
// the git state and the original brief instead, as a worker continued on
// another account is (account-failover.ts).
//
// Nothing is committed, stashed or reset: the uncommitted changes stay in the
// checkout and the hand-off lists them. Each move is recorded in
// `<bridge>/moves/<id>.json`; a dispatching Hook asking about the old pane
// (dispatch-returns.ts `workerStates`) follows the record to the new agent,
// so the dispatch and its receipt carry on as one worker.
import { redactSecretsForLog } from "../content/secrets.js";
import { redactApproval } from "./approval-summary.js";
import { stableComputerName } from "./computer-names.js";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { lstat, mkdir, open, readFile, readdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import { logger } from "../logger.js";
import { quoted, withOriginalBrief } from "./account-failover.js";
import { recordedConductor, runsAgent, transferConductor } from "./conductor-role.js";
import { DEFAULT_ACCOUNT } from "./claude-accounts.js";
import { DISPATCH_HARNESSES, DispatchService, dispatchProjectDirectory, projectName, moveToSchema, repointDispatchOrigins } from "./dispatch.js";
import { hasUsable } from "./harnesses.js";
import { findPane } from "./herdr.js";
import { briefArrival, briefId, briefRoot, assertUnusedLaunchId, writeLaunchBrief } from "./launch-brief.js";
import { atomic, BridgeError, bridgeRoot, object, startingTargetSchema, targetSchema } from "./protocol.js";
import { publicAssistant } from "./schedule-watch.js";
const run = promisify(execFile);
export { moveToSchema };
export const moveRequestSchema = z.object({
target: targetSchema,
to: moveToSchema,
mode: z.enum(["replace", "new-chat"]).default("replace"),
/** Kept on a retry: the same id returns the move already under way. */
id: z.string().uuid().optional(),
/** How long the agent gets to write its hand-off before Phren builds one. */
handoffTimeoutMs: z.number().int().min(5_000).max(900_000).optional(),
}).strict();
const MOVE_STATES = ["handing-off", "exiting", "launching", "linking", "uncertain", "moved", "failed"];
const timestamp = z.string().datetime();
const moveRecordSchema = z.object({
id: z.string().uuid(),
state: z.enum(MOVE_STATES),
createdAt: timestamp, updatedAt: timestamp,
from: z.object({ target: targetSchema, account: z.string().max(64).optional(), label: z.string().max(200).optional() }).strict(),
to: moveToSchema,
computerFingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional(),
computerId: z.string().uuid().optional(),
accountChoice: z.string().max(400).optional(),
project: projectName.optional(),
mode: z.enum(["replace", "new-chat"]).optional(),
modeForced: z.literal("conductor").optional(),
handedOffTo: z.object({ computer: z.string().optional(), target: z.union([targetSchema, startingTargetSchema]) }).strict().optional(),
handedOffFrom: z.object({ target: targetSchema, title: z.string().max(200) }).strict().optional(),
cwd: z.string().max(4096),
/** The dispatch the moved worker was launched for, as its turn record names it. */
dispatch: briefId.optional(),
workerDispatch: briefId.optional(),
handoff: z.object({ path: z.string().max(4096), source: z.enum(["agent", "fallback"]), reason: z.string().max(300).optional(), chars: z.number().int().nonnegative() }).strict().optional(),
/** How the old agent ended: its own exit command, an interrupt, or a kill after it hung. */
exit: z.enum(["clean", "interrupted", "killed"]).optional(),
placement: z.enum(["same-pane", "adjacent-pane", "peer"]).optional(),
target: z.union([targetSchema, startingTargetSchema]).optional(),
paneTarget: targetSchema.omit({ session: true }).optional(),
linkPending: z.boolean().optional(),
notice: z.string().max(500).optional(),
movedAt: timestamp.optional(),
error: z.string().max(500).optional(),
recovery: z.object({ attempts: z.number().int().positive(), nextAt: timestamp }).strict().optional(),
}).strict();
/** The slash command each harness ends its interactive session with. */
const EXIT_COMMANDS = { claude: "/exit", codex: "/quit", copilot: "/exit", opencode: "/exit", phren: "/exit" };
const HARNESS_NAMES = { claude: "Claude", codex: "Codex", opencode: "OpenCode", copilot: "Copilot", phren: "phren agent" };
const MOVE_KEEP_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_MOVES = 256;
const MAX_RECORD_BYTES = 32_768;
/** The launch brief's limit; a longer hand-off is read from its file. */
const PROMPT_LIMIT = 32_768;
export function movesRoot() { return path.join(bridgeRoot(), "moves"); }
const recordFile = (id) => path.join(movesRoot(), `${z.string().uuid().parse(id)}.json`);
/** The hand-off sits beside the new agent's brief, in a folder Claude is given read access to. */
export const handoffPath = (id) => path.join(briefRoot(), id, "handoff.md");
// A committed hand-over remains observable even while its status file cannot
// be written. Key by full path because one process may access multiple stores.
const landedMoves = new Map();
let writes = Promise.resolve();
async function saveMove(record) {
const run = writes.then(async () => {
await mkdir(movesRoot(), { recursive: true, mode: 0o700 });
await atomic(recordFile(record.id), moveRecordSchema.parse(record));
landedMoves.delete(recordFile(record.id));
});
writes = run.catch(() => undefined);
return run;
}
export async function readMove(id) {
const file = recordFile(id);
if (landedMoves.has(file))
return structuredClone(landedMoves.get(file));
const info = await lstat(file).catch(() => undefined);
if (!info?.isFile() || info.size > MAX_RECORD_BYTES)
return undefined;
try {
return moveRecordSchema.parse(JSON.parse(await readFile(file, "utf8")));
}
catch {
return undefined;
}
}
/** Recent moves, newest first; old ones are removed on the way. */
export async function listMoves(now = Date.now()) {
const names = (await readdir(movesRoot()).catch(() => [])).filter(name => /^[a-f0-9-]{36}\.json$/.test(name));
const records = (await Promise.all(names.map(name => readMove(name.slice(0, -5))))).filter((record) => !!record)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const settled = (record) => record.state === "moved" || record.state === "failed";
const stale = records.filter((record, index) => settled(record) && (index >= MAX_MOVES || now - Date.parse(record.updatedAt) > MOVE_KEEP_MS));
await Promise.all(stale.map(record => rm(recordFile(record.id), { force: true }).catch(() => undefined)));
return records.filter(record => !stale.includes(record));
}
/** The move that took the agent out of `target`'s pane: a target with a
* conversation matches only that conversation; a starting one, the pane. */
export function moveFrom(records, target) {
return records.find(record => {
const from = record.from.target;
return from.server === target.server && from.pane === target.pane && from.source === target.source
&& (target.session === undefined || from.session === target.session);
});
}
/** Conversation-bound annotations shared by tab and pane overview rows. */
export function moveSessionFields(records, target, title) {
const matches = (other) => other.server === target.server && other.workspace === target.workspace
&& other.tab === target.tab && other.pane === target.pane && other.source === target.source
&& "session" in other && other.session === target.session;
const outgoing = records.find(record => record.state === "moved" && record.mode === "new-chat" && matches(record.from.target));
const incoming = records.find(record => record.state === "moved" && record.mode === "new-chat" && !record.to.computer && record.target && matches(record.target));
const suffix = outgoing ? ` (handed off to ${outgoing.to.harness})` : "";
return { ...(outgoing?.handedOffTo ? { handedOffTo: outgoing.handedOffTo,
title: `${(title ?? outgoing.from.label ?? outgoing.from.target.source).replace(/ \(handed off to [^)]+\)$/, "")}${suffix}` } : {}),
...(incoming?.handedOffFrom ? { handedOffFrom: incoming.handedOffFrom } : {}) };
}
/** The receipt fields for a finished move. */
export function movedWorker(record) {
if (record.state !== "moved" || !record.target)
return undefined;
return { id: record.id, at: record.movedAt ?? record.updatedAt, ...(record.handoff ? { handoff: record.handoff.path } : {}),
...(record.computerFingerprint ? { computerFingerprint: record.computerFingerprint } : {}),
...(record.computerId ? { computerId: record.computerId } : {}),
from: { harness: record.from.target.source, ...(record.from.account ? { account: record.from.account } : {}) }, to: record.to, target: record.target };
}
/** A harness as people name it, with its account and model: "Claude (work, opus)". */
export function harnessName(to) {
const details = [to.account && to.account !== DEFAULT_ACCOUNT ? `account ${to.account}` : undefined, to.model, to.effort ? `${to.effort} effort` : undefined].filter(Boolean);
return `${HARNESS_NAMES[to.harness] ?? to.harness}${details.length ? ` (${details.join(", ")})` : ""}`;
}
const markers = (id) => {
const short = id.replaceAll("-", "").slice(0, 8);
return { begin: `=== PHREN HANDOFF ${short} BEGIN ===`, end: `=== PHREN HANDOFF ${short} END ===` };
};
/** What the moving agent is asked to write. */
export function handoffRequest(id, to, mode = "replace") {
const { begin, end } = markers(id);
return [
`Phren is moving this session to ${harnessName(to)}, which continues the work in this same folder. Write a hand-off for it now.`,
"",
"Do not commit, stash, reset, clean or stop anything. Your uncommitted changes and anything you left running stay as they are; Phren records them.",
"",
"Reply with only the hand-off, in Markdown, between these two lines:",
begin,
end,
"",
"Use these headings:",
"## Goal: what the owner asked for, in their terms",
"## Done: what is finished, with commits, PRs and files",
"## Current state: the branch, each uncommitted change and why it is there, processes or servers you started that are still running, anything half done",
"## Decisions: what you chose and why, including approaches you ruled out",
"## Next steps: in order",
"## Open questions: for the owner, or still undecided",
"## Key files: paths, a few words each, including files you had open or changed",
...(mode === "new-chat" ? ["The target starts in a new chat. Keep this agent running; do not exit after the hand-off."] : []),
"",
"Write it in full; do not shorten it. Use no tools once you start it. Phren saves it to a file and starts the next agent with it.",
].join("\n");
}
/** The hand-off between the markers in the agent's replies after the request,
* or undefined while its end marker has not arrived. Code fences around the
* block are dropped. */
export function extractHandoff(replies, id) {
const { begin, end } = markers(id);
const text = replies.join("\n\n");
const start = text.lastIndexOf(begin);
if (start < 0)
return undefined;
const stop = text.indexOf(end, start + begin.length);
if (stop < 0)
return undefined;
const body = text.slice(start + begin.length, stop).replace(/^\s*```[a-z]*\s*\n/i, "\n").replace(/\n\s*```\s*$/, "\n").trim();
return body || undefined;
}
/** The text an owner's (or another agent's) prompt row carries, without tool results or harness context. */
function userText(raw, source) {
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
const blocks = (content, types) => typeof content === "string" ? content
: Array.isArray(content) ? content.map(object).filter(block => types.includes(String(block.type))).map(block => String(block.text ?? "")).join("\n") : "";
let text = "";
if (source === "claude") {
const message = object(raw.message);
if (raw.type !== "user" || message.role !== "user" || raw.isMeta === true || raw.isSidechain === true)
return undefined;
text = blocks(message.content, ["text"]);
}
else if (source === "codex") {
const payload = object(raw.payload);
if (raw.type !== "response_item" || payload.type !== "message" || payload.role !== "user")
return undefined;
text = blocks(payload.content, ["input_text", "text"]);
}
else if (source === "copilot") {
if (raw.type !== "user.message" || raw.agentId)
return undefined;
text = String(object(raw.data).content ?? "");
}
else if (source === "opencode") {
const message = object(object(raw.data).message);
if (raw.type !== "user/message" || message.role !== "user")
return undefined;
text = blocks(message.content, ["text"]);
}
text = text.trim();
// Harness context and command echoes are not the owner's words.
if (!text || /^<(environment_context|user_instructions|command-|local-command|task-notification|system-reminder)/.test(text) || text.startsWith("# AGENTS.md"))
return undefined;
return text;
}
/** The owner's prompts and the agent's replies in a transcript's lines, oldest first. */
export function conversationEntries(lines, source) {
const entries = [];
for (const line of lines) {
if (!line.trim())
continue;
let raw;
try {
raw = JSON.parse(line);
}
catch {
continue;
}
const owner = userText(raw, source);
if (owner) {
entries.push({ role: "owner", text: owner });
continue;
}
const agent = publicAssistant(raw, source);
if (agent)
entries.push({ role: "agent", text: agent });
}
return entries;
}
/** Where the move's request sits in the conversation, -1 before it arrived. */
function requestIndex(entries, id) {
const { begin } = markers(id);
for (let index = entries.length - 1; index >= 0; index--)
if (entries[index].role === "owner" && entries[index].text.includes(begin))
return index;
return -1;
}
/** The agent's replies after the move's request: the request names the move, so an older hand-off never counts. */
export function repliesAfterRequest(entries, id) {
const asked = requestIndex(entries, id);
return asked < 0 ? [] : entries.slice(asked + 1).filter(entry => entry.role === "agent").map(entry => entry.text);
}
export async function readGitState(directory) {
const git = async (...args) => (await run("git", ["-C", directory, ...args], { timeout: 5_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
const root = (await git("rev-parse", "--show-toplevel").catch(() => "")).trim();
if (!root)
return undefined;
const [branch, head, upstream, status] = await Promise.all([
git("rev-parse", "--abbrev-ref", "HEAD").then(value => value.trim(), () => ""),
git("log", "-1", "--format=%h %s").then(value => value.trim(), () => ""),
git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}").then(value => value.trim(), () => ""),
git("status", "--porcelain=v1", "--untracked-files=all").catch(() => ""),
]);
const counts = upstream ? (await git("rev-list", "--left-right", "--count", `${upstream}...HEAD`).catch(() => "")).trim().split(/\s+/).map(Number) : [];
return { root, ...(branch && branch !== "HEAD" ? { branch } : {}), ...(head ? { head } : {}), ...(upstream ? { upstream } : {}),
...(counts.length === 2 && counts.every(Number.isFinite) ? { behind: counts[0], ahead: counts[1] } : {}),
changes: status.split("\n").filter(line => line.trim()) };
}
/** The commands of every process below `pids` (the agent's own children), not the agent itself. */
export async function processesBelow(pids) {
if (!pids.length || process.platform === "win32")
return [];
const { stdout } = await run("ps", ["-A", "-o", "pid=,ppid=,args="], { timeout: 5_000, maxBuffer: 8 * 1024 * 1024 });
const rows = stdout.split("\n").map(line => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)).filter((match) => !!match)
.map(match => ({ pid: Number(match[1]), ppid: Number(match[2]), args: match[3].trim() }));
const below = new Set(pids);
const found = [];
for (let grew = true; grew;) {
grew = false;
for (const row of rows)
if (below.has(row.ppid) && !below.has(row.pid)) {
below.add(row.pid);
found.push(`${row.pid} ${row.args}`);
grew = true;
}
}
return found;
}
/** What Phren itself saw at the move, appended to every hand-off. */
export function observedState(git, processes, processCommands = true) {
const lines = ["## Checked by Phren at the move", ""];
if (!git)
lines.push("- Not a git checkout, or git could not be read.");
else {
const tracking = git.upstream ? ` tracking ${git.upstream}${git.ahead !== undefined ? ` (ahead ${git.ahead}, behind ${git.behind})` : ""}` : ", with no upstream";
lines.push(`- Branch: ${git.branch ?? "detached HEAD"}${tracking}. HEAD: ${git.head ?? "unknown"}.`);
if (!git.changes.length)
lines.push("- No uncommitted changes.");
else
lines.push(`- Uncommitted changes (${git.changes.length}). The move committed, stashed and reset nothing: they are the previous agent's work in progress, so keep them.`, "", "```", ...git.changes, "```");
}
lines.push("");
if (!processCommands)
lines.push(`- The previous agent had ${processes.length} child processes running; process command lines are withheld on cross-computer moves.`);
else if (processes.length)
lines.push(`- Processes the previous agent had running (${processes.length}):`, "", "```", ...processes, "```");
else
lines.push("- The previous agent had no child processes running.");
return lines.join("\n");
}
/** A hand-off built without the agent: the conversation's tail, the original brief and the git state. */
export function fallbackHandoff(input) {
const lines = [
`The previous agent (${input.from}) did not write a hand-off: ${input.reason} Phren built this one from the end of its conversation instead.`,
"Check the checkout and what it already did before doing anything; do not start over.",
"",
];
if (input.dispatch)
lines.push(`It was working on dispatch ${input.dispatch}${input.label ? ` ("${input.label}")` : ""}.`, "");
const tail = input.entries.slice(-12);
if (!tail.length)
lines.push("Its conversation could not be read.", "");
else {
lines.push(`The last ${tail.length} messages of its conversation, oldest first:`, "");
for (const entry of tail)
lines.push(...quoted(entry.role === "owner" ? "Prompt to it" : "Its reply", entry.text));
}
const head = lines.join("\n");
// Written to a file, so the brief is never cut here.
return input.original ? withOriginalBrief(head, input.original, Number.MAX_SAFE_INTEGER, "the original brief file") : head;
}
/** The hand-off file: who moved where and when, the body, then what Phren saw. */
export function handoffDocument(record, body, observed, at, conductor = false) {
return [
`# Hand-off from ${harnessName({ harness: record.from.target.source, account: record.from.account })} to ${harnessName(record.to)}`,
"",
`Moved by Phren at ${at} (move ${record.id}) in ${record.cwd}.${record.dispatch ? ` Dispatch ${record.dispatch}.` : ""}`,
"",
...(record.mode === "new-chat" ? ["## Previous chat", "",
`Title: ${record.from.label ?? record.from.target.source}`,
"Target (JSON, for opening the old chat):", "```json", JSON.stringify(record.from.target), "```", ""] : []),
...(conductor ? ["## Conductor role", "", "You will become the conductor when the Hook confirms the hand-over; until then don't dispatch or answer workers.", ""] : []),
body.trim(),
"",
observed,
"",
].join("\n");
}
/** The new agent's first prompt: the whole hand-off when it fits the brief, else where to read it. */
export function continuePrompt(document, file) {
const lead = "Phren moved this session to you. The hand-off from the previous agent follows. Continue from here: check the current state first, keep its uncommitted changes, and do not start over.";
const whole = `${lead}\n\n${document}`;
if (whole.length <= PROMPT_LIMIT)
return whole;
return `Phren moved this session to you. The previous agent's hand-off is ${document.length} characters, too long for this prompt: read all of ${file} before anything else. Then continue from here: check the current state first, keep its uncommitted changes, and do not start over.`;
}
/** A pane's place without its agent: `findPane` with a source finds it only while that agent runs. */
const placeOf = (target) => ({ workspace: target.workspace, tab: target.tab, pane: target.pane });
/** Control characters other than tab and newline cannot ride in a brief. */
const briefText = (value) => value.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
const DEFAULT_HANDOFF_MS = 240_000;
/** Runs moves on the computer that runs the session. A move outlives the
* request that started it (the Hook answers in seconds); its record says where it is. */
export class SessionMover {
deps;
cancelled = new Set();
admissions = new Set();
running = new Map();
constructor(deps) {
this.deps = deps;
}
get now() { return (this.deps.now ?? Date.now)(); }
sleep(ms) { return (this.deps.sleep ?? (value => new Promise(resolve => setTimeout(resolve, value))))(ms); }
get poll() { return this.deps.pollMs ?? 2_000; }
/** Checks the move can happen, records it and starts it. Refuses before anything is typed. */
async start(input, caller = {}) {
const data = moveRequestSchema.parse(input);
if (data.to.computer && !caller.owner)
throw new BridgeError(403, "Only the owner can continue a chat on another computer.");
const key = `${data.target.server}\0${data.target.pane}`;
if (this.admissions.has(key))
throw new BridgeError(409, "This session is already being moved.");
this.admissions.add(key);
let release;
try {
return await this.admit(data, caller, () => { release = this.deps.succession?.reserveCaller(data.target); });
}
finally {
release?.();
this.admissions.delete(key);
}
}
async admit(input, caller, reserve) {
const data = moveRequestSchema.parse(input);
if (data.to.computer && !caller.owner)
throw new BridgeError(403, "Only the owner can continue a chat on another computer.");
if (data.id) {
const earlier = await readMove(data.id);
if (earlier) {
if ((earlier.modeForced === "conductor" || earlier.to.computer) && !caller.owner)
throw new BridgeError(403, "Only the owner can move the conductor.");
const from = earlier.from.target;
if (from.server !== data.target.server || from.pane !== data.target.pane || from.session !== data.target.session)
throw new BridgeError(409, "This move id was already used for another session.");
return earlier;
}
}
const { target } = data;
const peer = data.to.computer ? await (await import("./move-computers.js")).movePeer(data.to.computer, stableComputerName()) : undefined;
const to = { ...data.to };
if (!peer)
delete to.computer;
const active = (await listMoves(this.now)).find(record => record.from.target.server === target.server && record.from.target.pane === target.pane
&& record.state !== "moved" && record.state !== "failed");
if (active || [...this.running.keys()].some(key => key === `${target.server}\0${target.pane}`))
throw new BridgeError(409, "This session is already being moved.", { code: "move_in_progress" });
const s = await this.deps.snapshot(target.server);
const pane = findPane(s, placeOf(target));
if (!pane)
throw new BridgeError(404, "That pane is no longer open.");
if (pane.agent !== target.source)
throw new BridgeError(409, `That pane no longer runs ${HARNESS_NAMES[target.source] ?? target.source}.`);
const session = await this.deps.identity(target.server, pane).catch(() => undefined);
if (session && session !== target.session)
throw new BridgeError(409, "That pane runs another conversation now.");
const holdsRole = await this.deps.isConductor?.(target.server, pane) ?? false;
if (holdsRole && !caller.owner)
throw new BridgeError(403, "Only the owner can move the conductor.");
reserve();
const conductor = holdsRole ? await recordedConductor() : undefined;
if (holdsRole && (!conductor || conductor.server !== target.server || conductor.pane !== target.pane)) {
throw new BridgeError(409, "The conductor changed during the move; nothing was transferred.");
}
if (conductor && peer && !this.deps.succession)
throw new BridgeError(409, "Update this Hook to move the conductor to another computer.");
if (conductor && to.harness === "copilot")
throw new BridgeError(400, "Copilot cannot run as a conductor.");
const account = target.source === "claude" ? this.deps.paneAccount?.(target.server, pane) ?? DEFAULT_ACCOUNT : undefined;
const sameHarness = to.harness === target.source && (to.harness !== "claude" || (to.account ?? account) === account);
if (!peer && sameHarness && !to.model && !to.effort)
throw new BridgeError(400, "The session already runs there. Choose another harness, account, model or effort.");
// Only harnesses signed in and usable here; an inventory that cannot be read lets the move go on, as a launch does.
const inventory = peer ? undefined : await this.deps.inventory().catch(() => undefined);
if (inventory) {
const availability = hasUsable(inventory, to.harness, to.harness === "claude" ? to.account : undefined);
if (!availability.ok) {
const usable = inventory.harnesses.filter(entry => entry.usable && DISPATCH_HARNESSES.includes(entry.source)).map(entry => entry.source);
throw new BridgeError(409, `${HARNESS_NAMES[to.harness]} can't take this session here: ${availability.reason}.${usable.length ? ` Usable here: ${usable.join(", ")}.` : ""}`, { code: availability.code });
}
}
const cwd = String(pane.foreground_cwd || pane.cwd || "");
if (!path.isAbsolute(cwd))
throw new BridgeError(409, "The pane's folder is unknown, so the new agent could not start there.");
let project;
let accountChoice;
let computerId;
if (peer) {
const { getProjectDirs } = await import("../phren-paths.js");
const { phrenStoreRoot } = await import("./transcripts.js");
const { checkMovePeer } = await import("./move-computers.js");
// Match the registered checkout, including sessions in its subfolders.
const sourceDirectory = await realpath(cwd).catch(() => cwd);
let longest = 0;
for (const directoryName of getProjectDirs(phrenStoreRoot())) {
const name = path.basename(directoryName);
const directory = await dispatchProjectDirectory(name).catch(() => undefined);
if (directory && directory.length > longest && (sourceDirectory === directory || sourceDirectory.startsWith(directory + path.sep))) {
project = name;
longest = directory.length;
}
}
if (!project && conductor)
project = "phren-conductor";
if (!project)
throw new BridgeError(409, "The source folder is not a registered project checkout.");
projectName.parse(project);
const selected = await checkMovePeer(peer, project, to, !!conductor);
computerId = selected.computerId;
if (selected.account) {
to.account = selected.account;
accountChoice = selected.accountChoice;
}
}
const dispatch = await this.deps.paneDispatch?.(target.server, pane).catch(() => undefined);
const workerDispatch = peer && dispatch ? await (await import("./worker-relocation.js")).preflightWorkerRelocation(dispatch, target) : undefined;
const label = [pane.label, pane.title, pane.terminal_title_stripped, pane.agent_name].find(value => typeof value === "string" && value.trim() && !/^\d+$/.test(value));
const at = new Date(this.now).toISOString();
const record = { id: data.id ?? randomUUID(), state: "handing-off", createdAt: at, updatedAt: at,
...(project ? { project } : {}), mode: conductor || peer ? "new-chat" : data.mode, ...(conductor ? { modeForced: "conductor" } : {}), from: { target, ...(account ? { account } : {}), ...(label ? { label: label.slice(0, 200) } : {}) }, to, cwd, ...(computerId ? { computerId } : {}), ...(peer?.fingerprint ? { computerFingerprint: peer.fingerprint } : {}), ...(accountChoice ? { accountChoice } : {}),
...(dispatch && briefId.safeParse(dispatch).success ? { dispatch } : {}), ...(workerDispatch ? { workerDispatch } : {}) };
await assertUnusedLaunchId(record.id);
if (conductor && peer) {
if (!computerId)
throw new BridgeError(409, "The destination did not report its verified Hook identity.");
await this.deps.succession.begin(record.id, conductor, target, peer, computerId, record);
}
await saveMove(record);
const key = `${target.server}\0${target.pane}`;
const work = this.run(record, data.handoffTimeoutMs ?? DEFAULT_HANDOFF_MS, conductor, peer, project).finally(() => this.running.delete(key));
this.running.set(key, work);
return record;
}
/** For tests: the move under way in a pane, until it settles. */
settled(server, pane) { return this.running.get(`${server}\0${pane}`); }
async update(record, change) {
Object.assign(record, change, { updatedAt: new Date(this.now).toISOString() });
await saveMove(record);
}
async run(record, handoffMs, conductor, peer, project) {
const from = record.from.target;
let roleTransferred = false;
try {
// What the checkout and the agent's processes look like before it stops.
const pane = findPane(await this.deps.snapshot(from.server), placeOf(from));
const pids = pane ? (await this.deps.terminal.processes(from.server, from.pane).catch(() => undefined))?.foregroundPids ?? [] : [];
const [git, processes] = await Promise.all([(this.deps.git ?? readGitState)(record.cwd).catch(() => undefined),
(this.deps.processesBelow ?? processesBelow)(pids).catch(() => [])]);
const written = await this.askForHandoff(record, handoffMs);
const fallback = "text" in written ? undefined : await this.fallback(record, written.reason);
const body = "text" in written ? written.text : record.mode === "new-chat"
? `## Goal and current state\n\n${fallback}\n\n## Next steps\n\nContinue the owner's goal above after checking the checkout and the latest agent reply.\n\n## Key files\n\nOpen or changed files are named in the conversation above and the checked Git state below; open editor tabs are not available to the Hook.`
: fallback;
const document = handoffDocument(record, body, observedState(git, processes, !peer), new Date(this.now).toISOString(), !!conductor);
await this.assertNotAborted(record);
const file = handoffPath(record.id);
const prompt = briefText(continuePrompt(document, file));
// The folder is the new agent's brief folder; the hand-off is written into it, whole.
await writeLaunchBrief({ id: record.id, text: prompt }, this.now, record.from.label);
await atomic(file, document);
await this.update(record, { state: record.mode === "new-chat" ? "launching" : "exiting", handoff: { path: file, source: "text" in written ? "agent" : "fallback",
...("reason" in written ? { reason: written.reason.slice(0, 300) } : {}), chars: document.length } });
await this.checkPromotion(record);
if (record.mode !== "new-chat") {
const exit = await this.exit(record);
await this.update(record, { state: "launching", exit });
await this.deps.afterExit?.(from.server, from.pane).catch(() => undefined);
}
let launched;
if (peer) {
peer = await (await import("./move-computers.js")).currentMovePeer(peer);
const target = await this.continueOnPeer(record, peer, project, document);
launched = { target, to: record.to, placement: "peer" };
}
else
launched = await this.relaunch(record, prompt);
await this.checkPromotion(record);
if (conductor && peer) {
if (!launched.target || !("session" in launched.target))
throw new BridgeError(409, "The prepared successor conversation was not confirmed.");
await this.deps.succession.prepared(record.id, launched.target);
await this.deps.succession.commit(record.id);
roleTransferred = true;
await this.deps.succession.finish(record.id);
}
else if (conductor) {
try {
if (launched.linkPending || !launched.target || !("session" in launched.target))
throw new Error("The new conductor conversation is not linked; the hand-over was not confirmed.");
const confirmedTarget = launched.target;
let accepted = false;
for (const until = this.now + 30_000; this.now < until; await this.sleep(this.poll)) {
const arrival = (await briefArrival(record.id))?.accepted?.target;
if (arrival && Object.keys(confirmedTarget).every(key => arrival[key] === confirmedTarget[key])) {
accepted = true;
break;
}
}
if (!accepted)
throw new Error("The new conductor's hand-off brief was not confirmed within 30 seconds.");
const place = record.paneTarget;
const next = place && findPane(await this.deps.snapshot(place.server), place);
if (!place || !runsAgent(next) || next.agent !== record.to.harness)
throw new Error("The new agent is no longer running in its launched pane.");
if (!["idle", "working"].includes(String(next.agent_status)))
throw new Error(`The new conductor is not ready (${String(next.agent_status)}); answer its startup dialog before retrying.`);
if (typeof next.terminal_id !== "string" || !next.terminal_id)
throw new Error("The new pane has no terminal identity for worker returns.");
const origin = { server: place.server, workspace: place.workspace, tab: place.tab, pane: place.pane,
agent: record.to.harness, terminal: next.terminal_id };
await transferConductor(conductor, place.server, next, launched.target && "session" in launched.target ? launched.target.session : undefined, commit => repointDispatchOrigins(conductor, origin, commit));
roleTransferred = true;
const notice = await this.conductorNotice(record, "The Hook confirmed the hand-over; you are now the conductor. Continue coordinating the owner's work and existing workers.", "confirmed");
if (notice)
record.notice = notice;
}
catch (error) {
if (error instanceof BridgeError)
throw error;
throw new Error(`Conductor role transfer failed. The old chat was left running. ${error instanceof Error ? error.message : String(error)}`);
}
}
await this.update(record, { state: "moved", movedAt: new Date(this.now).toISOString(), ...launched,
...(record.mode === "new-chat" ? {
...(launched.target ? { handedOffTo: { ...(peer ? { computer: peer.name } : {}), target: launched.target } } : {}),
handedOffFrom: { target: from, title: record.from.label ?? from.source },
} : {}) });
logger.info("move", `Moved ${from.source} in pane ${from.pane} to ${harnessName(record.to)} (${launched.placement}); hand-off ${record.handoff?.source}.`);
}
catch (error) {
const message = (error instanceof Error ? error.message : String(error)).replace(/[\x00-\x1f\x7f]+/g, " ");
if (roleTransferred) {
// update() has already populated the complete moved record. The role
// and worker routes committed; a later status save cannot undo them.
record.state = peer ? "uncertain" : "moved";
record.notice = `Conductor moved with worker routing, but saving move status failed: ${message}`.slice(0, 500);
delete record.error;
landedMoves.set(recordFile(record.id), record);
while (landedMoves.size > MAX_MOVES)
landedMoves.delete(landedMoves.keys().next().value);
await saveMove(record).catch(() => undefined);
logger.warn("move", record.notice);
return;
}
if (conductor && peer) {
await this.deps.succession.abort(record.id).catch(() => undefined);
const status = await this.deps.succession.status(record.id).catch(() => undefined);
if (status?.phase === "committed") {
await this.update(record, { state: "uncertain", error: "The ownership commit is being reconciled; the previous generation remains fenced." }).catch(() => undefined);
return;
}
}
if (conductor && record.paneTarget && !peer) {
record.notice = await this.conductorNotice(record, "The conductor hand-over failed; you are not the conductor. The previous conductor keeps coordinating.", "failed")
?? "The new chat was told the conductor hand-over failed; it is not the conductor.";
}
const stopped = record.exit !== undefined;
const hint = stopped && record.handoff ? ` The old agent has exited; start ${HARNESS_NAMES[record.to.harness]} in the pane with the hand-off at ${record.handoff.path}.` : "";
await this.update(record, { state: record.state === "uncertain" ? "uncertain" : "failed", error: `${message}${hint}`.slice(0, 500) }).catch(() => undefined);
logger.warn("move", `Moving pane ${from.pane} to ${record.to.harness} failed: ${message}`);
}
}
async abort(id) {
const record = await readMove(id);
if (!record)
throw new BridgeError(404, "No move with that id is recorded.");
if (record.state === "moved")
throw new BridgeError(409, "This move is already committed; it cannot be aborted.");
if (record.modeForced === "conductor" && record.to.computer) {
await this.deps.succession.abort(id);
if ((await this.deps.succession.status(id)).phase === "committed")
throw new BridgeError(409, "This ownership generation is committed; reconcile it instead of rolling back.");
}
else if (record.state !== "handing-off" && record.state !== "failed")
throw new BridgeError(409, "This launch may already exist; reconcile it before aborting.");
this.cancelled.add(id);
await this.update(record, { state: "failed", error: "The owner aborted this uncommitted move; the original chat keeps its role." });
return record;
}
async assertNotAborted(record) {
if (this.cancelled.has(record.id) || (await readMove(record.id))?.state === "failed" || record.modeForced === "conductor" && record.to.computer && (await this.deps.succession.status(record.id)).phase === "aborted")
throw new BridgeError(409, "This uncommitted move was aborted; no conductor role was transferred.");
}
async continueOnPeer(record, peer, project, document) {
// Full inline text only: the peer cannot read this Hook's brief path.
// Preserve commit ids and the complete hand-off, redacting credentials
// in place instead of applying the lossy approval/log summarizer to it.
const safeDocument = document.replaceAll(record.cwd, "<source checkout>");
const remotePrompt = redactPeerHandoff(briefText(`Continue the owner's work from this hand-off. This is a different checkout: local uncommitted files and running processes were not copied. Check its branch and files before proceeding.\n\n${safeDocument}`));
if (remotePrompt.length > PROMPT_LIMIT)
throw new BridgeError(409, "The hand-off is too long to send in full to another computer; the old chat remains open.");
const checked = await (await import("./move-computers.js")).checkMovePeer(peer, project, record.to, record.modeForced === "conductor");
if (record.computerId && checked.computerId !== record.computerId)
throw new BridgeError(409, "The destination Hook identity changed during the move; nothing was launched.");
await this.checkPromotion(record);
await this.assertNotAborted(record);
await this.update(record, { state: "uncertain", placement: "peer" });
const label = redactSecretsForLog(redactPeerHandoff(redactApproval(briefText(record.from.label ?? "Continued chat"))));
const { computer: _destination, ...destination } = record.to;
await saveContinuationIntent({ computerId: record.computerId, project, to: destination, label, brief: { id: record.id, text: remotePrompt }, worker: !!record.dispatch, ...(record.workerDispatch ? { workerDispatch: record.workerDispatch } : {}) });
if (record.modeForced === "conductor") {
const { computer: _computer, ...to } = record.to;
await this.deps.succession.saveLaunch(record.id, { computerId: record.computerId, project, to, label, brief: { id: record.id, text: remotePrompt }, worker: !!record.dispatch, ...(record.workerDispatch ? { workerDispatch: record.workerDispatch } : {}) });
}
const target = await new DispatchService().continueChat(peer, project, record.to, { id: record.id, text: remotePrompt }, label, async (target) => {
const parsed = targetSchema.safeParse(target);
const pane = targetSchema.omit({ session: true }).safeParse(object(target).paneTarget);
if (pane.success)
await this.update(record, { state: "uncertain", paneTarget: pane.data, placement: "peer" });
if (parsed.success) {
await this.update(record, { state: "uncertain", target: parsed.data, placement: "peer" });
if (record.modeForced === "conductor")
await this.deps.succession.prepared(record.id, parsed.data);
}
}, record.computerId, !!record.dispatch, record.workerDispatch);
return target;
}
/** Startup and tick recovery reuse the receiving Hook's durable move id.
* No fresh id, brief or pane is created after an uncertain launch. */
async reconcile() {
const transaction = (await (await import("./conductor-role.js")).readRoleState())?.succession;
if (transaction?.side === "origin" && ["preparing", "prepared"].includes(transaction.phase) && transaction.resume && !await readMove(transaction.id))
await saveMove(moveRecordSchema.parse(transaction.resume));
for (const record of await listMoves(this.now)) {
if (["moved", "failed"].includes(record.state) || !record.to.computer || !record.project)
continue;
if (record.recovery && Date.parse(record.recovery.nextAt) > this.now)
continue;
const key = `${record.from.target.server}\0${record.from.target.pane}`;
if (this.running.has(key) || this.admissions.has(key))
continue;
const work = (async () => {
try {
const { movePeer, currentMovePeer } = await import("./move-computers.js");
const enrolled = await movePeer(record.to.computer);
if (!enrolled || enrolled.fingerprint !== record.computerFingerprint)
throw new BridgeError(409, "The destination enrollment changed; reconciliation is paused.");
const peer = await currentMovePeer(enrolled);
if (record.modeForced === "conductor" && transaction?.id === record.id && transaction.phase === "aborted") {
await this.update(record, { state: "failed", error: transaction.revoked ? "The owner stopped the delegated conductor; no previous chat regained the role." : "The hand-over was aborted; the previous conductor keeps the role." });
return;
}
if (this.now - Date.parse(record.createdAt) >= 10 * 60_000) {
const placement = await peer.request(`/v1/sessions/move/launch?id=${record.id}`, undefined, 5_000);
if (placement.state === "not-placed") {
if (record.modeForced === "conductor") {
await this.deps.succession.abort(record.id);
if ((await this.deps.succession.status(record.id)).phase === "committed")
throw new BridgeError(409, "The ownership is committed; reconciliation cannot abort it.");
}
await this.update(record, { state: "failed", error: "The destination confirmed no pane exists after ten minutes. The move was aborted; the original chat keeps its role." });
return;
}
}
if (record.modeForced === "conductor")
await this.deps.succession.ensurePrepared(record.id);
const document = await readFile(handoffPath(record.id), "utf8").catch(() => undefined);
if (!document) {
await this.run(record, DEFAULT_HANDOFF_MS, record.modeForced === "conductor" ? transaction?.previous : undefined, peer, record.project);
if (record.state === "uncertain")
throw new BridgeError(503, record.error ?? "The launch is still uncertain.");
return;
}
const target = await this.continueOnPeer(record, peer, record.project, document);
if (record.modeForced === "conductor") {
const status = await this.deps.succession.status(record.id);
if (status.phase !== "committed") {
await this.deps.succession.prepared(record.id, target);
await this.deps.succession.commit(record.id);
}
await this.deps.succession.finish(record.id);
}
await this.update(record, { state: "moved", movedAt: new Date(this.now).toISOString(), target, placement: "peer", error: undefined,
handedOffTo: { computer: peer.name, target }, handedOffFrom: { target: record.from.target, title: record.from.label ?? record.from.target.source } });
}
catch (error) {
const attempts = (record.recovery?.attempts ?? 0) + 1;
const deadline = Date.parse(record.createdAt) + 10 * 60_000;
const refused = error instanceof BridgeError && error.status >= 400 && error.status < 500;
let message = String(error instanceof Error ? error.message : error);
if (refused || this.now >= deadline) {
if (record.modeForced === "conductor") {
// abort() reads the durable commit point and cannot roll back a
// committed generation, including a lost commit acknowledgement.
await this.deps.succession.abort(record.id).catch(() => undefined);
const state = await this.deps.succession.status(record.id).catch(() => undefined);
message += state?.phase === "committed" ? " Ownership is committed; the old chat remains fenced. Reconcile conductor status on the original Hook." : state?.phase === "aborted" ? " The uncommitted move was aborted; the original conductor keeps the role." : " Ownership is uncertain; verify the original Hook's generation before changing roles.";
}
await this.update(record, { state: "failed", error: message.slice(0, 500) }).catch(() => undefined);
}
else {
const delay = Math.min(600_000, 5_000 * 2 ** Math.min(attempts - 1, 20));
await this.update(record, { state: "uncertain", error: message.slice(0, 500), recovery: { attempts, nextAt: new Date(Math.min(this.now + delay, deadline)).toISOString() } }).catch(() => undefined);
}
}
})().finally(() => this.running.delete(key));
this.running.set(key, work);
}
}
/** An ordinary move never acquires permission to stop a conductor merely
* because the owner promoted its source while the hand-off was running. */
async checkPromotion(record) {
if (record.modeForced === "conductor")
return;
const current = await recordedConductor();
if (current?.server === record.from.target.server && current.pane === record.from.target.pane) {
throw new BridgeError(409, "The pane became the conductor during the move; nothing was changed.");
}
}
/** Uses the same durable prompt path as the hand-off and its first brief. */
async conductorNotice(record, text, outcome) {
try {
const target = targetSchema.safeParse(record.target);
if (target.success) {
const sent = await this.deps.deliver(target.data, text, `move-${record.id}-conductor-${outcome}`);
if (sent.state === "failed" || (sent.ok !== true && sent.queued !== true) || (sent.delivered === false && sent.queued !== true)) {
throw new Error(String(sent.error ?? "delivery was not confirmed"));
}
}
else if (record.paneTarget) {
await this.deps.terminal.prompt(record.paneTarget.server, record.paneTarget.pane, text);
return `The conductor hand-over ${outcome} notice was typed to the new pane; conversation delivery is unconfirmed.`;
}
return undefined;
}
catch (error) {
return `The conductor hand-over ${outcome}, but the new chat's notice could not be delivered: ${error instanceof Error ? error.message : String(error)}`.slice(0, 500);
}
}
/** The agent's own hand-off, or why there is none. Fails only when the session itself went away. */
async askForHandoff(record, handoffMs) {
const from = record.from.target;
const deadline = this.now + handoffMs;
try {
const sent = await this.deps.deliver(from, handoffRequest(record.id, record.to, record.mode), `move-${record.id}`);
// A lost delivery acknowledgement can still have reached the agent;
// its marked transcript reply is authoritative for the normal window.
if (sent.state !== "uncertain" && sent.ok !== true && sent.queued !== true)
return { reason: `the request could not be delivered (${String(sent.error ?? sent.state ?? "not confirmed")}).` };
}
catch (error) {
return { reason: `the request could not be delivered (${error instanceof Error ? error.message : String(error)}).` };
}
while (this.now < deadline) {
await this.sleep(this.poll);
const pane = findPane(await this.deps.snapshot(from.server), placeOf(from));
if (!pane || pane.agent !== from.source)
throw new BridgeError(409, "The session ended before it wrote its hand-off; nothing was started.");
const lines = await this.deps.transcript(from).catch(() => []);
const text = extractHandoff(repliesAfterRequest(conversationEntries(lines, from.source), record.id), record.id);
if (!text)
continue;
// Its turn ends with the hand-off; wait for that before typing the exit.
while (this.now < deadline) {
const now = findPane(await this.deps.snapshot(from.server), placeOf(from));
if (!now || now.agent_status !== "working")
break;
await this.sleep(this.poll);
}
return { text };
}
return { reason: `it did not reply with one within ${Math.round(handoffMs / 1000)} seconds.` };
}
async fallback(record, reason) {
const from = record.from.target;
const lines = await this.deps.transcript(from).catch(() => []);
// The move's own request and anything after it are not the work.
const entries = conversationEntries(lines, from.source);
const asked = requestIndex(entries, record.id);
const original = record.dispatch ? await this.deps.brief?.(record.dispatch).catch(() => undefined) : undefined;
return fallbackHandoff({ reason, from: harnessName({ harness: from.source, account: record.from.account }),
entries: asked < 0 ? entries : entries.slice(0, asked), ...(original ? { original } : {}),
...(record.dispatch ? { dispatch: record.dispatch } : {}), ...(record.from.label ? { label: record.from.label } : {}) });
}
/** Ends the old agent with its own exit command; an interrupt, then a kill, only when it hangs. */
async exit(record) {
const from = record.from.target;
const exitMs = this.deps.exitMs ?? 20_000;
const gone = async (ms) => {
const until = this.now + ms;
for (;;) {
const pane = findPane(await this.deps.snapshot(from.server), placeOf(from));
if (!pane)
throw new BridgeError(409, "The pane closed while the old agent exited; nothing was started.");
if (pane.agent !== from.source)
return true;
if (this.now >= until)
return false;
await this.sleep(Math.min(this.poll, 500));
}
};
const pane = findPane(await this.deps.snapshot(from.server), placeOf(from));
// A turn still running (a hand-off that ran late) is stopped first, as Esc in the terminal would.
if (pane?.agent_status === "working") {
await this.checkPromotion(record);
await this.deps.terminal.sendKeys(from.server, from.pane, ["esc"]).catch(() => undefined);
await this.sleep(1_000);
}
await this.checkPromotion(record);
await this.deps.terminal.prompt(from.server, from.pane, EXIT_COMMANDS[from.source] ?? "/exit").catch(() => undefined);
if (await gone(exitMs))
return "clean";
for (let n = 0; n < 2; n++) {
await this.checkPromotion(record);
await this.deps.terminal.sendKeys(from.server, from.pane, ["ctrl+c"]).catch(() => undefined);
await this.sleep(500);
}
if (await gone(5_000))
return "interrupted";
const { foregroundPids, shellPid } = await this.deps.terminal.processes(from.server, from.pane);
const agent = foregroundPids.filter(pid => pid !== shellPid);
const kill = this.deps.kill ?? ((pid, signal) => process.kill(pid, signal));
for (const signal of ["SIGTERM", "SIGKILL"]) {
await this.checkPromotion(record);
for (const pid of agent) {
try {
kill(pid, signal);
}
catch { /* already gone */ }
}
if (await gone(5_000))
return "killed";
}
throw new BridgeError(409, "The old agent did not exit, even when killed; nothing was started.");
}
/** Starts the target in the old pane when its shell has what the target needs, else in a new tab beside it. */
async relaunch(record, prompt) {
const from = record.from.target;
const s = await this.deps.snapshot(from.server);
const pane = findPane(s, placeOf(from));
if (!pane)
throw new BridgeError(409, "The pane closed after the old agent exited.");
// A Claude target with no account keeps the pane's own, so its shell's CLAUDE_CONFIG_DIR is right.
const shellAccount = record.from.account ?? DEFAULT_ACCOUNT;
const account = record.to.harness === "claude" ? record.to.account ?? shellAccount : undefined;
const samePane = record.mode !== "new-chat" && ((this.deps.envAtStart?.(from.server) ?? false) || record.to.harness !== "claude" || account === shellAccount);
const data = { kind: record.to.harness, label: record.from.label ?? HARNESS_NAMES[record.to.harness], cwd: record.cwd,
...(record.to.model ? { model: record.to.model } : {}), ...(record.to.effort ? { effort: record.to.effort } : {}), ...(account ? { account } : {}),
brief: { id: record.id, text: prompt }, ...(samePane ? {} : { workspaceId: from.workspace }) };
const into = samePane ? { workspaceId: from.workspace, tabId: from.tab, paneId: from.pane } : undefined;
await this.checkPromotion(record);
const launched = await this.deps.launch(from.server, data, { ...(into ? { into } : {}), ...(record.dispatch && record.modeForced !== "conductor" ? { dispatchId: record.dispatch } : {}) });
// Keep the launched location even if delivering its first prompt fails.
const paneTarget = targetSchema.omit({ session: true }).parse({ server: from.server,
workspace: launched.workspaceId, tab: launched.tabId, pane: launched.paneId, source: record.to.harness });
const launchedTarget = z.union([targetSchema, startingTargetSchema]).safeParse(launched.target);
await this.update(record, { paneTarget, placement: samePane ? "same-pane" : "adjacent-pane",
...(launchedTarget.success ? { target: launchedTarget.data } : {}) });
// The target that takes no brief at launch (Copilot) gets it typed, as dispatch does.
if (launched.briefLaunched === false) {
try {
const typedTo = targetSchema.safeParse(launched.target);
if (typedTo.success) {
const sent = await this.deps.deliver(typedTo.data, prompt, `move-${record.id}-brief`);
if (sent.state === "failed" || (sent.ok !== true && sent.queued !== true)
|| (sent.delivered === false && sent.queued !== true)) {
throw new Error(String(sent.error ?? sent.state ?? "delivery was not confirmed"));
}
}
else
await this.deps.terminal.prompt(from.server, paneTarget.pane, prompt);
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${record.mode === "new-chat" ? "New chat" : "Target"} started but did not receive the hand-off. The launched pane was left open. ${reason}`);
}
}
if (!samePane && record.mode !== "new-chat") {
await this.checkPromotion(record);
await this.deps.terminal.closePane(from.server, from.pane).catch(() => undefined);
}
// The account the new agent runs under, also when the move named none.
const to = account ? { ...record.to, account } : record.to;
return { to, placement: samePane ? "same-pane" : "adjacent-pane", ...await this.newTarget(record, launched, record.to.harness) };
}
/** New chats remain open as linking while the harness establishes its conversation. */
async newTarget(record, launched, source) {
const server = record.from.target.server;
const full = targetSchema.safeParse(launched.target);
if (full.success)
return { target: full.data };
const place = { server, workspace: String(launched.workspaceId), tab: String(launched.tabId), pane: String(launched.paneId), source };
const newChat = record.mode === "new-chat";
if (newChat)
await this.update(record, { state: "linking" });
for (const until = this.now + (newChat ? 180_000 : 30_000); this.now < until; await this.sleep(this.poll)) {
const pane = findPane(await this.deps.snapshot(server).catch(() => ({})), place);
const session = pane && pane.agent === source ? await this.deps.identity(server, pane).catch(() => undefined) : undefined;
const target = targetSchema.safeParse({ ...place, session });
if (target.success)
return { target: target.data };
}
const starting = startingTargetSchema.safeParse(launched.target);
return { ...(starting.success ? { target: starting.data } : {}),
...(newChat ? { linkPending: true, notice: "New chat started; the conversation link is pending after three minutes. Use the pane target to open it." } : {}) };
}
}
/** The last `bytes` of a transcript as lines, the first (cut) one dropped. */
export async function transcriptTail(file, bytes = 512 * 1024) {
const handle = await open(file, "r");
try {
const { size } = await handle.stat();
const start = Math.max(0, size - bytes), buffer = Buffer.alloc(size - start);
await handle.read(buffer, 0, buffer.length, start);
const lines = buffer.toString("utf8").split("\n");
return start > 0 ? lines.slice(1) : lines;
}
finally {
await handle.close();
}
}