Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-gatekeeper/src/mount.ts

Snapshot at 34e3175

Containing directory

4,147 bytes · typescriptView raw
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