Files
hyperframes/packages/cli/src/utils/desktopApp.ts
T
WaterrrForever 6fe3c2ce94 feat(cli): catch up on what the desktop app did since a project was handed over (#5114)
* 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.
2026-10-06 13:20:27 +00:00

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;
}