UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

191 lines (190 loc) • 9.27 kB
import { open } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { codexHome } from "../home-paths.js"; import { stripTerminal } from "../terminal-text.js"; import { codexServers } from "./codex-servers.js"; import { paneIdentity, snapshot } from "./herdr.js"; import { emptyComposer } from "./model-switch.js"; import { paneClient, servedPane } from "./opencode-panes.js"; import { BridgeError, id, objects } from "./protocol.js"; import { terminalProvider } from "./terminal.js"; import { visibleTerminalChoice } from "./terminal-choice.js"; import { targetTranscriptPath } from "./transcripts.js"; /** Renaming a session from the phone: the pane's and tab's terminal labels, * then the harness's own rename where one can be driven remotely. */ /** What the phone may send as a session name: 1 to 80 characters, no control characters. */ export const renameLabel = z.string().trim().min(1).max(80).refine(t => !/[\x00-\x1f\x7f]/.test(t)); export const renameRequest = z.object({ workspaceId: id, tabId: id, paneId: id.optional(), label: renameLabel }); const TAIL_BYTES = 262_144; /** The last `custom-title` in the tail of a Claude transcript. */ export function lastCustomTitle(text) { for (const line of text.split("\n").reverse()) { if (!line.includes('"custom-title"')) continue; try { const row = JSON.parse(line); if (row.type === "custom-title" && typeof row.customTitle === "string") return row.customTitle; } catch { /* a line cut by the tail window */ } } return undefined; } /** The last `thread_name` written for `thread` in a Codex `session_index.jsonl`. */ export function lastIndexedName(text, thread) { for (const line of text.split("\n").reverse()) { if (!line.includes(thread)) continue; try { const row = JSON.parse(line); if (row.id === thread && typeof row.thread_name === "string") return row.thread_name; } catch { /* a line cut by the tail window */ } } return undefined; } async function tail(file) { const handle = await open(file, "r").catch(() => undefined); if (!handle) return undefined; try { const { size } = await handle.stat(); const length = Math.min(size, TAIL_BYTES), buffer = Buffer.alloc(length); await handle.read(buffer, 0, length, size - length); return buffer.toString("utf8"); } finally { await handle.close(); } } export const defaultRenameDeps = { snapshot, identity: (server, pane) => paneIdentity(server, pane, true), terminal: terminalProvider, async codexRename(target, label) { const entry = codexServers.forTarget(target); if (!entry) return undefined; await codexServers.renameThread(entry, label); return true; }, opencodeClient(server, pane) { const entry = servedPane(server, pane); return entry ? paneClient(entry) : undefined; }, async claudeTitle(target) { const text = await tail(await targetTranscriptPath({ ...target, source: "claude" })); return text === undefined ? undefined : lastCustomTitle(text); }, async codexIndexTitle(thread) { const text = await tail(path.join(codexHome(), "session_index.jsonl")); return text === undefined ? undefined : lastIndexedName(text, thread); }, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), verifyMs: 4_000, }; /** The agent pane a rename addresses: the named pane, else the tab's only agent pane, else its only pane. */ export function renamePane(panes, paneId) { if (!panes.length) throw new BridgeError(409, "The tab changed."); const agents = panes.filter(p => p.agent); if (paneId) { const pane = panes.find(p => p.pane_id === paneId); if (!pane) throw new BridgeError(409, "The pane changed."); return { pane, renameTab: agents.length ? agents.length === 1 && !!pane.agent : panes.length === 1 }; } if (agents.length === 1) return { pane: agents[0], renameTab: true }; if (!agents.length && panes.length === 1) return { pane: panes[0], renameTab: true }; throw new BridgeError(400, "Choose a pane to rename."); } /** Types a slash command into an agent pane that is showing an empty composer. */ async function typeCommand(deps, server, pane, text) { const terminal = deps.terminal(); const before = await terminal.readScreen(server, String(pane.pane_id), { scope: "pane", source: "visible", lines: 40, format: "ansi", timeoutMs: 2_000 }).catch(() => ""); const composer = before.split(/\r?\n/).reverse().find(line => /^\s*[›❯>]/.test(stripTerminal(line))); if (!composer || !emptyComposer(composer) || visibleTerminalChoice(stripTerminal(before))) return "the terminal has a draft or an open menu"; await terminal.prompt(server, String(pane.pane_id), text); return undefined; } /** Polls `seen` until it holds or the deadline passes. */ async function confirmed(deps, seen) { const deadline = Date.now() + deps.verifyMs; do { if (await seen().catch(() => false)) return true; await deps.sleep(150); } while (Date.now() < deadline); return false; } /** The harness's own rename for the pane's session, never failing the request: a refusal is reported as `reason`. */ async function nativeRename(deps, server, workspace, tab, pane, label) { const harness = typeof pane.agent === "string" && pane.agent ? pane.agent : "none"; const skip = (mechanism, reason) => ({ harness, mechanism, applied: false, reason }); if (!["claude", "codex", "opencode"].includes(harness)) return skip("none", "This harness has no remote rename."); const paneId = String(pane.pane_id), status = String(pane.agent_status); let session; try { session = await deps.identity(server, pane); } catch { session = undefined; } if (!session && harness !== "opencode") return skip("none", "The session is not identified yet."); const target = { server, workspace, tab, pane: paneId, source: harness, session: session ?? "" }; let mechanism = harness === "opencode" ? "opencode-api" : "slash-command"; try { if (harness === "opencode") { const client = deps.opencodeClient(server, paneId); if (!client) return skip("none", "This OpenCode was not started by the Hook."); const chosen = session ?? (await client.currentSession())?.id; if (!chosen) return skip("opencode-api", "OpenCode has no session yet."); const title = await client.setTitle(chosen, label); return title === label ? { harness, mechanism: "opencode-api", applied: true } : skip("opencode-api", "OpenCode did not confirm the new name."); } if (harness === "codex") { mechanism = "app-server"; const served = await deps.codexRename(target, label); if (served) return { harness, mechanism, applied: true }; mechanism = "slash-command"; } // A typed command runs only where the harness reads it as one: never over a dialog. if (["blocked", "waiting", "unknown"].includes(status)) return skip("slash-command", "The agent needs input in the terminal first."); if (harness === "codex" && status === "working") return skip("slash-command", "The agent is working."); const refused = await typeCommand(deps, server, pane, `/rename ${label}`); if (refused) return skip("slash-command", `Not typed: ${refused}.`); const applied = await confirmed(deps, harness === "claude" ? async () => (await deps.claudeTitle(target)) === label : async () => (await deps.codexIndexTitle(session)) === label); return applied ? { harness, mechanism: "slash-command", applied: true } : skip("slash-command", "The harness did not confirm the new name."); } catch (error) { return skip(mechanism, error instanceof Error ? error.message.slice(0, 200) : "The rename failed."); } } /** `POST /v1/sessions/rename`: terminal labels first, then the harness's own name. */ export async function renameSession(server, data, deps = defaultRenameDeps) { const { workspaceId, tabId, paneId, label } = renameRequest.parse(data); const s = await deps.snapshot(server); if (!objects(s.tabs).some(t => t.tab_id === tabId && t.workspace_id === workspaceId)) throw new BridgeError(409, "The tab changed."); const { pane, renameTab } = renamePane(objects(s.panes).filter(p => p.tab_id === tabId && p.workspace_id === workspaceId), paneId); const terminal = deps.terminal(); await terminal.renamePane(server, String(pane.pane_id), label); if (renameTab) await terminal.groupAction(server, "rename", { workspace: workspaceId, tab: tabId }, label); const native = await nativeRename(deps, server, workspaceId, tabId, pane, label); return { ok: true, label, pane: pane.pane_id, native }; }