mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* feat(cli): catch up on what the desktop app did since a project was handed over The desktop app records each chat turn in .hyperframes/app-history.jsonl. `hyperframes catch-up [dir]` prints the turns since the hand-off or the last catch-up (what the person asked, what Framey did, which files changed) plus the project files changed since, by the app or by hand, then marks them seen. `hyperframes open` and Studio's Edit with Framey mark the hand-off moment. Any command run on a project with unseen turns ends with a line naming catch-up, so an agent back in the terminal hears about it even when it forgets. /hyperframes and /hyperframes-cli tell the agent to catch up as soon as the person writes again. * feat(cli): hand a Grok terminal session over to the desktop app Grok gives every tool command its session as GROK_SESSION_ID (grok 1.0.4), so `hyperframes open` and Edit with Framey now name a Grok session in .hyperframes/agent-handoff.json, after Claude Code and Codex. The desktop app reads its turns from Grok's own session files. * feat(cli): tell a new project's agent to catch up after the desktop app Scaffolded projects' CLAUDE.md and AGENTS.md now say to run `npx hyperframes catch-up` before the next change once the project was opened in the desktop app. Claude Code, Codex and Grok read these files on their own, so a resumed session catches up even when it never loaded a HyperFrames skill: a resumed Claude Code session that had not, asked to change the title, ran catch-up first. catch-up also prints each reply on one line. * fix(cli): read and write .hyperframes records without following links, mark seen after showing Review (Jerrai, Rames): - One helper (projectRecords.ts) now reads and writes every record in .hyperframes/. A linked folder is refused. A read opens once with O_NOFOLLOW and checks the open file is a plain file, which clears the CodeQL race. A write goes to a temp file renamed over the record, so a link is replaced, never written through. agent-handoff.json, the seen record and the history all go through it. - catch-up marks a turn seen only after showing it, and only up to the newest turn shown, so a turn written just after the read shows next time. Changed files have their own "checked" time. - Turns show only once the project was handed over from this machine, so a history file shipped in a cloned repo is never shown as the person's words. The end-of-command check skips reading when the history is older than the seen record. - The skills mention catch-up only when the CLI printed a line naming it, so an older CLI never meets an unknown command. - The notice test uses basename, which fixes Windows. * feat(cli): add the catch-up line to an older project's instructions on hand-off A project scaffolded before catch-up existed has no line telling its agent to catch up, and a resumed session that never loaded a HyperFrames skill then edits without knowing what happened in the app. When `hyperframes open` or Edit with Framey hands such a project over, its scaffolded CLAUDE.md and AGENTS.md (they start "# HyperFrames Composition Project") gain the template's line, in the template's place. A file of the person's own, a link, a missing file, or one that has the line is left alone. The file is rewritten through a temp file with its mode kept. A test keeps the line the same as the template's. * fix(cli): keep catch-up's cursor in the person's home, never reset it, read without links on Windows Review (Rames, Jerrai): - The seen record moved from the project to ~/.hyperframes/catch-up/, keyed by the project's real path. A seen file a cloned repo commits can no longer make its history read as the person's words. - A second hand-off (`open` again, or another Edit with Framey click) keeps the cursor, so turns the app recorded meanwhile still show. - Reads lstat the path, open it once, and refuse a file whose dev/ino differs from what lstat saw. That holds where O_NOFOLLOW doesn't exist, fixing the Windows test. The note writer reads the same way, which clears the new CodeQL alert. - Writes use core's replaceFileAtomically, which retries a rename that Windows briefly refuses. - catch-up names every changed file, and the cursor is the newest `at` shown, not the last line's. - `open --json` carries `catchUp` after a hand-off. - The bin scripts are back to mode 644. * fix(cli): open before checking for CodeQL, never show a turn dated ahead Review (Rames, Jerrai): - readPlainFile opens the file first, then requires lstat to find the same plain file (dev/ino) at the path, and reads only through the fd. That is just as safe, and it clears CodeQL's check-then-use alert. - catch-up skips turns dated after the moment it reads. So a cloned history can't show a far-future turn as the person's words or park the cursor past real ones. - The seen key uses core's realpath (realpathSync.native), so letter case can't split a project in two. ~/.hyperframes/catch-up is made 0700, and a hand-off that can't note itself says so. - Tests stub HOME with vi.stubEnv and restore it.
224 lines
8.7 KiB
TypeScript
224 lines
8.7 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { basename, isAbsolute, join, relative, win32 } from "node:path";
|
|
import { addCatchUpNote, markSeen, readSeen } from "./appHistory.js";
|
|
import { writeRecord } from "./projectRecords.js";
|
|
|
|
// The HyperFrames desktop app (not "Studio"): `open -b` on macOS, its executable elsewhere; released, then Canary.
|
|
const DESKTOP_BUNDLE_IDS = ["dev.hyperframes.desktop", "dev.hyperframes.desktop.canary"] as const;
|
|
const DESKTOP_APP_NAMES = ["HyperFrames.app", "HyperFrames Canary.app"];
|
|
const APP_NAMES = ["HyperFrames", "HyperFrames Canary"] as const;
|
|
const STUDIO_PAGE = "https://hyperframes.dev/studio";
|
|
const DOWNLOADS: Partial<Record<NodeJS.Platform, string>> = {
|
|
darwin: `${STUDIO_PAGE}/download`,
|
|
linux: `${STUDIO_PAGE}/download?os=linux`,
|
|
};
|
|
|
|
/** This machine's build of the app; null where there is none yet (Windows). */
|
|
export const desktopDownloadUrl = (platform = process.platform): string | null =>
|
|
DOWNLOADS[platform] ?? null;
|
|
|
|
export const downloadHint = (url: string): string =>
|
|
`Keep editing by chatting with Framey in the HyperFrames desktop app → ${url}`;
|
|
|
|
// ponytail: the Mac app takes a handed-over folder since b254 (hyperframes-internal#2601); Windows and Linux join
|
|
// once its folder-argv change ships. Until then every surface offers the download and writes no hand-off.
|
|
export const HANDOFF_READY = process.platform === "darwin";
|
|
|
|
export interface AgentSession {
|
|
engine: "claude" | "codex" | "grok";
|
|
sessionId: string;
|
|
}
|
|
|
|
export const AGENT_HANDOFF_FILE = join(".hyperframes", "agent-handoff.json");
|
|
|
|
// ponytail: Claude Code, then Codex, then Grok; an agent started inside another's shell carries both, unsorted.
|
|
export function agentSession(env: NodeJS.ProcessEnv = process.env): AgentSession | null {
|
|
if (env.CLAUDE_CODE_SESSION_ID)
|
|
return { engine: "claude", sessionId: env.CLAUDE_CODE_SESSION_ID };
|
|
if (env.CODEX_THREAD_ID) return { engine: "codex", sessionId: env.CODEX_THREAD_ID };
|
|
if (env.GROK_SESSION_ID) return { engine: "grok", sessionId: env.GROK_SESSION_ID };
|
|
return null;
|
|
}
|
|
|
|
type AppName = "the HyperFrames desktop app" | "HyperFrames Canary";
|
|
|
|
export type DesktopOpenResult =
|
|
| { opened: true; app: AppName; handedOver: AgentSession | null }
|
|
| {
|
|
opened: false;
|
|
reason: "handoff-unavailable" | "unsupported-platform" | "not-installed" | "open-failed";
|
|
downloadUrl: string;
|
|
};
|
|
|
|
const openWithBundle = (bundleId: string, dir: string): boolean =>
|
|
spawnSync("open", ["-b", bundleId, dir], { stdio: "ignore" }).status === 0;
|
|
|
|
/** Single-instance app: a running copy is forwarded the folder. Only a refused spawn counts as failed. */
|
|
function launchApp(executable: string, dir: string): boolean {
|
|
try {
|
|
const child = spawn(executable, [dir], { detached: true, stdio: "ignore" });
|
|
child.on("error", () => {});
|
|
child.unref();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const readText = (path: string): string | null => {
|
|
try {
|
|
return readFileSync(path, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
function launcherAppImage(text: string | null): string | null {
|
|
try {
|
|
const launcher: unknown = JSON.parse(text ?? "null");
|
|
return typeof launcher === "object" &&
|
|
launcher !== null &&
|
|
"appImage" in launcher &&
|
|
typeof launcher.appImage === "string"
|
|
? launcher.appImage
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface AppLookup {
|
|
platform?: NodeJS.Platform;
|
|
env?: NodeJS.ProcessEnv;
|
|
home?: string;
|
|
exists?: (path: string) => boolean;
|
|
read?: (path: string) => string | null;
|
|
}
|
|
|
|
/** Released then Canary: the Windows install path, or the AppImage Linux records in launcher.json; null on macOS. */
|
|
function desktopApps({
|
|
platform = process.platform,
|
|
env = process.env,
|
|
home = homedir(),
|
|
exists = existsSync,
|
|
read = readText,
|
|
}: AppLookup = {}): { executable: string; canary: boolean }[] | null {
|
|
if (platform !== "win32" && platform !== "linux") return null;
|
|
const config = env.XDG_CONFIG_HOME || join(home, ".config");
|
|
const where = (name: string): string | null => {
|
|
if (platform === "win32")
|
|
return env.LOCALAPPDATA
|
|
? win32.join(env.LOCALAPPDATA, "Programs", name, `${name}.exe`)
|
|
: null;
|
|
const appImage = launcherAppImage(read(join(config, name, "launcher.json")));
|
|
return appImage && isAbsolute(appImage) ? appImage : null;
|
|
};
|
|
return APP_NAMES.flatMap((name, i) => {
|
|
const executable = where(name);
|
|
return executable && exists(executable) ? [{ executable, canary: i === 1 }] : [];
|
|
});
|
|
}
|
|
|
|
/** The app reads the hand-off at its first chat send, so a project it cannot be written to just opens without it. */
|
|
function leaveHandoff(dir: string, session: AgentSession | null): AgentSession | null {
|
|
if (!session || !writeRecord(dir, "agent-handoff.json", JSON.stringify(session))) return null;
|
|
const now = Date.now();
|
|
if (!readSeen(dir).at && !markSeen(dir, { at: now, checked: now }))
|
|
console.warn("◇ Couldn't note the hand-off in ~/.hyperframes, so `catch-up` won't see it.");
|
|
addCatchUpNote(dir);
|
|
return session;
|
|
}
|
|
|
|
// ponytail: success on Windows and Linux means the spawn started, not that the app took the folder; wait for the
|
|
// app's own acknowledgement if a broken install ever needs reporting.
|
|
export function openInDesktop(
|
|
dir: string,
|
|
{
|
|
ready = HANDOFF_READY,
|
|
platform = process.platform,
|
|
open = openWithBundle,
|
|
launch = launchApp,
|
|
installed = () => desktopInstalled({ platform }),
|
|
env = process.env,
|
|
...where
|
|
}: AppLookup & {
|
|
ready?: boolean;
|
|
open?: (bundleId: string, dir: string) => boolean;
|
|
launch?: (executable: string, dir: string) => boolean;
|
|
installed?: () => boolean;
|
|
} = {},
|
|
): DesktopOpenResult {
|
|
const notOpened = (
|
|
reason: Extract<DesktopOpenResult, { opened: false }>["reason"],
|
|
): DesktopOpenResult => ({
|
|
opened: false,
|
|
reason,
|
|
downloadUrl: desktopDownloadUrl(platform) ?? STUDIO_PAGE,
|
|
});
|
|
const opened = (canary: boolean): DesktopOpenResult => ({
|
|
opened: true,
|
|
app: canary ? "HyperFrames Canary" : "the HyperFrames desktop app",
|
|
handedOver: leaveHandoff(dir, agentSession(env)),
|
|
});
|
|
if (!ready) return notOpened("handoff-unavailable");
|
|
if (platform === "darwin") {
|
|
const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir));
|
|
if (!bundleId) return notOpened(installed() ? "open-failed" : "not-installed");
|
|
return opened(bundleId.endsWith(".canary"));
|
|
}
|
|
const apps = desktopApps({ platform, env, ...where });
|
|
if (!apps) return notOpened("unsupported-platform");
|
|
if (!apps.length) return notOpened("not-installed");
|
|
const app = apps.find(({ executable }) => launch(executable, dir));
|
|
return app ? opened(app.canary) : notOpened("open-failed");
|
|
}
|
|
|
|
/** The `hyperframes open` line for a project, relative to where the person is. */
|
|
export function openCommandFor(dir: string, cwd = process.cwd()): string {
|
|
const shown = relative(cwd, dir) || ".";
|
|
return `hyperframes open ${/\s/.test(shown) ? JSON.stringify(shown) : shown}`;
|
|
}
|
|
|
|
/** Spotlight's copies of an app with this bundle id; an updater's hidden leftover (`.X.app.installing-…`) is none. */
|
|
const spotlightFinds = (bundleId: string): boolean =>
|
|
spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${bundleId}'`], { encoding: "utf8" })
|
|
.stdout?.split("\n")
|
|
.some((path) => path.endsWith(".app") && !basename(path).startsWith(".")) ?? false;
|
|
|
|
/** Whether this machine has the app, without launching it (on a Mac: install folders, then Spotlight). */
|
|
export function desktopInstalled({
|
|
spotlight = spotlightFinds,
|
|
...where
|
|
}: AppLookup & { spotlight?: (bundleId: string) => boolean } = {}): boolean {
|
|
const { platform = process.platform, home = homedir(), exists = existsSync } = where;
|
|
if (platform !== "darwin") return (desktopApps(where)?.length ?? 0) > 0;
|
|
const folders = ["/Applications", join(home, "Applications")];
|
|
if (folders.some((folder) => DESKTOP_APP_NAMES.some((name) => exists(join(folder, name)))))
|
|
return true;
|
|
return DESKTOP_BUNDLE_IDS.some(spotlight);
|
|
}
|
|
|
|
/** The app line render and preview print; null inside the app, or with nothing here to open or download. */
|
|
export function desktopHint(
|
|
dir: string,
|
|
{
|
|
env = process.env,
|
|
ready = HANDOFF_READY,
|
|
installed,
|
|
platform = process.platform,
|
|
}: {
|
|
env?: NodeJS.ProcessEnv;
|
|
ready?: boolean;
|
|
installed?: boolean;
|
|
platform?: NodeJS.Platform;
|
|
} = {},
|
|
): string | null {
|
|
if (env.HYPERFRAMES_DESKTOP_PROJECT) return null;
|
|
if (ready && (installed ?? desktopInstalled({ platform, env })))
|
|
return `Keep editing by chatting with Framey in the desktop app: ${openCommandFor(dir)}`;
|
|
const url = desktopDownloadUrl(platform);
|
|
return url ? downloadHint(url) : null;
|
|
}
|