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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. See [standa
### 🐛 Bug Fixes

- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)).
- Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)).
- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`<dataHome>/usage.jsonl`: the project partition, `<repo>/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)).
- `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)).
- A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role <ns>`, since the scan needs it to tell which namespaces are the member's.
Expand Down
24 changes: 12 additions & 12 deletions src/__tests__/codex-stop-hint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@ describe('Codex Stop hint handoff with persisted session state', () => {

it('keeps Stop silent, persists the hint, and delivers it only once on the next prompt', async () => {
await seed(CONTRIBUTE_SMART_THRESHOLD + 1);
expect(await stop.execute(stdin, 'codex')).toBeNull();
expect(await stop.execute(stdin, 'codex', null)).toBeNull();
const state = await readContributeState(stdin.session_id);
expect(state.hinted).toBe(true);
expect(state.pendingHint).toContain('teamai skill get share');
expect(await stop.execute(stdin, 'codex')).toBeNull();
expect(await stop.execute(stdin, 'codex', null)).toBeNull();
expect((await readContributeState(stdin.session_id)).pendingHint).toBe(state.pendingHint);
expect(JSON.parse((await prompt.execute(stdin, 'codex'))!)).toEqual({
expect(JSON.parse((await prompt.execute(stdin, 'codex', null))!)).toEqual({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: state.pendingHint },
});
expect((await readContributeState(stdin.session_id)).pendingHint).toBeUndefined();
expect(await prompt.execute(stdin, 'codex')).toBeNull();
expect(await stop.execute(stdin, 'codex')).toBeNull();
expect(await prompt.execute(stdin, 'codex', null)).toBeNull();
expect(await stop.execute(stdin, 'codex', null)).toBeNull();
});

it.each(['codex', 'codex-internal', 'tcodex'])(
Expand All @@ -65,7 +65,7 @@ describe('Codex Stop hint handoff with persisted session state', () => {
// listed, so they took the Claude branch, Codex rejected it, and the
// stash that would have recovered the hint never ran.
await seed(CONTRIBUTE_SMART_THRESHOLD + 1);
expect(await stop.execute(stdin, tool)).toBeNull();
expect(await stop.execute(stdin, tool, null)).toBeNull();
expect((await readContributeState(stdin.session_id)).pendingHint).toBeTruthy();
},
);
Expand All @@ -74,7 +74,7 @@ describe('Codex Stop hint handoff with persisted session state', () => {
await seed(CONTRIBUTE_SMART_THRESHOLD + 1);

// Hidden path: the host shows nothing, so the model has to pass it on.
await stop.execute(stdin, 'codex');
await stop.execute(stdin, 'codex', null);
const stashed = (await readContributeState(stdin.session_id)).pendingHint!;
expect(stashed).toContain('verbatim');
expect(stashed).toContain('[teamai]');
Expand All @@ -89,25 +89,25 @@ describe('Codex Stop hint handoff with persisted session state', () => {
lastEvaluated: Date.now(),
friction: { interrupt: 0, toolReject: 0, correction: 1, toolError: 0 },
});
const payload = JSON.parse((await stop.execute(fresh, 'claude'))!);
const payload = JSON.parse((await stop.execute(fresh, 'claude', null))!);
expect(payload.hookSpecificOutput.additionalContext).toContain('[teamai]');
expect(payload.hookSpecificOutput.additionalContext).not.toContain('verbatim');
});

it('does not queue or deliver a hint below threshold', async () => {
await seed(CONTRIBUTE_SMART_THRESHOLD - 1);
expect(await stop.execute(stdin, 'codex')).toBeNull();
expect(await stop.execute(stdin, 'codex', null)).toBeNull();
expect((await readContributeState(stdin.session_id)).hinted).toBeFalsy();
expect(await prompt.execute(stdin, 'codex')).toBeNull();
expect(await prompt.execute(stdin, 'codex', null)).toBeNull();
});

it('drops a queued hint if the user contributed before the next prompt', async () => {
await seed(CONTRIBUTE_SMART_THRESHOLD + 1);
await stop.execute(stdin, 'codex');
await stop.execute(stdin, 'codex', null);
await writeContributeState(stdin.session_id, {
...await readContributeState(stdin.session_id), contributed: true,
});
expect(await prompt.execute(stdin, 'codex')).toBeNull();
expect(await prompt.execute(stdin, 'codex', null)).toBeNull();
expect((await readContributeState(stdin.session_id)).pendingHint).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion src/__tests__/git-kind-reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ describe('skill usage stays in the scope that recorded it (#748)', () => {
async function useSkill(cwd: string, skill: string): Promise<void> {
const track = buildHandlerRegistry().find((r) => r.handler.name === 'track');
if (!track) throw new Error('track handler is not registered');
await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude');
await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude', null);
}

async function reportedSkills(origin: string): Promise<string[]> {
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/hook-dispatch-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,33 @@ describe('deriveDispatchSessionId', () => {
});

describe('hookDispatchCli', () => {
it('starts the background pass from the temp dir when the payload cwd no longer exists', async () => {
// spawn() fails on a missing cwd, and that error is swallowed: no background
// handler (session-start pull, webhook, update check) would run at all.
const stdinFile = path.join(os.tmpdir(), `gone-cwd-hook-${process.pid}-${Date.now()}.json`);
const gone = path.join(os.tmpdir(), `teamai-deleted-worktree-${process.pid}-${Date.now()}`);
fs.writeFileSync(stdinFile, JSON.stringify({ hook_event_name: 'Stop', session_id: 's', cwd: gone }));
const originalCwd = process.cwd();
mockSpawn.mockClear();
mockSpawn.mockReturnValue({
on: vi.fn(),
stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) },
unref: vi.fn(),
});

try {
await hookDispatchCli('stop', 'claude', '*', { stdinFile });
expect(mockSpawn).toHaveBeenCalledOnce();
// The same fallback the Windows WMI launch uses: a directory that exists
// and belongs to no project, so a handler still reading the process cwd
// (the session-start pull) cannot land in the launcher's project.
expect(mockSpawn.mock.calls[0][2]).toMatchObject({ cwd: os.tmpdir() });
} finally {
process.chdir(originalCwd);
fs.rmSync(stdinFile, { force: true });
}
});

it('passes a path-free fallback session ID to a Copilot detached handler', async () => {
const stdinFile = path.join(os.tmpdir(), `copilot-hook-${process.pid}-${Date.now()}.json`);
const cwd = process.cwd();
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/hook-dispatch-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('node:child_process', async (importOriginal) => ({
vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) }));
vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) }));
vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) }));
vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) }));

const { hookDispatchCli } = await import('../hook-dispatch-cli.js');
const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js');
Expand Down Expand Up @@ -104,6 +105,55 @@ describe('hook runs and the scope they belong to (#748)', () => {
expect(fs.readFileSync(path.join(teamaiHome(), 'usage.jsonl'), 'utf-8')).toContain('skill-g');
});

it('handlers follow the scope the dispatcher resolved, not the directory the hook process runs in (#752)', async () => {
userScope();
fs.writeFileSync(path.join(teamaiHome(), 'team-repo', 'teamai.yaml'), 'team: user-team\nrepo: https://example.test/acme/user-team.git\n');
const root = gitRepo('project-a');
const dataHome = await resolveProjectDataHome(root);
const teamRepoA = path.join(dataHome, 'team-repo');
fs.mkdirSync(teamRepoA, { recursive: true });
fs.writeFileSync(path.join(teamRepoA, 'teamai.yaml'), [
'team: team-a',
'repo: https://example.test/acme/team-a.git',
'sharing:',
' intervention:',
' correctionKeywords: [rehazlo]',
' webhooks:',
' enabled: true',
' endpoints:',
' - { url: "https://hooks.team-a.test/in", type: json, retries: 0 }',
'',
].join('\n'));
await saveLocalConfigForScope({
repo: { localPath: teamRepoA, remote: 'https://example.test/acme/team-a.git' },
username: 'alice', scope: 'project', projectRoot: root, additionalRoles: [], dataHome,
});
const fetchSpy = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
const transcript = path.join(tmp, 'transcript.jsonl');
fs.writeFileSync(transcript, JSON.stringify({ type: 'assistant', message: { content: [{
type: 'text',
text: '<!-- teamai:recalled-doc-ids: [doc-1] --> <!-- teamai:referenced-doc-ids: [doc-1] -->',
}] } }) + '\n');
// The session's worktree is gone, so chdir fails and the host's launch
// directory, project A, stays the process cwd.
process.chdir(root);
const base = { session_id: 'sid-g', cwd: path.join(tmp, 'deleted-worktree') };

try {
await hook('prompt-submit', '*', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'rehazlo' });
await hook('stop', '*', { ...base, hook_event_name: 'Stop', transcript_path: transcript });
} finally {
vi.unstubAllGlobals();
}

const events = fs.readFileSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'), 'utf-8')
.split('\n').filter(Boolean).map((line) => JSON.parse(line) as { type: string; correction?: boolean });
expect(events.find((e) => e.type === 'prompt_submit')?.correction).toBe(false);
expect(fs.readdirSync(path.join(teamaiHome(), 'votes'))).toEqual(['tester.yaml']);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('a project whose config cannot be read records nothing, not even in the user scope', async () => {
userScope();
const root = gitRepo('project-a');
Expand Down
Loading
Loading