diff --git a/README.md b/README.md index eac14381..97673145 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,13 @@ When a recall hit comes from a codebase page, the result includes a `Sources:` l Global options: `--dry-run`, `--verbose` +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `TEAMAI_TURN_LIMIT` | Turn-limit reminder threshold; shown after the current response, then every 3 turns | `20` | +| `TEAMAI_TURN_HINT_DISABLED` | Set to `1` to disable turn-limit reminders | unset | + ## License [MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index f8fc0980..f90ffb33 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -194,6 +194,13 @@ teamai codebase --lint # 健康检查 全局选项:`--dry-run`、`--verbose` +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `TEAMAI_TURN_LIMIT` | 轮次提醒阈值;当前回复完成后展示提醒,之后每 3 轮重复 | `20` | +| `TEAMAI_TURN_HINT_DISABLED` | 设为 `1` 关闭轮次提醒 | 未设置 | + ## 许可证 [MIT](LICENSE) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 34d29f95..e7895458 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -923,6 +923,28 @@ scope: user # or project projectRoot: /path/to/project # project scope only ``` +### `TEAMAI_TURN_LIMIT` + +Turn-limit reminder threshold. When a session reaches this many user turns, teamai queues a **visible reminder after the current response completes**, then repeats it every 3 turns until a new session starts. Default: `20`. + +```bash +# Set a custom threshold of 30 turns +export TEAMAI_TURN_LIMIT=30 +``` + +### `TEAMAI_TURN_HINT_DISABLED` + +Set to `1` to completely disable turn-limit reminders. This is independent from `TEAMAI_RECALL_DISABLED`. + +```bash +# Disable turn-limit reminders permanently +export TEAMAI_TURN_HINT_DISABLED=1 +``` + +To mute only the current session, reply `关闭轮次提醒` (or `disable turn hint`). Other and future sessions remain enabled. + +> Cursor receives the reminder through its native Stop-hook `followup_message` mechanism, which automatically starts a follow-up Agent turn. + --- ## Uninstall diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d5070ec4..0962793b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -921,6 +921,28 @@ scope: user # 或 project projectRoot: /path/to/project # 仅 project scope ``` +### `TEAMAI_TURN_LIMIT` + +轮次提醒阈值。当会话达到该用户对话轮次时,teamai 会在**当前回复完成后展示一条可见提醒**;此后每 3 轮重复一次,直到开启新会话。默认 `20`。 + +```bash +# 自定义阈值为 30 轮 +export TEAMAI_TURN_LIMIT=30 +``` + +### `TEAMAI_TURN_HINT_DISABLED` + +设为 `1` 永久关闭轮次提醒功能。与 `TEAMAI_RECALL_DISABLED` 独立,互不影响。 + +```bash +# 永久关闭轮次提醒 +export TEAMAI_TURN_HINT_DISABLED=1 +``` + +仅静默当前会话时,直接回复 `关闭轮次提醒`(或 `disable turn hint`);其他及后续会话仍保持开启。 + +> Cursor 通过原生 Stop hook `followup_message` 接收提醒;该机制会自动开启一轮后续 Agent 对话。 + --- ## 卸载 diff --git a/src/__tests__/hook-dispatch.test.ts b/src/__tests__/hook-dispatch.test.ts index 448acd4f..db30730e 100644 --- a/src/__tests__/hook-dispatch.test.ts +++ b/src/__tests__/hook-dispatch.test.ts @@ -160,6 +160,31 @@ describe('hook-dispatch', () => { expect(result.output).toBeNull(); }); + + it('acknowledges only the output selected by registration priority', async () => { + const firstAccepted = vi.fn(); + const secondAccepted = vi.fn(); + const first: HookHandler = { + name: 'first', + execute: vi.fn().mockResolvedValue({ output: 'first output', onAccepted: firstAccepted }), + }; + const second: HookHandler = { + name: 'second', + execute: vi.fn().mockResolvedValue({ output: 'second output', onAccepted: secondAccepted }), + }; + const dispatcher = createDispatcher({ + handlers: [ + { event: 'stop', matcher: '*', handler: first }, + { event: 'stop', matcher: '*', handler: second }, + ], + }); + + const result = await dispatcher.dispatch('stop', '*', {}, 'claude'); + + expect(result.output).toBe('first output'); + expect(firstAccepted).toHaveBeenCalledOnce(); + expect(secondAccepted).not.toHaveBeenCalled(); + }); }); describe('stdin sharing', () => { diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 34fd4713..947c92aa 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; // ── Mocks ──────────────────────────────────────────────── // Mock the underlying modules so handlers don't do real I/O @@ -86,13 +89,14 @@ describe('hook-handlers registry', () => { expect(sessionStartHandlers).toContain('dashboard-report'); }); - it('stop has update, contribute-check, and dashboard-report handlers', () => { + it('stop has update, contribute-check, turn-limit-hint, and dashboard-report handlers', () => { const registry = buildHandlerRegistry(); const stopHandlers = registry .filter((r) => r.event === 'stop' && r.matcher === '*') .map((r) => r.handler.name); expect(stopHandlers).toContain('update'); expect(stopHandlers).toContain('contribute-check'); + expect(stopHandlers).toContain('turn-limit-hint'); expect(stopHandlers).toContain('dashboard-report'); }); @@ -145,8 +149,8 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] hello' }); const result = await handler.execute({ session_id: 's', cwd: '/x' }, 'cursor'); - expect(result).not.toBeNull(); - const parsed = JSON.parse(result!); + expect(typeof result).toBe('string'); + const parsed = JSON.parse(result as string); expect(parsed.followup_message).toBe('[teamai] hello'); }); @@ -244,6 +248,88 @@ describe('hook-handlers registry', () => { expect(handlers).toContain('dashboard-report'); }); + it('prompt-submit records turn-limit state without competing for visible output', () => { + const registry = buildHandlerRegistry(); + const promptSubmitHandlers = registry + .filter((r) => r.event === 'prompt-submit' && r.matcher === '*') + .map((r) => r.handler.name); + expect(promptSubmitHandlers).toContain('turn-limit-counter'); + expect(promptSubmitHandlers).not.toContain('turn-limit-hint'); + + // The counter runs after local-agent-sync, while the visible notification + // is deferred to Stop so the current user request is never interrupted. + const localAgentIdx = promptSubmitHandlers.indexOf('local-agent-sync'); + const counterIdx = promptSubmitHandlers.indexOf('turn-limit-counter'); + expect(counterIdx).toBeGreaterThan(localAgentIdx); + }); + + it('turn-limit-hint follows contribute-check on Stop so higher-priority hints win first', () => { + const registry = buildHandlerRegistry(); + const stopHandlers = registry + .filter((r) => r.event === 'stop' && r.matcher === '*') + .map((r) => r.handler.name); + expect(stopHandlers.indexOf('turn-limit-hint')).toBeGreaterThan( + stopHandlers.indexOf('contribute-check'), + ); + }); + + it('delivers a due turn-limit hint from Stop and acknowledges it after selection', async () => { + const tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-turn-stop-')); + vi.stubEnv('HOME', tmpHome); + vi.stubEnv('TEAMAI_TURN_LIMIT', '3'); + vi.stubEnv('TEAMAI_RECALL_DISABLED', '1'); + vi.stubEnv('TEAMAI_TURN_HINT_DISABLED', ''); + + try { + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const stdin = { session_id: 'stop-delivery', prompt: '继续', cwd: '/tmp/project' }; + + await dispatcher.dispatch('prompt-submit', '*', stdin, 'claude'); // count=1 + await dispatcher.dispatch('prompt-submit', '*', stdin, 'claude'); // count=2 + await dispatcher.dispatch('prompt-submit', '*', stdin, 'claude'); // count=3, pending=true + + const { hasPendingTurnLimitHint } = await import('../turn-limit-hint.js'); + expect(hasPendingTurnLimitHint('stop-delivery')).toBe(true); + + const result = await dispatcher.dispatch('stop', '*', stdin, 'claude'); + const output = JSON.parse(result.output!); + expect(output.hookSpecificOutput.hookEventName).toBe('Stop'); + expect(output.hookSpecificOutput.additionalContext).toContain('[teamai:turn-limit-hint]'); + expect(hasPendingTurnLimitHint('stop-delivery')).toBe(false); + } finally { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + } + }); + + it('delivers a due turn-limit hint through Cursor native followup_message', async () => { + const tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-turn-cursor-')); + vi.stubEnv('HOME', tmpHome); + vi.stubEnv('TEAMAI_TURN_LIMIT', '3'); + vi.stubEnv('TEAMAI_RECALL_DISABLED', '1'); + vi.stubEnv('TEAMAI_TURN_HINT_DISABLED', ''); + + try { + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const stdin = { session_id: 'cursor-followup', prompt: '继续', cwd: '/tmp/project' }; + + await dispatcher.dispatch('prompt-submit', '*', stdin, 'cursor'); + await dispatcher.dispatch('prompt-submit', '*', stdin, 'cursor'); + await dispatcher.dispatch('prompt-submit', '*', stdin, 'cursor'); + + const { hasPendingTurnLimitHint } = await import('../turn-limit-hint.js'); + const result = await dispatcher.dispatch('stop', '*', stdin, 'cursor'); + + expect(JSON.parse(result.output!)).toEqual({ + followup_message: expect.stringContaining('[teamai:turn-limit-hint]'), + }); + expect(hasPendingTurnLimitHint('cursor-followup')).toBe(false); + } finally { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + } + }); + it('all handlers have timeoutMs set', () => { const registry = buildHandlerRegistry(); for (const reg of registry) { diff --git a/src/__tests__/turn-limit-hint.test.ts b/src/__tests__/turn-limit-hint.test.ts new file mode 100644 index 00000000..51dff9e1 --- /dev/null +++ b/src/__tests__/turn-limit-hint.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { + buildTurnLimitHintMessage, + resolveTurnLimit, + isTurnHintDisabled, + recordTurnAndShouldHint, + hasPendingTurnLimitHint, + acknowledgeTurnLimitHint, + isMuteSignal, + getTurnLimitCachePath, +} from '../turn-limit-hint.js'; + +describe('buildTurnLimitHintMessage', () => { + it('contains the [teamai:turn-limit-hint] prefix', () => { + expect(buildTurnLimitHintMessage()).toContain('[teamai:turn-limit-hint]'); + }); + + it('uses concise Chinese copy', () => { + const msg = buildTurnLimitHintMessage(); + expect(msg).toMatch(/当前会话/); + expect(msg).not.toMatch(/Long sessions/); + }); + + it('tells the user how to disable the reminder', () => { + const msg = buildTurnLimitHintMessage(); + expect(msg).toContain('TEAMAI_TURN_HINT_DISABLED=1'); + expect(msg).toMatch(/关闭轮次提醒/); + }); + + it('does not instruct the model to repeat already-visible Stop feedback', () => { + expect(buildTurnLimitHintMessage()).not.toContain('Print the following message verbatim to the user'); + }); +}); + +describe('resolveTurnLimit', () => { + const original = process.env.TEAMAI_TURN_LIMIT; + + afterEach(() => { + if (original === undefined) delete process.env.TEAMAI_TURN_LIMIT; + else process.env.TEAMAI_TURN_LIMIT = original; + }); + + it('defaults to 20 when unset', () => { + delete process.env.TEAMAI_TURN_LIMIT; + expect(resolveTurnLimit()).toBe(20); + }); + + it('respects TEAMAI_TURN_LIMIT when set to a valid number', () => { + process.env.TEAMAI_TURN_LIMIT = '5'; + expect(resolveTurnLimit()).toBe(5); + }); + + it('falls back to 20 for non-numeric values', () => { + process.env.TEAMAI_TURN_LIMIT = 'abc'; + expect(resolveTurnLimit()).toBe(20); + }); + + it('falls back to 20 for zero or negative', () => { + process.env.TEAMAI_TURN_LIMIT = '0'; + expect(resolveTurnLimit()).toBe(20); + process.env.TEAMAI_TURN_LIMIT = '-5'; + expect(resolveTurnLimit()).toBe(20); + }); +}); + +describe('isTurnHintDisabled', () => { + const original = process.env.TEAMAI_TURN_HINT_DISABLED; + + afterEach(() => { + if (original === undefined) delete process.env.TEAMAI_TURN_HINT_DISABLED; + else process.env.TEAMAI_TURN_HINT_DISABLED = original; + }); + + it('returns false when unset', () => { + delete process.env.TEAMAI_TURN_HINT_DISABLED; + expect(isTurnHintDisabled()).toBe(false); + }); + + it('returns true when set to 1', () => { + process.env.TEAMAI_TURN_HINT_DISABLED = '1'; + expect(isTurnHintDisabled()).toBe(true); + }); +}); + +describe('recordTurnAndShouldHint — counting and recurring hints', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-turnlimit-test-')); + await fse.ensureDir(path.join(tmpHome, '.teamai', 'sessions')); + vi.stubEnv('HOME', tmpHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + }); + + it('returns false before reaching the limit', () => { + for (let i = 1; i < 20; i++) { + expect(recordTurnAndShouldHint('session-A', 20)).toBe(false); + } + }); + + it('returns true the first time count reaches the limit', () => { + for (let i = 1; i < 20; i++) { + recordTurnAndShouldHint('session-B', 20); + } + expect(recordTurnAndShouldHint('session-B', 20)).toBe(true); + }); + + it('returns true every 3 turns after the limit (recurring, not one-shot)', () => { + // limit=3, interval=3 → hint at count 3, 6, 9, ... + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=1 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=2 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(true); // count=3 (limit) + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=4 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=5 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(true); // count=6 (limit+3) + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=7 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(false); // count=8 + expect(recordTurnAndShouldHint('session-recur', 3)).toBe(true); // count=9 (limit+6) + }); + + it('keeps a scheduled reminder pending until Stop delivery acknowledges it', () => { + recordTurnAndShouldHint('session-pending', 3); // count=1 + recordTurnAndShouldHint('session-pending', 3); // count=2 + expect(recordTurnAndShouldHint('session-pending', 3)).toBe(true); // count=3 + expect(hasPendingTurnLimitHint('session-pending')).toBe(true); + + // Further turns do not lose the pending reminder before Stop can deliver it. + expect(recordTurnAndShouldHint('session-pending', 3)).toBe(false); // count=4 + expect(hasPendingTurnLimitHint('session-pending')).toBe(true); + + acknowledgeTurnLimitHint('session-pending'); + expect(hasPendingTurnLimitHint('session-pending')).toBe(false); + + // The next interval schedules another reminder. + recordTurnAndShouldHint('session-pending', 3); // count=5 + expect(recordTurnAndShouldHint('session-pending', 3)).toBe(true); // count=6 + expect(hasPendingTurnLimitHint('session-pending')).toBe(true); + }); + + it('treats different sessions independently', () => { + for (let i = 1; i < 20; i++) { + recordTurnAndShouldHint('session-D', 20); + } + expect(recordTurnAndShouldHint('session-D', 20)).toBe(true); + // session-E is fresh + expect(recordTurnAndShouldHint('session-E', 20)).toBe(false); + }); + + it('respects a custom limit', () => { + expect(recordTurnAndShouldHint('session-F', 3)).toBe(false); + expect(recordTurnAndShouldHint('session-F', 3)).toBe(false); + expect(recordTurnAndShouldHint('session-F', 3)).toBe(true); + expect(recordTurnAndShouldHint('session-F', 3)).toBe(false); + }); + + it('writes the cache file under ~/.teamai/sessions/-turn-count.json', () => { + recordTurnAndShouldHint('session-path-test', 20); + const expectedPath = getTurnLimitCachePath('session-path-test'); + expect(expectedPath).toContain(path.join('.teamai', 'sessions')); + expect(expectedPath).toContain('session-path-test-turn-count.json'); + expect(fse.pathExistsSync(expectedPath)).toBe(true); + }); + + it('sanitizes session IDs before using them in cache paths', () => { + const cachePath = getTurnLimitCachePath('../../outside'); + expect(cachePath).toContain('.._.._outside-turn-count.json'); + expect(cachePath).toContain(path.join('.teamai', 'sessions')); + }); + + it('persists count across calls (simulating process restart)', () => { + // Simulate 19 calls in one "process" + for (let i = 1; i < 20; i++) { + recordTurnAndShouldHint('session-restart', 20); + } + // The 20th call in a new "process" should still see count=19 and trigger + expect(recordTurnAndShouldHint('session-restart', 20)).toBe(true); + }); +}); + +describe('recordTurnAndShouldHint — TTL reset', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-turnlimit-ttl-')); + await fse.ensureDir(path.join(tmpHome, '.teamai', 'sessions')); + vi.stubEnv('HOME', tmpHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + }); + + it('treats cache older than 24h as fresh (resets count)', async () => { + // Write a stale cache: count=19, 25h ago + const cachePath = getTurnLimitCachePath('session-stale'); + const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + await fse.writeJson(cachePath, { count: 19, updatedAt: staleTime }); + + // Should NOT hint on first call (stale cache ignored, count starts at 1) + expect(recordTurnAndShouldHint('session-stale', 20)).toBe(false); + }); + + it('treats cache older than 24h as fresh (resets count past limit)', async () => { + // Write a stale cache: count=25 (already past limit), 25h ago + const cachePath = getTurnLimitCachePath('session-stale-past'); + const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + await fse.writeJson(cachePath, { count: 25, updatedAt: staleTime }); + + // Should be able to hint again after TTL reset (count starts at 1) + for (let i = 1; i < 20; i++) { + recordTurnAndShouldHint('session-stale-past', 20); + } + expect(recordTurnAndShouldHint('session-stale-past', 20)).toBe(true); + }); +}); + +describe('isMuteSignal', () => { + it('detects Chinese mute keyword', () => { + expect(isMuteSignal('请关闭轮次提醒,太烦了')).toBe(true); + expect(isMuteSignal('别再提醒了')).toBe(true); + }); + + it('detects English mute keyword (case-insensitive)', () => { + expect(isMuteSignal('please disable turn hint')).toBe(true); + expect(isMuteSignal('DISABLE TURN HINT')).toBe(true); + expect(isMuteSignal('turn hint off')).toBe(true); + }); + + it('returns false for normal prompts', () => { + expect(isMuteSignal('帮我写一个函数')).toBe(false); + expect(isMuteSignal('how does this work?')).toBe(false); + }); +}); + +describe('recordTurnAndShouldHint — per-session mute', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-mute-test-')); + await fse.ensureDir(path.join(tmpHome, '.teamai', 'sessions')); + vi.stubEnv('HOME', tmpHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + }); + + it('mutes the current session when user sends mute keyword', () => { + // limit=3, reach the limit first + recordTurnAndShouldHint('session-mute', 3); // count=1 + recordTurnAndShouldHint('session-mute', 3); // count=2 + expect(recordTurnAndShouldHint('session-mute', 3)).toBe(true); // count=3, hint! + + // User says "关闭轮次提醒" → this turn is muted, no hint, and any + // reminder waiting for Stop delivery is discarded. + expect(recordTurnAndShouldHint('session-mute', 3, '请关闭轮次提醒')).toBe(false); // count=4 + expect(hasPendingTurnLimitHint('session-mute')).toBe(false); + + // Subsequent turns in the same session stay muted + expect(recordTurnAndShouldHint('session-mute', 3, '继续工作')).toBe(false); // count=5 + expect(recordTurnAndShouldHint('session-mute', 3, '继续工作')).toBe(false); // count=6 (would have hinted) + }); + + it('does not affect other sessions', () => { + // Mute session-X + recordTurnAndShouldHint('session-X', 3, '关闭轮次提醒'); + + // session-Y is independent and still gets hints + expect(recordTurnAndShouldHint('session-Y', 3)).toBe(false); // count=1 + expect(recordTurnAndShouldHint('session-Y', 3)).toBe(false); // count=2 + expect(recordTurnAndShouldHint('session-Y', 3)).toBe(true); // count=3, hint! + }); + + it('mute keyword in a normal-length prompt works', () => { + // Long prompt that happens to contain the keyword + const prompt = '我正在调试一个问题,顺便说一下请关闭轮次提醒,谢谢'; + expect(recordTurnAndShouldHint('session-long', 3, prompt)).toBe(false); + + // Session is now muted + recordTurnAndShouldHint('session-long', 3); // count=2 + recordTurnAndShouldHint('session-long', 3); // count=3 (would have hinted) + // Verify via cache + const cache = JSON.parse( + fse.readFileSync(getTurnLimitCachePath('session-long'), 'utf-8'), + ); + expect(cache.muted).toBe(true); + }); +}); diff --git a/src/hook-dispatch.ts b/src/hook-dispatch.ts index 35b93d0e..04d55ffc 100644 --- a/src/hook-dispatch.ts +++ b/src/hook-dispatch.ts @@ -13,9 +13,21 @@ // ─── Public types ─────────────────────────────────────── +/** + * A handler output whose acknowledgement runs only when the dispatcher selects + * it for STDOUT. Useful for durable notifications that must remain pending when + * a higher-priority handler wins the one-output-per-event slot. + */ +export interface AcknowledgedHookOutput { + output: string; + onAccepted?: () => void | Promise; +} + +export type HookHandlerOutput = string | AcknowledgedHookOutput | null; + export interface HookHandler { name: string; - execute(stdin: Record, tool: string): Promise; + execute(stdin: Record, tool: string): Promise; } export interface HandlerRegistration { @@ -85,6 +97,10 @@ function withTimeout(promise: Promise, ms: number, handlerName: string): P }); } +function normalizeOutput(result: Exclude): AcknowledgedHookOutput { + return typeof result === 'string' ? { output: result } : result; +} + /** * Create a dispatcher with the given handler registrations. * @@ -133,10 +149,21 @@ export function createDispatcher(config: DispatcherConfig): Dispatcher { handlerName, error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)), }); - } else if (result.status === 'fulfilled' && result.value != null) { - // First non-null output wins (at most one handler should produce output per event) - if (output === null) { - output = result.value; + } else if (result.status === 'fulfilled' && result.value != null && output === null) { + // First non-null output wins. Handlers with persistent state can defer + // acknowledgement until this point, so an output that loses this race + // remains eligible for a later dispatch. + const selected = normalizeOutput(result.value); + output = selected.output; + if (selected.onAccepted) { + try { + await selected.onAccepted(); + } catch (error) { + errors.push({ + handlerName, + error: error instanceof Error ? error : new Error(String(error)), + }); + } } } } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 7b1df0f9..9bbf88e1 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -317,6 +317,50 @@ const localAgentHandler: HookHandler = { }, }; +const turnLimitCounterHandler: HookHandler = { + name: 'turn-limit-counter', + async execute(stdin, _tool) { + const { isTurnHintDisabled, resolveTurnLimit, recordTurnAndShouldHint } = await import('./turn-limit-hint.js'); + if (isTurnHintDisabled()) return null; + + const sessionId = deriveSessionId(stdin, { includeCwd: true }); + const limit = resolveTurnLimit(); + const prompt = typeof stdin.prompt === 'string' ? stdin.prompt : undefined; + + // UserPromptSubmit only records state. Its additionalContext is invisible to + // users unless a model chooses to repeat it, so visible delivery happens at + // the subsequent Stop hook after the current answer is complete. + recordTurnAndShouldHint(sessionId, limit, prompt); + return null; + }, +}; + +const turnLimitHintHandler: HookHandler = { + name: 'turn-limit-hint', + async execute(stdin, tool) { + const { + acknowledgeTurnLimitHint, + buildTurnLimitHintMessage, + hasPendingTurnLimitHint, + isTurnHintDisabled, + } = await import('./turn-limit-hint.js'); + if (isTurnHintDisabled()) return null; + + const sessionId = deriveSessionId(stdin, { includeCwd: true }); + if (!hasPendingTurnLimitHint(sessionId)) return null; + + const message = buildTurnLimitHintMessage(); + const { formatStopHookOutput } = await import('./utils/hook-output.js'); + return { + output: formatStopHookOutput(message, tool), + // The dispatcher calls this only when no earlier Stop handler supplied + // output. If a higher-priority hint wins, keep this notification pending + // and retry at a later Stop instead of silently losing it. + onAccepted: () => acknowledgeTurnLimitHint(sessionId), + }; + }, +}; + // ─── Registry builder ─────────────────────────────────── /** @@ -332,15 +376,15 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, // ─── Stop ───────────────────────────────────────── - // votes-sync and contribute-check may return a hint the host injects back - // into the session, so they run inline (capped at FOREGROUND_HOOK_TIMEOUT_MS). - // The rest are pure side effects — the update check in particular shells out - // to the npm registry — so they run detached to avoid pushing the Stop hook - // past the host's hook timeout (CodeBuddy kills hooks at ~10s regardless of - // the declared timeout). + // votes-sync, contribute-check, and turn-limit-hint may return a user-visible + // follow-up, so they run inline under the shared foreground budget. Their + // registration order is priority order: when an earlier handler wins, a + // pending turn-limit hint remains queued for a later Stop event. The rest + // are pure side effects and run detached to keep the host responsive. { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS, background: true }, { event: 'stop', matcher: '*', handler: votesSyncHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, + { event: 'stop', matcher: '*', handler: turnLimitHintHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS }, { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, { event: 'stop', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, @@ -354,6 +398,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'prompt-submit', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + { event: 'prompt-submit', matcher: '*', handler: turnLimitCounterHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS }, ]; } diff --git a/src/turn-limit-hint.ts b/src/turn-limit-hint.ts new file mode 100644 index 00000000..7e587f13 --- /dev/null +++ b/src/turn-limit-hint.ts @@ -0,0 +1,190 @@ +import path from 'node:path'; +import fs from 'node:fs'; + +// ─── Turn-limit hint data flow ────────────────────────── +// +// UserPromptSubmit hook (matcher: '*') +// │ +// ▼ +// Record ~/.teamai/sessions/-turn-count.json +// │ → count + 1 +// │ → prompt contains mute keyword? → set muted=true +// │ → count reaches a reminder interval? → set pending=true +// │ +// ▼ +// Stop hook (matcher: '*') +// │ +// ├─ pending=true → return user-visible Stop output +// └─ dispatcher selects this output → clear pending=true +// + +/** TTL for the per-session cache. Older sessions are treated as fresh. */ +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** Default turn limit before hinting. */ +const DEFAULT_TURN_LIMIT = 20; + +/** After the limit is reached, schedule a reminder every N turns. */ +const HINT_INTERVAL = 3; + +/** Keywords that mute the hint for the current session when detected in the user prompt. */ +const MUTE_KEYWORDS = [ + '关闭轮次提醒', + '别再提醒', + '停止轮次提醒', + '不再提醒轮次', + 'disable turn hint', + 'stop turn hint', + 'mute turn hint', + 'turn hint off', +]; + +interface TurnLimitCache { + count: number; + muted: boolean; + /** A due reminder that has not yet been selected for Stop-hook delivery. */ + pending: boolean; + updatedAt: string; +} + +/** + * Resolve the turn limit from TEAMAI_TURN_LIMIT env var. + * Falls back to 20 for unset or invalid values. + */ +export function resolveTurnLimit(): number { + const raw = process.env.TEAMAI_TURN_LIMIT; + if (raw === undefined) return DEFAULT_TURN_LIMIT; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_TURN_LIMIT; + return Math.floor(n); +} + +/** Returns true when the turn-limit hint is explicitly disabled. */ +export function isTurnHintDisabled(): boolean { + return process.env.TEAMAI_TURN_HINT_DISABLED === '1'; +} + +/** + * Resolve the cache path for a session. Session IDs originate in hook payloads, + * so normalize them before using them as a filename to prevent path traversal. + */ +export function getTurnLimitCachePath(sessionId: string): string { + const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '_'); + return path.join( + process.env.HOME ?? '', + '.teamai', + 'sessions', + `${safeSessionId}-turn-count.json`, + ); +} + +function readCache(sessionId: string): TurnLimitCache | null { + try { + const cachePath = getTurnLimitCachePath(sessionId); + if (!fs.existsSync(cachePath)) return null; + const raw = fs.readFileSync(cachePath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const timestamp = typeof parsed.updatedAt === 'string' ? new Date(parsed.updatedAt).getTime() : NaN; + const age = Date.now() - timestamp; + if (!Number.isFinite(timestamp) || age > CACHE_TTL_MS) return null; + + return { + count: Number.isSafeInteger(parsed.count) && parsed.count! >= 0 ? parsed.count! : 0, + muted: parsed.muted === true, + // Older cache files predate pending delivery; treat them as acknowledged. + pending: parsed.pending === true, + updatedAt: new Date(timestamp).toISOString(), + }; + } catch { + return null; + } +} + +function writeCache(sessionId: string, cache: TurnLimitCache): void { + try { + const cachePath = getTurnLimitCachePath(sessionId); + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); + } catch { + // best-effort; do not throw + } +} + +/** + * Check whether the user's prompt contains a mute signal. + * Case-insensitive for English keywords. + */ +export function isMuteSignal(prompt: string): boolean { + const lower = prompt.toLowerCase(); + return MUTE_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase())); +} + +/** + * Record a user turn and schedule a reminder for the next Stop hook when due. + * + * Reaching the limit, and every HINT_INTERVAL turns thereafter, sets `pending`. + * The Stop handler owns user-visible delivery and clears that flag only after + * the dispatcher actually chooses its output. This avoids relying on a model to + * relay UserPromptSubmit additionalContext. + */ +export function recordTurnAndShouldHint( + sessionId: string, + limit: number, + prompt?: string, +): boolean { + const cache = readCache(sessionId); + const count = (cache?.count ?? 0) + 1; + const wasMuted = cache?.muted ?? false; + const muted = wasMuted || (prompt ? isMuteSignal(prompt) : false); + const shouldSchedule = !muted && count >= limit && (count - limit) % HINT_INTERVAL === 0; + + writeCache(sessionId, { + count, + muted, + // Muting takes effect immediately and discards an older undelivered hint. + pending: muted ? false : (cache?.pending ?? false) || shouldSchedule, + updatedAt: new Date().toISOString(), + }); + + return shouldSchedule; +} + +/** True when this session has a scheduled, user-visible reminder to deliver. */ +export function hasPendingTurnLimitHint(sessionId: string): boolean { + const cache = readCache(sessionId); + return cache?.pending === true && cache.muted !== true; +} + +/** + * Mark a pending reminder as delivered. Call this only after the dispatcher + * selects the handler's output for STDOUT; losing an output race must preserve + * the pending reminder for a later Stop hook. + */ +export function acknowledgeTurnLimitHint(sessionId: string): void { + const cache = readCache(sessionId); + if (!cache?.pending) return; + + writeCache(sessionId, { + ...cache, + pending: false, + updatedAt: new Date().toISOString(), + }); +} + +/** + * Build the reminder text delivered by the Stop hook. + * + * Claude Code already renders Stop-hook feedback to the user. Do not add a + * "Print this verbatim" instruction here: that instruction makes the model + * repeat an already-visible feedback message as a second assistant reply. + */ +export function buildTurnLimitHintMessage(): string { + return [ + '[teamai:turn-limit-hint] 当前会话已进行较多轮对话。', + '', + '长会话会累积大量上下文,可能降低响应质量并增加成本。', + '建议在完成当前任务后,开启新的会话继续后续工作。', + '如不需要此提醒,请回复"关闭轮次提醒"静默当前会话,或设置环境变量 TEAMAI_TURN_HINT_DISABLED=1 永久关闭。', + ].join('\n'); +} diff --git a/src/utils/hook-output.ts b/src/utils/hook-output.ts index 175ef1ce..8ede41c7 100644 --- a/src/utils/hook-output.ts +++ b/src/utils/hook-output.ts @@ -2,7 +2,7 @@ * Format Stop hook STDOUT for the given AI tool. * * Schema choice per tool: - * - Cursor: `{ followup_message }` (Cursor stop hook docs) + * - Cursor: `{ followup_message }` * - Everyone else (Claude / CodeBuddy / WorkBuddy / Codex / unknown): * `{ hookSpecificOutput: { hookEventName: 'Stop', additionalContext } }` * (Claude Code stop hook docs — the "additional context that continues