mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +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.
84 lines
3.0 KiB
TypeScript
84 lines
3.0 KiB
TypeScript
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
|
|
|
|
// The records the CLI and the desktop app keep in a project's .hyperframes/. A project can be a clone of someone
|
|
// else's repo, so a link in place of the folder or of a record is never followed, on reading or on writing.
|
|
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
const NON_BLOCK = constants.O_NONBLOCK ?? 0;
|
|
|
|
/** The folder, made when missing and asked for; null when something other than a real folder holds its name. */
|
|
function recordsDir(projectDir: string, create = false): string | null {
|
|
const dir = join(projectDir, ".hyperframes");
|
|
try {
|
|
return lstatSync(dir).isDirectory() ? dir : null;
|
|
} catch {
|
|
if (!create) return null;
|
|
try {
|
|
mkdirSync(dir);
|
|
return dir;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The last `maxBytes` of a plain file; "" for a link, a device, or a missing file. The file is opened first and
|
|
* read only when `lstat` then finds the same plain file at the path, so this holds where O_NOFOLLOW doesn't exist
|
|
* (Windows), and nothing path-based follows the check. */
|
|
export function readPlainFile(path: string, maxBytes: number): string {
|
|
let fd: number;
|
|
try {
|
|
fd = openSync(path, constants.O_RDONLY | NO_FOLLOW | NON_BLOCK);
|
|
} catch {
|
|
return "";
|
|
}
|
|
try {
|
|
const stats = fstatSync(fd);
|
|
const named = lstatSync(path);
|
|
if (!stats.isFile() || !named.isFile() || named.ino !== stats.ino || named.dev !== stats.dev)
|
|
return "";
|
|
const length = Math.min(stats.size, maxBytes);
|
|
const buffer = Buffer.alloc(length);
|
|
readSync(fd, buffer, 0, length, stats.size - length);
|
|
return buffer.toString("utf8");
|
|
} catch {
|
|
return "";
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
export function readRecord(projectDir: string, name: string, maxBytes: number): string {
|
|
const dir = recordsDir(projectDir);
|
|
return dir ? readPlainFile(join(dir, name), maxBytes) : "";
|
|
}
|
|
|
|
/** When a record last changed; 0 when it is missing or not a plain file. */
|
|
export function recordChangedAt(projectDir: string, name: string): number {
|
|
const dir = recordsDir(projectDir);
|
|
try {
|
|
const stats = dir ? lstatSync(join(dir, name)) : null;
|
|
return stats?.isFile() ? stats.mtimeMs : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/** A file written whole through a temp file renamed over it, so a link in its place is replaced, never written
|
|
* through, and a crash mid-write leaves the old file. False when it could not be written. */
|
|
export function replaceFile(path: string, data: string, mode: number): boolean {
|
|
try {
|
|
replaceFileAtomically(path, data, mode);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** A record written whole and owner-only, in a real .hyperframes folder. */
|
|
export function writeRecord(projectDir: string, name: string, data: string): boolean {
|
|
const dir = recordsDir(projectDir, true);
|
|
return dir !== null && replaceFile(join(dir, name), data, 0o600);
|
|
}
|