diff --git a/CHANGELOG.md b/CHANGELOG.md index 090095c..938e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **`. Silent once renamed, and the Agentctl name then follows the `/rename` name. Reads Claude Code's own `sessions/.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 diff --git a/README.md b/README.md index 14787b3..f5d073b 100644 --- a/README.md +++ b/README.md @@ -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 `. 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 diff --git a/package-lock.json b/package-lock.json index 49baa9b..5010047 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roypadina/agentctl", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roypadina/agentctl", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index af49f47..3620a71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/hook.ts b/src/cli/hook.ts index 677dec4..cdd3c0a 100644 --- a/src/cli/hook.ts +++ b/src/cli/hook.ts @@ -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. @@ -13,6 +14,14 @@ export interface HookDeps { all?: () => Map; 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. */ @@ -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.', @@ -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 ""`, and end' + + ' your reply with `Rename: **/rename **` on its own line.\n' + ); +} + function readStdin(): Promise { return new Promise(resolve => { if (process.stdin.isTTY) { resolve(''); return; } @@ -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 + } + }); } diff --git a/src/core/liveState.ts b/src/core/liveState.ts index ca22eb4..9beafc6 100644 --- a/src/core/liveState.ts +++ b/src/core/liveState.ts @@ -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[] = []; diff --git a/src/core/types.ts b/src/core/types.ts index 83b95df..0b50df5 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -106,6 +106,15 @@ export interface LiveSession { profile?: string; } +/** The naming fields of a Claude Code `sessions/.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' diff --git a/tests/cli/hook.test.ts b/tests/cli/hook.test.ts index fc06f54..08fd7c8 100644 --- a/tests/cli/hook.test.ts +++ b/tests/cli/hook.test.ts @@ -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 => ({ sessionId: ID, flags: [], labels: [], done: false, hidden: false, deleted: false, ...o }); -const none = { annotation: () => null, all: () => new Map() }; +const none = { annotation: () => null, all: () => new Map(), registry: () => null }; describe('parseSessionId', () => { it('reads session_id and survives junk', () => { @@ -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'); @@ -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 => + ({ kind: 'interactive', nameSource: 'derived', name: 'agentctl-44', ...o }); + const run = (deps: Parameters[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 ""'); + expect(out).toContain('Rename: **/rename **'); + 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(''); + }); +}); diff --git a/tests/core/liveState.test.ts b/tests/core/liveState.test.ts index fb2d5e3..22fe6d6 100644 --- a/tests/core/liveState.test.ts +++ b/tests/core/liveState.test.ts @@ -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; @@ -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);