diff --git a/sdk/typescript/src/cli/harness-wrapper.ts b/sdk/typescript/src/cli/harness-wrapper.ts index da4bb95b..4ea328b0 100644 --- a/sdk/typescript/src/cli/harness-wrapper.ts +++ b/sdk/typescript/src/cli/harness-wrapper.ts @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, readdirSync, + realpathSync, statSync, writeFileSync, } from "node:fs"; @@ -87,6 +88,12 @@ export interface HarnessWrapperConfig { receiveFrom?: string; receivePollMs: number; sessionFile?: string; + // A resumed session appends to a file that already existed when the tailer + // started, so it lands in `ignoredSessionFiles` and would never be tailed. + // Setting this un-ignores that one file so the resumed turns still stream + // (without pinning — a resume that instead spawns a fresh file is still + // located normally). See tui.ts resume flow. + resumeSessionFile?: string; sessionPollMs: number; sessionsDir: string; sessionTailGraceMs: number; @@ -629,6 +636,12 @@ function buildAgentLaunch(config: HarnessWrapperConfig): AgentLaunch { export class HarnessSessionTailer { private ignoredSessionFiles = new Set(); private lineOffset = 0; + // Lines already present in a resumed file when the tailer started — the offset + // to begin at so the pre-existing transcript isn't replayed to OpenHuman. + private resumeStartOffset = 0; + // Canonicalized (symlink/relative-resolved) path of the resumed file, so the + // ignore-set removal and the located-path match key off one stable identity. + private resumeSessionPath: string | undefined; private startedAt: Date | undefined; private sessionFile: string | undefined; private sessionMeta: SessionMeta | undefined; @@ -648,6 +661,25 @@ export class HarnessSessionTailer { public start(startedAt: Date): void { this.startedAt = startedAt; this.ignoredSessionFiles = new Set(listSessionFiles(this.config)); + // Resume: the file the resumed session appends to already existed, so it was + // just swept into the ignore set. Drop it back out so its new turns tail. + if (this.config.resumeSessionFile) { + // Canonicalize once: the resume path comes from a different origin (session + // discovery) than listSessionFiles, so a symlinked / relative / absolute + // alias of the same file would otherwise stay ignored or fall through to a + // newer fresh file and replay history. Match on the resolved identity. + this.resumeSessionPath = canonicalPath(this.config.resumeSessionFile); + for (const ignored of this.ignoredSessionFiles) { + if (canonicalPath(ignored) === this.resumeSessionPath) { + this.ignoredSessionFiles.delete(ignored); + } + } + // Skip the transcript that already exists — only turns appended after the + // resume should stream, or OpenHuman would re-receive the whole history. + this.resumeStartOffset = readAllLines( + this.config.resumeSessionFile, + ).length; + } bridgeLog("tailer.start", { startedAt: startedAt.toISOString(), cwd: this.cwd, @@ -684,7 +716,14 @@ export class HarnessSessionTailer { } this.sessionFile = located.path; this.sessionMeta = located.meta; - this.lineOffset = 0; + // A resumed file starts past its existing transcript; any other located + // file is new, so start at 0. Compare on the canonical identity so a + // path alias of the resumed file still seeds past its history. + this.lineOffset = + this.resumeSessionPath !== undefined && + canonicalPath(located.path) === this.resumeSessionPath + ? this.resumeStartOffset + : 0; bridgeLog("tailer.located", { path: located.path, sessionId: located.meta?.sessionId, @@ -1659,6 +1698,19 @@ function readNewLines( .filter((entry) => entry.line > lineOffset); } +/** + * Resolve a path to a stable identity for equality checks: symlinks + relative + * segments collapsed. Falls back to `resolve()` (absolute, no symlink walk) when + * the file doesn't exist yet, so callers still get a normalized comparison key. + */ +function canonicalPath(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + function readAllLines(path: string): Array { try { return readFileSync(path, "utf8") diff --git a/sdk/typescript/src/cli/session-history.ts b/sdk/typescript/src/cli/session-history.ts new file mode 100644 index 00000000..20b2babb --- /dev/null +++ b/sdk/typescript/src/cli/session-history.ts @@ -0,0 +1,379 @@ +import { + closeSync, + existsSync, + openSync, + readSync, + readdirSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve, sep } from "node:path"; + +/** + * Recent-session history for the tiny.place home screen. This module is the read + * model behind the home "resume" pane: it scans the agents' own session dirs + * (`~/.claude/projects`, `~/.codex/sessions`) — the same files the wrapper's + * tailer streams — so the list is always accurate with no separate store to keep + * in sync. A row resolves to `{ agent, id }`, which the TUI relaunches via + * `claude --resume ` / `codex resume ` in the session's original cwd. + */ + +export type SessionAgentKind = "claude" | "codex"; + +export interface RecentSession { + /** Agent session id (claude `sessionId` / codex `session_id`). */ + id: string; + agent: SessionAgentKind; + /** Working directory the session ran in, when recorded. */ + cwd?: string; + /** First human prompt, single-lined + truncated; drives the resume label. */ + label: string; + /** Last-activity epoch ms (session-file mtime). */ + lastActive: number; + /** Absolute path to the session's JSONL file. */ + path: string; +} + +export interface ListRecentSessionsOptions { + /** Max rows returned (default 24). */ + limit?: number; + /** Cap on files whose head we parse, before the final sort/slice (default 60). */ + scanLimit?: number; +} + +const DEFAULT_LIMIT = 24; +const DEFAULT_SCAN_LIMIT = 60; +/** Bytes read from each session file — enough for meta + the first prompt. */ +const HEAD_BYTES = 64 * 1024; +const LABEL_MAX = 72; + +interface RawSessionFile { + agent: SessionAgentKind; + path: string; + mtimeMs: number; +} + +interface SessionSummary { + id: string; + cwd?: string; + label: string; +} + +/** + * The most recent agent sessions across both harnesses, ordered + * **current-folder-first, then most-recent**, so a resume from where you're + * standing is always at the top. Cost is bounded: only the newest `scanLimit` + * files are opened, and only their first `HEAD_BYTES` are parsed. + */ +export function listRecentSessions( + env: Record, + cwd: string, + options: ListRecentSessionsOptions = {}, +): Array { + const limit = options.limit ?? DEFAULT_LIMIT; + const scanLimit = options.scanLimit ?? DEFAULT_SCAN_LIMIT; + + const raw = [ + ...collectSessionFiles("claude", claudeSessionsDir(env)), + ...collectSessionFiles("codex", codexSessionsDir(env)), + ] + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, scanLimit); + + const here = safeResolve(cwd); + // Dedupe by agent+id, keeping the freshest file (a resumed claude session + // appends to one file; codex resume can spawn a sibling rollout). + const byId = new Map(); + for (const file of raw) { + const summary = readSessionSummary(file.agent, file.path); + if (!summary) { + continue; + } + const key = `${file.agent}:${summary.id}`; + const existing = byId.get(key); + if (existing && existing.lastActive >= file.mtimeMs) { + continue; + } + byId.set(key, { + agent: file.agent, + id: summary.id, + label: summary.label, + lastActive: file.mtimeMs, + path: file.path, + ...(summary.cwd ? { cwd: summary.cwd } : {}), + }); + } + + return [...byId.values()] + .sort((left, right) => { + const leftHere = isHere(left.cwd, here) ? 0 : 1; + const rightHere = isHere(right.cwd, here) ? 0 : 1; + if (leftHere !== rightHere) { + return leftHere - rightHere; + } + return right.lastActive - left.lastActive; + }) + .slice(0, limit); +} + +/** Resolve the Claude session directory, honoring the env overrides. */ +export function claudeSessionsDir( + env: Record, +): string { + return ( + firstEnv(env, [ + "TINYVERSE_CLAUDE_SESSIONS_DIR", + "TINYPLACE_CLAUDE_SESSIONS_DIR", + ]) ?? join(homedir(), ".claude", "projects") + ); +} + +/** Resolve the Codex session directory, honoring the env override. */ +export function codexSessionsDir( + env: Record, +): string { + return ( + env.TINYPLACE_CODEX_SESSIONS_DIR ?? join(homedir(), ".codex", "sessions") + ); +} + +function collectSessionFiles( + agent: SessionAgentKind, + dir: string, +): Array { + if (!existsSync(dir)) { + return []; + } + const out: Array = []; + const visit = (directory: string): void => { + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + visit(path); + continue; + } + if (!entry.isFile() || !isSessionFile(agent, path, entry.name)) { + continue; + } + try { + out.push({ agent, path, mtimeMs: statSync(path).mtimeMs }); + } catch { + continue; + } + } + }; + visit(dir); + return out; +} + +function isSessionFile( + agent: SessionAgentKind, + path: string, + name: string, +): boolean { + if (agent === "codex") { + return name.startsWith("rollout-") && name.endsWith(".jsonl"); + } + return name.endsWith(".jsonl") && !path.includes(`${sep}subagents${sep}`); +} + +function readSessionSummary( + agent: SessionAgentKind, + path: string, +): SessionSummary | undefined { + const lines = readHeadLines(path); + return agent === "claude" + ? readClaudeSummary(lines) + : readCodexSummary(lines); +} + +function readClaudeSummary(lines: Array): SessionSummary | undefined { + let id: string | undefined; + let cwd: string | undefined; + let label: string | undefined; + for (const raw of lines) { + const record = parseObject(raw); + if (!record) { + continue; + } + id = asString(record.sessionId) ?? id; + cwd = asString(record.cwd) ?? cwd; + if (label === undefined && record.type === "user") { + label = firstPromptText(asMessageContent(record.message)); + } + } + if (!id) { + return undefined; + } + return { id, label: label ?? "(no prompt)", ...(cwd ? { cwd } : {}) }; +} + +function readCodexSummary(lines: Array): SessionSummary | undefined { + let id: string | undefined; + let cwd: string | undefined; + let label: string | undefined; + for (const raw of lines) { + const record = parseObject(raw); + if (!record) { + continue; + } + if (record.type === "session_meta") { + const payload = asObject(record.payload); + if (payload) { + id = asString(payload.session_id) ?? asString(payload.id) ?? id; + cwd = asString(payload.cwd) ?? cwd; + } + continue; + } + if (label !== undefined || record.type !== "response_item") { + continue; + } + const payload = asObject(record.payload); + if ( + payload?.type === "message" && + asString(payload.role) === "user" + ) { + label = firstPromptText(payload.content); + } + } + if (!id) { + return undefined; + } + return { id, label: label ?? "(no prompt)", ...(cwd ? { cwd } : {}) }; +} + +/** + * Turn a user message's `content` into a display label, or undefined when the + * content is not a real prompt. System-injected turns are wrapped in + * `<...>` tags (codex `` / ``, claude + * `` / ``) and tool-result turns carry no text + * block — both are skipped so the label reflects the first thing the human said. + */ +function firstPromptText(content: unknown): string | undefined { + const text = extractText(content); + if (text === undefined) { + return undefined; + } + const trimmed = text.trim(); + if (trimmed.length === 0 || trimmed.startsWith("<")) { + return undefined; + } + return truncateLabel(trimmed); +} + +function extractText(content: unknown): string | undefined { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return undefined; + } + for (const item of content) { + const object = asObject(item); + if (!object) { + continue; + } + // claude blocks are {type:"text",text}; codex are {type:"input_text",text}. + if (object.type === "text" || object.type === "input_text") { + const text = asString(object.text); + if (text !== undefined) { + return text; + } + } + } + return undefined; +} + +function asMessageContent(message: unknown): unknown { + const object = asObject(message); + if (!object || asString(object.role) !== "user") { + return undefined; + } + return object.content; +} + +function truncateLabel(text: string): string { + // Labels come from arbitrary prior prompts, which can carry terminal control + // bytes (ESC/CSI, BEL, C1). Strip C0/DEL/C1 to a space so a pasted escape + // sequence can't move the cursor or recolor the pane when the label renders — + // sanitizing here covers every consumer (TTY rows, the non-TTY snapshot, tests). + const single = text + .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (single.length <= LABEL_MAX) { + return single; + } + return `${single.slice(0, LABEL_MAX - 1).trimEnd()}…`; +} + +function readHeadLines(path: string): Array { + let fd: number; + try { + fd = openSync(path, "r"); + } catch { + return []; + } + try { + const buffer = Buffer.alloc(HEAD_BYTES); + const read = readSync(fd, buffer, 0, HEAD_BYTES, 0); + const text = buffer.toString("utf8", 0, read); + const lines = text.split(/\r?\n/); + // When the read hit the cap the final line is likely truncated — drop it so + // a half-JSON line doesn't parse-fail noisily. + if (read >= HEAD_BYTES && lines.length > 1) { + lines.pop(); + } + return lines.filter((line) => line.length > 0); + } catch { + return []; + } finally { + closeSync(fd); + } +} + +function isHere(cwd: string | undefined, here: string | undefined): boolean { + return cwd !== undefined && here !== undefined && safeResolve(cwd) === here; +} + +function safeResolve(path: string): string | undefined { + try { + return resolve(path); + } catch { + return undefined; + } +} + +function firstEnv( + env: Record, + names: Array, +): string | undefined { + return names + .map((name) => env[name]) + .find((value) => value !== undefined && value !== ""); +} + +function parseObject(raw: string): Record | undefined { + try { + return asObject(JSON.parse(raw)); + } catch { + return undefined; + } +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/sdk/typescript/src/cli/session-lock.ts b/sdk/typescript/src/cli/session-lock.ts new file mode 100644 index 00000000..b31a67d4 --- /dev/null +++ b/sdk/typescript/src/cli/session-lock.ts @@ -0,0 +1,125 @@ +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { join } from "node:path"; + +/** + * Per-session mutual exclusion for the home resume flow. + * + * Resuming a session pins a *stable* OpenHuman thread id (`tp--resume- + * `) so re-resuming continues the same conversation. The hazard is + * two `tinyplace` instances resuming the SAME session at once: sharing one + * wallet + one thread key, they double-stream into one OpenHuman thread and two + * ` --resume ` processes scribble into the same session JSONL. + * + * The guard is mutual exclusion, not key-uniqueness (uniqueness would defeat the + * resume continuity): a lockfile keyed on agent+sessionId, taken before launch. + * A second instance sees it held and refuses. A dead holder's lock is reclaimed + * via a PID-liveness probe, so a crashed session never wedges a resume forever. + */ + +export interface AcquiredSessionLock { + path: string; +} + +/** + * Try to claim the lock for `agent`/`sessionId`. Returns the handle on success, + * or undefined when another *live* instance already holds it. + */ +export function acquireSessionLock( + lockDir: string, + agent: string, + sessionId: string, +): AcquiredSessionLock | undefined { + mkdirSync(lockDir, { recursive: true }); + const path = join(lockDir, `${agent}-${sanitize(sessionId)}.lock`); + if (writeLock(path)) { + return { path }; + } + // Held — reclaim only if the recorded holder is gone, and do it atomically. + // A plain unlink-then-create lets two contenders both delete the stale file + // and race the re-create; worse, the second's unlink can wipe the FIRST's + // fresh lock, leaving two holders. Instead, move the stale file aside with a + // rename: renameSync is atomic and fails with ENOENT once the source is gone, + // so only one contender wins the steal. The final claim is still an exclusive + // `wx` create, which lets at most one winner publish the lock. + if (isStale(path)) { + const aside = `${path}.stale-${process.pid}`; + try { + renameSync(path, aside); + } catch { + // Lost the steal race (already moved/gone) — just try a clean create. + return writeLock(path) ? { path } : undefined; + } + try { + unlinkSync(aside); + } catch { + // Best-effort cleanup of the moved-aside stale file. + } + if (writeLock(path)) { + return { path }; + } + } + return undefined; +} + +/** Release a previously acquired lock. Best-effort; a missing file is fine. */ +export function releaseSessionLock(lock: AcquiredSessionLock): void { + try { + unlinkSync(lock.path); + } catch { + // Already gone (manual cleanup / reclaimed as stale) — nothing to do. + } +} + +/** Exclusively create the lock file, stamping our pid. False if it exists. */ +function writeLock(path: string): boolean { + let fd: number; + try { + fd = openSync(path, "wx"); // wx = create, fail if present (atomic) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + throw error; + } + try { + writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); + } finally { + closeSync(fd); + } + return true; +} + +/** True when the lock's recorded holder is no longer a running process. */ +function isStale(path: string): boolean { + let pid: unknown; + try { + pid = JSON.parse(readFileSync(path, "utf8")).pid; + } catch { + return true; // unreadable / corrupt / vanished → reclaimable + } + if (typeof pid !== "number") { + return true; + } + if (pid === process.pid) { + return true; // our own leftover from an earlier run in this process + } + try { + process.kill(pid, 0); // signal 0 = liveness probe, no signal delivered + return false; // alive → genuinely held + } catch (error) { + // ESRCH = no such process → dead → stale. EPERM = alive but not ours → held. + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function sanitize(sessionId: string): string { + return sessionId.replace(/[^A-Za-z0-9._-]/g, "_"); +} diff --git a/sdk/typescript/src/cli/tui.ts b/sdk/typescript/src/cli/tui.ts index 58e73938..c5140224 100644 --- a/sdk/typescript/src/cli/tui.ts +++ b/sdk/typescript/src/cli/tui.ts @@ -9,12 +9,18 @@ import { } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; -import { dirname, relative, resolve, join, sep } from "node:path"; +import { basename, dirname, relative, resolve, join, sep } from "node:path"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { IPty } from "node-pty"; import { bridgeLog, createBridgeDiagSink } from "./bridge-debug.js"; -import { saveOpenHumanOwner } from "./context.js"; +import { configPathFor, saveOpenHumanOwner } from "./context.js"; +import { listRecentSessions, type RecentSession } from "./session-history.js"; +import { + acquireSessionLock, + releaseSessionLock, + type AcquiredSessionLock, +} from "./session-lock.js"; import { HarnessSessionTailer, InboundMessageReceiver, @@ -45,6 +51,10 @@ interface TuiState { openHumanSessionId?: string; selectedIndex: number; view: TuiView; + // Home mode only: which pane holds the cursor (left actions vs the recent- + // session resume list), and the highlighted row in the sessions pane. + homeFocus?: "actions" | "sessions"; + sessionIndex?: number; } interface AgentLaunch { @@ -61,6 +71,9 @@ interface AgentProfile { pendingSessionId: string; sessionPollEnv: string; sessionsDir: string; + // Set when this profile resumes a specific session (home resume pane): drives + // the `--resume`/`resume` launch arg and a stable per-session OpenHuman scope. + resumeSessionId?: string; } interface AgentSessionMeta { @@ -118,7 +131,7 @@ export async function runTinyPlaceTui( code: 0, stderr: "", stdout: homeMode - ? renderHomeSnapshot(ctx) + ? renderHomeSnapshot(ctx, options.cwd ?? process.cwd()) : renderStaticSnapshot(ctx, profile), }; } @@ -148,7 +161,13 @@ function runInteractiveBlessedTui( homeMode: boolean, ): Promise { return new Promise((resolve) => { - const app = new BlessedTinyPlaceTui(ctx, options, profile, resolve, homeMode); + const app = new BlessedTinyPlaceTui( + ctx, + options, + profile, + resolve, + homeMode, + ); app.start(); }); } @@ -183,6 +202,20 @@ class BlessedTinyPlaceTui { // inbound receiver, reused from the harness wrapper. private bridgeTailer?: HarnessSessionTailer; private bridgeReceiver?: InboundMessageReceiver; + // Home-screen resume pane: the recent sessions across both agents, loaded once + // at start. Empty in fixed-agent mode. + private recentSessions: Array = []; + // Per-launch working directory. A resumed session launches in its own original + // cwd (so its OpenHuman folder-thread and session-file lookup line up); a fresh + // launch leaves this undefined and falls back to the process cwd. + private launchCwd?: string; + // Resume only: the session's JSONL path, handed to the tailer so it keeps + // streaming a resumed file that already existed at bridge start. + private launchSessionFile?: string; + // Resume only: the per-session exclusive lock held while this instance bridges + // it, so a second tinyplace can't resume the same session concurrently (which + // would double-stream into one OpenHuman thread + fight over the JSONL). + private sessionLock?: AcquiredSessionLock; // Loop guard: count consecutive auto-submitted inbound turns with no human // keystroke in between. OpenHuman's master auto-replies, so unbounded // auto-submit ping-pongs forever; after MAX_AUTO_INJECTS we stop submitting @@ -215,12 +248,19 @@ class BlessedTinyPlaceTui { } /** Home mode: bind the chosen agent, then resolve the owner for THAT agent - * (provider-specific recipients only take effect once the agent is known). */ - private setProfile(kind: TinyVerseAgentKind): void { - this.profile = buildAgentProfile(this.ctx.env, kind); + * (provider-specific recipients only take effect once the agent is known). An + * optional `resumeSessionId` binds a resume launch (home resume pane). */ + private setProfile(kind: TinyVerseAgentKind, resumeSessionId?: string): void { + this.profile = buildAgentProfile(this.ctx.env, kind, resumeSessionId); this.adoptRememberedOwner(); } + /** Working directory for the current launch: a resumed session runs where it + * originally ran; everything else follows the process/option cwd. */ + private effectiveCwd(): string { + return this.launchCwd ?? this.options.cwd ?? process.cwd(); + } + private createBlessedLayout(): void { this.screen = blessed.screen({ fullUnicode: true, @@ -278,6 +318,12 @@ class BlessedTinyPlaceTui { // to the wrong owner. if (!this.homeMode) { this.adoptRememberedOwner(); + } else { + this.recentSessions = safeListRecentSessions( + this.ctx.env, + this.effectiveCwd(), + ); + this.state = { ...this.state, homeFocus: "actions", sessionIndex: 0 }; } this.render(); this.scheduleAutoStart(); @@ -328,6 +374,17 @@ class BlessedTinyPlaceTui { this.screen.key(["down", "j"], () => { this.moveSelection(1); }); + // Home mode has two panes (actions + recent sessions); Tab and ←/→ move the + // cursor between them. No-op in fixed-agent mode / non-welcome views. + this.screen.key(["tab", "S-tab"], () => { + this.toggleHomeFocus(); + }); + this.screen.key(["right", "l"], () => { + this.toggleHomeFocus("sessions"); + }); + this.screen.key(["left", "h"], () => { + this.toggleHomeFocus("actions"); + }); this.screen.key(["c", "n"], () => { // Home mode has no single default agent — force the picker (arrows/Enter) // so `c`/`n`/`x` can't silently launch Codex when the user wanted Claude. @@ -364,6 +421,22 @@ class BlessedTinyPlaceTui { return; } this.clearAutoStart(); + if (this.homeMode && this.state.homeFocus === "sessions") { + if (this.recentSessions.length === 0) { + return; + } + this.state = { + ...this.state, + notice: undefined, + sessionIndex: clamp( + (this.state.sessionIndex ?? 0) + delta, + 0, + this.recentSessions.length - 1, + ), + }; + this.render(); + return; + } this.state = { ...this.state, notice: undefined, @@ -376,6 +449,28 @@ class BlessedTinyPlaceTui { this.render(); } + /** Move the home cursor between the actions pane and the resume pane. No-op + * outside home/welcome, or into an empty sessions pane. */ + private toggleHomeFocus(target?: "actions" | "sessions"): void { + if (!this.homeMode || this.state.view !== "welcome") { + return; + } + const next = + target ?? + ((this.state.homeFocus ?? "actions") === "actions" + ? "sessions" + : "actions"); + if (next === "sessions" && this.recentSessions.length === 0) { + return; + } + if (next === (this.state.homeFocus ?? "actions")) { + return; + } + this.clearAutoStart(); + this.state = { ...this.state, homeFocus: next, notice: undefined }; + this.render(); + } + private activateSelection(): void { if (this.state.view === "agent") { return; @@ -391,6 +486,10 @@ class BlessedTinyPlaceTui { this.render(); return; } + if (this.homeMode && this.state.homeFocus === "sessions") { + this.resumeSelectedSession(); + return; + } switch (this.selectedAction()) { case "launch": void this.startAgent(); @@ -425,6 +524,67 @@ class BlessedTinyPlaceTui { this.render(); } + /** Resume the highlighted recent session: bind its agent + id (so the launch + * carries `--resume`/`resume` and a stable per-session OpenHuman scope), run + * it in its original folder, and hand off to the normal agent launch. */ + private resumeSelectedSession(): void { + const session = this.recentSessions[this.state.sessionIndex ?? 0]; + if (!session) { + return; + } + // Refuse a second concurrent resume of the same session (see session-lock). + const lock = this.acquireSessionLockOrNotice(session); + if (!lock) { + return; + } + this.sessionLock = lock; + this.setProfile(session.agent, session.id); + this.launchCwd = session.cwd; + this.launchSessionFile = session.path; + this.state = { + ...this.state, + notice: `Resuming ${session.agent} session…`, + }; + void this.startAgent(); + } + + /** Claim the per-session lock; on contention leave a notice and return + * undefined so the caller stays on the home screen. Never throws — a lock + * filesystem hiccup degrades to "allowed" rather than blocking a resume. */ + private acquireSessionLockOrNotice( + session: RecentSession, + ): AcquiredSessionLock | undefined { + let lock: AcquiredSessionLock | undefined; + try { + lock = acquireSessionLock( + join(dirname(configPathFor(this.ctx.env)), "locks"), + session.agent, + session.id, + ); + } catch { + // Can't touch the lock dir — don't wedge the user; allow the resume. + return { path: "" }; + } + if (!lock) { + this.state = { + ...this.state, + notice: `This ${session.agent} session is already live in another tinyplace window.`, + }; + this.render(); + } + return lock; + } + + /** Release the per-session resume lock, if held. */ + private releaseSessionLockIfHeld(): void { + if (this.sessionLock) { + if (this.sessionLock.path) { + releaseSessionLock(this.sessionLock); + } + this.sessionLock = undefined; + } + } + /** * The owner we bridge to. Priority: entered this session (overrides) → env * (explicit per-run override) → persisted config (remembered from a previous @@ -567,7 +727,18 @@ class BlessedTinyPlaceTui { config.dmRecipient = owner; config.receiveFrom = owner; config.receiveEnabled = true; - const cwd = this.options.cwd ?? process.cwd(); + // Resume: pin the OpenHuman thread to THIS agent session (stable, derived + // from its id) so re-resuming the same session continues the same thread + // regardless of folder. Fresh launches keep the default folder scope. + if (this.profile.resumeSessionId) { + config.scope = "session"; + config.wrapperSessionId = `tp-${this.profile.kind}-resume-${this.profile.resumeSessionId}`; + // Keep tailing the resumed file even though it predates the tailer. + if (this.launchSessionFile) { + config.resumeSessionFile = this.launchSessionFile; + } + } + const cwd = this.effectiveCwd(); bridgeLog("bridge.start", { owner, provider: config.provider, @@ -686,9 +857,7 @@ class BlessedTinyPlaceTui { } return; } - this.writeAgentInput( - Buffer.from(autoSubmit ? `${body}\r` : body, "utf8"), - ); + this.writeAgentInput(Buffer.from(autoSubmit ? `${body}\r` : body, "utf8")); } private async startAgent(): Promise { @@ -706,7 +875,8 @@ class BlessedTinyPlaceTui { }; this.agentSessionMonitor = new AgentSessionMonitor( this.ctx, - this.options, + // Locate the resumed/fresh session against the folder it actually runs in. + { ...this.options, cwd: this.effectiveCwd() }, this.profile, (meta) => { this.state = { @@ -763,7 +933,7 @@ class BlessedTinyPlaceTui { const { spawn } = await import("node-pty"); const pty = spawn(launch.command, launch.args, { cols: terminalColumns(this.options), - cwd: this.options.cwd ?? process.cwd(), + cwd: this.effectiveCwd(), env: childEnv(this.ctx.env), name: terminalName(this.ctx.env), rows: terminalRows(this.options), @@ -790,7 +960,7 @@ class BlessedTinyPlaceTui { const spawnFn = this.options.spawn ?? spawnChild; const child = spawnFn(launch.command, launch.args, { - cwd: this.options.cwd ?? process.cwd(), + cwd: this.effectiveCwd(), env: childEnv(this.ctx.env), }); this.child = child; @@ -862,7 +1032,7 @@ class BlessedTinyPlaceTui { ["-L", socket, "attach-session", "-t", session], { cols: terminalColumns(this.options), - cwd: this.options.cwd ?? process.cwd(), + cwd: this.effectiveCwd(), env: tmuxEnv(this.ctx.env), name: terminalName(this.ctx.env), rows: terminalPhysicalRows(this.options), @@ -983,7 +1153,7 @@ class BlessedTinyPlaceTui { launch: AgentLaunch, ): boolean { const command = shellCommandFor(launch); - const cwd = this.options.cwd ?? process.cwd(); + const cwd = this.effectiveCwd(); if ( !runTmuxCommand( socket, @@ -1000,6 +1170,13 @@ class BlessedTinyPlaceTui { ["set-option", "-t", session, "status-left-length", "200"], ["set-option", "-t", session, "status-right", ""], ["set-option", "-t", session, "status-style", "bg=black,fg=white"], + // Capture the mouse so the scroll wheel drives tmux copy-mode (scrollback) + // instead of leaking past tmux to the agent. The agents run INLINE (no + // alternate screen), so an uncaptured wheel scrolls the agent's own prompt + // up and down; `mouse on` routes it to tmux scrollback and anchors the + // prompt. Trade-off: drag-select becomes tmux's selection (still copies) + // rather than the host terminal's native selection. + ["set-option", "-t", session, "mouse", "on"], ["set-window-option", "-t", session, "window-status-format", ""], ["set-window-option", "-t", session, "window-status-current-format", ""], [ @@ -1060,6 +1237,7 @@ class BlessedTinyPlaceTui { this.agentSessionMonitor?.stop(); this.agentSessionMonitor = undefined; this.stopBridge(); + this.releaseSessionLockIfHeld(); this.cleanupNativeRelay(); this.pty = undefined; this.terminal?.destroy(); @@ -1072,6 +1250,11 @@ class BlessedTinyPlaceTui { this.clearBody(); if (this.state.view === "settings") { this.body.setContent(renderSettingsContent(this.ctx, this.profile)); + } else if (this.homeMode && this.state.view === "welcome") { + // Home welcome is a two-pane layout (actions + resume list) built from + // child boxes rather than a single content string. + this.body.setContent(""); + this.renderHomePanes(); } else { this.body.setContent( renderWelcomeContent( @@ -1087,6 +1270,97 @@ class BlessedTinyPlaceTui { this.screen.render(); } + /** Home welcome: left = actions, right = recent-session resume list. Focused + * pane gets the cyan border + the live cursor highlight. */ + private renderHomePanes(): void { + const focus = this.state.homeFocus ?? "actions"; + const actionsFocused = focus === "actions"; + blessed.box({ + parent: this.body, + top: 0, + left: 0, + width: "42%", + height: "100%", + tags: true, + border: { type: "line" }, + label: " tiny.place ", + style: { + bg: "black", + fg: "white", + border: { fg: actionsFocused ? "cyan" : "gray" }, + }, + content: this.renderHomeActionsContent(actionsFocused), + }); + const sessions = blessed.box({ + parent: this.body, + top: 0, + left: "42%", + width: "58%", + height: "100%", + tags: true, + scrollable: true, + alwaysScroll: true, + border: { type: "line" }, + label: " resume a session ", + style: { + bg: "black", + fg: "white", + border: { fg: actionsFocused ? "gray" : "cyan" }, + }, + content: this.renderSessionsContent(!actionsFocused), + }); + // Keep the highlighted row in view when the list overflows the pane. + if (!actionsFocused) { + sessions.setScroll(this.state.sessionIndex ?? 0); + } + } + + private renderHomeActionsContent(focused: boolean): string { + const actions = this.actions(); + const rows = actions.map((action, index) => { + const [label, colorTag] = actionRow(action, "", this.state); + const selected = focused && this.state.selectedIndex === index; + return renderActionRow(selected, label, colorTag); + }); + const owner = this.state.openHumanSessionId; + const openHuman = this.state.bridgeLive + ? renderKeyValue("OpenHuman", `connected ${owner ?? ""}`, "{green-fg}") + : this.state.openHumanConnected + ? renderKeyValue( + "OpenHuman", + `remembered ${owner ?? ""}`, + "{yellow-fg}", + ) + : renderKeyValue("OpenHuman", "disconnected", "{gray-fg}"); + return [ + renderKeyValue("tiny.place", this.ctx.baseUrl, "{cyan-fg}"), + renderKeyValue("wallet", walletIdFor(this.ctx), "{yellow-fg}"), + openHuman, + "", + ...rows, + "", + "{gray-fg}↑↓ move · Tab → sessions{/gray-fg}", + ].join("\n"); + } + + private renderSessionsContent(focused: boolean): string { + if (this.recentSessions.length === 0) { + return [ + "{gray-fg}No recent sessions yet.{/gray-fg}", + "", + "{gray-fg}Start one from the left; it{/gray-fg}", + "{gray-fg}shows up here to resume later.{/gray-fg}", + ].join("\n"); + } + const here = this.effectiveCwd(); + return this.recentSessions + .map((session, index) => { + const selected = focused && (this.state.sessionIndex ?? 0) === index; + return renderSessionRow(session, selected, here); + }) + .join("\n"); + } + private renderFooter(): void { const activeSession = this.state.activeSessionId ?? "none"; const connected = Boolean(this.ctx.baseUrl); @@ -1119,6 +1393,7 @@ class BlessedTinyPlaceTui { this.child = undefined; } this.stopBridge(); + this.releaseSessionLockIfHeld(); this.cleanupNativeRelay(); this.agentSessionMonitor?.stop(); this.agentSessionMonitor = undefined; @@ -1378,6 +1653,7 @@ function renderSettingsContent(ctx: CliContext, profile: AgentProfile): string { function buildAgentProfile( env: Record, kind: TinyVerseAgentKind, + resumeSessionId?: string, ): AgentProfile { if (kind === "claude") { const command = @@ -1386,31 +1662,41 @@ function buildAgentProfile( const args = splitShellWords( firstEnv(env, ["TINYVERSE_CLAUDE_ARGS", "TINYPLACE_CLAUDE_ARGS"]) ?? "", ); + // `claude --resume ` resumes a specific conversation by session id. + const launchArgs = resumeSessionId + ? [...args, "--resume", resumeSessionId] + : args; return { disabledPtyEnv: "TINYPLACE_CLAUDE_NO_PTY", displayName: "Claude", kind, - launch: buildLaunch(command, args), - pendingSessionId: "claude:pending", + launch: buildLaunch(command, launchArgs), + pendingSessionId: resumeSessionId ?? "claude:pending", sessionPollEnv: "TINYPLACE_CLAUDE_SESSION_POLL_MS", sessionsDir: firstEnv(env, [ "TINYVERSE_CLAUDE_SESSIONS_DIR", "TINYPLACE_CLAUDE_SESSIONS_DIR", ]) ?? join(homedir(), ".claude", "projects"), + ...(resumeSessionId ? { resumeSessionId } : {}), }; } const command = env.TINYPLACE_CODEX_BIN ?? "codex"; const args = splitShellWords(env.TINYPLACE_CODEX_ARGS ?? ""); + // `codex resume ` is a subcommand, so it must lead the arg list. + const launchArgs = resumeSessionId + ? ["resume", resumeSessionId, ...args] + : args; return { disabledPtyEnv: "TINYPLACE_CODEX_NO_PTY", displayName: "Codex", kind, - launch: buildLaunch(command, args), - pendingSessionId: "codex:pending", + launch: buildLaunch(command, launchArgs), + pendingSessionId: resumeSessionId ?? "codex:pending", sessionPollEnv: "TINYPLACE_CODEX_SESSION_POLL_MS", sessionsDir: env.TINYPLACE_CODEX_SESSIONS_DIR ?? join(homedir(), ".codex", "sessions"), + ...(resumeSessionId ? { resumeSessionId } : {}), }; } @@ -1823,7 +2109,7 @@ function renderStaticSnapshot(ctx: CliContext, profile: AgentProfile): string { * Non-TTY snapshot for bare `tinyplace` (home mode) — the agent picker. Mirrors * the interactive home menu so scripts / `| cat` see the same options. */ -function renderHomeSnapshot(ctx: CliContext): string { +function renderHomeSnapshot(ctx: CliContext, cwd: string): string { const owner = bridgeOwner(ctx.env) ?? ctx.openHumanOwner; const lines = [ "welcome to tiny.place", @@ -1843,12 +2129,29 @@ function renderHomeSnapshot(ctx: CliContext): string { " [ Settings ]", " [ Quit ]", "", - "Arrows/j,k move · Enter selects · q quits.", + ...renderSnapshotSessions(ctx, cwd), + "Arrows/j,k move · Tab switches panes · Enter selects · q quits.", `${ctx.baseUrl ? "Connected to tiny.place" : "Disconnected"} - Chat id: none`, ]; return `${lines.join("\n")}\n`; } +/** Recent-session lines for the non-TTY home snapshot (scripts / `| cat`). */ +function renderSnapshotSessions(ctx: CliContext, cwd: string): Array { + const sessions = safeListRecentSessions(ctx.env, cwd, { limit: 8 }); + if (sessions.length === 0) { + return []; + } + return [ + "recent sessions (resume):", + ...sessions.map( + (session) => + ` ${session.agent} ${relativeTime(session.lastActive)} ${session.label}`, + ), + "", + ]; +} + function walletIdFor(ctx: CliContext): string { return ctx.signer?.agentId ?? "mock-wallet-8Hf3Qp2N"; } @@ -1877,6 +2180,66 @@ function bridgeOwner( ]); } +/** One row in the home resume pane: agent tag · relative time · prompt · (folder + * when it differs from where you're standing). Highlighted when it's the cursor. */ +function renderSessionRow( + session: RecentSession, + selected: boolean, + here: string, +): string { + const tag = + session.agent === "claude" + ? "{magenta-fg}claude{/magenta-fg}" + : "{blue-fg}codex {/blue-fg}"; + const when = relativeTime(session.lastActive).padStart(4, " "); + const label = blessed.escape(session.label); + const elsewhere = + session.cwd !== undefined && safeSameDir(session.cwd, here) === false + ? ` {gray-fg}· ${blessed.escape(basename(session.cwd))}{/gray-fg}` + : ""; + const row = `${selected ? ">" : " "} ${tag} {gray-fg}${when}{/gray-fg} ${label}${elsewhere}`; + return selected ? `{inverse}${row}{/inverse}` : row; +} + +function safeSameDir(left: string, right: string): boolean { + try { + return resolve(left) === resolve(right); + } catch { + return false; + } +} + +/** Compact "time since" for the resume list (now/5m/3h/2d). */ +function relativeTime(epochMs: number): string { + const deltaMs = Date.now() - epochMs; + if (deltaMs < 60_000) { + return "now"; + } + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + return `${Math.floor(hours / 24)}d`; +} + +/** Never let a session-scan error crash the TUI — the home screen degrades to + * "no recent sessions" instead. */ +function safeListRecentSessions( + env: Record, + cwd: string, + options?: Parameters[2], +): Array { + try { + return listRecentSessions(env, cwd, options); + } catch { + return []; + } +} + function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } diff --git a/sdk/typescript/tests/harness-resume-tail.test.ts b/sdk/typescript/tests/harness-resume-tail.test.ts new file mode 100644 index 00000000..6debfd24 --- /dev/null +++ b/sdk/typescript/tests/harness-resume-tail.test.ts @@ -0,0 +1,226 @@ +import { + appendFileSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + HarnessSessionTailer, + SessionEnvelopePublisher, + type HarnessWrapperConfig, +} from "../src/cli/harness-wrapper.js"; +import type { SessionEnvelope } from "../src/index.js"; +import type { TinyPlaceCliOptions } from "../src/cli/types.js"; + +// A resumed session appends to a file that already existed at bridge start. The +// tailer must begin PAST that transcript, or resuming replays the whole history +// back into the OpenHuman thread. This drives the real tailer (dry-run → the +// Writable) and asserts only the post-resume turn is emitted. + +function resumeConfig(file: string): HarnessWrapperConfig { + return { + agentArgs: [], + agentBin: "claude", + bucket: "hour", + captureError: true, + captureInput: true, + captureOutput: true, + captureSession: true, + dryRun: true, + emitV2: false, + outDir: join(tmpdir(), "tp-resume-out"), + provider: "claude", + receiveEnabled: false, + receivePollMs: 1500, + // Pin + resume the same file: locate resolves it deterministically AND the + // offset-seed path (`located.path === resumeSessionFile`) is exercised. + sessionFile: file, + resumeSessionFile: file, + sessionPollMs: 500, + sessionsDir: join(tmpdir(), "tp-resume-sessions"), + sessionTailGraceMs: 0, + scope: "session", + statusHeartbeatMs: 15_000, + statusIdleMs: 30_000, + usePty: false, + wrapperSessionId: "tp-claude-resume-sess-1", + }; +} + +const EXISTING = [ + JSON.stringify({ + type: "user", + sessionId: "sess-1", + timestamp: "2026-07-07T10:00:00.000Z", + message: { role: "user", content: "old turn one" }, + }), + JSON.stringify({ + type: "user", + sessionId: "sess-1", + timestamp: "2026-07-07T10:00:01.000Z", + message: { role: "user", content: "old turn two" }, + }), +]; + +function v1Texts(chunks: Array): Array { + return chunks + .join("") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as SessionEnvelope) + .filter((env) => env.envelope_version === "tinyplace.harness.session.v1") + .map((env) => env.message.text); +} + +describe("HarnessSessionTailer resume offset", () => { + const dirs: Array = []; + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + it("skips the existing transcript and emits only the post-resume turn", async () => { + const dir = mkdtempSync(join(tmpdir(), "tp-resume-")); + dirs.push(dir); + const file = join(dir, "session.jsonl"); + writeFileSync(file, `${EXISTING.join("\n")}\n`, "utf8"); + + const chunks: Array = []; + const out = new Writable({ + write(chunk, _enc, cb): void { + chunks.push(chunk.toString()); + cb(); + }, + }); + const config = resumeConfig(file); + const options = { env: {} } as unknown as TinyPlaceCliOptions; + const publisher = new SessionEnvelopePublisher(config, options, out); + const tailer = new HarnessSessionTailer( + config, + "/work/proj", + out, + publisher, + ); + + // start() records the 2 existing lines as the baseline; the first poll + // locates the file and begins past them. + tailer.start(new Date("2026-07-07T09:59:00.000Z")); + // The resumed agent appends a fresh turn. + appendFileSync( + file, + `${JSON.stringify({ + type: "user", + sessionId: "sess-1", + timestamp: "2026-07-07T11:00:00.000Z", + message: { role: "user", content: "fresh turn after resume" }, + })}\n`, + "utf8", + ); + await tailer.stop(); + + const texts = v1Texts(chunks); + // Exact sequence: only the post-resume turn, no history replay, no dupes. + expect(texts).toEqual(["fresh turn after resume"]); + }); + + it("resolves a symlinked resume path and ignores a competing fresh file", async () => { + const dir = mkdtempSync(join(tmpdir(), "tp-resume-alias-")); + dirs.push(dir); + + // The real resumed transcript lives under the scan dir; its meta pins cwd. + const real = join(dir, "rollout-real.jsonl"); + writeFileSync( + real, + `${[ + JSON.stringify({ + type: "user", + sessionId: "sess-1", + cwd: "/work/proj", + timestamp: "2026-07-07T10:00:00.000Z", + message: { role: "user", content: "old turn one" }, + }), + JSON.stringify({ + type: "user", + sessionId: "sess-1", + timestamp: "2026-07-07T10:00:01.000Z", + message: { role: "user", content: "old turn two" }, + }), + ].join("\n")}\n`, + "utf8", + ); + // A DIFFERENT-origin alias of the same file: raw `===` against the scan + // path would miss, leaving the file ignored / replayed. Canonicalizing fixes it. + const alias = join(dir, "alias-link.jsonl"); + symlinkSync(real, alias); + + // A newer, competing fresh file for a DIFFERENT cwd — must NOT be selected. + const competitor = join(dir, "rollout-other.jsonl"); + writeFileSync( + competitor, + `${JSON.stringify({ + type: "user", + sessionId: "sess-2", + cwd: "/some/other/proj", + timestamp: "2026-07-07T12:00:00.000Z", + message: { role: "user", content: "competitor turn" }, + })}\n`, + "utf8", + ); + + const chunks: Array = []; + const out = new Writable({ + write(chunk, _enc, cb): void { + chunks.push(chunk.toString()); + cb(); + }, + }); + const config: HarnessWrapperConfig = { + ...resumeConfig(real), + sessionFile: undefined, // force the scan path (locateSession), not the override + resumeSessionFile: alias, // the symlink alias, resolved via realpath + sessionsDir: dir, + }; + const options = { env: {} } as unknown as TinyPlaceCliOptions; + const publisher = new SessionEnvelopePublisher(config, options, out); + const tailer = new HarnessSessionTailer( + config, + "/work/proj", + out, + publisher, + ); + + // Fixed mtimes (after startedAt) BEFORE start(): start() polls immediately, + // so selection must be deterministic without relying on the wall clock. The + // competitor is excluded by cwd, not recency, so real is always chosen. + const mtime = new Date("2026-07-07T10:30:00.000Z"); + utimesSync(real, mtime, mtime); + utimesSync(competitor, mtime, mtime); + + tailer.start(new Date("2026-07-07T09:59:00.000Z")); + // Resumed agent appends a fresh turn to the REAL file (via the pinned cwd). + appendFileSync( + real, + `${JSON.stringify({ + type: "user", + sessionId: "sess-1", + timestamp: "2026-07-07T11:00:00.000Z", + message: { role: "user", content: "fresh turn after resume" }, + })}\n`, + "utf8", + ); + await tailer.stop(); + + const texts = v1Texts(chunks); + expect(texts).toEqual(["fresh turn after resume"]); + expect(texts).not.toContain("competitor turn"); + }); +}); diff --git a/sdk/typescript/tests/session-history.test.ts b/sdk/typescript/tests/session-history.test.ts new file mode 100644 index 00000000..c8822227 --- /dev/null +++ b/sdk/typescript/tests/session-history.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, mkdir, writeFile, utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { listRecentSessions } from "../src/cli/session-history.js"; + +/** Build a claude session JSONL: leading meta lines, then user/tool turns. */ +function claudeSession( + sessionId: string, + cwd: string, + userTexts: Array, +): string { + const lines: Array> = [ + { type: "last-prompt", sessionId }, + { type: "mode", mode: "normal", sessionId }, + // A tool-result user turn (array content, no text block) must be skipped. + { + type: "user", + sessionId, + cwd, + message: { + role: "user", + content: [{ type: "tool_result", content: "ok" }], + }, + }, + ...userTexts.map((text) => ({ + type: "user", + sessionId, + cwd, + message: { role: "user", content: text }, + })), + ]; + return lines.map((line) => JSON.stringify(line)).join("\n"); +} + +/** Build a codex rollout JSONL: session_meta, developer noise, then user turns. */ +function codexSession( + sessionId: string, + cwd: string, + userTexts: Array, +): string { + const lines: Array> = [ + { type: "session_meta", payload: { session_id: sessionId, id: sessionId, cwd } }, + { + type: "response_item", + payload: { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "" }], + }, + }, + ...userTexts.map((text) => ({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }, + })), + ]; + return lines.map((line) => JSON.stringify(line)).join("\n"); +} + +async function stage(): Promise<{ + env: Record; + claudeDir: string; + codexDir: string; +}> { + const root = await mkdtemp(join(tmpdir(), "tp-history-")); + const claudeDir = join(root, "claude"); + const codexDir = join(root, "codex"); + await mkdir(claudeDir, { recursive: true }); + await mkdir(codexDir, { recursive: true }); + return { + claudeDir, + codexDir, + env: { + TINYPLACE_CLAUDE_SESSIONS_DIR: claudeDir, + TINYPLACE_CODEX_SESSIONS_DIR: codexDir, + }, + }; +} + +/** Write a file then bump its mtime so ordering is deterministic. */ +async function writeSession( + path: string, + content: string, + mtime: Date, +): Promise { + await writeFile(path, content); + await utimes(path, mtime, mtime); +} + +describe("listRecentSessions", () => { + it("extracts the first real prompt as the label, skipping wrappers", async () => { + const { env, claudeDir, codexDir } = await stage(); + await writeSession( + join(claudeDir, "a.jsonl"), + claudeSession("claude-1", "/work/alpha", ["fix the login bug"]), + new Date("2026-07-01T10:00:00Z"), + ); + await writeSession( + join(codexDir, "rollout-1.jsonl"), + // First user turn is an wrapper → skipped. + codexSession("codex-1", "/work/beta", [ + "/work/beta", + "add a payment endpoint", + ]), + new Date("2026-07-01T09:00:00Z"), + ); + + const sessions = listRecentSessions(env, "/somewhere/else"); + + expect(sessions).toHaveLength(2); + const claude = sessions.find((s) => s.agent === "claude"); + const codex = sessions.find((s) => s.agent === "codex"); + expect(claude).toMatchObject({ + id: "claude-1", + cwd: "/work/alpha", + label: "fix the login bug", + }); + expect(codex).toMatchObject({ + id: "codex-1", + cwd: "/work/beta", + label: "add a payment endpoint", + }); + }); + + it("orders current-folder sessions first, then by recency", async () => { + const { env, claudeDir } = await stage(); + // Older, but in the current folder → must sort ahead of the newer one. + await writeSession( + join(claudeDir, "here.jsonl"), + claudeSession("here", "/work/here", ["local work"]), + new Date("2026-07-01T08:00:00Z"), + ); + await writeSession( + join(claudeDir, "there.jsonl"), + claudeSession("there", "/work/there", ["remote work"]), + new Date("2026-07-02T08:00:00Z"), + ); + + const sessions = listRecentSessions(env, "/work/here"); + + expect(sessions.map((s) => s.id)).toEqual(["here", "there"]); + }); + + it("honors the limit", async () => { + const { env, claudeDir } = await stage(); + for (let index = 0; index < 5; index += 1) { + await writeSession( + join(claudeDir, `s${index}.jsonl`), + claudeSession(`s${index}`, "/work/x", [`prompt ${index}`]), + new Date(2026, 6, 1, 0, index), + ); + } + + const sessions = listRecentSessions(env, "/work/x", { limit: 2 }); + + expect(sessions).toHaveLength(2); + }); + + it("truncates a long prompt to a single line", async () => { + const { env, claudeDir } = await stage(); + const long = `${"a".repeat(200)}\nsecond line`; + await writeSession( + join(claudeDir, "long.jsonl"), + claudeSession("long", "/work/x", [long]), + new Date("2026-07-01T10:00:00Z"), + ); + + const [session] = listRecentSessions(env, "/work/x"); + + expect(session.label.length).toBeLessThanOrEqual(72); + expect(session.label).not.toContain("\n"); + expect(session.label.endsWith("…")).toBe(true); + }); + + it("strips terminal control bytes from the label", async () => { + const { env, claudeDir } = await stage(); + // ESC[31m … BEL — an escape sequence smuggled through a prior prompt. + const nasty = "hi \u001b[31mRED\u001b[0m\u0007 there"; + await writeSession( + join(claudeDir, "ctl.jsonl"), + claudeSession("ctl", "/work/x", [nasty]), + new Date("2026-07-01T10:00:00Z"), + ); + + const [session] = listRecentSessions(env, "/work/x"); + + // eslint-disable-next-line no-control-regex -- asserting they were stripped + expect(session.label).not.toMatch(/[\u0000-\u001F\u007F-\u009F]/); + expect(session.label).toContain("RED"); + }); + + it("returns an empty list when no session dirs exist", () => { + const sessions = listRecentSessions( + { + TINYPLACE_CLAUDE_SESSIONS_DIR: "/nope/claude", + TINYPLACE_CODEX_SESSIONS_DIR: "/nope/codex", + }, + "/work/x", + ); + expect(sessions).toEqual([]); + }); +}); diff --git a/sdk/typescript/tests/session-lock.test.ts b/sdk/typescript/tests/session-lock.test.ts new file mode 100644 index 00000000..cc33cf8d --- /dev/null +++ b/sdk/typescript/tests/session-lock.test.ts @@ -0,0 +1,100 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + acquireSessionLock, + releaseSessionLock, +} from "../src/cli/session-lock.js"; + +async function lockDir(): Promise { + return mkdtemp(join(tmpdir(), "tp-lock-")); +} + +// A deterministically-dead pid: spawn a process that exits immediately and wait +// for its exit, so `process.kill(pid, 0)` reliably throws ESRCH. Beats a fixed +// high number (which can collide with a real pid on some hosts). +async function reapedPid(): Promise { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + const pid = child.pid as number; + await new Promise((resolveExit) => + child.on("exit", () => resolveExit()), + ); + return pid; +} + +describe("acquireSessionLock", () => { + it("grants the lock and writes a pid stamp", async () => { + const dir = await lockDir(); + const lock = acquireSessionLock(dir, "claude", "sess-1"); + expect(lock).toBeDefined(); + expect(existsSync(lock!.path)).toBe(true); + const stamped = JSON.parse(await readFile(lock!.path, "utf8")); + expect(stamped.pid).toBe(process.pid); + await rm(dir, { recursive: true, force: true }); + }); + + it("refuses a second holder while a LIVE process holds it", async () => { + const dir = await lockDir(); + // Simulate another live instance: stamp the lock with our parent's pid + // (a different, running process), so the liveness probe reports "held". + const path = join(dir, "claude-sess-1.lock"); + await writeFile( + path, + JSON.stringify({ pid: process.ppid, at: Date.now() }), + ); + + expect(acquireSessionLock(dir, "claude", "sess-1")).toBeUndefined(); + await rm(dir, { recursive: true, force: true }); + }); + + it("reclaims a corrupt / unreadable lock", async () => { + const dir = await lockDir(); + const path = join(dir, "claude-sess-1.lock"); + await writeFile(path, "not json"); + + const lock = acquireSessionLock(dir, "claude", "sess-1"); + expect(lock).toBeDefined(); + await rm(dir, { recursive: true, force: true }); + }); + + it("reclaims a lock whose holder process is dead", async () => { + const dir = await lockDir(); + const path = join(dir, "claude-sess-1.lock"); + // A reaped pid → process.kill(pid, 0) throws ESRCH, exercising the primary + // isStale liveness branch (not the corrupt path). + const dead = await reapedPid(); + await writeFile(path, JSON.stringify({ pid: dead, at: Date.now() })); + + const lock = acquireSessionLock(dir, "claude", "sess-1"); + expect(lock).toBeDefined(); + const stamped = JSON.parse(await readFile(lock!.path, "utf8")); + expect(stamped.pid).toBe(process.pid); + await rm(dir, { recursive: true, force: true }); + }); + + it("re-grants after release", async () => { + const dir = await lockDir(); + const first = acquireSessionLock(dir, "codex", "sess-2"); + expect(first).toBeDefined(); + releaseSessionLock(first!); + expect(existsSync(first!.path)).toBe(false); + + const second = acquireSessionLock(dir, "codex", "sess-2"); + expect(second).toBeDefined(); + await rm(dir, { recursive: true, force: true }); + }); + + it("keeps different sessions independent", async () => { + const dir = await lockDir(); + const a = acquireSessionLock(dir, "claude", "sess-a"); + const b = acquireSessionLock(dir, "claude", "sess-b"); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + expect(a!.path).not.toBe(b!.path); + await rm(dir, { recursive: true, force: true }); + }); +});