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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
89 changes: 89 additions & 0 deletions docs/plans/2026-09-18-session-lifecycle-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/cli/install-commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import {
SUPPORTED_HARNESSES,
planSessionHooks,
detectHarness,
isDeprecatedHarness,
parseHarnessList,
Expand Down Expand Up @@ -73,6 +74,7 @@ export async function runInstallCommand(parsed: ParsedCommand): Promise<void> {
process.stdout.write(`${line}\n`);
}
for (const action of [
...harnesses.flatMap(h => planSessionHooks(h, "install", installOptions)),
...(harnesses.includes("grok")
? [
planGrokSessionHookInstall(installOptions),
Expand Down Expand Up @@ -114,6 +116,7 @@ export async function runInstallCommand(parsed: ParsedCommand): Promise<void> {
const results = skillerResults
? [
...skillerResults,
...await runSkillInstallActions(harnesses.flatMap(h => planSessionHooks(h, "install", installOptions)), installOptions),
...(harnesses.includes("grok")
? await runSkillInstallActions(
[
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")}`;
Expand All @@ -396,6 +401,7 @@ function planUninstallActions(
installOptions: { skipMissing: boolean }
): InstallAction[] {
return harnesses.flatMap((harness) => [
...planSessionHooks(harness, "uninstall", installOptions),
planSkillUninstall(harness, {
...installOptions,
skipMissing: false
Expand Down Expand Up @@ -436,6 +442,7 @@ async function runSkillUninstall(
installOptions: { skipMissing: boolean }
): Promise<InstallResult[]> {
const actions = [
...planSessionHooks(harness, "uninstall", { ...installOptions, skipMissing: false }),
planSkillUninstall(harness, {
...installOptions,
skipMissing: false
Expand Down Expand Up @@ -471,6 +478,7 @@ function planInstallActionsForHarness(
): InstallAction[] {
return [
planSkillInstall(harness, installOptions),
...planSessionHooks(harness, "install", installOptions),
...(harness === "grok"
? [
planGrokSessionHookInstall(installOptions),
Expand Down Expand Up @@ -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<InstallStatus>(["added", "updated", "ok"]);
if (!results.some((result) => result.ok && changed.has(result.status))) {
return;
Expand Down
6 changes: 6 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <claude|codex|grok>", 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.",
Expand Down
41 changes: 41 additions & 0 deletions src/cli/session-hook.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown>;
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 */ }
}
}
}
21 changes: 21 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);`
}
];

Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 57 additions & 0 deletions src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface ExecAction {

export interface FilePatchAction {
kind: "file-patch";
dedupeKey?: string;
harness: HarnessId;
filePath: string;
description: string;
Expand Down Expand Up @@ -312,6 +313,62 @@ export function buildClaudeStopGuardHook(): Record<string, unknown> {
};
}

export function mergeSessionHooks(existing: string | null, harness: string, remove = false): string | null {
try {
const isRecord = (value: unknown): value is Record<string, unknown> =>
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<string, unknown> = { ...(settings.hooks as Record<string, unknown> | 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;
Expand Down
Loading
Loading