From aea7a5d0ed10b7c2b3acb6e06e88ea23a28427c8 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 18 Sep 2026 15:00:47 -0400 Subject: [PATCH 1/4] Retire ended harness sessions through lifecycle hooks --- CHANGELOG.md | 4 + .../2026-09-18-session-lifecycle-cleanup.md | 53 ++++++++ docs/reference.md | 27 ++++ src/cli/install-commands.ts | 14 +- src/cli/registry.ts | 6 + src/cli/session-hook.ts | 38 ++++++ src/db.ts | 21 +++ src/errors.ts | 1 + src/install.ts | 57 ++++++++ src/service.ts | 72 ++++++++++ tests/cli.test.ts | 2 + tests/session-hook.test.ts | 124 ++++++++++++++++++ 12 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-09-18-session-lifecycle-cleanup.md create mode 100644 src/cli/session-hook.ts create mode 100644 tests/session-hook.test.ts 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..e1f763b --- /dev/null +++ b/docs/plans/2026-09-18-session-lifecycle-cleanup.md @@ -0,0 +1,53 @@ +# 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. + +Full suite before final extra GC/reservation tests: 652 passed, one skipped; +typecheck and build passed. Independent compiled-hook live verification is +pending. No release or real harness configuration change yet. 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..58aaf6c --- /dev/null +++ b/src/cli/session-hook.ts @@ -0,0 +1,38 @@ +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 event = input.hook_event_name ?? input.hookEventName; + const sessionId = input.session_id ?? input.sessionId; + if ((event !== "SessionEnd" && event !== "SessionStart") || + 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..1ab9045 --- /dev/null +++ b/tests/session-hook.test.ts @@ -0,0 +1,124 @@ +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(); +}); From f8d500ecc92ab747df1db09c94d7387961cd55f5 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 18 Sep 2026 15:07:53 -0400 Subject: [PATCH 2/4] Normalize Grok lifecycle event names --- docs/plans/2026-09-18-session-lifecycle-cleanup.md | 8 +++++--- src/cli/session-hook.ts | 7 +++++-- tests/session-hook.test.ts | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-09-18-session-lifecycle-cleanup.md b/docs/plans/2026-09-18-session-lifecycle-cleanup.md index e1f763b..16b3063 100644 --- a/docs/plans/2026-09-18-session-lifecycle-cleanup.md +++ b/docs/plans/2026-09-18-session-lifecycle-cleanup.md @@ -48,6 +48,8 @@ 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. -Full suite before final extra GC/reservation tests: 652 passed, one skipped; -typecheck and build passed. Independent compiled-hook live verification is -pending. No release or real harness configuration change yet. +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. Independent compiled-hook +live verification is pending. Draft PR #87 is open; no release or real harness +configuration change yet. diff --git a/src/cli/session-hook.ts b/src/cli/session-hook.ts index 58aaf6c..1be3354 100644 --- a/src/cli/session-hook.ts +++ b/src/cli/session-hook.ts @@ -15,9 +15,12 @@ export async function runSessionHookCommand(harness: string | undefined, options } const input = JSON.parse(raw) as Record; if (!input || typeof input !== "object" || Array.isArray(input)) return; - const event = input.hook_event_name ?? input.hookEventName; + 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 !== "SessionEnd" && event !== "SessionStart") || + 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; diff --git a/tests/session-hook.test.ts b/tests/session-hook.test.ts index 1ab9045..09fcfa6 100644 --- a/tests/session-hook.test.ts +++ b/tests/session-hook.test.ts @@ -122,3 +122,17 @@ test("installer preserves foreign hooks even in a shared entry and is idempotent 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(); +}); From 53035433debfffe1419bbed72d3703ba86d0c86e Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 18 Sep 2026 15:13:05 -0400 Subject: [PATCH 3/4] Record live verification of the session lifecycle hook The plan listed compiled-hook live verification as pending. Record what the disposable Claude 2.1.277 run against f8d500e actually showed: /clear retires the old session, that session's ID is refused when it tries to rejoin from the same process, the new session joins, and /resume retires the successor and readmits the original ID. Also record what was not proven live: the independent fixture run did not exercise resume because its session was empty, and lease release rests on the regression suite. --- .../2026-09-18-session-lifecycle-cleanup.md | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-09-18-session-lifecycle-cleanup.md b/docs/plans/2026-09-18-session-lifecycle-cleanup.md index 16b3063..47a0f36 100644 --- a/docs/plans/2026-09-18-session-lifecycle-cleanup.md +++ b/docs/plans/2026-09-18-session-lifecycle-cleanup.md @@ -50,6 +50,24 @@ 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. Independent compiled-hook -live verification is pending. Draft PR #87 is open; no release or real harness -configuration change yet. +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; no release or real harness configuration change yet. From 630e326cce532d9b0d50568ae820dafc0c72c54e Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 18 Sep 2026 15:18:47 -0400 Subject: [PATCH 4/4] Clarify local lifecycle hook installation scope --- .../2026-09-18-session-lifecycle-cleanup.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-09-18-session-lifecycle-cleanup.md b/docs/plans/2026-09-18-session-lifecycle-cleanup.md index 47a0f36..0d625e8 100644 --- a/docs/plans/2026-09-18-session-lifecycle-cleanup.md +++ b/docs/plans/2026-09-18-session-lifecycle-cleanup.md @@ -70,4 +70,20 @@ 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; no release or real harness configuration change yet. +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.