Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-gatekeeper/src/mount.ts
Snapshot at 34e3175
1import { mountDashboard } from "../../forkhorse-dashboard/src/dashboard.ts";
2import type { Brand, DashboardTransport } from "../../forkhorse-dashboard/src/contracts.ts";
3import type { FramePresentation } from "../../forkhorse-dashboard/src/view.tsx";
4import { publicCoreOrigin } from "../app/owner-origin.ts";
5
6type SetPresenting = (active: boolean) => Promise<{ rect: unknown; willResize: boolean }>;
7
8/** Like the pinned Context Library, retire stale acknowledgements before restoring the host. */
9export function framePresentation(setPresenting: SetPresenting): FramePresentation & { dispose(): void } {
10 let expanded = false;
11 let disposed = false;
12 let epoch = 0;
13 const listeners = new Set<() => void>();
14 const publish = () => { for (const listener of listeners) listener(); };
15 async function restore() {
16 try { await setPresenting(false); } catch { /* A retired host may already be disconnected. */ }
17 }
18 async function request(active: boolean) {
19 if (disposed) return;
20 const current = ++epoch;
21 expanded = active;
22 publish();
23 try {
24 await setPresenting(active);
25 // An opening acknowledgement can arrive after reconnect or unmount. Never resurrect it.
26 if (current !== epoch && active && !expanded) await restore();
27 } catch {
28 if (current !== epoch) return;
29 expanded = false;
30 publish();
31 console.warn("Frame presentation unavailable.");
32 if (active) await restore();
33 }
34 }
35 return {
36 subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
37 getSnapshot: () => expanded,
38 toggle: () => { void request(!expanded); },
39 reset: () => { void request(false); },
40 dispose() {
41 if (disposed) return;
42 void request(false);
43 disposed = true;
44 epoch++;
45 listeners.clear();
46 },
47 };
48}
49
50/** Missing or malformed host metadata must never produce a guessed owner address. */
51export function ownerLink(coreOrigin: string | undefined, project: string): string | undefined {
52 const origin = publicCoreOrigin(coreOrigin);
53 if (!origin || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(project) || project.length > 48) return;
54 return `${origin}/app?project=${encodeURIComponent(project)}`;
55}
56
57/** Presentation never grants owner authority; the agent transport independently refuses writes. */
58export function mountSidebar(
59 root: HTMLElement,
60 options: {
61 brand: Brand;
62 project: string;
63 transport: DashboardTransport;
64 openPrompt(prompt: string): Promise<void>;
65 setPresenting?: SetPresenting;
66 coreOrigin?: string;
67 fixture?: boolean;
68 agent?: string;
69 },
70) {
71 const presentation = options.setPresenting ? framePresentation(options.setPresenting) : undefined;
72 const view = mountDashboard(root, {
73 ...options,
74 framePresentation: presentation,
75 ownerLink: ownerLink(options.coreOrigin, options.project),
76 openPrompt: () => options.openPrompt(
77 `Use the attached agent fork for project ${options.project} in ${options.brand.name}. ` +
78 "Call context() first and follow existing work before starting a new packet. " +
79 "If no fork is attached, select one owned by the connected agent from the resource picker. " +
80 "For new work, use claim(packet, repoName), or join() only when a new fork is needed; " +
81 "never repeat join() to resume. Stop for each approval decision and read context() again. " +
82 "After join is approved, attach the new fork before editing. " +
83 "Use getHead() to create a worktree, edit only the packet paths, run its checks, commit, " +
84 "and push(branch, commitId). After push approval, requestReview(packet, repoName, sha) " +
85 "for that exact commit. Report pending approvals and blockers; promotion stays with the owner.",
86 ),
87 owner: false,
88 projects: [{ id: options.project, label: options.project }],
89 initialProject: options.project,
90 });
91 let disposed = false;
92 return {
93 ...view,
94 resetPresentation() { presentation?.reset(); },
95 dispose() {
96 if (disposed) return;
97 disposed = true;
98 presentation?.dispose();
99 view.dispose();
100 },
101 };
102}
103