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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes to Agentctl are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org).

## [0.14.0] — 2026-11-05

### Added

- `agentctl hook user-prompt-submit` (agentctl-sessions plugin 0.3.0): until the session has a human `/rename` name — the only kind other sessions can @-address — it asks Claude, from the second prompt on, to propose a short kebab name, run `agentctl name` and end the reply with `Rename: **/rename <name>**`. Silent once renamed, and the Agentctl name then follows the `/rename` name. Reads Claude Code's own `sessions/<pid>.json` record (`nameSource`); no network, no model call, ~30 ms.

### Changed

- `agentctl hook session-start` drops its "no Agentctl name" nudge when Claude Code records `/rename` for the session (the prompt hook covers naming); older Claude Code keeps it.

## [0.13.0] — 2026-10-29

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ The [`agentctl-sessions` plugin](https://github.com/roypadina/padina-claude-code
`/agentctl-remind`, `/agentctl-due`, `/agentctl-done`.
- **A `SessionStart` hook** that hands each session its own name, labels, note and due state back, so a
resumed session knows what it is and what is overdue.
- **A `UserPromptSubmit` hook** (0.14.0+) for the name other sessions can address: until you `/rename`
the session, Claude proposes a short name from your second prompt on and ends each reply with
`Rename: /rename <name>`. Once you rename it the hook goes silent and the Agentctl name follows.
- **A skill** that teaches Claude to do it unprompted — name the session once the task is clear, label it
with the issue key from your branch — and to drive every command when you just ask in plain words
("mark this done", "remind me in 2h", "what do I need to get back to?"). If `agentctl` isn't installed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@roypadina/agentctl",
"version": "0.13.0",
"version": "0.14.0",
"description": "Start and resume Claude Code & Codex sessions from one terminal menu — fuzzy-resume past sessions with transcript preview.",
"type": "module",
"license": "MIT",
Expand Down
53 changes: 50 additions & 3 deletions src/cli/hook.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Command } from 'commander';
import {
detectIssueKeys, isOverdue, isReminderDue, readAllAnnotations, readAnnotation,
detectIssueKeys, isOverdue, isReminderDue, isValidSessionId, readAllAnnotations, readAnnotation, writeAnnotation,
} from '../core/annotations.js';
import { readGitBranch } from '../core/git.js';
import type { Annotation } from '../core/types.js';
import { bumpPromptCount, registryEntry } from '../core/liveState.js';
import type { Annotation, RegistryEntry } from '../core/types.js';

// Entry points for the agentctl-sessions Claude Code plugin. A hook must NEVER break the session it
// runs in, so every path here exits 0 and prints nothing rather than throwing.
Expand All @@ -13,6 +14,14 @@ export interface HookDeps {
all?: () => Map<string, Annotation>;
now?: Date;
branch?: (cwd: string | undefined) => string | null;
registry?: (id: string) => RegistryEntry | null;
setName?: (id: string, name: string) => void;
bump?: (id: string) => number;
}

/** Claude Code tracks a human `/rename` for this session, so the prompt hook owns naming. */
function registryNames(e: RegistryEntry | null): e is RegistryEntry {
return e?.kind === 'interactive' && typeof e.nameSource === 'string';
}

/** The session's working directory, used to read its git branch for an issue key. */
Expand Down Expand Up @@ -51,7 +60,7 @@ export function sessionStartContext(raw: string, deps: HookDeps = {}): string {
const lines: string[] = [];

if (a?.name) lines.push(`Agentctl calls this session "${a.name}".`);
else {
else if (!registryNames((deps.registry ?? registryEntry)(id))) {
lines.push(
'This session has no Agentctl name. Once the user\'s first task is clear, name it with' +
' `agentctl name "<3-5 words>"` — run it once, quietly, and do not ask permission or announce it.',
Expand Down Expand Up @@ -98,6 +107,33 @@ export function sessionStartContext(raw: string, deps: HookDeps = {}): string {
return lines.join('\n') + '\n';
}

/**
* Context injected at UserPromptSubmit: until the user gives this session a human `/rename` name
* (other sessions can only @-address those), ask the model to propose one. Silent — zero tokens —
* once named, and on the first prompt, which rarely makes the task clear. Once named, the Agentctl
* name follows the `/rename` name, so there is one name everywhere.
*/
export function userPromptContext(raw: string, deps: HookDeps = {}): string {
const id = parseSessionId(raw);
if (!id || !isValidSessionId(id)) return '';
const entry = (deps.registry ?? registryEntry)(id);
if (!registryNames(entry)) return '';
if (entry.nameSource !== 'derived') {
const a = (deps.annotation ?? ((i: string) => readAnnotation(i)))(id);
if (entry.name && a?.name !== entry.name) {
(deps.setName ?? ((i: string, n: string) => { writeAnnotation(i, { name: n }); }))(id, entry.name);
}
return '';
}
if ((deps.bump ?? ((i: string) => bumpPromptCount(i)))(id) < 2) return '';
return (
'This session has no human name yet, so other sessions cannot @-address it. Propose ONE short' +
' kebab-case name from the subject plus a Jira key / PR number / role (e.g. RD-14189-linear-history,' +
' pr-412-review; keep your earlier pick unless the task changed), run `agentctl name "<name>"`, and end' +
' your reply with `Rename: **/rename <name>**` on its own line.\n'
);
}

function readStdin(): Promise<string> {
return new Promise(resolve => {
if (process.stdin.isTTY) { resolve(''); return; }
Expand All @@ -124,4 +160,15 @@ export function registerHookCommands(program: Command) {
// never break the session that ran us
}
});

hook
.command('user-prompt-submit')
.description('print UserPromptSubmit context: ask for a /rename until the session has a human name')
.action(async () => {
try {
process.stdout.write(userPromptContext(await readStdin()));
} catch {
// never break the session that ran us
}
});
}
46 changes: 44 additions & 2 deletions src/core/liveState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,50 @@
import { readdirSync, readFileSync } from 'node:fs';
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { sessionsDirs } from './paths.js';
import type { LiveSession } from './types.js';
import type { LiveSession, RegistryEntry } from './types.js';

/**
* Claude Code's own record of one session, whatever its status — `readLiveSessions` keeps only
* busy/idle, but a session running a `!` command reports `shell`. Newest wins when a stale pid
* file shares the id.
*/
export function registryEntry(id: string): RegistryEntry | null {
let best: RegistryEntry | null = null;
for (const dir of sessionsDirs()) {
let files: string[];
try {
files = readdirSync(dir);
} catch {
continue;
}
for (const f of files) {
if (!f.endsWith('.json')) continue;
try {
const d = JSON.parse(readFileSync(join(dir, f), 'utf8')) as RegistryEntry & { sessionId?: unknown };
if (d.sessionId === id && (!best || (d.updatedAt ?? 0) > (best.updatedAt ?? 0))) best = d;
} catch {
// skip corrupt
}
}
}
return best;
}

/** Prompts submitted in a session so far, this one included. A tmp file per session is enough. */
export function bumpPromptCount(id: string, dir = join(tmpdir(), 'agentctl-prompts')): number {
const file = join(dir, id);
let n = 0;
try {
n = Number(readFileSync(file, 'utf8')) || 0;
} catch {
// first prompt
}
mkdirSync(dir, { recursive: true });
writeFileSync(file, String(n + 1));
return n + 1;
}

export function readLiveSessions(): LiveSession[] {
const out: LiveSession[] = [];
Expand Down
9 changes: 9 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ export interface LiveSession {
profile?: string;
}

/** The naming fields of a Claude Code `sessions/<pid>.json` record (2.1.318+). */
export interface RegistryEntry {
name?: string;
/** `derived` until the user runs `/rename`, then `user`. */
nameSource?: string;
kind?: string;
updatedAt?: number;
}

export type TranscriptRole = 'user' | 'assistant' | 'system' | 'tool';
export type TranscriptKind =
| 'text'
Expand Down
60 changes: 57 additions & 3 deletions tests/cli/hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { parseSessionId, sessionStartContext } from '../../src/cli/hook.js';
import type { Annotation } from '../../src/core/types.js';
import { parseSessionId, sessionStartContext, userPromptContext } from '../../src/cli/hook.js';
import type { Annotation, RegistryEntry } from '../../src/core/types.js';

const ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const payload = JSON.stringify({ session_id: ID, hook_event_name: 'SessionStart', source: 'startup' });
const ann = (o: Partial<Annotation> = {}): Annotation => ({ sessionId: ID, flags: [], labels: [], done: false, hidden: false, deleted: false, ...o });
const none = { annotation: () => null, all: () => new Map<string, Annotation>() };
const none = { annotation: () => null, all: () => new Map<string, Annotation>(), registry: () => null };

describe('parseSessionId', () => {
it('reads session_id and survives junk', () => {
Expand Down Expand Up @@ -56,6 +56,11 @@ describe('sessionStartContext', () => {
expect(out).toContain('agentctl annotations --due');
});

it('leaves naming to the prompt hook when Claude Code tracks /rename', () => {
const registry = (): RegistryEntry => ({ kind: 'interactive', nameSource: 'derived', name: 'agentctl-44' });
expect(sessionStartContext(payload, { ...none, registry })).not.toContain('has no Agentctl name');
});

it('always ends with the tool list so the model knows the commands', () => {
const last = sessionStartContext(payload, none).trimEnd().split('\n').at(-1)!;
expect(last).toContain('agentctl name/note/label/flag/remind/due/done');
Expand Down Expand Up @@ -91,3 +96,52 @@ describe('sessionStartContext', () => {
expect(upcoming).not.toContain('past its due date');
});
});

describe('userPromptContext', () => {
const prompt = JSON.stringify({ session_id: ID, hook_event_name: 'UserPromptSubmit', prompt: 'hi' });
const reg = (o: Partial<RegistryEntry> = {}) => (): RegistryEntry =>
({ kind: 'interactive', nameSource: 'derived', name: 'agentctl-44', ...o });
const run = (deps: Parameters<typeof userPromptContext>[1]) => {
const renamed: [string, string][] = [];
const out = userPromptContext(prompt, { annotation: () => null, setName: (i, n) => { renamed.push([i, n]); }, ...deps });
return { out, renamed };
};

it('stays silent on the first prompt of an unnamed session', () => {
expect(run({ registry: reg(), bump: () => 1 }).out).toBe('');
});

it('asks for a /rename from the second prompt on', () => {
const { out, renamed } = run({ registry: reg(), bump: () => 2 });
expect(out).toContain('agentctl name "<name>"');
expect(out).toContain('Rename: **/rename <name>**');
expect(out.trimEnd().split('\n')).toHaveLength(1);
expect(renamed).toEqual([]);
});

it('says nothing and counts nothing once the user has renamed it', () => {
let bumped = false;
const { out } = run({
registry: reg({ nameSource: 'user', name: 'pr-412-review' }),
annotation: () => ann({ name: 'pr-412-review' }),
bump: () => { bumped = true; return 5; },
});
expect(out).toBe('');
expect(bumped).toBe(false);
});

it('syncs the Agentctl name to the /rename name, only when it differs', () => {
const named = reg({ nameSource: 'user', name: 'pr-412-review' });
expect(run({ registry: named, annotation: () => ann({ name: 'old words' }) }).renamed).toEqual([[ID, 'pr-412-review']]);
expect(run({ registry: named }).renamed).toEqual([[ID, 'pr-412-review']]);
expect(run({ registry: named, annotation: () => ann({ name: 'pr-412-review' }) }).renamed).toEqual([]);
});

it('exits silently without a registry entry, for non-interactive sessions, or on junk input', () => {
expect(run({ registry: () => null, bump: () => 9 }).out).toBe('');
expect(run({ registry: reg({ kind: 'sdk-cli' }), bump: () => 9 }).out).toBe('');
expect(run({ registry: reg({ nameSource: undefined }), bump: () => 9 }).out).toBe('');
expect(userPromptContext('not json', { registry: reg(), bump: () => 9 })).toBe('');
expect(userPromptContext(JSON.stringify({ session_id: '../etc' }), { registry: reg(), bump: () => 9 })).toBe('');
});
});
24 changes: 23 additions & 1 deletion tests/core/liveState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readLiveSessions, isPidAlive, liveSessionById } from '../../src/core/liveState.js';
import { readLiveSessions, isPidAlive, liveSessionById, registryEntry, bumpPromptCount } from '../../src/core/liveState.js';

let home: string;
let origHome: string | undefined;
Expand Down Expand Up @@ -39,6 +39,28 @@ describe('readLiveSessions', () => {
});
});

describe('registryEntry', () => {
const ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
it('finds the id whatever its status, newest pid file first', () => {
const put = (f: string, o: object) => writeFileSync(join(home, 'sessions', f), JSON.stringify(o));
put('1.json', { sessionId: ID, status: 'shell', nameSource: 'derived', updatedAt: 1 });
put('2.json', { sessionId: ID, status: 'shell', nameSource: 'user', name: 'pr-412-review', updatedAt: 2 });
put('3.json', { sessionId: 'other', nameSource: 'user', updatedAt: 9 });
writeFileSync(join(home, 'sessions', 'bad.json'), 'not json');
expect(registryEntry(ID)).toMatchObject({ nameSource: 'user', name: 'pr-412-review' });
expect(registryEntry('missing')).toBeNull();
});
});

describe('bumpPromptCount', () => {
it('counts up per session', () => {
const dir = join(home, 'prompts');
expect(bumpPromptCount('a', dir)).toBe(1);
expect(bumpPromptCount('a', dir)).toBe(2);
expect(bumpPromptCount('b', dir)).toBe(1);
});
});

describe('isPidAlive', () => {
it('returns true for the current process', () => {
expect(isPidAlive(process.pid)).toBe(true);
Expand Down
Loading