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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions cli-tools/tether-cli-hook/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,37 @@ const CLAUDE_NOTIFICATION_TYPES = new Set([
'elicitation_dialog', 'elicitation_complete', 'elicitation_response',
]);

// Notification types that mean the same thing to the status side channel as
// one we already forward. A browser-URL elicitation blocks Claude on the user
// exactly like a form elicitation does.
const CLAUDE_NOTIFICATION_ALIASES = new Map([
['elicitation_url_dialog', 'elicitation_dialog'],
]);

function classifyClaude(payload) {
// Claude hook payloads carry `hook_event_name` ('Notification' | 'Stop' | ...)
// and, for Notification, `notification_type` ('permission_prompt' |
// 'idle_prompt' | 'auth_success' | 'elicitation_*'). Map both into our
// flat event enum so the detector doesn't need to know the Claude schema.
switch (payload.hook_event_name) {
case 'Stop': return 'turn_complete';
// Claude runs StopFailure INSTEAD of Stop when a turn ends on an API error
// (rate limit, overload, auth). Either way the turn is over.
case 'StopFailure': return 'turn_complete';
case 'UserPromptSubmit': return 'turn_start';
case 'PreToolUse': return 'tool_start';
case 'PostToolUse':
case 'PostToolUseFailure': return 'tool_complete';
case 'SubagentStart': return 'subagent_start';
case 'SubagentStop': return 'subagent_stop';
case 'Notification':
return CLAUDE_NOTIFICATION_TYPES.has(payload.notification_type) ? payload.notification_type : null;
// /clear and an in-pane /resume end one conversation and start another
// under a new session id; Tether follows the pane across that boundary.
case 'SessionStart': return 'session_start';
case 'SessionEnd': return 'session_end';
case 'Notification': {
const notificationType = CLAUDE_NOTIFICATION_ALIASES.get(payload.notification_type) || payload.notification_type;
return CLAUDE_NOTIFICATION_TYPES.has(notificationType) ? notificationType : null;
}
default: return null;
}
}
Expand Down Expand Up @@ -184,6 +200,12 @@ function claudeHookMetadata(payload) {
hook_event_name: cappedString(payload.hook_event_name, 128),
notification_type: cappedString(payload.notification_type, 128),
agent_id: cappedString(payload.agent_id, 256),
// Short enum-like fields: SessionStart `source`, SessionEnd `reason`,
// StopFailure `error` (the error TYPE; never `error_details` or
// `last_assistant_message`, which carry free text).
source: cappedString(payload.source, 64),
reason: cappedString(payload.reason, 64),
error: cappedString(payload.error, 64),
};
}

Expand Down
23 changes: 22 additions & 1 deletion src/docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ workspace intact. Restart Tether to retry before relying on workspace saving.
Stopping a session cancels its conversation-ID watcher immediately. A transcript
read failure ends that watcher without interrupting the running CLI.

With [CLI hooks](settings.md#cli-hooks) on, Tether follows a Claude Code pane when you run `/clear` or switch conversations with `/resume` inside it: status, usage, subagent rows and the conversation that restart or workspace restore reopens all move to the new conversation. Without hooks (including Coder sessions), the pane stays tied to the conversation it was launched with.

For local Claude Code, Codex CLI, GitHub Copilot CLI, and OpenCode sessions, use **Resume conversation** in the launcher to choose history for the working directory. Tether uses `claude --resume <id>`, `codex resume <id>`, `copilot --resume <id>`, or `opencode --session <id>`. The conversation picker is local-only.

OpenCode history requires a working OpenCode installation in an absolute directory on your system PATH. Windows native executables and npm `.cmd` installations are supported. Tether asks OpenCode for its session list; if that command is unavailable or cannot finish, the picker stays empty without interrupting running sessions.
Expand All @@ -66,7 +68,7 @@ For Claude Code and Codex CLI over **SSH or Coder**, **Restart in this pane** an

For **OpenCode over SSH or Coder**, restart and workspace restore also pass a known saved native conversation ID to `opencode --session <id>`. Tether does not check that ID against history on your local machine or substitute the latest conversation. Automatic discovery of IDs for fresh remote OpenCode sessions is not available yet, so those sessions cannot automatically resume through Tether unless a native conversation ID is already known and saved.

Recovery requires the same remote host or Coder workspace, CLI user/home, and saved conversation history. If Claude Code has no history for the saved ID (the pane closed before its first message, the conversation was switched with `/resume` or `/clear`, or the history was deleted), the pane opens a fresh Claude conversation under that same ID instead of stopping on a resume error; use `/resume` inside Claude to reopen another conversation. Codex CLI and OpenCode handle missing saved history themselves; Tether does not retry with another conversation. If Tether never captured an ID (for example, remote Codex usage collection was unavailable before disconnection, or a fresh remote OpenCode session has no known ID), restarting opens a fresh session and you can use the CLI's own resume picker to recover older history. Resuming restores saved conversation context; it does not reattach a surviving process or keep a task running through disconnection.
Recovery requires the same remote host or Coder workspace, CLI user/home, and saved conversation history. If Claude Code has no history for the saved ID (the pane closed before its first message, the conversation was switched with `/resume` or `/clear` while hooks were off, or the history was deleted), the pane opens a fresh Claude conversation under that same ID instead of stopping on a resume error; use `/resume` inside Claude to reopen another conversation. Codex CLI and OpenCode handle missing saved history themselves; Tether does not retry with another conversation. If Tether never captured an ID (for example, remote Codex usage collection was unavailable before disconnection, or a fresh remote OpenCode session has no known ID), restarting opens a fresh session and you can use the CLI's own resume picker to recover older history. Resuming restores saved conversation context; it does not reattach a surviving process or keep a task running through disconnection.

Claude Code and GitHub Copilot transcript lookups require a full session UUID. Invalid identifiers are treated as unavailable conversations and are not used as filesystem paths.

Expand Down Expand Up @@ -101,6 +103,25 @@ echo and window resizing. Claude stays running while tracked subagents are
active, including when the parent pauses for their results. Answering an
elicitation resumes work; it does not mean the turn has finished.

Claude Code does not report every way a turn can end, so hook-driven Claude
sessions keep three safeguards:

- A turn that ends on an API error (rate limit, overloaded, sign-in problem)
counts as finished, the same as a normal stop.
- Claude sends nothing when you interrupt it. Pressing **Esc** or **Ctrl+C** in
a running Claude pane hands that session back to output-based estimates until
its next hook arrives: if Claude keeps working, it stays running; if output
stops for a few seconds, it shows waiting. Tether only notices the keypress;
it does not change or delay what you type.
- A running session that produces no terminal output and no hook for two
minutes is marked waiting. Claude redraws its spinner and timer for as long as
it works, so that much silence means the turn ended without saying so. This
does not apply while a tracked subagent is still running.

About a minute after Claude stops, its idle notice confirms the pause. Because
Claude only sends that notice once nothing is running in the background, it also
clears any subagent whose stop was never reported.

When hooks are unavailable, detection estimates activity from output and silence.
It cannot reliably distinguish a quiet model or remote subagent from an idle
prompt. A user-owned Codex `notify` command is preserved; without Tether's notify
Expand Down
8 changes: 6 additions & 2 deletions src/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,12 @@ When on, Tether installs an additive entry in your `~/.claude/settings.json` and
Local CLI hooks require a separate Node.js installation on PATH. Tether checks it at launch and writes its absolute executable path into the hooks. If Node.js is missing or cannot run, Tether removes its old entries, skips installation, and shows one warning; sessions use output-based status detection. Install Node.js and restart Tether to enable hooks. Existing user hooks are preserved. SSH hosts check their own Node.js installation separately.

Claude hooks also observe prompt submission, tool activity and subagent
start/stop events so quiet work stays running. The status helper forwards only
event names and identifiers, without prompts, tool arguments or results.
start/stop events so quiet work stays running, turns that end on an API error,
and the conversation changes made by `/clear` and in-pane `/resume` (the helper
does not run on ordinary startup, compaction or exit). The status helper
forwards only event names, short type codes and identifiers, without prompts,
tool arguments, results or error text. See [session status](sessions.md#session-states)
for how Tether covers endings Claude does not report.
Existing user hooks are preserved. If Claude disables all hooks, an overlay
fails to install, or another integration owns Codex's `notify` command, the
affected CLI uses output-cadence estimates unless its other Tether hooks are
Expand Down
63 changes: 62 additions & 1 deletion src/main/cli-config/claude-settings-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,72 @@ describe('Claude settings overlay', () => {
expect(s.hooks.Notification).toHaveLength(1);
expect(s.hooks.Stop).toHaveLength(1);
const hooks = s.hooks as Record<string, unknown[]>;
for (const event of ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop']) {
for (const event of [
'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop',
'StopFailure', 'SessionStart', 'SessionEnd',
]) {
expect(hooks[event]).toHaveLength(1);
}
});

it('observes failed turns, and conversation boundaries only for /clear and /resume', async () => {
const { settingsPath } = makeCtx();
await installClaudeHooks({ helperPath: HELPER, settingsPath });
const s = readSettings(settingsPath) as {
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
};
// StopFailure replaces Stop when a turn ends on an API error: every error type counts.
expect(s.hooks.StopFailure[0].matcher).toBeUndefined();
expect(s.hooks.StopFailure[0].hooks[0].command).toContain('tether-cli-hook');
// No helper process on ordinary startup, compaction or exit.
expect(s.hooks.SessionStart[0].matcher).toBe('resume|clear');
expect(s.hooks.SessionEnd[0].matcher).toBe('clear|resume');
expect(s.hooks.Notification[0].matcher?.split('|')).toContain('elicitation_url_dialog');
});

it('adds its conversation-boundary observers beside the user\'s own and removes only its own', async () => {
const { settingsPath } = makeCtx();
const original = {
hooks: {
SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'load-context' }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: 'save-notes' }] }],
StopFailure: [{ matcher: 'rate_limit', hooks: [{ type: 'command', command: 'page-me' }] }],
},
};
fs.writeFileSync(settingsPath, JSON.stringify(original));
await installClaudeHooks({ helperPath: HELPER, settingsPath });
const installed = readSettings(settingsPath) as { hooks: Record<string, unknown[]> };
for (const event of ['SessionStart', 'SessionEnd', 'StopFailure']) {
expect(installed.hooks[event]).toHaveLength(2);
expect(installed.hooks[event][0]).toEqual(original.hooks[event as keyof typeof original.hooks][0]);
}
await uninstallClaudeHooks({ helperPath: HELPER, settingsPath });
expect(readSettings(settingsPath)).toEqual(original);
});

it('scrubs a Tether entry from an event this version does not install, and nothing else there', async () => {
const { settingsPath } = makeCtx();
const stale = { hooks: [{ type: 'command', command: 'node /old/tether-cli-hook/index.js --claude' }] };
const theirs = { hooks: [{ type: 'command', command: 'their-compact-hook' }] };
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
PreCompact: [stale, theirs],
PostCompact: [stale],
// Keys with nothing of ours in them are left exactly as written.
TeammateIdle: [],
ConfigChange: [{ hooks: [] }, 'malformed'],
},
}));
await uninstallClaudeHooks({ helperPath: HELPER, settingsPath });
expect(readSettings(settingsPath)).toEqual({
hooks: {
PreCompact: [theirs],
TeammateIdle: [],
ConfigChange: [{ hooks: [] }, 'malformed'],
},
});
});

it('reports disabled hooks without changing the user setting', async () => {
const { settingsPath } = makeCtx();
fs.writeFileSync(settingsPath, JSON.stringify({ disableAllHooks: true, model: 'sonnet' }));
Expand Down
56 changes: 44 additions & 12 deletions src/main/cli-config/claude-settings-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ export interface ClaudeOverlayContext {

const withMutex = createOverlayMutex();
const ACTIVITY_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop'] as const;
/**
* Turn-ending events, installed without a matcher so every occurrence fires.
* Claude runs `StopFailure` INSTEAD of `Stop` when a turn ends on an API error
* (rate limit, overload, auth), so observing only `Stop` leaves such a turn
* looking like it is still running.
*/
const TURN_END_EVENTS = ['Stop', 'StopFailure'] as const;
/**
* Conversation-boundary events and the matcher each is installed with. Only
* `/clear` and an in-pane `/resume` move a pane to a different session id, so
* the helper is not spawned on ordinary startup, compaction or exit — where it
* would add latency (SessionStart delays the first prompt, SessionEnd delays
* quitting) and report nothing Tether acts on.
*/
const SESSION_BOUNDARY_EVENTS = {
SessionStart: 'resume|clear',
SessionEnd: 'clear|resume',
} as const;
/** Every non-Notification event key Tether installs under `hooks`. */
const OBSERVED_EVENTS: readonly string[] = [...TURN_END_EVENTS, ...Object.keys(SESSION_BOUNDARY_EVENTS), ...ACTIVITY_EVENTS];

interface CommandHook { type?: string; command?: string; [k: string]: unknown }
interface NotificationGroup { matcher?: string; hooks?: CommandHook[]; [k: string]: unknown }
Expand Down Expand Up @@ -104,6 +124,14 @@ function isTetherManaged(hook: CommandHook | undefined): boolean {
return typeof hook?.command === 'string' && hook.command.includes(SENTINEL_TOKEN);
}

/** True for a bare `{type, command}` hook or a `{hooks: [...]}` group that holds a Tether-managed command. */
function hasTetherManagedHook(entry: unknown): boolean {
if (!entry || typeof entry !== 'object') return false;
if (isTetherManaged(entry as CommandHook)) return true;
const hooks = (entry as StopEntryGroup).hooks;
return Array.isArray(hooks) && hooks.some(isTetherManaged);
}

/**
* Strip every Tether-managed entry from the observed hook arrays.
* Returns true if the structure changed (used to decide whether to write).
Expand Down Expand Up @@ -138,9 +166,15 @@ function scrubTetherEntries(settings: SettingsShape): boolean {
}
}

for (const event of ['Stop', ...ACTIVITY_EVENTS]) {
// Events this version installs, then any other event key: an entry left by a
// different Tether version (or a crash) must be removed even when this
// version no longer observes that event. Keys we never installed into are
// only rewritten when one of our entries is actually found in them.
const otherEvents = Object.keys(settings.hooks).filter((event) => event !== 'Notification' && !OBSERVED_EVENTS.includes(event));
for (const event of [...OBSERVED_EVENTS, ...otherEvents]) {
const stop = settings.hooks[event];
if (!Array.isArray(stop)) continue;
if (!OBSERVED_EVENTS.includes(event) && !stop.some(hasTetherManagedHook)) continue;
const filtered: StopEntry[] = [];
for (const entry of stop) {
if (!entry || typeof entry !== 'object') continue;
Expand Down Expand Up @@ -211,6 +245,7 @@ const NOTIFICATION_MATCHER = [
'idle_prompt',
'auth_success',
'elicitation_dialog',
'elicitation_url_dialog',
'elicitation_complete',
'elicitation_response',
].join('|');
Expand All @@ -236,28 +271,25 @@ export function mergeClaudeSettings(text: string | null, helperCmd: string): str
notif.push({ matcher: NOTIFICATION_MATCHER, hooks: [{ type: 'command', command: helperCmd }] });
settings.hooks.Notification = notif;

const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
stop.push({ hooks: [{ type: 'command', command: helperCmd }] });
settings.hooks.Stop = stop;

for (const event of ACTIVITY_EVENTS) {
for (const event of [...TURN_END_EVENTS, ...ACTIVITY_EVENTS]) {
const entries = Array.isArray(settings.hooks[event]) ? settings.hooks[event] as StopEntry[] : [];
entries.push({ hooks: [{ type: 'command', command: helperCmd }] });
settings.hooks[event] = entries;
}

for (const [event, matcher] of Object.entries(SESSION_BOUNDARY_EVENTS)) {
const entries = Array.isArray(settings.hooks[event]) ? settings.hooks[event] as StopEntry[] : [];
entries.push({ matcher, hooks: [{ type: 'command', command: helperCmd }] });
settings.hooks[event] = entries;
}

return serializeSettings(settings);
}

export function hasTetherClaudeHooks(text: string): boolean {
const settings = parseSettings(text, 'settings.json');
if (settings.disableAllHooks === true) return false;
return Array.isArray(settings.hooks?.Stop) && settings.hooks.Stop.some((entry) => {
if (!entry || typeof entry !== 'object') return false;
if (isTetherManaged(entry as CommandHook)) return true;
const hooks = (entry as StopEntryGroup).hooks;
return Array.isArray(hooks) && hooks.some(isTetherManaged);
});
return Array.isArray(settings.hooks?.Stop) && settings.hooks.Stop.some(hasTetherManagedHook);
}

/**
Expand Down
Loading
Loading