Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ADAPTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Shared preferences live at `~/.config/pstack/config.json`. `bin/config.mjs` supp

`references/runtime.md` holds the shared contract. The Codex and Claude adapters hold only native tool and goal differences. Every skill reads the shared contract; parent agents relay the same resolved policy to workers. Canonical project skills remain in `.agents/skills`, with individual non-overwriting `.claude/skills` links for discovery. Existing projects are not rewritten by installation.

`bin/transcripts.mjs` is a local addition with no upstream counterpart. `recall`, `correct` and `automate-me` list prior sessions through it instead of describing transcript paths in prose, and it enforces the shared runtime's cwd and time restriction before any content is read. Claude sessions come only from the project directory Claude Code derives from each requested cwd, and a session counts only if it recorded that cwd, because distinct paths can share a directory name and a session moved from another directory keeps its earlier cwd. Codex rollouts are opened only when modified inside the window, and only for their first `session_meta` record. The Claude directory rule was read from Claude Code 2.1.302 and checked against every existing project directory on the maintainer's machine; upstream's harness note (cursor/plugins#637) names only `/` and `.`, which misses paths with spaces, underscores or non-ASCII characters and those longer than 200 characters. `scripts/transcripts.test.mjs` covers both hosts with a temporary home.

Benny remains dormant. Its copied runtime entry discovers this shared contract instead of maintaining a duplicated adapter. Installing the package does not arm messages, remote writes, webhooks, goals, or scheduled runs.

## Upstream sync
Expand Down
246 changes: 246 additions & 0 deletions bin/transcripts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
#!/usr/bin/env node

// Lists one checkout's own session transcripts, newest first, for recall, correct and automate-me.
// A session matches by the working directory its host recorded; the helper reads that metadata and
// never prints message content.
import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const HOSTS = ["claude", "codex"];
const DEFAULT_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Claude Code (checked against 2.1.302 and this machine's project directories) replaces every UTF-16
// code unit outside [a-zA-Z0-9] with "-", so spaces, dots, underscores and CJK become one dash and an
// emoji two; past 200 characters it keeps the first 200 and appends a base-36 string hash.
const CLAUDE_SLUG_LIMIT = 200;
const READ_CHUNK = 64 * 1024;
const NEWLINE = 0x0a;

function claudeProjectSlug(cwd) {
const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
if (slug.length <= CLAUDE_SLUG_LIMIT) return slug;
let hash = 0;
for (let i = 0; i < cwd.length; i += 1) hash = ((hash << 5) - hash + cwd.charCodeAt(i)) | 0;
return `${slug.slice(0, CLAUDE_SLUG_LIMIT)}-${Math.abs(hash).toString(36)}`;
}

function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function entries(dir) {
return statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? readdirSync(dir, { withFileTypes: true }) : [];
}

// The .jsonl files directly inside dir; stem is the name without the extension.
function jsonlFiles(dir) {
return entries(dir)
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map((entry) => ({ file: path.join(dir, entry.name), stem: entry.name.slice(0, -".jsonl".length) }));
}

// Splits only at newline bytes, which never occur inside a UTF-8 sequence, so each line decodes whole.
// Stopping early closes the file through the generator's finally block.
function* lines(file) {
const fd = openSync(file, "r");
try {
const buffer = Buffer.alloc(READ_CHUNK);
let pending = [];
for (let bytes; (bytes = readSync(fd, buffer, 0, buffer.length, null)) > 0;) {
const chunk = buffer.subarray(0, bytes);
let start = 0;
for (let end; (end = chunk.indexOf(NEWLINE, start)) !== -1; start = end + 1) {
pending.push(chunk.subarray(start, end));
yield Buffer.concat(pending).toString("utf8");
pending = [];
}
pending.push(Buffer.from(chunk.subarray(start)));
}
const rest = Buffer.concat(pending);
if (rest.length) yield rest.toString("utf8");
} finally {
closeSync(fd);
}
}

function parseRecord(line) {
try {
const value = JSON.parse(line);
return isObject(value) ? value : null;
} catch {
return null;
}
}

function transcript({ file, stat, ...fields }) {
return { ...fields, path: file, mtime: new Date(stat.mtimeMs).toISOString(), size: stat.size };
}

// A session can start elsewhere and be moved into this directory, and distinct paths such as a-b and
// a.b share one directory name, so the first recorded cwd is not enough: any top-level cwd must match.
function claudeSessionCwd(file, cwds) {
for (const line of lines(file)) {
const cwd = parseRecord(line)?.cwd;
if (cwds.has(cwd)) return cwd;
}
return null;
}

function listClaude(cwds, inWindow) {
const root = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude"), "projects");
const found = [];
for (const dir of new Set([...cwds].map((cwd) => path.join(root, claudeProjectSlug(cwd))))) {
for (const { file, stem: session } of jsonlFiles(dir)) {
const candidates = [
{ file, kind: "session", session, parent: null },
...jsonlFiles(path.join(dir, session, "subagents")).map((sub) => ({ file: sub.file, kind: "subagent", session: sub.stem, parent: session })),
]
.map((candidate) => ({ ...candidate, stat: statSync(candidate.file) }))
.filter((candidate) => inWindow(candidate.stat.mtimeMs));
if (!candidates.length) continue;
const cwd = claudeSessionCwd(file, cwds);
if (cwd === null) continue;
for (const candidate of candidates) found.push(transcript({ ...candidate, cwd }));
}
}
return found;
}

function* codexRollouts(root) {
const numbered = (dir, digits) => entries(dir).filter((entry) => entry.isDirectory() && new RegExp(`^\\d{${digits}}$`).test(entry.name));
for (const year of numbered(root, 4)) {
for (const month of numbered(path.join(root, year.name), 2)) {
for (const day of numbered(path.join(root, year.name, month.name), 2)) {
yield* jsonlFiles(path.join(root, year.name, month.name, day.name)).map(({ file }) => file);
}
}
}
}

function firstRecord(file) {
for (const line of lines(file)) return parseRecord(line);
return null;
}

// Codex keeps no per-project directory, so a rollout's cwd is in its first record (session_meta).
// The mtime check comes first: only rollouts inside the window are opened, and only that first line.
function listCodex(cwds, inWindow) {
const root = path.join(process.env.CODEX_HOME || path.join(homedir(), ".codex"), "sessions");
const found = [];
for (const file of codexRollouts(root)) {
const stat = statSync(file);
if (!inWindow(stat.mtimeMs)) continue;
const record = firstRecord(file);
const meta = record?.type === "session_meta" && isObject(record.payload) ? record.payload : null;
if (!meta || !cwds.has(meta.cwd)) continue;
const subagent = isObject(meta.source) && isObject(meta.source.subagent) ? meta.source.subagent : null;
const parent = subagent?.thread_spawn?.parent_thread_id;
found.push(transcript({
file,
stat,
kind: subagent ? "subagent" : "session",
session: typeof meta.id === "string" ? meta.id : null,
parent: typeof parent === "string" ? parent : null,
cwd: meta.cwd,
}));
}
return found;
}

// Hosts record the process cwd, which resolves symlinks (macOS /tmp is /private/tmp), so both
// spellings match. A directory that no longer exists, such as a removed worktree, still matches as given.
function matchableCwds(cwds) {
const forms = new Set();
for (const cwd of cwds) {
const resolved = path.resolve(cwd);
forms.add(resolved);
try {
forms.add(realpathSync.native(resolved));
} catch (error) {
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
}
}
return forms;
}

function listTranscripts({ host, cwds, since, until }) {
if (!HOSTS.includes(host)) throw new Error(`host must be one of ${HOSTS.join(" or ")}`);
if (since > until) throw new Error("since must not be later than until");
const list = host === "claude" ? listClaude : listCodex;
const found = list(matchableCwds(cwds), (mtime) => mtime >= since && mtime <= until);
found.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.path.localeCompare(b.path));
return {
host,
cwds: cwds.map((cwd) => path.resolve(cwd)),
since: new Date(since).toISOString(),
until: new Date(until).toISOString(),
transcripts: found,
};
}

// A bare date is a local calendar day: --since starts at its midnight and --until includes the whole day.
function parseTime(value, flag, { endOfDay }) {
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (day) {
const [year, month, date] = day.slice(1).map(Number);
const start = new Date(year, month - 1, date);
if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== date) {
throw new Error(`${flag} ${value} is not a calendar date`);
}
return endOfDay ? new Date(year, month - 1, date + 1).getTime() - 1 : start.getTime();
}
const time = /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : Number.NaN;
if (Number.isNaN(time)) throw new Error(`${flag} must be YYYY-MM-DD or an ISO 8601 date-time`);
Comment thread
mtmtian marked this conversation as resolved.
return time;
}

const USAGE = "Usage: transcripts.mjs --host claude|codex [--cwd dir]... [--since YYYY-MM-DD|date-time] [--until YYYY-MM-DD|date-time] [--json]";

function parseArgs(args) {
const options = { cwd: [], json: false };
for (let i = 0; i < args.length; i += 1) {
const flag = args[i];
if (flag === "--json") {
options.json = true;
continue;
}
const key = flag.startsWith("--") ? flag.slice(2) : null;
if (!["host", "cwd", "since", "until"].includes(key)) throw new Error(`Unknown argument: ${flag}\n${USAGE}`);
const value = args[i + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`Option ${flag} requires a value`);
if (key === "cwd") options.cwd.push(value);
else if (Object.hasOwn(options, key)) throw new Error(`Option ${flag} may only be provided once`);
else options[key] = value;
i += 1;
}
if (!options.host) throw new Error(`--host is required\n${USAGE}`);
return options;
}

function runCli(args) {
const options = parseArgs(args);
const now = Date.now();
const result = listTranscripts({
host: options.host,
cwds: options.cwd.length ? options.cwd : [process.cwd()],
since: options.since === undefined ? now - DEFAULT_DAYS * DAY_MS : parseTime(options.since, "--since", { endOfDay: false }),
until: options.until === undefined ? now : parseTime(options.until, "--until", { endOfDay: true }),
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
for (const item of result.transcripts) process.stdout.write(`${item.mtime}\t${item.kind}\t${item.size}\t${item.path}\n`);
process.stderr.write(`${result.transcripts.length} ${result.host} transcript(s) for ${result.cwds.join(", ")} from ${result.since} to ${result.until}\n`);
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
}
}
2 changes: 1 addition & 1 deletion references/claude-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ Read [the shared runtime](./runtime.md) first. This file defines only Claude Cod
- For isolated writers, use an actual worktree at the intended base SHA. Do not assume the default branch used by a host-created worktree matches the parent's HEAD. Verify it.
- Claude's native `/goal` is a session feature with its own evaluator and lifecycle. Check for an existing goal before replacing it. A skill invocation does not activate one. On explicit user intent, use the native command/control actually exposed in that session; if it requires user command entry, explain that exact prerequisite and continue feasible work. Do not invent `create_goal`, `get_goal`, or `update_goal` tools. Surface the completion evidence for the evaluator instead of pretending to set Codex-style statuses. On stop/pause follow the host's real goal clearing behavior and preserve a checkpoint.
- `/loop` and Cron tools, when present, handle separately requested session scheduling. Verify the actual job ID and cadence. A goal is not a schedule; a background Bash process is not a durable automation. Do not promise operation after session exit or restart without testing that exact path.
- Project/user instructions come from the current Claude environment. Use supported history/resume tools or narrowly scoped local session records; do not invoke Codex task APIs or assume Cursor transcript paths.
- Project/user instructions come from the current Claude environment. Use supported history/resume tools first. For local session records, `bin/transcripts.mjs --host claude` reads `~/.claude/projects/<slug>/` (under `CLAUDE_CONFIG_DIR` when set), where the slug is the cwd with every UTF-16 code unit outside `[A-Za-z0-9]` replaced by `-` and, past 200 characters, cut to 200 plus a hash. It keeps only sessions that recorded the requested cwd and lists their `subagents/` files with them. Do not invoke Codex task APIs or assume Cursor transcript paths.

Official references: [plugins](https://code.claude.com/docs/en/plugins), [subagents](https://code.claude.com/docs/en/sub-agents), [goal](https://code.claude.com/docs/en/goal), [scheduling](https://code.claude.com/docs/en/scheduled-tasks).
2 changes: 1 addition & 1 deletion references/codex-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Read [the shared runtime](./runtime.md) first. This file defines only Codex-spec
- Close children only after a final answer, explicit completion event, or confirmed completed/failed/canceled status. Preserve host-specific cancellation and progress rules.
- Create a native goal only on an explicit user request. Use actual `create_goal`, `get_goal`, and `update_goal` schemas. Reuse a matching active goal, never replace another one implicitly. Mark complete only with evidence; pause only on request; use blocked only under the tool's repeated-blocker conditions. Set a token budget only when the user requested one. Retain returned final usage accounting when applicable.
- Native goals do not imply timers. Use available process/session waits, events, or separately authorized automations. Report missing continuation or scheduling honestly.
- Prefer supported Codex task/history tools for prior sessions. Apply the user's current AGENTS.md and applicable local rules; do not copy user-specific rules into the plugin.
- Prefer supported Codex task/history tools for prior sessions. For local rollouts, `bin/transcripts.mjs --host codex` lists `~/.codex/sessions/YYYY/MM/DD/*.jsonl` (under `CODEX_HOME` when set) whose first record, `session_meta`, has `payload.cwd` equal to the checkout, and marks `payload.source.subagent` rollouts as subagents. It opens only rollouts modified inside the window, and only to read that first record. Compressed rollouts in `archived_sessions/` are not listed. Apply the user's current AGENTS.md and applicable local rules; do not copy user-specific rules into the plugin.
4 changes: 2 additions & 2 deletions references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ Prefer the project's existing verification harness. In this user's environment,

Upstream `deslop`, `control-ui`, and `control-cli` dependencies are replaced by direct diff review and actually available tools. `gh` is the default forge; use Origin/Graphite only when present, compatible, and authorized. Do not install optional connectors or activate the dormant Benny pack implicitly.

Use the current task and authorized project artifacts first. For history, follow the host adapter; restrict local transcript candidates by repository/cwd, session, and time before reading content. Do not mine unrelated sessions or write persistent memory without an explicit request.
Use the current task and authorized project artifacts first. For history, follow the host adapter; restrict local transcript candidates by repository/cwd, session, and time before reading content. `node <plugin-root>/bin/transcripts.mjs --host <claude|codex> --cwd <checkout> [--since <date>] [--until <date>] [--json]` applies the cwd and time restriction (default: the current directory and the last 7 days) and lists the candidates newest first by modification time; read only listed files, and only the parts the task needs. A linked worktree records its own cwd, so pass every checkout in scope (`git worktree list`) as another `--cwd`. Do not mine unrelated sessions or write persistent memory without an explicit request.
Comment thread
mtmtian marked this conversation as resolved.

The Node helpers `bin/orch.mjs`, `bin/watch-pr.mjs`, and `bin/config.mjs` need no runtime dependency downloads. Resolve them from the plugin root, never the target project's current directory. Source helper development uses Bun; only rebuild the existing bundles when their TypeScript sources change. Keep verification evidence after cleanup. Discovery, structural validation, script tests, and actual agent behavior are separate gates.
The Node helpers `bin/orch.mjs`, `bin/watch-pr.mjs`, `bin/config.mjs`, and `bin/transcripts.mjs` need no runtime dependency downloads. Resolve them from the plugin root, never the target project's current directory. Source helper development uses Bun; only rebuild the existing bundles when their TypeScript sources change. Keep verification evidence after cleanup. Discovery, structural validation, script tests, and actual agent behavior are separate gates.
Loading
Loading