From 6d857f59143b5d590446fd006b0d33e3f0864466 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:08:53 +0200 Subject: [PATCH 1/7] wip: checkpoint independent Promachos v2 home and view port --- apps/server/src/promachos/PromachosHome.ts | 143 ++++++++++++ apps/server/src/ws.ts | 6 + apps/web/src/components/AppSidebarLayout.tsx | 5 + .../components/promachos/PromachosChat.tsx | 84 +++++++ .../promachos/PromachosModeSwitch.tsx | 38 ++++ .../components/promachos/PromachosNewHome.tsx | 112 +++++++++ .../components/promachos/PromachosSidebar.tsx | 214 ++++++++++++++++++ .../promachos/promachosBubbles.test.tsx | 58 +++++ .../components/promachos/promachosBubbles.ts | 56 +++++ .../promachos/promachosConversations.test.ts | 56 +++++ .../promachos/promachosConversations.ts | 36 +++ .../src/components/promachos/promachosMode.ts | 59 +++++ .../promachos/promachosStart.test.ts | 123 ++++++++++ .../components/promachos/promachosStart.ts | 55 +++++ .../components/promachos/promachosTimeline.ts | 81 +++++++ .../src/components/sidebar/SidebarChrome.tsx | 10 + docs/user/promachos-mode.md | 41 ++++ .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/promachosHome.ts | 44 ++++ packages/contracts/src/rpc.ts | 2 + 21 files changed, 1226 insertions(+) create mode 100644 apps/server/src/promachos/PromachosHome.ts create mode 100644 apps/web/src/components/promachos/PromachosChat.tsx create mode 100644 apps/web/src/components/promachos/PromachosModeSwitch.tsx create mode 100644 apps/web/src/components/promachos/PromachosNewHome.tsx create mode 100644 apps/web/src/components/promachos/PromachosSidebar.tsx create mode 100644 apps/web/src/components/promachos/promachosBubbles.test.tsx create mode 100644 apps/web/src/components/promachos/promachosBubbles.ts create mode 100644 apps/web/src/components/promachos/promachosConversations.test.ts create mode 100644 apps/web/src/components/promachos/promachosConversations.ts create mode 100644 apps/web/src/components/promachos/promachosMode.ts create mode 100644 apps/web/src/components/promachos/promachosStart.test.ts create mode 100644 apps/web/src/components/promachos/promachosStart.ts create mode 100644 apps/web/src/components/promachos/promachosTimeline.ts create mode 100644 docs/user/promachos-mode.md create mode 100644 packages/contracts/src/promachosHome.ts diff --git a/apps/server/src/promachos/PromachosHome.ts b/apps/server/src/promachos/PromachosHome.ts new file mode 100644 index 0000000000..b633dc88a0 --- /dev/null +++ b/apps/server/src/promachos/PromachosHome.ts @@ -0,0 +1,143 @@ +/** + * Creates a starter Promachos home (toolboxmd/chromeria#139) on this server's machine, so it + * works the same for local and remote environments. + * + * Every step keeps what is already there: an existing folder, git repository (including a + * parent repository or a worktree), `AGENTS.md`, `CLAUDE.md` or project is reused. A failed + * attempt therefore leaves nothing that blocks a retry. + */ +import { + CommandId, + ProjectId, + PromachosHomeError, + type PromachosHomeCreateInput, + type PromachosHomeCreateResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as ProjectService from "../project/ProjectService.ts"; +import { randomUuidV4 } from "../orchestration-v2/RandomUuid.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; + +export const STARTER_AGENTS_MD = `# Promachos home + +This folder is the Promachos home. Every Promachos conversation runs here and reads this file. + +## Who you are + + +`; + +const STARTER_CLAUDE_MD = "@AGENTS.md\n"; + +export class PromachosHome extends Context.Service< + PromachosHome, + { + readonly create: ( + input: PromachosHomeCreateInput, + ) => Effect.Effect; + } +>()("t3/promachos/PromachosHome") {} + +const make = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcs = yield* VcsDriverRegistry; + const create = Effect.fn("PromachosHome.create")(function* (input: PromachosHomeCreateInput) { + const causeMessage = (cause: unknown) => + cause instanceof Error ? cause.message : String(cause); + + const fail = (detail: string) => Effect.fail(new PromachosHomeError({ detail })); + + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.path, { createIfMissing: true }) + .pipe( + Effect.catch((error) => + fail( + error._tag === "WorkspaceRootCreateFailedError" + ? `Could not create ${error.normalizedWorkspaceRoot}: ${causeMessage(error.cause)}` + : error.message, + ), + ), + ); + + // Git runs before any file is written, so a missing git leaves only an empty folder. + yield* Effect.gen(function* () { + const git = yield* vcs.get("git"); + if (!(yield* git.isInsideWorkTree(workspaceRoot))) { + yield* git.initRepository({ cwd: workspaceRoot }); + } + }).pipe( + Effect.catch((error) => + fail( + error._tag === "VcsProcessSpawnError" + ? "Git could not run on this machine. Install git and try again." + : `Could not create a git repository in ${workspaceRoot}: ${error.message}`, + ), + ), + ); + + for (const [name, contents] of [ + ["AGENTS.md", STARTER_AGENTS_MD], + ["CLAUDE.md", STARTER_CLAUDE_MD], + ] as const) { + const file = path.join(workspaceRoot, name); + // The file is written in full inside a private folder, then hard-linked into place. The + // link never replaces an existing file, and cleanup removes only the private folder, so a + // failure leaves nothing behind that blocks a retry. + yield* Effect.scoped( + Effect.gen(function* () { + const staging = yield* fileSystem.makeTempDirectoryScoped({ + directory: workspaceRoot, + prefix: ".promachos-home-", + }); + const staged = path.join(staging, name); + yield* fileSystem.writeFileString(staged, contents); + yield* fileSystem.link(staged, file).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + }), + ).pipe(Effect.catch((error) => fail(`Could not write ${file}: ${causeMessage(error)}`))); + } + + const find = projects.getByWorkspaceRoot(workspaceRoot).pipe(Effect.map(Option.getOrUndefined)); + const findOrCreate = Effect.gen(function* () { + const existing = yield* find; + if (existing !== undefined) return existing.id; + const id = yield* randomUuidV4; + return (yield* projects.create({ + commandId: CommandId.make(`promachos-home:${id}`), + projectId: ProjectId.make(id), + workspaceRoot, + title: path.basename(workspaceRoot), + })).id; + }); + const projectId = yield* findOrCreate.pipe( + Effect.catch((error) => + find.pipe( + Effect.flatMap((existing) => + existing === undefined ? Effect.fail(error) : Effect.succeed(existing.id), + ), + ), + ), + Effect.catch((error) => + fail(`Could not add ${workspaceRoot} as a project: ${error.message}`), + ), + ); + return { projectId, workspaceRoot } satisfies PromachosHomeCreateResult; + }); + + return PromachosHome.of({ create }); +}); +export const layer = Layer.effect(PromachosHome, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 580d46f496..124413c05f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,5 @@ +import * as PromachosHome from "./promachos/PromachosHome.ts"; +import { PROMACHOS_HOME_WS_METHODS } from "@t3tools/contracts"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -1211,6 +1213,9 @@ const layerWsRpc = ( const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; + const promachosHome = yield* PromachosHome.PromachosHome.pipe( + Effect.provide(PromachosHome.layer), + ); const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; const threadSearch = yield* ThreadSearch.ThreadSearch; @@ -2617,6 +2622,7 @@ const layerWsRpc = ( (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), ), + [PROMACHOS_HOME_WS_METHODS.create]: (input) => promachosHome.create(input), [WS_METHODS.projectsCreateNew]: (input) => managedFolders .createNamedProject(input) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 918d288450..4e86248d4f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,3 +1,5 @@ +import { PromachosSidebar } from "./promachos/PromachosSidebar"; +import { usePromachosMode } from "./promachos/promachosMode"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -219,6 +221,7 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + const [promachosMode] = usePromachosMode(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread @@ -328,6 +331,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { + ) : promachosMode ? ( + ) : legacySidebarEnabled ? ( ) : ( diff --git a/apps/web/src/components/promachos/PromachosChat.tsx b/apps/web/src/components/promachos/PromachosChat.tsx new file mode 100644 index 0000000000..9619a43023 --- /dev/null +++ b/apps/web/src/components/promachos/PromachosChat.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { ComposerBanner } from "../chat/ComposerBanner"; + +/** + * Re-points the message tokens so the user's bubble reads as the outbound + * side of a chat: the theme's foreground as surface, its background as ink. + */ +export const PROMACHOS_OUTBOUND_BUBBLE_CLASS_NAME = + "rounded-2xl px-4 py-2.5 [--contrast-message-foreground:var(--background)] [--message-foreground:var(--background)] [--message-surface:var(--foreground)]"; + +const INBOUND_BUBBLE_CLASS_NAME = + "min-w-0 max-w-[85%] rounded-2xl bg-message px-4 py-2.5 text-message-foreground"; + +/** Static, so a long turn costs no repaints. */ +export function PromachosWorkingBubble() { + return ( +
+ + + + + +
+ ); +} + +/** Where the composer's question or approval card appears inside the chat. */ +export function PromachosInlineCardHost({ + hostRef, +}: { + hostRef: (element: HTMLDivElement | null) => void; +}) { + return ( +
+ ); +} + +/** + * Renders a composer card inline in the chat when the chat has a host for it, + * and attached to the composer otherwise. The card keeps its owner, so its + * handlers and state are the composer's own. + */ +export function PromachosInlineCard({ + host, + children, +}: { + host: HTMLElement | null; + children: ReactNode; +}) { + if (host === null) return {children}; + return createPortal(
{children}
, host); +} + +/** + * The inline card host, owned by the thread whose timeline renders it. While + * a thread switch still paints the previous thread's timeline, the active + * thread's composer gets no host and keeps its cards attached to itself. + */ +export function usePromachosInlineCardHost(input: { + enabled: boolean; + activeThreadKey: string | null; + displayedThreadKey: string | null; +}) { + const { enabled, activeThreadKey, displayedThreadKey } = input; + const [host, setHost] = useState<{ threadKey: string | null; element: HTMLDivElement } | null>( + null, + ); + const timelineHostRef = useCallback( + (element: HTMLDivElement | null) => + setHost(element === null ? null : { threadKey: displayedThreadKey, element }), + [displayedThreadKey], + ); + const composerHost = + enabled && host !== null && host.threadKey !== null && host.threadKey === activeThreadKey + ? host.element + : null; + return { timelineHostRef, composerHost }; +} diff --git a/apps/web/src/components/promachos/PromachosModeSwitch.tsx b/apps/web/src/components/promachos/PromachosModeSwitch.tsx new file mode 100644 index 0000000000..13eebce874 --- /dev/null +++ b/apps/web/src/components/promachos/PromachosModeSwitch.tsx @@ -0,0 +1,38 @@ +import { useLocation } from "@tanstack/react-router"; + +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { usePromachosMode } from "./promachosMode"; + +/** + * Switches the sidebar and home threads between the standard view and + * Promachos mode. Its own full-width row, so it fits at the sidebar's minimum + * width beside any titlebar inset. + */ +export function PromachosModeSwitch() { + const onSettings = useLocation({ + select: (location) => + location.pathname === "/settings" || location.pathname.startsWith("/settings/"), + }); + const [enabled, setEnabled] = usePromachosMode(); + if (onSettings) return null; + + return ( +
+ { + if (next[0] !== undefined) setEnabled(next[0] === "promachos"); + }} + > + + Code + + + Promachos + + +
+ ); +} diff --git a/apps/web/src/components/promachos/PromachosNewHome.tsx b/apps/web/src/components/promachos/PromachosNewHome.tsx new file mode 100644 index 0000000000..a2cc264f61 --- /dev/null +++ b/apps/web/src/components/promachos/PromachosNewHome.tsx @@ -0,0 +1,112 @@ +import { + createEnvironmentRpcCommand, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + DEFAULT_PROMACHOS_HOME_PATH, + type EnvironmentId, + PROMACHOS_HOME_WS_METHODS, + type ScopedProjectRef, +} from "@t3tools/contracts"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; + +const createHome = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "promachos:create-home", + tag: PROMACHOS_HOME_WS_METHODS.create, +}); + +/** + * The picker's **New home** entry for one environment: creates a starter home folder on that + * environment's machine (toolboxmd/chromeria#139) and selects it. + */ +export function PromachosNewHome({ + environmentId, + label, + onCreated, +}: { + environmentId: EnvironmentId; + label: string; + onCreated: (home: ScopedProjectRef) => void; +}) { + const create = useAtomCommand(createHome, { reportFailure: false }); + // Null while the form is closed. + const [path, setPath] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + if (path === null) { + return ( + + setPath(DEFAULT_PROMACHOS_HOME_PATH)}> + + {label} + + + ); + } + + const submit = async () => { + if (path.trim().length === 0 || pending) return; + setPending(true); + setError(null); + const result = await create({ environmentId, input: { path: path.trim() } }); + setPending(false); + if (result._tag === "Success") { + onCreated({ environmentId, projectId: result.value.projectId }); + setPath(null); + return; + } + if (isAtomCommandInterrupted(result)) return; + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "The home could not be created."); + }; + + return ( + +
{ + event.preventDefault(); + void submit(); + }} + > + {label}: folder for the new home + setPath(event.target.value)} + /> + {error === null ? null :

{error}

} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/promachos/PromachosSidebar.tsx b/apps/web/src/components/promachos/PromachosSidebar.tsx new file mode 100644 index 0000000000..3b330396bf --- /dev/null +++ b/apps/web/src/components/promachos/PromachosSidebar.tsx @@ -0,0 +1,214 @@ +import { PersonPicker } from "../people/PersonPicker"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { Link, useParams } from "@tanstack/react-router"; +import { HouseIcon, SquarePenIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { isElectron } from "../../env"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { useEnvironments } from "../../state/environments"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../../threadRoutes"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "../Sidebar.logic"; +import { SidebarChromeFooter, SidebarChromeHeader } from "../sidebar/SidebarChrome"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "../ui/sidebar"; +import { SharedThreadLabel } from "../people/SharedThreadLabel"; +import { usePersonViewThreads } from "../people/usePersonView"; +import { promachosConversations } from "./promachosConversations"; +import { PromachosNewHome } from "./PromachosNewHome"; +import { usePromachosHome, useStartPromachosConversation } from "./promachosMode"; + +const STATUS_BADGE: Partial< + Record +> = { + approval: { label: "Needs you", variant: "warning" }, + input: { label: "Needs you", variant: "warning" }, + working: { label: "Working", variant: "info" }, + failed: { label: "Failed", variant: "error" }, + waiting: { label: "Waiting", variant: "info" }, + limited: { label: "Limit reached", variant: "warning" }, +}; + +function projectLabel( + project: EnvironmentProject, + environmentLabelById: ReadonlyMap, + multipleEnvironments: boolean, +): string { + const environmentLabel = environmentLabelById.get(project.environmentId); + return multipleEnvironments && environmentLabel + ? `${project.title} · ${environmentLabel}` + : project.title; +} + +/** Promachos mode's sidebar: the home's conversations in Chromeria's own chrome. */ +export function PromachosSidebar() { + const [home, setHome] = usePromachosHome(); + const projects = useProjects(); + const { environments } = useEnvironments(); + const environmentLabelById = useMemo( + () => + new Map(environments.map((environment) => [environment.environmentId, environment.label])), + [environments], + ); + const multipleEnvironments = new Set(projects.map((project) => project.environmentId)).size > 1; + const homeProject = + home === null + ? null + : (projects.find( + (project) => + project.environmentId === home.environmentId && project.id === home.projectId, + ) ?? null); + + return ( + <> + + + + {homeProject !== null && home !== null ? ( + setHome(null)} + /> + ) : ( + +

+ {home === null + ? "Choose the project that is the Promachos home, or create a new one. His conversations live there." + : "The Promachos home is not available here. Choose it again."} +

+ + {projects.map((project) => ( + + + setHome({ environmentId: project.environmentId, projectId: project.id }) + } + > + + {projectLabel(project, environmentLabelById, multipleEnvironments)} + + + ))} + {environments.map((environment) => ( + 1 ? `New home · ${environment.label}` : "New home"} + onCreated={setHome} + /> + ))} + +
+ )} +
+ + + ); +} + +function PromachosConversationList({ + home, + homeLabel, + onChangeHome, +}: { + home: ScopedProjectRef; + homeLabel: string; + onChangeHome: () => void; +}) { + const threads = useThreadShells(); + const personThreads = usePersonViewThreads(threads); + const conversations = useMemo( + () => promachosConversations(personThreads, home), + [personThreads, home], + ); + const startConversation = useStartPromachosConversation(); + const { isMobile, setOpenMobile } = useSidebar(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // Re-render each minute so the relative times stay true. + useNowMinute(); + + return ( + +
+ + }> + + {homeLabel} + + + Choose another home + + + +
+ + {conversations.length === 0 ? ( +

No conversations yet.

+ ) : null} + {conversations.map((thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const status = resolveSidebarThreadStatus(thread); + const badge = STATUS_BADGE[status]; + return ( + + { + if (isMobile) setOpenMobile(false); + }} + /> + } + > + {thread.title} + + {badge ? ( + + {badge.label} + + ) : ( + + {formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + )} + + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/promachos/promachosBubbles.test.tsx b/apps/web/src/components/promachos/promachosBubbles.test.tsx new file mode 100644 index 0000000000..ebdfd5e7a0 --- /dev/null +++ b/apps/web/src/components/promachos/promachosBubbles.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkPromachosBubbles } from "./promachosBubbles"; + +// The real parser and Markdown-to-HTML step ChatMarkdown uses, so the checks +// cover what the parser makes of each block, not only the grouping. +function bubbles(text: string): string { + return renderToStaticMarkup( + {text}, + ).replaceAll("\n", ""); +} + +describe("remarkPromachosBubbles", () => { + it("gives each paragraph its own bubble", () => { + expect( + bubbles("Found it.\n\nA reseller lists it at 21.90 EUR.\n\n\nGardazon looks fine."), + ).toBe( + "

Found it.

A reseller lists it at 21.90 EUR.

Gardazon looks fine.

", + ); + }); + + it("keeps indented and fenced code whole across blank lines", () => { + expect(bubbles("Run:\n\n first\n\n second\n\nThen:\n\n```sh\na\n\nb\n```")).toBe( + '

Run:

first\nsecond\n

Then:

a\n\nb\n
'.replaceAll( + "\n", + "", + ), + ); + }); + + it("keeps a loose list and a table whole, with a heading joined to its block", () => { + const html = bubbles( + "1. First\n\n2. Second\n\n more\n\n## Prices\n\n| a | b |\n| - | - |\n| 1 | 2 |", + ); + expect( + html.match( + /^
  1. First<\/p><\/li>

  2. Second<\/p>

    more<\/p><\/li><\/ol><\/div>/, + ), + ).not.toBeNull(); + expect(html).toContain("

    Prices

    "); + expect(html.match(/
    /g)).toHaveLength(2); + }); + + it("resolves reference links across bubbles and gives definitions no bubble", () => { + expect(bubbles("See [the docs][d].\n\nMore later.\n\n[d]: https://example.com/docs")).toBe( + '

    More later.

    ', + ); + }); + + it("leaves a thematic break between bubbles", () => { + expect(bubbles("Before.\n\n---\n\nAfter.")).toBe( + "

    Before.


    After.

    ", + ); + }); +}); diff --git a/apps/web/src/components/promachos/promachosBubbles.ts b/apps/web/src/components/promachos/promachosBubbles.ts new file mode 100644 index 0000000000..2a4c2e4f33 --- /dev/null +++ b/apps/web/src/components/promachos/promachosBubbles.ts @@ -0,0 +1,56 @@ +/** + * Groups an assistant reply's top-level Markdown blocks into chat bubbles, + * one per paragraph. It runs inside the reply's single Markdown parse, so a + * code block, list or table is always one bubble, and reference links still + * resolve against definitions anywhere in the reply. + */ +import type { Parent, Root, RootContent } from "mdast"; + +declare module "mdast" { + interface RootContentMap { + promachosBubble: PromachosBubble; + } +} + +interface PromachosBubble extends Parent { + type: "promachosBubble"; + children: RootContent[]; +} + +export function remarkPromachosBubbles() { + return (tree: Root) => { + const children: RootContent[] = []; + let bubble: PromachosBubble | null = null; + // A heading introduces the block after it, so they share a bubble. + let headingOnly = false; + for (const node of tree.children) { + if (node.type === "definition" || node.type === "footnoteDefinition") { + // Rendered nowhere, so never an empty bubble. + children.push(node); + continue; + } + if (node.type === "thematicBreak") { + children.push(node); + bubble = null; + continue; + } + if (bubble === null || (!headingOnly && node.type !== "html")) { + bubble = { type: "promachosBubble", data: { hName: "div" }, children: [] }; + children.push(bubble); + } + bubble.children.push(node); + headingOnly = bubble.children.every((child) => child.type === "heading"); + } + tree.children = children; + }; +} + +/** + * ChatMarkdown props for a bubbled reply. Sanitizing drops class names from + * Markdown output, so the bubbles are styled as the root's direct `div`s. + */ +export const PROMACHOS_BUBBLE_MARKDOWN = { + extraRemarkPlugins: [remarkPromachosBubbles], + className: + "flex flex-col items-start gap-1 [&>div]:min-w-0 [&>div]:max-w-[85%] [&>div]:rounded-2xl [&>div]:bg-message [&>div]:px-4 [&>div]:py-2.5 [&>div]:text-message-foreground [&>div>:first-child]:mt-0 [&>div>:last-child]:mb-0 [&>div:empty]:hidden [&>hr]:self-stretch [&>section]:self-stretch", +}; diff --git a/apps/web/src/components/promachos/promachosConversations.test.ts b/apps/web/src/components/promachos/promachosConversations.test.ts new file mode 100644 index 0000000000..2235fe8ed1 --- /dev/null +++ b/apps/web/src/components/promachos/promachosConversations.test.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { promachosConversations } from "./promachosConversations"; + +const home = { environmentId: EnvironmentId.make("env-a"), projectId: ProjectId.make("home") }; + +function thread( + id: string, + overrides: Partial<{ + environmentId: string; + projectId: string; + archivedAt: string | null; + latestUserMessageAt: string | null; + lineage: { parentThreadId: ThreadId | null; relationshipToParent: "fork" | "subagent" | null }; + updatedAt: string; + }> = {}, +) { + return { + id: ThreadId.make(id), + lineage: overrides.lineage ?? { parentThreadId: null, relationshipToParent: null }, + environmentId: EnvironmentId.make(overrides.environmentId ?? "env-a"), + projectId: ProjectId.make(overrides.projectId ?? "home"), + archivedAt: overrides.archivedAt ?? null, + latestUserMessageAt: overrides.latestUserMessageAt ?? null, + updatedAt: overrides.updatedAt ?? "2026-10-01T08:00:00.000Z", + }; +} + +describe("promachosConversations", () => { + it("lists only the home's own top-level threads on the home's environment", () => { + const threads = [ + thread("chat"), + thread("elsewhere", { projectId: "satrapy" }), + thread("same-id-other-machine", { environmentId: "env-b" }), + thread("archived", { archivedAt: "2026-10-01T09:00:00.000Z" }), + thread("native-child", { + lineage: { parentThreadId: ThreadId.make("chat"), relationshipToParent: "subagent" }, + }), + ]; + expect(promachosConversations(threads, home).map((entry) => entry.id)).toEqual(["chat"]); + }); + + it("puts the latest conversation first", () => { + const threads = [ + thread("older", { latestUserMessageAt: "2026-10-01T08:00:00.000Z" }), + thread("newer", { latestUserMessageAt: "2026-10-01T09:00:00.000Z" }), + thread("never-messaged", { updatedAt: "2026-10-01T08:30:00.000Z" }), + ]; + expect(promachosConversations(threads, home).map((entry) => entry.id)).toEqual([ + "newer", + "never-messaged", + "older", + ]); + }); +}); diff --git a/apps/web/src/components/promachos/promachosConversations.ts b/apps/web/src/components/promachos/promachosConversations.ts new file mode 100644 index 0000000000..196b2f674b --- /dev/null +++ b/apps/web/src/components/promachos/promachosConversations.ts @@ -0,0 +1,36 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { ScopedProjectRef } from "@t3tools/contracts"; + +import { firstValidTimestampMs } from "../Sidebar.logic"; + +/** + * The Promachos's conversations: the home's own top-level threads, newest + * activity first. Drafters he starts as child threads stay in the Lineage + * section of the conversation that started them. + */ +export function promachosConversations< + T extends Pick< + EnvironmentThreadShell, + | "id" + | "environmentId" + | "projectId" + | "archivedAt" + | "latestUserMessageAt" + | "updatedAt" + | "lineage" + >, +>(threads: ReadonlyArray, home: ScopedProjectRef): T[] { + return threads + .filter( + (thread) => + thread.environmentId === home.environmentId && + thread.projectId === home.projectId && + thread.archivedAt === null && + thread.lineage.relationshipToParent !== "subagent", + ) + .toSorted( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ); +} diff --git a/apps/web/src/components/promachos/promachosMode.ts b/apps/web/src/components/promachos/promachosMode.ts new file mode 100644 index 0000000000..fe92fa66e8 --- /dev/null +++ b/apps/web/src/components/promachos/promachosMode.ts @@ -0,0 +1,59 @@ +/** + * Promachos mode (toolboxmd/chromeria#116): a chat-first view of the + * conversations in the Promachos home, switched per client. + * + * Both values live in this client's local storage: the mode is a view + * preference, and the home names one project on one environment, so the same + * project id on two environments never collides. + */ +import { ScopedProjectRef } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; + +const PROMACHOS_MODE_STORAGE_KEY = "chromeria:promachos-mode"; +const PROMACHOS_HOME_STORAGE_KEY = "chromeria:promachos-home"; +const PromachosHome = Schema.NullOr(ScopedProjectRef); + +export function usePromachosMode(): [boolean, (enabled: boolean) => void] { + const [enabled, setEnabled] = useLocalStorage(PROMACHOS_MODE_STORAGE_KEY, false, Schema.Boolean); + return [enabled, setEnabled]; +} + +export function usePromachosHome(): [ + ScopedProjectRef | null, + (home: ScopedProjectRef | null) => void, +] { + const [home, setHome] = useLocalStorage(PROMACHOS_HOME_STORAGE_KEY, null, PromachosHome); + return [home, setHome]; +} + +function isPromachosHome( + projectRef: ScopedProjectRef | null, + home: ScopedProjectRef | null, +): boolean { + return ( + projectRef !== null && + home !== null && + projectRef.environmentId === home.environmentId && + projectRef.projectId === home.projectId + ); +} + +/** Whether a thread in `projectRef` renders as a Promachos chat right now. */ +export function usePromachosChat(projectRef: ScopedProjectRef | null): boolean { + const [enabled] = usePromachosMode(); + const [home] = usePromachosHome(); + return enabled && isPromachosHome(projectRef, home); +} + +/** + * Opens a new Promachos conversation in the home. Its first send starts on + * the Promachos's Prism role when that role has a model list + * (`withPromachosStart`, toolboxmd/chromeria#115). + */ +export function useStartPromachosConversation() { + const startNewThread = useNewThreadHandler(); + return (home: ScopedProjectRef) => startNewThread(home); +} diff --git a/apps/web/src/components/promachos/promachosStart.test.ts b/apps/web/src/components/promachos/promachosStart.test.ts new file mode 100644 index 0000000000..62832667f0 --- /dev/null +++ b/apps/web/src/components/promachos/promachosStart.test.ts @@ -0,0 +1,123 @@ +import { + ClientOrchestrationCommand, + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { promachosMultipleModelSelections, withPromachosStart } from "./promachosStart"; + +const decodeCommand = Schema.decodeUnknownSync(ClientOrchestrationCommand); +const createdAt = "2026-10-01T20:00:00.000Z"; +// The composer's own selection: a valid placeholder Prism replaces. +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }; +const promachosModels = [{ instanceId: "claudeAgent", model: "claude-opus-5-5" }]; + +function firstSend(threadId: string) { + return { + environmentId: EnvironmentId.make("env"), + input: { + threadId: ThreadId.make(threadId), + message: { + messageId: MessageId.make("m1"), + role: "user" as const, + text: "Hi", + attachments: [], + }, + modelSelection, + titleSeed: "Hi", + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + bootstrap: { + createThread: { + projectId: ProjectId.make("home"), + title: "Hi", + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }, + }, + createdAt, + }, + }; +} + +function followUp(threadId: string) { + const { bootstrap: _bootstrap, ...input } = firstSend(threadId).input; + return { environmentId: EnvironmentId.make("env"), input }; +} + +/** Records what reaches the dispatch, as the wire command the server decodes. */ +function recordingStart(result: R) { + const sent: ClientOrchestrationCommand[] = []; + const start = (request: { input: object }) => { + sent.push( + decodeCommand({ + ...request.input, + type: "thread.turn.start", + commandId: CommandId.make(`c${sent.length}`), + }), + ); + return result; + }; + return { sent, start }; +} + +describe("withPromachosStart", () => { + it("asks Prism for a new top-level Promachos conversation with a configured list", () => { + const { sent, start } = recordingStart("ok"); + withPromachosStart(start, promachosModels)(firstSend("new-thread")); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + type: "thread.turn.start", + threadId: "new-thread", + prismRole: "promachos", + modelSelection, + bootstrap: { createThread: { projectId: "home", modelSelection } }, + }); + }); + + it("starts normally when the Promachos list is empty or the thread is not his chat", () => { + const { sent, start } = recordingStart("ok"); + withPromachosStart(start, [])(firstSend("empty-list")); + withPromachosStart(start, null)(firstSend("standard-view")); + expect(sent).toHaveLength(2); + for (const command of sent) expect(command).not.toHaveProperty("prismRole"); + }); + + it("never asks Prism for an existing conversation or a child thread", () => { + const { sent, start } = recordingStart("ok"); + withPromachosStart(start, promachosModels)(followUp("existing")); + withPromachosStart(start, promachosModels)(firstSend("sub.parent.child")); + for (const command of sent) expect(command).not.toHaveProperty("prismRole"); + }); + + it("returns a Prism refusal as the send's failure without a normal retry", () => { + const refusal = { _tag: "Failure", message: "No Promachos model has capacity." }; + const { sent, start } = recordingStart(refusal); + expect(withPromachosStart(start, promachosModels)(firstSend("refused"))).toBe(refusal); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ prismRole: "promachos" }); + }); +}); + +describe("promachosMultipleModelSelections", () => { + const saved = [modelSelection, { ...modelSelection, model: "gpt-5.5-mini" }]; + + it("acts on no saved multiple-model selection where Prism picks the model", () => { + expect(promachosMultipleModelSelections(saved, promachosModels)).toBeNull(); + }); + + it("keeps the saved selection for an empty list and the standard view", () => { + expect(promachosMultipleModelSelections(saved, [])).toBe(saved); + expect(promachosMultipleModelSelections(saved, null)).toBe(saved); + }); +}); diff --git a/apps/web/src/components/promachos/promachosStart.ts b/apps/web/src/components/promachos/promachosStart.ts new file mode 100644 index 0000000000..72b6cc54f0 --- /dev/null +++ b/apps/web/src/components/promachos/promachosStart.ts @@ -0,0 +1,55 @@ +interface TurnStartInput { + readonly threadId: string; + readonly bootstrap?: { readonly createThread?: unknown } | undefined; +} + +/** + * Asks Prism for the Promachos's model when a send creates a new top-level + * Promachos conversation and his role has a model list (toolboxmd/chromeria#115). + * The server then replaces the composer's model, which stays as a valid + * placeholder. With an empty list the conversation starts normally. + * + * `promachosModels` is the home's Promachos model list, or null when the + * thread is not a Promachos chat. + */ +function promachosTurnStartInput( + input: T, + promachosModels: ReadonlyArray | null, +): T { + const createsTopLevelThread = input.bootstrap?.createThread !== undefined; + return createsTopLevelThread && promachosPicksModel(promachosModels) + ? { ...input, prismRole: "promachos" } + : input; +} + +/** Whether Prism picks the model for a new conversation. */ +export function promachosPicksModel(promachosModels: ReadonlyArray | null): boolean { + return promachosModels !== null && promachosModels.length > 0; +} + +/** + * The multiple-model selection the view acts on. Where Prism picks the + * model, each started thread would be a new Promachos conversation, so there + * is none: the composer offers no multiple-model start and the workspace + * controls behave as for one model. The saved selection is kept and returns + * in the standard view. + */ +export function promachosMultipleModelSelections( + saved: T | null, + promachosModels: ReadonlyArray | null, +): T | null { + return promachosPicksModel(promachosModels) ? null : saved; +} + +/** + * Wraps the turn-start command so a qualifying send starts as a Promachos + * conversation. A Prism refusal comes back as that send's own failure: it is + * never retried as a normal start, so the user sees why it did not start. + */ +export function withPromachosStart( + start: (request: { environmentId: E; input: I }) => R, + promachosModels: ReadonlyArray | null, +) { + return (request: { environmentId: E; input: I }): R => + start({ ...request, input: promachosTurnStartInput(request.input, promachosModels) }); +} diff --git a/apps/web/src/components/promachos/promachosTimeline.ts b/apps/web/src/components/promachos/promachosTimeline.ts new file mode 100644 index 0000000000..c9d87699cc --- /dev/null +++ b/apps/web/src/components/promachos/promachosTimeline.ts @@ -0,0 +1,81 @@ +import { TurnId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import type { TimelineEntry } from "../../session-logic"; +import type { MessagesTimelineRow } from "../chat/MessagesTimeline.logic"; + +const WORKING_ROW: Extract = { + kind: "working", + id: "working-indicator-row", + createdAt: null, +}; + +/** + * The timeline as a Promachos chat: the conversation, plans and setup cards, + * with tool activity, reasoning and turn folds collapsed into one working + * indicator after the latest message while the turn runs. + */ +export function promachosTimelineRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { + const conversation: MessagesTimelineRow[] = []; + const queued: MessagesTimelineRow[] = []; + let working = false; + for (const row of rows) { + switch (row.kind) { + case "message": + if (row.message.role !== "reasoning") conversation.push(row); + break; + case "proposed-plan": + case "worktree-setup": + conversation.push(row); + break; + case "work": + // Sharing changes stay in the chat (toolboxmd/chromeria#121). + if (row.groupedEntries.every((entry) => entry.sourceActivityKind === "thread.sharing")) { + conversation.push(row); + } + break; + case "queued-message": + queued.push(row); + break; + case "working": + case "thinking": + working = true; + break; + default: + break; + } + } + return working ? [...conversation, WORKING_ROW, ...queued] : [...conversation, ...queued]; +} + +function entryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") return entry.message.turnId ?? null; + if (entry.kind === "proposed-plan") return entry.proposedPlan.turnId; + return entry.entry.turnId ?? null; +} + +/** + * Every turn in the timeline, so a Promachos chat never folds a finished + * turn: his interim replies stay where they were written. Keyed by the ids, + * not the entries, so streamed text keeps the timeline's fast path. + */ +export function usePromachosExpandedTurnIds( + timelineEntries: ReadonlyArray, + enabled: boolean, +): ReadonlySet | null { + const key = enabled + ? [...new Set(timelineEntries.map(entryTurnId).filter((id) => id !== null))].join("\n") + : null; + return useMemo( + () => + key === null + ? null + : new Set( + key + .split("\n") + .filter(Boolean) + .map((id) => TurnId.make(id)), + ), + [key], + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index c78235703e..2d2537cbf0 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,3 +1,4 @@ +import { PromachosModeSwitch } from "../promachos/PromachosModeSwitch"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, CircleDotIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; @@ -37,6 +38,15 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + return ( + <> + + + + ); +}); + +const SidebarTitlebar = memo(function SidebarTitlebar({ isElectron }: { isElectron: boolean }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const backdropVariant = resolveSidebarStageBackdropVariant( diff --git a/docs/user/promachos-mode.md b/docs/user/promachos-mode.md new file mode 100644 index 0000000000..5f7e23d2e6 --- /dev/null +++ b/docs/user/promachos-mode.md @@ -0,0 +1,41 @@ +# Promachos mode + +Promachos mode is a chat-first view of your conversations with the Promachos. His +replies show as bubbles, one per paragraph, and his tool activity collapses into a +working indicator. Questions and approvals appear as cards in the chat. + +## Start + +1. On web or desktop, choose **Promachos** in the switch at the top of the sidebar. +2. Choose the project that is the Promachos home, or choose **New home** to create + one. The choice belongs to that project on that environment, and each browser or + desktop app remembers its own. +3. Use the new conversation button next to the home's name to start a conversation. + +Choose **Code** in the same switch to return to the standard view. To pick another +home, open the menu on the home's name. + +## Create a home + +**New home** asks for a folder, `~/promachos` unless you change it, and creates it on +the environment's own machine, so it works for remote environments too. The folder +becomes a git repository with a starter `AGENTS.md` and a `CLAUDE.md` that points to +it, is added as a project, and is selected as the home. + +Write the Promachos persona under "Who you are" in that `AGENTS.md`. Every +conversation in the home reads it. + +Files, a git repository or a project already there are kept as they are, so you can +point **New home** at an existing folder. If creation fails, the picker shows why; +fix the cause and choose **Create** again. + +## Good to know + +- Only conversations in the home show as chats. Other threads keep the standard view, + and agents a conversation starts stay in its Lineage section. +- Type a custom answer to a question in the composer, as in the standard view. +- A new conversation starts on the first model with capacity in the Promachos list + under **Settings → Prism**. If that list is empty, it starts on the model picked in + the composer. If no listed model has capacity, the conversation does not start and + the chat shows why. +- The right panel and the composer work as they do in the standard view. diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 820129daad..d7ba452986 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -1,3 +1,4 @@ +import { PROMACHOS_HOME_WS_METHODS } from "./promachosHome.ts"; import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { @@ -10,6 +11,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [PROMACHOS_HOME_WS_METHODS.create]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e4cb835171..1c07355887 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -69,3 +69,4 @@ export * from "./clientRpcPermissions.ts"; export * from "./people.ts"; export * from "./prism.ts"; export * from "./wight.ts"; +export * from "./promachosHome.ts"; diff --git a/packages/contracts/src/promachosHome.ts b/packages/contracts/src/promachosHome.ts new file mode 100644 index 0000000000..dbf2795200 --- /dev/null +++ b/packages/contracts/src/promachosHome.ts @@ -0,0 +1,44 @@ +import * as Schema from "effect/Schema"; +import * as Rpc from "effect/rpc/Rpc"; +import * as RpcGroup from "effect/rpc/RpcGroup"; + +import { EnvironmentAuthorizationError } from "./auth.ts"; +import { ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Creates a starter Promachos home on the environment's own machine (toolboxmd/chromeria#139): + * the folder, a git repository, a generic `AGENTS.md` and a `CLAUDE.md`, then its project. + * Anything already there is kept, so a failed attempt can simply be retried. + */ +export const PROMACHOS_HOME_WS_METHODS = { + create: "promachos.createHome", +} as const; + +/** The folder a new home suggests; `~` expands on the environment's machine. */ +export const DEFAULT_PROMACHOS_HOME_PATH = "~/promachos"; + +export class PromachosHomeError extends Schema.TaggedError()( + "PromachosHomeError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +export const PromachosHomeCreateInput = Schema.Struct({ path: TrimmedNonEmptyString }); +export type PromachosHomeCreateInput = typeof PromachosHomeCreateInput.Type; + +export const PromachosHomeCreateResult = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: TrimmedNonEmptyString, +}); +export type PromachosHomeCreateResult = typeof PromachosHomeCreateResult.Type; + +export const PromachosHomeRpcGroup = RpcGroup.make( + Rpc.make(PROMACHOS_HOME_WS_METHODS.create, { + payload: PromachosHomeCreateInput, + success: PromachosHomeCreateResult, + error: Schema.Union([PromachosHomeError, EnvironmentAuthorizationError]), + }), +); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b39539d7e9..e271c2e343 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,4 @@ +import { PromachosHomeRpcGroup } from "./promachosHome.ts"; import { OrchestrationV2SearchThreadError, OrchestrationV2SearchThreadInput, @@ -1992,5 +1993,6 @@ export const WsRpcGroup = RpcGroup.make( ...IssueRpcs, ) // Fork: Issue links (toolboxmd/t3code#28). + .merge(PromachosHomeRpcGroup) .merge(IssueLinksRpcGroup) .middleware(RpcScopeAuthorization); From de6c35897f49434cc7fcb3ae7b177a299ee38927 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:23:39 +0200 Subject: [PATCH 2/7] feat(promachos): port home and chat onto v2 launch --- apps/server/src/auth/RpcAuthorization.test.ts | 11 ++ .../src/observability/RpcInstrumentation.ts | 8 +- .../ThreadLaunchService.test.ts | 109 +++++++++++++- .../src/promachos/PromachosHome.test.ts | 128 ++++++++++++++++ apps/server/src/promachos/PromachosLaunch.ts | 95 ++++++++++++ apps/server/src/ws.ts | 27 +++- apps/web/src/components/ChatView.tsx | 46 +++++- apps/web/src/components/chat/ChatComposer.tsx | 10 +- .../src/components/chat/MessagesTimeline.tsx | 83 +++++++++-- .../components/promachos/PromachosNewHome.tsx | 15 +- .../promachos/promachosConversations.test.ts | 5 +- .../promachos/promachosStart.test.ts | 22 +-- .../promachos/promachosTimeline.test.ts | 141 ++++++++++++++++++ .../components/promachos/promachosTimeline.ts | 95 ++++++------ docs/README.md | 1 + docs/fork-features.md | 52 +++++++ .../src/operations/commands.test.ts | 2 + .../client-runtime/src/operations/commands.ts | 2 + packages/contracts/src/orchestrationV2.ts | 1 + scripts/fork-upstream-edits.txt | 6 + 20 files changed, 765 insertions(+), 94 deletions(-) create mode 100644 apps/server/src/promachos/PromachosHome.test.ts create mode 100644 apps/server/src/promachos/PromachosLaunch.ts create mode 100644 apps/web/src/components/promachos/promachosTimeline.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 97a8f8dc04..bd8a4fe8f4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,4 +1,6 @@ import { + PROMACHOS_HOME_WS_METHODS, + clientRpcRequiredScopes, AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope, AuthFilesystemReadScope, @@ -31,6 +33,15 @@ import { import * as RpcAuthorization from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { + it("requires operate scope for Promachos home creation on both client and server", () => { + expect(requiredScopeForRpcMethod(PROMACHOS_HOME_WS_METHODS.create)).toBe( + AuthOrchestrationOperateScope, + ); + expect( + clientRpcRequiredScopes(PROMACHOS_HOME_WS_METHODS.create, { path: "~/promachos" }), + ).toEqual([AuthOrchestrationOperateScope]); + }); + it("declares exactly one scope for every RPC in the server group", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2bb82196c3..8c773f0bb5 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -1,4 +1,9 @@ -import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type WsRpcGroup } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WS_METHODS, + PROMACHOS_HOME_WS_METHODS, + WS_METHODS, + type WsRpcGroup, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; @@ -16,6 +21,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error. */ const RPC_AGGREGATES = { + [PROMACHOS_HOME_WS_METHODS.create]: "promachos", [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index bd9fe23a0b..e40d130ac5 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1,3 +1,5 @@ +import * as PromachosLaunch from "../promachos/PromachosLaunch.ts"; +import * as Prism from "../prism/PrismService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SqlClient from "effect/sql/SqlClient"; import * as Scheduler from "../scheduling/Scheduler.ts"; @@ -9,7 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as ServerConfig from "../config.ts"; import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; import * as ThreadMessageIntake from "./ThreadMessageIntake.ts"; -import { assert, it, vi } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, @@ -2539,3 +2541,108 @@ it.effect.each([false, true])( ); }), ); + +const promachosProvider: ServerProvider = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-08T00:00:00.000Z", + availability: "available", + models: [{ slug: modelSelection.model, name: "Promachos", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], +}; +const layerPromachos = PromachosLaunch.layer.pipe( + Layer.provide(Prism.layer), + Layer.provide(CommandReceiptStore.layer), +); +it.effect( + "Promachos launch persists the Prism model and kit once, then replays its receipt", + () => { + const harness = makeHarness({ + providers: [promachosProvider], + serverSettings: { + prismRoles: { + promachos: { + models: [{ ...modelSelection, effort: "high" }], + instructions: "Read the home persona.", + }, + }, + }, + }); + return Effect.gen(function* () { + const promachos = yield* PromachosLaunch.PromachosLaunch; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const input = { + ...launchInput({ command: "promachos:first", thread: "promachos:first", message: "Hello" }), + modelSelection: { ...modelSelection, model: "placeholder" }, + prismRole: "promachos" as const, + }; + const first = yield* launches.launch(yield* promachos.prepare(input)); + expect(first.projection.thread.modelSelection).toEqual({ + ...modelSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }); + expect(first.projection.messages.map((message) => message.text)).toEqual([ + "Read the home persona.\n\nHello", + ]); + const second = yield* launches.launch(yield* promachos.prepare(input)); + expect(second.resumed).toBe(true); + expect(second.projection.messages).toHaveLength(1); + expect(second.projection.runs).toHaveLength(1); + expect(second.projection.thread.modelSelection).toEqual( + first.projection.thread.modelSelection, + ); + }).pipe(Effect.provide(layerPromachos.pipe(Layer.provideMerge(harness.layer)))); + }, +); +it.effect( + "Promachos launch refuses an unavailable configured model without a normal launch", + () => { + const harness = makeHarness({ + providers: [], + serverSettings: { prismRoles: { promachos: { models: [modelSelection] } } }, + }); + return Effect.gen(function* () { + const promachos = yield* PromachosLaunch.PromachosLaunch; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = { + ...launchInput({ + command: "promachos:refused", + thread: "promachos:refused", + message: "Hello", + }), + prismRole: "promachos" as const, + }; + expect((yield* promachos.prepare(input).pipe(Effect.flip)).reason).toBe("routing"); + expect(yield* threads.getThreadShell(input.threadId!)).toBeNull(); + }).pipe(Effect.provide(layerPromachos.pipe(Layer.provideMerge(harness.layer)))); + }, +); +it.effect("Promachos launch cannot reroute an existing conversation or a reusable thread", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const promachos = yield* PromachosLaunch.PromachosLaunch; + const input = launchInput({ + command: "ordinary:first", + thread: "ordinary:first", + message: "Hello", + }); + yield* launches.launch(input); + const reroute = { + ...input, + commandId: CommandId.make("promachos:reroute"), + prismRole: "promachos" as const, + }; + expect((yield* promachos.prepare(reroute).pipe(Effect.flip)).reason).toBe("initial-only"); + expect( + (yield* promachos.prepare({ ...reroute, reuseExistingThread: true }).pipe(Effect.flip)) + .reason, + ).toBe("initial-only"); + }).pipe(Effect.provide(layerPromachos.pipe(Layer.provideMerge(harness.layer)))); +}); diff --git a/apps/server/src/promachos/PromachosHome.test.ts b/apps/server/src/promachos/PromachosHome.test.ts new file mode 100644 index 0000000000..6ac9921fc6 --- /dev/null +++ b/apps/server/src/promachos/PromachosHome.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off -- real git is the external boundary under test. +import * as NodeChildProcess from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProjectId, type Project } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ServerConfig from "../config.ts"; +import * as PromachosHome from "./PromachosHome.ts"; + +const base = Layer.mergeAll( + NodeServices.layer, + WorkspacePaths.layer.pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-promachos-home-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)), +); +const temporary = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "175-promachos-" }); +}); +function projects(existing?: ProjectId) { + const created: string[] = []; + let id = existing; + const layer = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(id ? Option.some({ id } as Project) : Option.none()), + create: (input) => + Effect.sync(() => { + created.push(input.workspaceRoot); + id = input.projectId; + return { id } as Project; + }), + }); + return { created, layer }; +} +const create = (path: string, layer: Layer.Layer) => + PromachosHome.PromachosHome.pipe( + Effect.flatMap((home) => home.create({ path })), + Effect.provide(PromachosHome.layer.pipe(Layer.provide(layer))), + ); + +it.layer(base, { excludeTestServices: true })("Promachos home", (it) => { + it.effect("creates a git home and registers it through ProjectService once", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join(yield* temporary, "promachos"); + const service = projects(); + const first = yield* create(root, service.layer); + const second = yield* create(root, service.layer); + expect(second).toEqual(first); + expect(service.created).toEqual([root]); + expect( + NodeChildProcess.execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: root, + encoding: "utf8", + }).trim(), + ).toBe("true"); + expect(yield* fs.readFileString(path.join(root, "AGENTS.md"))).toContain("## Who you are"); + expect(yield* fs.readFileString(path.join(root, "CLAUDE.md"))).toBe("@AGENTS.md\n"); + }), + ); + it.effect("keeps an existing persona, repository and registered project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* temporary; + NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: root }); + yield* fs.writeFileString(path.join(root, "AGENTS.md"), "# My persona\n"); + yield* fs.writeFileString(path.join(root, "CLAUDE.md"), "my instructions\n"); + const service = projects(ProjectId.make("existing")); + expect((yield* create(root, service.layer)).projectId).toBe("existing"); + expect(service.created).toEqual([]); + expect(yield* fs.readFileString(path.join(root, "AGENTS.md"))).toBe("# My persona\n"); + expect(yield* fs.readFileString(path.join(root, "CLAUDE.md"))).toBe("my instructions\n"); + }), + ); + it.effect("does not nest a git repository inside an existing checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* temporary; + NodeChildProcess.execFileSync("git", ["init", "--quiet"], { cwd: parent }); + const root = path.join(parent, "home"); + yield* create(root, projects().layer); + expect(yield* fs.exists(path.join(root, ".git"))).toBe(false); + expect(yield* fs.exists(path.join(root, "AGENTS.md"))).toBe(true); + }), + ); + it.effect("reuses a project that another client registers during creation", () => + Effect.gen(function* () { + const root = yield* temporary; + let added = false; + const id = ProjectId.make("concurrent-home"); + const layer = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: () => + Effect.sync(() => (added ? Option.some({ id } as Project) : Option.none())), + create: () => + Effect.sync(() => { + added = true; + }).pipe( + Effect.andThen( + Effect.fail( + new ProjectService.ProjectConflictError({ + projectId: ProjectId.make("ours"), + workspaceRoot: root, + conflictingProjectId: id, + }), + ), + ), + ), + }); + expect((yield* create(root, layer)).projectId).toBe(id); + }), + ); +}); diff --git a/apps/server/src/promachos/PromachosLaunch.ts b/apps/server/src/promachos/PromachosLaunch.ts new file mode 100644 index 0000000000..720e82d824 --- /dev/null +++ b/apps/server/src/promachos/PromachosLaunch.ts @@ -0,0 +1,95 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Receipts from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as Threads from "../orchestration-v2/ThreadManagementService.ts"; +import * as Prism from "../prism/PrismService.ts"; + +export class PromachosLaunchError extends Schema.TaggedError()( + "PromachosLaunchError", + { + reason: Schema.Literals(["initial-only", "routing", "read-thread"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + switch (this.reason) { + case "initial-only": + return "Prism can select the Promachos model only when creating a new top-level conversation."; + case "routing": + return "No eligible Promachos model with usage headroom."; + case "read-thread": + return "Could not verify the new Promachos conversation."; + } + } +} +export type PromachosLaunchInput = ThreadLaunch.ThreadLaunchInput & { + readonly prismRole?: "promachos"; +}; + +export class PromachosLaunch extends Context.Service< + PromachosLaunch, + { + readonly prepare: ( + input: PromachosLaunchInput, + ) => Effect.Effect; + } +>()("t3/promachos/PromachosLaunch") {} + +const make = Effect.gen(function* () { + const threads = yield* Threads.ThreadManagementService; + const receipts = yield* Receipts.CommandReceiptStoreV2; + const prism = yield* Prism.PrismService; + const prepare = Effect.fn("PromachosLaunch.prepare")(function* (input: PromachosLaunchInput) { + const { prismRole, ...launch } = input; + if (prismRole === undefined) return launch; + if (input.reuseExistingThread === true || input.initialMessage === undefined) { + return yield* new PromachosLaunchError({ reason: "initial-only" }); + } + const receipt = yield* receipts + .getByCommandId(input.commandId) + .pipe(Effect.mapError((cause) => new PromachosLaunchError({ reason: "read-thread", cause }))); + const threadId = + input.threadId ?? (Option.isSome(receipt) ? receipt.value.threadId : undefined); + const existing = + threadId === undefined + ? null + : yield* threads + .getThreadShell(threadId) + .pipe( + Effect.mapError( + (cause) => new PromachosLaunchError({ reason: "read-thread", cause }), + ), + ); + if (existing !== null) { + // A transport retry replays the upstream launch receipt, never today's kit/settings. + if ( + Option.isSome(receipt) && + receipt.value.status === "accepted" && + receipt.value.commandType === "thread.create" && + receipt.value.threadId === existing.id && + existing.projectId === input.projectId && + existing.lineage.relationshipToParent !== "subagent" + ) { + return { ...launch, modelSelection: existing.modelSelection }; + } + return yield* new PromachosLaunchError({ reason: "initial-only" }); + } + const selected = yield* prism + .resolve({ projectId: input.projectId, role: "promachos", inherited: input.modelSelection }) + .pipe(Effect.mapError((cause) => new PromachosLaunchError({ reason: "routing", cause }))); + return { + ...launch, + modelSelection: selected.modelSelection, + initialMessage: { + ...input.initialMessage, + text: [selected.kitText, input.initialMessage.text].filter(Boolean).join("\n\n"), + }, + }; + }); + return PromachosLaunch.of({ prepare }); +}); +export const layer = Layer.effect(PromachosLaunch, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 124413c05f..e3f95fcad2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,6 @@ +import * as CommandReceiptStore from "./orchestration-v2/CommandReceiptStore.ts"; +import * as PromachosLaunch from "./promachos/PromachosLaunch.ts"; +import * as Prism from "./prism/PrismService.ts"; import * as PromachosHome from "./promachos/PromachosHome.ts"; import { PROMACHOS_HOME_WS_METHODS } from "@t3tools/contracts"; import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; @@ -1213,6 +1216,14 @@ const layerWsRpc = ( const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; + const promachosLaunch = yield* PromachosLaunch.PromachosLaunch.pipe( + Effect.provide( + PromachosLaunch.layer.pipe( + Layer.provide(Prism.layer), + Layer.provide(CommandReceiptStore.layer), + ), + ), + ); const promachosHome = yield* PromachosHome.PromachosHome.pipe( Effect.provide(PromachosHome.layer), ); @@ -1834,7 +1845,7 @@ const layerWsRpc = ( stampSessionPerson(sessions, currentSessionId, command).pipe( Effect.flatMap(ThreadMessageIntake.dispatchCommand), ); - const launchThreadAsSessionPerson = (input: ThreadLaunchService.ThreadLaunchInput) => + const launchThreadAsSessionPerson = (input: PromachosLaunch.PromachosLaunchInput) => sessionPerson(sessions, currentSessionId).pipe( Effect.mapError( (cause) => @@ -1845,7 +1856,11 @@ const layerWsRpc = ( cause, }), ), - Effect.flatMap((owner) => ThreadMessageIntake.launchThread({ ...input, owner })), + Effect.flatMap((owner) => + promachosLaunch + .prepare({ ...input, owner }) + .pipe(Effect.flatMap(ThreadMessageIntake.launchThread)), + ), ); const handlers = ServerWsRpcGroup.of({ @@ -1993,6 +2008,7 @@ const layerWsRpc = ( startup .enqueueCommand( launchThreadAsSessionPerson({ + ...(input.prismRole === undefined ? {} : { prismRole: input.prismRole }), commandId: input.commandId, ...(input.threadId === undefined ? {} : { threadId: input.threadId }), ...(input.reuseExistingThread === undefined @@ -2043,6 +2059,13 @@ const layerWsRpc = ( projection: projectThreadProjectionForWire(result.projection), })), Effect.catchTags({ + PromachosLaunchError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: cause.message, + cause, + }), AttachmentClaimError: (cause) => new OrchestrationV2ThreadLaunchError({ commandId: input.commandId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a92ca01678..908e8bade3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,10 @@ +import { usePromachosInlineCardHost } from "./promachos/PromachosChat"; +import { usePromachosChat } from "./promachos/promachosMode"; +import { + promachosMultipleModelSelections, + promachosPicksModel, + withPromachosStart, +} from "./promachos/promachosStart"; import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; @@ -2483,6 +2490,7 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const promachosChat = usePromachosChat(activeProjectRef); // Environment settings with the active project's overrides applied. const activeProjectSettings = useMemo( () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined), @@ -2495,6 +2503,13 @@ export default function ChatView(props: ChatViewProps) { // A project added by cloning exists before its files do. The draft stays // editable throughout; only sending waits for the clone, and a failed // clone offers its retry right where the user is looking. + const promachosModels = promachosChat + ? activeProjectSettings.settings.prismRoles.promachos.models + : null; + const viewMultipleModelSelections = promachosMultipleModelSelections( + multipleModelSelections, + promachosModels, + ); const activeProjectClone = useProjectClone(activeProjectRef); const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { reportFailure: false, @@ -3960,6 +3975,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); + const promachosCardHost = usePromachosInlineCardHost({ + enabled: promachosChat, + activeThreadKey, + displayedThreadKey: displayedTimeline.displayThreadKey, + }); const worktreeSetupOwnerKey = draftId ?? routeThreadKey; const worktreeSetupActive = worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; @@ -7691,7 +7711,7 @@ export default function ChatView(props: ChatViewProps) { !nativeResumeCompactionDismissed && !compactDisabled && !hasHeldQueuedRuns && - multipleModelSelections === null && + viewMultipleModelSelections === null && shouldOfferResumeCompaction({ provider: selectedProvider, usedTokens: activeContextWindow.usedTokens, @@ -9885,7 +9905,10 @@ export default function ChatView(props: ChatViewProps) { ? scopeThreadRef(environmentId, threadIdForSend) : null; if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); - const startPromise = startThreadTurn({ + const startPromise = withPromachosStart( + startThreadTurn, + promachosModels, + )({ environmentId, input: { threadId: threadIdForSend, @@ -10765,7 +10788,7 @@ export default function ChatView(props: ChatViewProps) { ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { - if (multipleModelSelections !== null) return; + if (viewMultipleModelSelections !== null) return; if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); @@ -10788,7 +10811,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - multipleModelSelections, + viewMultipleModelSelections, activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, @@ -11105,7 +11128,7 @@ export default function ChatView(props: ChatViewProps) { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, onPresentationChange: setThreadPanelPresentation, - forceNewWorktree: multipleModelSelections !== null, + forceNewWorktree: viewMultipleModelSelections !== null, environmentId: activeThread.environmentId, threadId: activeThread.id, ...(draftId ? { draftId } : {}), @@ -11360,6 +11383,12 @@ export default function ChatView(props: ChatViewProps) {
    {/* Messages — LegendList handles virtualization and scrolling internally */} 0); const isComposerApprovalState = activePendingApproval !== null; const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState; const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0; @@ -6674,8 +6679,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? ( - + ) : null} - + ) : null} {!activityStackItem && isTasksDrawerOpen && diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4b2f69d303..92b60d0864 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,13 @@ +import { + PromachosInlineCardHost, + PromachosWorkingBubble, + PROMACHOS_OUTBOUND_BUBBLE_CLASS_NAME, +} from "../promachos/PromachosChat"; +import { PROMACHOS_BUBBLE_MARKDOWN } from "../promachos/promachosBubbles"; +import { + promachosTimelineRows, + usePromachosTimelineDisclosures, +} from "../promachos/promachosTimeline"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; @@ -311,6 +321,7 @@ import { // --------------------------------------------------------------------------- interface TimelineRowSharedState { + promachos: boolean; citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; timestampFormat: TimestampFormat; @@ -400,12 +411,15 @@ const TIMELINE_LIST_FADE_HEADER = ( function TimelineListFooter({ composerInset, children, + onInlineCardHost, }: { + readonly onInlineCardHost?: (element: HTMLDivElement | null) => void; readonly composerInset: number; readonly children?: ReactNode; }) { return (
    + {onInlineCardHost ? : null} {children}
    @@ -426,6 +440,10 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; +const PROMACHOS_MAINTAIN_SCROLL_AT_END = { + ...TIMELINE_MAINTAIN_SCROLL_AT_END, + on: { ...TIMELINE_MAINTAIN_SCROLL_AT_END.on, footerLayout: true }, +} as const satisfies MaintainScrollAtEndOptions; const EMPTY_TIMELINE_RUNS: ReadonlyArray = []; const EMPTY_RUN_IDS: ReadonlySet = new Set(); // Streamed text lands a paragraph at a time. A smooth scroll to the end @@ -436,6 +454,11 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { animated: true, } as const satisfies MaintainScrollAtEndOptions; +const PROMACHOS_MAINTAIN_SCROLL_AT_END_SMOOTH = { + ...PROMACHOS_MAINTAIN_SCROLL_AT_END, + animated: true, +} as const satisfies MaintainScrollAtEndOptions; + // --------------------------------------------------------------------------- // Props (public API) // --------------------------------------------------------------------------- @@ -448,6 +471,8 @@ export interface MessagesTimelineHistoryControls { } interface MessagesTimelineProps { + presentation?: "standard" | "promachos"; + onInlineCardHost?: (element: HTMLDivElement | null) => void; citationRequest?: AssistantCitationRequest | null; citationHistoryLoading?: boolean; onCiteAssistantText?: ( @@ -565,6 +590,8 @@ export function MessagesTimeline(props: MessagesTimelineProps) { } const ConversationTimeline = memo(function ConversationTimeline({ + presentation = "standard", + onInlineCardHost, citationRequest = null, citationHistoryLoading = false, onCiteAssistantText, @@ -878,6 +905,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ readonly workspaceRoot: string | undefined; readonly projection: MessagesTimelineRowsProjection; } | null>(null); + const promachos = presentation === "promachos"; + const promachosDisclosures = usePromachosTimelineDisclosures( + { timelineEntries, latestRun, isWorking, runlessWorkActive, runningRunId }, + promachos, + ); const rawRows = useMemo(() => { const previous = rowsProjectionRef.current; const projection = deriveMessagesTimelineRowsWithState( @@ -885,8 +917,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ timelineEntries, latestRun, runningRunId, - expandedRunIds: visibleExpandedRunIds, - expandedAttemptIds: visibleExpandedAttemptIds, + expandedRunIds: promachosDisclosures?.runs ?? visibleExpandedRunIds, + expandedAttemptIds: promachosDisclosures?.attempts ?? visibleExpandedAttemptIds, expandedWorkGroupIds, isWorking, runlessWorkActive, @@ -900,8 +932,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ : null, ); rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection }; - return projection.rows; + return promachos ? promachosTimelineRows(projection.rows) : projection.rows; }, [ + promachos, + promachosDisclosures, rowsProjectionRef, listIdentityKey, workspaceRoot, @@ -1133,11 +1167,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); const timelineListFooter = useMemo( () => ( - + {footer} ), - [anchoredEndSpace, contentInsetEndAdjustment, footer], + [anchoredEndSpace, contentInsetEndAdjustment, footer, onInlineCardHost], ); const measureContentOverflow = useCallback( @@ -1299,6 +1336,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const sharedState = useMemo( () => ({ + promachos, citationRequest: readyCitationRequest, listRef, timestampFormat, @@ -1338,6 +1376,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupViewState, }), [ + promachos, readyCitationRequest, listRef, timestampFormat, @@ -1604,8 +1643,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ disclosureToggleSettling ? false : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END + ? promachos + ? PROMACHOS_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : promachos + ? PROMACHOS_MAINTAIN_SCROLL_AT_END + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ findActive || @@ -2396,7 +2439,12 @@ function UserTimelineRow({ row }: { row: Extract ) : null} -
    +
    You {(regularImages.length > 0 || userVideos.length > 0) && (
    @@ -2760,6 +2808,7 @@ function AssistantTimelineRow({ row }: { row: Extract
    - - {row.showAssistantMeta ? ( + {ctx.promachos ? null : ( + + )} + {!ctx.promachos && row.showAssistantMeta ? ( }) { + const promachos = use(TimelineRowCtx).promachos; const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = use(TimelineRowActivityCtx); + if (promachos) return ; // One span for every label so the setup-to-working handoff swaps text in // place instead of remounting the row. const shimmer = isPreparingWorktree || isCompacting; diff --git a/apps/web/src/components/promachos/PromachosNewHome.tsx b/apps/web/src/components/promachos/PromachosNewHome.tsx index a2cc264f61..57a918d02c 100644 --- a/apps/web/src/components/promachos/PromachosNewHome.tsx +++ b/apps/web/src/components/promachos/PromachosNewHome.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentRpcCommand, isAtomCommandInterrupted, @@ -37,6 +38,7 @@ export function PromachosNewHome({ onCreated: (home: ScopedProjectRef) => void; }) { const create = useAtomCommand(createHome, { reportFailure: false }); + const canCreate = useAtomValue(createHome.permissionAtom(environmentId)); // Null while the form is closed. const [path, setPath] = useState(null); const [pending, setPending] = useState(false); @@ -45,7 +47,10 @@ export function PromachosNewHome({ if (path === null) { return ( - setPath(DEFAULT_PROMACHOS_HOME_PATH)}> + setPath(DEFAULT_PROMACHOS_HOME_PATH)} + > {label} @@ -54,7 +59,7 @@ export function PromachosNewHome({ } const submit = async () => { - if (path.trim().length === 0 || pending) return; + if (!canCreate || path.trim().length === 0 || pending) return; setPending(true); setError(null); const result = await create({ environmentId, input: { path: path.trim() } }); @@ -90,7 +95,11 @@ export function PromachosNewHome({ /> {error === null ? null :

    {error}

    }
    -
    ); } + +/** Preserve the upstream titlebar DOM and attach the fork mode switch below it. */ +export function PromachosSidebarHeader(props: ComponentProps<"div">) { + return ( + <> +
    + + + ); +} diff --git a/apps/web/src/components/promachos/promachosMode.ts b/apps/web/src/components/promachos/promachosMode.ts index fe92fa66e8..dce2fa9b72 100644 --- a/apps/web/src/components/promachos/promachosMode.ts +++ b/apps/web/src/components/promachos/promachosMode.ts @@ -8,6 +8,7 @@ */ import { ScopedProjectRef } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { promachosMultipleModelSelections } from "./promachosStart"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; @@ -57,3 +58,18 @@ export function useStartPromachosConversation() { const startNewThread = useNewThreadHandler(); return (home: ScopedProjectRef) => startNewThread(home); } + +/** Keep home/model and saved fanout presentation policy behind one chat-view hook. */ +export function usePromachosPresentation( + projectRef: ScopedProjectRef | null, + configuredModels: ReadonlyArray, + savedMultipleModels: T | null, +) { + const enabled = usePromachosChat(projectRef); + const models = enabled ? configuredModels : null; + return { + enabled, + models, + multipleModels: promachosMultipleModelSelections(savedMultipleModels, models), + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 2d2537cbf0..b1770a55c7 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { PromachosModeSwitch } from "../promachos/PromachosModeSwitch"; +import { PromachosSidebarHeader } from "../promachos/PromachosModeSwitch"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, CircleDotIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; @@ -38,15 +38,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - return ( - <> - - - - ); -}); - -const SidebarTitlebar = memo(function SidebarTitlebar({ isElectron }: { isElectron: boolean }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const backdropVariant = resolveSidebarStageBackdropVariant( @@ -60,7 +51,7 @@ const SidebarTitlebar = memo(function SidebarTitlebar({ isElectron }: { isElectr return ( // The titlebar row, not a padded SidebarHeader: it aligns to the window controls. -
    ) : null}
    -
    + ); }); diff --git a/docs/fork-features.md b/docs/fork-features.md index 9d5c169e64..5e4f372c60 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -866,6 +866,8 @@ Child creation paths are owned by child-threads and shared with thread-people. "apps/server/src/promachos/PromachosHome.test.ts", "apps/server/src/promachos/PromachosHome.ts", "apps/server/src/promachos/PromachosLaunch.ts", + "apps/server/src/promachos/PromachosLaunch.tests.ts", + "apps/server/src/promachos/PromachosRpc.ts", "apps/web/src/components/promachos/PromachosChat.tsx", "apps/web/src/components/promachos/PromachosModeSwitch.tsx", "apps/web/src/components/promachos/PromachosNewHome.tsx", From dac3a4222db431ee6a34e14a525fa82bb2477e60 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 22:57:43 +0200 Subject: [PATCH 5/7] fix(promachos): register authorization test ownership --- docs/fork-features.md | 2 +- scripts/fork-upstream-edits.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/fork-features.md b/docs/fork-features.md index 5e4f372c60..1619ecfb55 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -885,13 +885,13 @@ Child creation paths are owned by child-threads and shared with thread-people. "packages/contracts/src/promachosHome.ts" ], "upstreamFiles": [ + "apps/server/src/auth/RpcAuthorization.test.ts", "apps/web/src/components/AppSidebarLayout.tsx", "apps/web/src/components/chat/ChatComposer.tsx", "packages/client-runtime/src/operations/commands.test.ts", "packages/client-runtime/src/operations/commands.ts" ], "sharedFiles": [ - "apps/server/src/auth/RpcAuthorization.test.ts", "apps/server/src/observability/RpcInstrumentation.ts", "docs/README.md", "apps/server/src/orchestration-v2/ThreadLaunchService.test.ts", diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index ee8e12c1dd..b974c89b6e 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -161,6 +161,7 @@ packages/contracts/src/providerInstance.ts packages/shared/src/serverSettings.ts # Promachos mode (#175) +apps/server/src/auth/RpcAuthorization.test.ts apps/web/src/components/AppSidebarLayout.tsx apps/web/src/components/chat/ChatComposer.tsx packages/client-runtime/src/operations/commands.test.ts From f06076f5072380706da374aa1ffe8c1926dd35d8 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 23:10:05 +0200 Subject: [PATCH 6/7] fix(promachos): keep module-local helpers private --- apps/server/src/promachos/PromachosHome.ts | 2 +- apps/web/src/components/promachos/PromachosModeSwitch.tsx | 2 +- apps/web/src/components/promachos/promachosMode.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/promachos/PromachosHome.ts b/apps/server/src/promachos/PromachosHome.ts index b633dc88a0..2c1ccde86d 100644 --- a/apps/server/src/promachos/PromachosHome.ts +++ b/apps/server/src/promachos/PromachosHome.ts @@ -25,7 +25,7 @@ import * as Path from "effect/Path"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; -export const STARTER_AGENTS_MD = `# Promachos home +const STARTER_AGENTS_MD = `# Promachos home This folder is the Promachos home. Every Promachos conversation runs here and reads this file. diff --git a/apps/web/src/components/promachos/PromachosModeSwitch.tsx b/apps/web/src/components/promachos/PromachosModeSwitch.tsx index 7ed20a3a93..bc42c54b0b 100644 --- a/apps/web/src/components/promachos/PromachosModeSwitch.tsx +++ b/apps/web/src/components/promachos/PromachosModeSwitch.tsx @@ -9,7 +9,7 @@ import { usePromachosMode } from "./promachosMode"; * Promachos mode. Its own full-width row, so it fits at the sidebar's minimum * width beside any titlebar inset. */ -export function PromachosModeSwitch() { +function PromachosModeSwitch() { const onSettings = useLocation({ select: (location) => location.pathname === "/settings" || location.pathname.startsWith("/settings/"), diff --git a/apps/web/src/components/promachos/promachosMode.ts b/apps/web/src/components/promachos/promachosMode.ts index dce2fa9b72..58688c3a9b 100644 --- a/apps/web/src/components/promachos/promachosMode.ts +++ b/apps/web/src/components/promachos/promachosMode.ts @@ -43,7 +43,7 @@ function isPromachosHome( } /** Whether a thread in `projectRef` renders as a Promachos chat right now. */ -export function usePromachosChat(projectRef: ScopedProjectRef | null): boolean { +function usePromachosChat(projectRef: ScopedProjectRef | null): boolean { const [enabled] = usePromachosMode(); const [home] = usePromachosHome(); return enabled && isPromachosHome(projectRef, home); From 73435bedda993ddc954347ce79072caad93ba1c9 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 23:58:34 +0200 Subject: [PATCH 7/7] fix(promachos): restore launch kit after partial create retry --- .../src/promachos/PromachosLaunch.tests.ts | 68 ++++++++++++++++++- apps/server/src/promachos/PromachosLaunch.ts | 29 +++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/apps/server/src/promachos/PromachosLaunch.tests.ts b/apps/server/src/promachos/PromachosLaunch.tests.ts index 598c2f86ce..ac7b8001c7 100644 --- a/apps/server/src/promachos/PromachosLaunch.tests.ts +++ b/apps/server/src/promachos/PromachosLaunch.tests.ts @@ -16,7 +16,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import type * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import type * as AdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import type * as ServerSettings from "../serverSettings.ts"; +import * as ServerSettings from "../serverSettings.ts"; type Services = | ThreadLaunch.ThreadLaunchService @@ -107,6 +107,72 @@ export function registerPromachosLaunchTests({ }).pipe(Effect.provide(layerPromachos.pipe(Layer.provideMerge(harness.layer)))); }, ); + it.effect("Promachos launch retains the kit after only its create receipt commits", () => { + const harness = makeHarness({ + providers: [promachosProvider], + serverSettings: { + prismRoles: { + promachos: { models: [modelSelection], instructions: "Read the home persona." }, + }, + }, + }); + return Effect.gen(function* () { + const promachos = yield* PromachosLaunch.PromachosLaunch; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const input = { + ...launchInput({ + command: "promachos:partial", + thread: "promachos:partial", + message: "Hello", + }), + modelSelection: { ...modelSelection, model: "placeholder" }, + prismRole: "promachos" as const, + }; + const prepared = yield* promachos.prepare(input); + // Reach the real durable create boundary without an initial-message receipt. + const creation = { ...prepared }; + delete creation.initialMessage; + const created = yield* launches.launch(creation); + expect(created.projection.messages).toHaveLength(0); + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2.pipe( + Effect.provide(CommandReceiptStore.layer), + ); + const createReceipt = yield* receipts.getByCommandId(input.commandId); + expect(createReceipt).toMatchObject({ + value: { status: "accepted", commandType: "thread.create" }, + }); + const messageReceipt = yield* receipts.getByCommandId( + CommandId.make(`${input.commandId}:initial-message`), + ); + expect(messageReceipt).toMatchObject({ _tag: "None" }); + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ + prismRoles: { + promachos: { + models: [{ ...modelSelection, model: "new-preference-must-not-reroute" }], + instructions: "Current home persona.", + }, + }, + }); + const retried = yield* launches.launch(yield* promachos.prepare(input)); + expect(retried.projection.thread.modelSelection).toEqual(modelSelection); + expect(retried.projection.messages.map((message) => message.text)).toEqual([ + "Current home persona.\n\nHello", + ]); + yield* settings.updateSettings({ + prismRoles: { promachos: { instructions: "Must not reload." } }, + }); + const replay = yield* promachos.prepare(input); + expect(replay.initialMessage?.text).toBe("Hello"); + const acceptedRetry = yield* launches.launch(replay); + expect(acceptedRetry.projection.messages.map((message) => message.text)).toEqual([ + "Current home persona.\n\nHello", + ]); + expect(acceptedRetry.projection.thread.modelSelection).toEqual(modelSelection); + expect(acceptedRetry.projection.messages).toHaveLength(1); + expect(acceptedRetry.projection.runs).toHaveLength(1); + }).pipe(Effect.provide(layerPromachos.pipe(Layer.provideMerge(harness.layer)))); + }); it.effect( "Promachos launch falls through an unavailable first adapter before creating a thread", () => { diff --git a/apps/server/src/promachos/PromachosLaunch.ts b/apps/server/src/promachos/PromachosLaunch.ts index 9a8dd54901..bf4dddfac9 100644 --- a/apps/server/src/promachos/PromachosLaunch.ts +++ b/apps/server/src/promachos/PromachosLaunch.ts @@ -1,3 +1,4 @@ +import { CommandId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -69,7 +70,7 @@ const make = Effect.gen(function* () { ), ); if (existing !== null) { - // A transport retry replays the upstream launch receipt, never today's kit/settings. + // A transport retry keeps the model committed by upstream thread creation. if ( Option.isSome(receipt) && receipt.value.status === "accepted" && @@ -78,7 +79,31 @@ const make = Effect.gen(function* () { existing.projectId === input.projectId && existing.lineage.relationshipToParent !== "subagent" ) { - return { ...launch, modelSelection: existing.modelSelection }; + const messageReceipt = yield* receipts + .getByCommandId(CommandId.make(`${input.commandId}:initial-message`)) + .pipe( + Effect.mapError((cause) => new PromachosLaunchError({ reason: "read-thread", cause })), + ); + if (Option.isSome(messageReceipt) && messageReceipt.value.status === "accepted") { + return { ...launch, modelSelection: existing.modelSelection }; + } + // Creation can commit before the first message. Its retry keeps the stored model + // and reads the current launch kit until that message has its own accepted receipt. + const selected = yield* prism + .resolve({ + projectId: input.projectId, + role: "promachos", + explicit: existing.modelSelection, + }) + .pipe(Effect.mapError((cause) => new PromachosLaunchError({ reason: "routing", cause }))); + return { + ...launch, + modelSelection: existing.modelSelection, + initialMessage: { + ...input.initialMessage, + text: [selected.kitText, input.initialMessage.text].filter(Boolean).join("\n\n"), + }, + }; } return yield* new PromachosLaunchError({ reason: "initial-only" }); }