Skip to content
Closed
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ teamai codebase --lint # 健康检查

全局选项:`--dry-run`、`--verbose`

## 环境变量

| 变量 | 说明 | 默认值 |
|------|------|--------|
| `TEAMAI_TURN_LIMIT` | 轮次提醒阈值;当前回复完成后展示提醒,之后每 3 轮重复 | `20` |
| `TEAMAI_TURN_HINT_DISABLED` | 设为 `1` 关闭轮次提醒 | 未设置 |

## 许可证

[MIT](LICENSE)
Expand Down
22 changes: 22 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 对话。

---

## 卸载
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/hook-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
92 changes: 89 additions & 3 deletions src/__tests__/hook-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading