Files
hyperframes/packages/cli/src/utils/projectRecords.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

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