diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f01c98..fe84f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ changes will be called out under **Breaking changes**. ## Unreleased +### Fixed + +- Installed lifecycle hooks retire an ended Claude, Codex, or Grok session by exact session and process identity, including its lease and wake endpoints. Old listeners cannot rejoin an ended session; a later resume permits it again. Claude `/clear` reports the old session immediately. Codex currently delays its end hook until thread teardown, so immediate Codex `/clear` cleanup remains unsupported. + ## [0.20.0] — 2026-09-18 Full notes: [`docs/releases/0.20.0.md`](docs/releases/0.20.0.md). diff --git a/docs/plans/2026-09-18-session-lifecycle-cleanup.md b/docs/plans/2026-09-18-session-lifecycle-cleanup.md new file mode 100644 index 0000000..0d625e8 --- /dev/null +++ b/docs/plans/2026-09-18-session-lifecycle-cleanup.md @@ -0,0 +1,89 @@ +# Exact session lifecycle cleanup + +The operator reported duplicate Claude and Codex entries after `/clear` in the +dicom-capacitor room. Both old/new pairs shared an unchanged harness PID and +process start time. Process liveness correctly reported the processes alive, +but could not establish whether each session was still attached. + +## Decision + +Install fail-open SessionStart/SessionEnd hooks for Claude, Codex, and Grok. +Retire only the session explicitly named by an end hook, bound to the local +host and exact harness PID/start time. Remove membership, receiver/wake +registrations, and any lease or reservation belonging to that session. +Retirement markers prevent a lingering old receiver from recreating membership. +SessionStart permits the named session to resume, including in a new process. + +Never infer that one verified session replaced another merely because both +share a process. This preserves concurrent threads and subagents. An inferred +predecessor ledger was rejected: it can undercount sessions created before +installation and evict the wrong live thread. + +The installer preserves foreign hooks, including hooks sharing an entry with +ours. Lifecycle and Stop actions use distinct deduplication keys even when +both patch Claude settings. Codex hooks require the harness's own trust review. + +## Evidence and limits + +- Disposable Claude 2.1.276/2.1.277 probes: `/clear` emits SessionEnd with the + **old** ID and reason `clear`, then SessionStart with a new ID. `/resume` + emits SessionEnd for the current session and SessionStart for the exact + resumed ID. Coordinating and operator sessions were not cleared. +- Disposable Codex 0.154.0 probe: SessionStart fires at the first model prompt, + with source `clear` after `/clear`; SessionEnd for old/new threads appeared + on normal quit, not immediately at `/clear`. +- [Codex's documented contract](https://learn.chatgpt.com/docs/hooks#sessionend) + also ends an idle thread after 30 minutes **only if it is not open in any + connected client**. Open standby sessions are excluded. Immediate Codex + `/clear` cleanup remains unsupported; no predecessor is guessed. +- Hooks cannot reconstruct events from before installation. Crash cleanup + continues using existing process liveness checks. +- Process tombstones older than 30 days are reclaimed only after confirmed + exact process death. Room-agent markers last until resume or room deletion. + +## Verification + +Regression coverage: concurrent same-PID sessions, other hosts, PID reuse, +lease/reservation revocation, endpoint cascade, duplicate end events, +metadata-less late receivers, same/new-process resume, malformed/subagent +hooks, hook merge/uninstall preservation, and conservative tombstone GC. + +Final suite: 657 passed, one skipped; typecheck passed. Grok's review caught +snake-case event values; the handler now normalizes spelling, with a regression +covering native Grok input and shell-to-Grok ancestry. + +Live verification of the compiled hook at `f8d500e`, with a disposable Claude +2.1.277 in an isolated data directory and workspace, loading only scratch +settings (`--setting-sources local`) so no real hook configuration changed. Every +lifecycle event went through `node dist/cli.js session-hook claude`: + +| Step | Result | +| --- | --- | +| Session A joins via `tt join` inside the session | A is a member | +| `/clear` | SessionEnd `clear` for A; A removed with a `leave … session_ended` event; new session B | +| A's ID rejoins from the same process | Refused with `session_ended`: a lingering old listener cannot resurrect it | +| B joins | Joined | +| `/resume A` | SessionEnd `resume` removes B; SessionStart `resume` for the same ID A; A rejoins | + +A second, independent run using a join fixture confirmed `/clear` and normal-exit +retirement. It did not exercise resume: its original session was empty, and an +empty session cannot be resumed at all, so no tombstone can trap one. Lease +release was not exercised live and rests on the regression suite. + +Draft PR #87 is open; nothing has been merged or released. After live verification, +Codex enabled the lifecycle hooks in the operator's real Claude, Codex, and Grok +configuration for local validation. These are global harness settings, so they +apply across projects, not only to this repository. Existing foreign settings +and hooks were structurally checked against private backups and preserved. +Codex's hooks still require its `/hooks` trust review. + +The installed commands resolve `tt` from PATH. On this machine `tt` is npm-linked +to this checkout's `dist/cli.js`, so behavior follows the last build of this +checkout, including this unmerged branch. Building an older branch without +`session-hook` makes these fail-open commands no-op. Local installation is not +evidence of a published release. The historical dicom-capacitor ghost entries +remain unchanged. + +Private pre-install backups were retained locally. For rollback, remove only +the managed lifecycle hooks from the current settings, preserving any later +foreign changes; do not blindly replace current settings with the snapshots. diff --git a/docs/reference.md b/docs/reference.md index 6a89e19..8892414 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -521,6 +521,33 @@ hook deliveries may retry after one minute at the next hook. Hooks never auto-jo ## Storage +### Session lifecycle + +`tt install claude codex grok` installs merge-only `SessionStart`/`SessionEnd` +hooks for those harnesses. Review and trust Codex's new hooks in `/hooks`; +reload hooks or restart other harnesses if their settings are cached. +Existing hooks and settings are preserved. + +An end event removes only the matching session on the same host and exact +harness process instance, releasing its turn and wake registrations. A saved +retirement marker prevents an old background listener from rejoining; a later +session-start event permits an explicit resume. Concurrent sessions remain +separate even when they share a process. Crashes without hooks still use process +liveness cleanup. + +Claude reports the old session ending during `/clear` and `/resume`. Codex +does **not** report an immediate end on `/clear`; cleanup waits for its actual +end hook (normal shutdown, archive/delete, or the documented unopened-idle +timeout). A still-open standby session does not meet that timeout condition. +See [Codex's lifecycle contract](https://learn.chatgpt.com/docs/hooks#sessionend). +Installing hooks cannot reconstruct end events that occurred before installation. + +Process retirement markers older than 30 days are reclaimed only when the +exact process is confirmed gone. Room-member markers remain until resume or +room deletion, protecting against metadata-less stale listeners. + +### Database location + The coordination database lives at: - Linux/macOS: `~/.local/share/talking-stick/rooms.sqlite` (or diff --git a/src/cli/install-commands.ts b/src/cli/install-commands.ts index 458aa6b..7f223e6 100644 --- a/src/cli/install-commands.ts +++ b/src/cli/install-commands.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { SUPPORTED_HARNESSES, + planSessionHooks, detectHarness, isDeprecatedHarness, parseHarnessList, @@ -73,6 +74,7 @@ export async function runInstallCommand(parsed: ParsedCommand): Promise { process.stdout.write(`${line}\n`); } for (const action of [ + ...harnesses.flatMap(h => planSessionHooks(h, "install", installOptions)), ...(harnesses.includes("grok") ? [ planGrokSessionHookInstall(installOptions), @@ -114,6 +116,7 @@ export async function runInstallCommand(parsed: ParsedCommand): Promise { const results = skillerResults ? [ ...skillerResults, + ...await runSkillInstallActions(harnesses.flatMap(h => planSessionHooks(h, "install", installOptions)), installOptions), ...(harnesses.includes("grok") ? await runSkillInstallActions( [ @@ -166,6 +169,7 @@ export async function runUninstallCommand( process.stdout.write(`${line}\n`); } for (const action of [ + ...harnesses.flatMap(h => planSessionHooks(h, "uninstall", installOptions)), ...(harnesses.includes("grok") ? [ planGrokSessionHookUninstall({ @@ -209,6 +213,7 @@ export async function runUninstallCommand( const results = skillerResults ? [ ...skillerResults, + ...await Promise.all(harnesses.flatMap(h => planSessionHooks(h, "uninstall", installOptions)).map(a => runAction(a, installOptions))), ...(harnesses.includes("grok") ? [ await runAction( @@ -383,7 +388,7 @@ function dedupeInstallActions(actions: InstallAction[]): InstallAction[] { function installActionDedupeKey(action: InstallAction): string { if (action.kind === "file-patch") { - return `${action.kind}:${action.operation ?? "op"}:${action.filePath}`; + return `${action.kind}:${action.operation ?? "op"}:${action.dedupeKey ?? action.filePath}`; } if (action.kind === "exec") { return `${action.kind}:${action.operation ?? "op"}:${action.command}:${action.args.join("\0")}`; @@ -396,6 +401,7 @@ function planUninstallActions( installOptions: { skipMissing: boolean } ): InstallAction[] { return harnesses.flatMap((harness) => [ + ...planSessionHooks(harness, "uninstall", installOptions), planSkillUninstall(harness, { ...installOptions, skipMissing: false @@ -436,6 +442,7 @@ async function runSkillUninstall( installOptions: { skipMissing: boolean } ): Promise { const actions = [ + ...planSessionHooks(harness, "uninstall", { ...installOptions, skipMissing: false }), planSkillUninstall(harness, { ...installOptions, skipMissing: false @@ -471,6 +478,7 @@ function planInstallActionsForHarness( ): InstallAction[] { return [ planSkillInstall(harness, installOptions), + ...planSessionHooks(harness, "install", installOptions), ...(harness === "grok" ? [ planGrokSessionHookInstall(installOptions), @@ -630,6 +638,10 @@ function reportInstallResults( } export function printInstructionHint(results: InstallResult[]): void { + if (results.some(result => result.ok && result.harness === "codex" && + result.action.kind === "file-patch" && result.action.dedupeKey?.endsWith(":lifecycle"))) { + process.stdout.write("Codex: review and trust the new lifecycle hooks with /hooks.\n"); + } const changed = new Set(["added", "updated", "ok"]); if (!results.some((result) => result.ok && changed.has(result.status))) { return; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index be80a34..d0dcac4 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -1,4 +1,5 @@ import { runGrokInboxHookCommand } from "./grok-inbox-hook.js"; +import { runSessionHookCommand } from "./session-hook.js"; import { deriveCliIdentity } from "./identity.js"; import { printResult } from "./output.js"; import { runGuardCommand } from "./guardian.js"; @@ -52,6 +53,11 @@ export interface CommandEntry { } export const COMMAND_REGISTRY: CommandEntry[] = [ + { + name: "session-hook", needsRuntime: false, startupMaintenance: false, internal: true, + usage: "tt session-hook ", description: "Track exact harness session lifecycle.", + handler: ({ parsed }) => runSessionHookCommand(parsed.positionals[0]) + }, { name: "grok-inbox-hook", needsRuntime: false, startupMaintenance: false, internal: true, usage: "tt grok-inbox-hook", description: "Deliver pending room events inside an active Grok session.", diff --git a/src/cli/session-hook.ts b/src/cli/session-hook.ts new file mode 100644 index 0000000..1be3354 --- /dev/null +++ b/src/cli/session-hook.ts @@ -0,0 +1,41 @@ +import { TalkingStickService } from "../service.js"; +import { findHarnessRootInAncestry } from "../identity.js"; +import { createSystemProcessInspector, type ProcessInspector } from "../process-utils.js"; + +export async function runSessionHookCommand(harness: string | undefined, options: { + stdin?: string; service?: TalkingStickService; inspector?: ProcessInspector; parentPid?: number; +} = {}): Promise { + let service: TalkingStickService | undefined; + try { + if (harness !== "claude" && harness !== "codex" && harness !== "grok") return; + let raw = options.stdin; + if (raw === undefined) { + raw = ""; + for await (const chunk of process.stdin) raw += chunk; + } + const input = JSON.parse(raw) as Record; + if (!input || typeof input !== "object" || Array.isArray(input)) return; + const rawEvent = input.hook_event_name ?? input.hookEventName; + const normalizedEvent = typeof rawEvent === "string" ? rawEvent.toLowerCase().replace(/[^a-z0-9]/g, "") : ""; + const event = normalizedEvent === "sessionstart" ? "SessionStart" + : normalizedEvent === "sessionend" ? "SessionEnd" : null; + const sessionId = input.session_id ?? input.sessionId; + if (!event || + typeof sessionId !== "string" || !sessionId.trim()) return; + // Some harness hook payloads identify the parent when a child runs a hook. + if (input.subagent_type || input.subagentType || input.agent_id || input.agentId) return; + const inspector = options.inspector ?? createSystemProcessInspector(); + const parentPid = options.parentPid ?? process.ppid; + const root = findHarnessRootInAncestry(harness, parentPid, inspector.inspect(parentPid), inspector, 20); + if (!root) return; + service = options.service ?? new TalkingStickService({}); + service.recordSessionLifecycle({ harness, sessionId: sessionId.trim(), event, + pid: root.pid, processStartedAt: root.startTime }); + } catch { + // A lifecycle hook must never prevent a clear, resume, or exit. + } finally { + if (service && !options.service) { + try { service.close(); } catch { /* fail open */ } + } + } +} diff --git a/src/db.ts b/src/db.ts index b167af5..8a43f25 100644 --- a/src/db.ts +++ b/src/db.ts @@ -347,6 +347,27 @@ const migrations: Migration[] = [ ALTER TABLE native_delivery_batches ADD COLUMN created_at TEXT; CREATE INDEX hook_delivery_pending ON native_delivery_batches(room_id, agent_id, source, created_at); ` + }, + { + id: 20, + name: "ended_harness_sessions", + up: `CREATE TABLE ended_harness_sessions ( + harness_name TEXT NOT NULL, + session_id TEXT NOT NULL, + host_id TEXT NOT NULL, + pid INTEGER NOT NULL, + process_started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + PRIMARY KEY (harness_name, session_id, host_id, pid, process_started_at) + ); + CREATE INDEX ended_harness_sessions_age ON ended_harness_sessions(ended_at); + CREATE TABLE ended_room_members ( + room_id TEXT NOT NULL REFERENCES path_rooms(room_id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + harness_name TEXT NOT NULL, session_id TEXT NOT NULL, host_id TEXT NOT NULL, + pid INTEGER NOT NULL, process_started_at TEXT NOT NULL, + PRIMARY KEY (room_id, agent_id) + );` } ]; diff --git a/src/errors.ts b/src/errors.ts index 6c41f14..6cf1ab9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -3,6 +3,7 @@ import type { RoomState } from "./types.js"; export type ProtocolErrorCode = | "room_not_found" | "unknown_member" + | "session_ended" | "observer_cannot_hold_turn" | "unknown_target" | "target_active" diff --git a/src/install.ts b/src/install.ts index b0aa158..2ac89df 100644 --- a/src/install.ts +++ b/src/install.ts @@ -58,6 +58,7 @@ export interface ExecAction { export interface FilePatchAction { kind: "file-patch"; + dedupeKey?: string; harness: HarnessId; filePath: string; description: string; @@ -312,6 +313,62 @@ export function buildClaudeStopGuardHook(): Record { }; } +export function mergeSessionHooks(existing: string | null, harness: string, remove = false): string | null { + try { + const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + const settings: unknown = existing?.trim() ? JSON.parse(existing) : {}; + if (!isRecord(settings)) return null; + if (settings.hooks !== undefined && !isRecord(settings.hooks)) return null; + const hooks: Record = { ...(settings.hooks as Record | undefined) }; + const marker = `talking-stick-lifecycle-${harness}`; + for (const event of ["SessionStart", "SessionEnd"]) { + if (hooks[event] !== undefined && !Array.isArray(hooks[event])) return null; + const entries: unknown[] = ((hooks[event] ?? []) as unknown[]).flatMap((entry: unknown) => { + if (!isRecord(entry) || !Array.isArray(entry.hooks)) return [entry]; + const remaining = entry.hooks.filter((hook: unknown) => !isRecord(hook) || typeof hook.command !== "string" || !hook.command.includes(marker)); + return remaining.length ? [{ ...entry, hooks: remaining }] : []; + }); + if (!remove) entries.push({ hooks: [{ type: "command", timeout: 5, + command: `: ${marker}; if command -v tt >/dev/null 2>&1; then tt session-hook ${harness} >/dev/null 2>/dev/null || true; fi` }] }); + if (entries.length) hooks[event] = entries; + else delete hooks[event]; + } + if (Object.keys(hooks).length) settings.hooks = hooks; + else delete settings.hooks; + const result = JSON.stringify(settings, null, 2) + "\n"; + return existing && JSON.stringify(JSON.parse(existing)) === JSON.stringify(settings) ? existing : result; + } catch { return null; } +} + +export function planSessionHooks(harness: HarnessId, operation: "install" | "uninstall", options: InstallOptions = {}): InstallAction[] { + if (harness !== "claude-code" && harness !== "codex" && harness !== "grok") return []; + const resolved = resolveOptions(options); + const configDir = harness === "codex" && resolved.env.CODEX_HOME?.trim() + ? resolved.env.CODEX_HOME.trim() : resolveHarnessConfigDirFromResolved(harness, resolved); + if (resolved.skipMissing && !resolved.hooks.pathExists(configDir)) return [skipAction(harness, `config directory not found: ${configDir}`)]; + const filePath = harness === "claude-code" ? resolveClaudeSettingsPath(resolved) + : path.join(configDir, ...(harness === "grok" ? ["hooks", "talking-stick-lifecycle.json"] : ["hooks.json"])); + const name = harness === "claude-code" ? "claude" : harness; + return [{ kind: "file-patch", harness, filePath, operation, dedupeKey: `${filePath}:lifecycle`, + description: `${operation} session lifecycle hooks in ${filePath}${harness === "codex" && operation === "install" ? " (review and trust in /hooks)" : ""}`, + inspect: () => { + const existing = resolved.hooks.readFile(filePath); + const next = mergeSessionHooks(existing, name, operation === "uninstall"); + if (next === null) return "different"; + if (operation === "uninstall") return existing === null || next === existing ? "absent" : "present"; + return next === existing ? "present" : "absent"; + }, + apply: () => { + const existing = resolved.hooks.readFile(filePath); + if (existing === null && operation === "uninstall") return; + const next = mergeSessionHooks(existing, name, operation === "uninstall"); + if (next === null) throw new Error(`Refusing to modify unparseable hook settings: ${filePath}`); + if (next !== existing) { resolved.hooks.ensureDir(path.dirname(filePath)); resolved.hooks.writeFile(filePath, next); } + } + }]; +} + function isStopGuardEntry(entry: unknown): boolean { if (typeof entry !== "object" || entry === null) return false; const hooks = (entry as { hooks?: unknown }).hooks; diff --git a/src/service.ts b/src/service.ts index f443e25..547956a 100644 --- a/src/service.ts +++ b/src/service.ts @@ -455,6 +455,54 @@ export class TalkingStickService { }); } + /** A hook's exact session identity is evidence that PID liveness cannot give. */ + recordSessionLifecycle(input: { + harness: string; sessionId: string; pid: number; processStartedAt: string; + event: "SessionStart" | "SessionEnd"; + }): number { + if (!input.sessionId.trim() || !input.processStartedAt.trim() || + !Number.isSafeInteger(input.pid) || input.pid <= 1) return 0; + const key = [input.harness, `harness:${input.sessionId}`, this.hostId, + input.pid, input.processStartedAt.trim()] as const; + return withImmediateTransaction(this.db, () => { + if (input.event === "SessionStart") { + this.db.prepare(`DELETE FROM ended_harness_sessions WHERE harness_name = ? + AND session_id = ? AND host_id = ? AND pid = ? AND process_started_at = ?`).run(...key); + this.db.prepare(`DELETE FROM ended_room_members WHERE harness_name = ? + AND session_id = ? AND host_id = ?`).run(input.harness, `harness:${input.sessionId}`, this.hostId); + return 0; + } + const timestamp = this.now().toISOString(); + this.db.prepare("INSERT OR REPLACE INTO ended_harness_sessions VALUES (?, ?, ?, ?, ?, ?)") + .run(...key, timestamp); + const members = this.db.prepare<[string, string, string, number, string], RoomMemberRow>( + `SELECT * FROM room_members WHERE harness_name = ? AND harness_session_id = ? + AND harness_host_id = ? AND harness_pid = ? AND trim(harness_process_started_at) = ?` + ).all(...key); + for (const member of members) { + const room = this.requireRoom(member.room_id); + this.db.prepare("INSERT OR REPLACE INTO ended_room_members VALUES (?, ?, ?, ?, ?, ?, ?)") + .run(member.room_id, member.agent_id, ...key); + const owner = room.owner === member.agent_id ? null : room.owner; + const reserved = room.reserved_for === member.agent_id ? null : room.reserved_for; + this.db.prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?") + .run(member.room_id, member.agent_id); + this.db.prepare(`UPDATE path_rooms SET owner = ?, reserved_for = ?, + lease_id = ?, lease_expires_at = ?, claim_expires_at = ?, pending_handoff_event_seq = ?, + state = ?, updated_at = ? WHERE room_id = ?`).run(owner, reserved, + owner ? room.lease_id : null, owner ? room.lease_expires_at : null, + reserved ? room.claim_expires_at : null, + owner ? room.pending_handoff_event_seq : null, + room.state === "closed" ? "closed" : owner ? "owned" : reserved ? "reserved" : "idle", + timestamp, room.room_id); + this.appendEvent({ room_id: room.room_id, turn_id: room.turn_id, event_type: "leave", + from_agent_id: member.agent_id, to_agent_id: null, handoff: null, + reason: "session_ended", created_at: timestamp }); + } + return members.length; + }); + } + leaveRoom(input: LeaveRoomInput): LeaveRoomResult { assertNonEmpty(input.agent_id, "agent_id"); assertNonEmpty(input.room_id, "room_id"); @@ -3593,6 +3641,14 @@ export class TalkingStickService { ): void { const existing = this.getMember(roomId, agentId); const normalized = normalizeProcessMetadata(options.processMetadata); + if (this.db.prepare("SELECT 1 FROM ended_room_members WHERE room_id = ? AND agent_id = ?").get(roomId, agentId) || + (normalized.harness_session_id && normalized.harness_pid && + this.db.prepare(`SELECT 1 FROM ended_harness_sessions WHERE harness_name = ? + AND session_id = ? AND host_id = ? AND pid = ? AND process_started_at = ?`) + .get(normalized.harness_name, normalized.harness_session_id, normalized.harness_host_id, + normalized.harness_pid, normalized.harness_process_started_at?.trim() ?? null))) { + throw new ProtocolError("session_ended", "This harness session ended; it cannot rejoin until resumed."); + } const hasIdentity = hasExactProcessIdentity(normalized) || hasHarnessProcessIdentity(normalized); @@ -4683,6 +4739,22 @@ export class TalkingStickService { } private purgeExpiredIdleRooms(now: Date): void { + // A timestamp alone is not evidence that a still-running old listener is + // harmless. Reclaim process tombstones only after exact process death; + // room/agent tombstones continue to block metadata-less old receivers. + const agedSessions = this.db.prepare(`SELECT * FROM ended_harness_sessions WHERE ended_at < ?`) + .all(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()) as Array<{ + harness_name: string; session_id: string; host_id: string; pid: number; + process_started_at: string; ended_at: string; + }>; + for (const ended of agedSessions) { + if (this.processLivenessChecker({ host_id: ended.host_id, pid: ended.pid, + process_started_at: ended.process_started_at, harness_host_id: ended.host_id, + harness_pid: ended.pid, harness_process_started_at: ended.process_started_at }) !== "gone") continue; + this.db.prepare(`DELETE FROM ended_harness_sessions WHERE harness_name = ? AND session_id = ? + AND host_id = ? AND pid = ? AND process_started_at = ? AND ended_at = ?`).run( + ended.harness_name, ended.session_id, ended.host_id, ended.pid, ended.process_started_at, ended.ended_at); + } const expireRooms = this.policy.idleRoomTtlMs > 0; const cutoffMs = now.getTime() - this.policy.idleRoomTtlMs; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 3db0f38..742809d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2230,11 +2230,13 @@ describe("tt notes", () => { expect(fs.readFileSync(settingsPath, "utf8")).toContain( "talking-stick-claude-stop-hook" ); + expect(fs.readFileSync(settingsPath, "utf8")).toContain("talking-stick-lifecycle-claude"); await captureStdout(["uninstall", "claude"]); expect(fs.readFileSync(settingsPath, "utf8")).not.toContain( "talking-stick-claude-stop-hook" ); + expect(fs.readFileSync(settingsPath, "utf8")).not.toContain("talking-stick-lifecycle-claude"); } finally { if (previousHome === undefined) { delete process.env.HOME; diff --git a/tests/session-hook.test.ts b/tests/session-hook.test.ts new file mode 100644 index 0000000..09fcfa6 --- /dev/null +++ b/tests/session-hook.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { TalkingStickService } from "../src/service.js"; +import { runSessionHookCommand } from "../src/cli/session-hook.js"; +import { mergeSessionHooks } from "../src/install.js"; + +const cleanup: Array<() => void> = []; +afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); }); + +function setup() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tt-lifecycle-")); + cleanup.push(() => fs.rmSync(root, { recursive: true, force: true })); + const service = new TalkingStickService({ dbPath: path.join(root, "state.sqlite"), hostId: "local", + processLivenessChecker: () => "alive", receiverLivenessChecker: () => "alive" }); + cleanup.push(() => service.close()); + const metadata = { harness_name: "claude", harness_session_id: "harness:old", + harness_host_id: "local", harness_pid: 123, harness_process_started_at: "start" }; + const join = (agent = "claude:old", process_metadata = metadata) => service.joinPath({ + agent_id: agent, context_path: root, process_metadata }); + const room = join(); + const hook = (event: "SessionStart" | "SessionEnd", overrides = {}) => service.recordSessionLifecycle({ + event, harness: "claude", sessionId: "old", pid: 123, processStartedAt: " start ", ...overrides }); + return { service, root, metadata, join, room, hook }; +} + +test("end removes only the exact session and revokes its lease without collapsing shared-PID peers", async () => { + const { service, join, metadata, room, hook } = setup(); + join("claude:other", { ...metadata, harness_session_id: "harness:other" }); + join("claude:remote", { ...metadata, harness_host_id: "remote" }); + join("claude:reused", { ...metadata, harness_process_started_at: "later" }); + const claim = await service.waitForTurn({ room_id: room.room_id, agent_id: "claude:old", max_wait_ms: 0 }); + expect(claim.status).toBe("your_turn"); + service.registerNativeWakeEndpoint({ room_id: room.room_id, agent_id: "claude:old", + transport: "claude_inbox", address: "/tmp/test.sock", secret: "test", + harness_session_id: "harness:old", host_id: "local" }); + expect(hook("SessionEnd")).toBe(1); + expect(service.db.prepare("SELECT agent_id FROM room_members ORDER BY agent_id").all()).toEqual([ + { agent_id: "claude:other" }, { agent_id: "claude:remote" }, { agent_id: "claude:reused" } + ]); + expect(service.db.prepare("SELECT owner, lease_id FROM path_rooms").get()).toEqual({ owner: null, lease_id: null }); + expect(service.db.prepare("SELECT * FROM member_wake_endpoints").all()).toEqual([]); + expect(hook("SessionEnd")).toBe(0); + expect(service.db.prepare("SELECT * FROM room_events WHERE reason = 'session_ended'").all()).toHaveLength(1); +}); + +test("old wait cannot resurrect a retired member, even without metadata; exact resume allows rejoin", () => { + const { service, join, room, hook, root } = setup(); + hook("SessionEnd"); + expect(() => join()).toThrow("session ended"); + expect(() => service.joinPath({ context_path: root, agent_id: "claude:old" })).toThrow("session ended"); + expect(() => service.registerReceiver({ room_id: room.room_id, agent_id: "claude:old", + receiver_id: "late", host_id: "local", pid: 555, process_started_at: "late", cursor_event_seq: 0 + })).toThrow("must join the room"); + hook("SessionStart", { sessionId: "other" }); + expect(() => join()).toThrow("session ended"); + hook("SessionStart"); + expect(join().room_id).toBe(room.room_id); +}); + +test("retirement revokes an unclaimed reservation while preserving other members", () => { + const { service, room, hook, join, metadata } = setup(); + join("claude:other", { ...metadata, harness_session_id: "harness:other" }); + service.db.prepare("UPDATE path_rooms SET reserved_for = ?, state = 'reserved', claim_expires_at = ? WHERE room_id = ?") + .run("claude:old", "2099-01-01T00:00:00Z", room.room_id); + hook("SessionEnd"); + expect(service.db.prepare("SELECT reserved_for, claim_expires_at, state FROM path_rooms").get()) + .toEqual({ reserved_for: null, claim_expires_at: null, state: "idle" }); +}); + +test.each(["alive", "unknown", "gone"] as const)("aged tombstone GC with %s exact process", (liveness) => { + const { service, root, hook } = setup(); + hook("SessionEnd"); + service.db.prepare("UPDATE ended_harness_sessions SET ended_at = '2000-01-01T00:00:00Z'").run(); + const observer = new TalkingStickService({ db: service.db, hostId: "local", + processLivenessChecker: () => liveness }); + observer.joinPath({ agent_id: "human:observer", context_path: root }); + expect(service.db.prepare("SELECT * FROM ended_harness_sessions").all()).toHaveLength(liveness === "gone" ? 0 : 1); + expect(service.db.prepare("SELECT * FROM ended_room_members").all()).toHaveLength(1); +}); + +test("new verified identities never retire existing sessions merely by sharing a process", () => { + const { service, join, metadata, hook } = setup(); + hook("SessionStart", { sessionId: "new" }); + join("claude:new", { ...metadata, harness_session_id: "harness:new" }); + expect(service.db.prepare("SELECT * FROM room_members").all()).toHaveLength(2); +}); + +test("resume in a new process permits that session but keeps the old process tombstoned", () => { + const { join, metadata, hook } = setup(); + hook("SessionEnd"); + hook("SessionStart", { pid: 456, processStartedAt: "later" }); + expect(() => join("claude:old", { ...metadata, harness_pid: 456, harness_process_started_at: "later" })).not.toThrow(); + expect(() => join()).toThrow("session ended"); +}); + +test("hook requires exact ancestry and rejects malformed and child payloads, failing open", async () => { + const { service } = setup(); + const inspector = { inspect: (pid: number) => pid === 123 + ? { pid, ppid: 1, command: "claude", startTime: "start" } : null }; + const run = (input: unknown, parentPid = 123) => runSessionHookCommand("claude", { + service, inspector, parentPid, stdin: typeof input === "string" ? input : JSON.stringify(input) }); + await run("bad json"); + await run({ hook_event_name: "SessionEnd", session_id: "old" }, 999); + await run({ hook_event_name: "SessionEnd", session_id: "old", agent_id: "child" }); + expect(service.db.prepare("SELECT * FROM room_members").all()).toHaveLength(1); + await run({ hook_event_name: "SessionEnd", session_id: "old", reason: "clear" }); + expect(service.db.prepare("SELECT * FROM room_members").all()).toHaveLength(0); +}); + +test("installer preserves foreign hooks even in a shared entry and is idempotent", () => { + const foreign = { type: "command", command: "my-hook" }; + const original = JSON.stringify({ other: 42, hooks: { SessionEnd: [{ matcher: "clear", hooks: [foreign] }] } }); + const installed = mergeSessionHooks(original, "claude")!; + expect(mergeSessionHooks(installed, "claude")).toBe(installed); + expect(JSON.parse(mergeSessionHooks(installed, "claude", true)!)).toEqual(JSON.parse(original)); + const combined = JSON.parse(installed); + combined.hooks.SessionEnd[1].hooks.push(foreign); + const removed = JSON.parse(mergeSessionHooks(JSON.stringify(combined), "claude", true)!); + expect(removed.hooks.SessionEnd[1].hooks).toEqual([foreign]); + expect(mergeSessionHooks('{"hooks":[]}', "codex")).toBeNull(); + expect(mergeSessionHooks('{"hooks":{"SessionEnd":1}}', "codex")).toBeNull(); +}); + +test("Grok snake-case event values bind through a shell to the Grok parent", async () => { + const { service, join, metadata } = setup(); + join("grok:old", { ...metadata, harness_name: "grok" }); + const inspector = { inspect: (pid: number) => pid === 999 + ? { pid, ppid: 123, command: "bash", startTime: "shell" } + : pid === 123 ? { pid, ppid: 1, command: "grok", startTime: "start" } : null }; + await runSessionHookCommand("grok", { service, inspector, parentPid: 999, + stdin: JSON.stringify({ hookEventName: "session_end", sessionId: "old" }) }); + expect(service.db.prepare("SELECT agent_id FROM room_members").all()).toEqual([{ agent_id: "claude:old" }]); + await runSessionHookCommand("grok", { service, inspector, parentPid: 999, + stdin: JSON.stringify({ hookEventName: "session_start", sessionId: "old" }) }); + expect(() => join("grok:old", { ...metadata, harness_name: "grok" })).not.toThrow(); +});