From 251552c9a1a459f74b6d8d12b1d5af37c34114d1 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:14:25 +0000 Subject: [PATCH 1/8] test: define Copilot telemetry contract --- src/__tests__/builtin-hooks.test.ts | 12 +++ src/__tests__/copilot-adapter.test.ts | 6 +- src/__tests__/dashboard-collector.test.ts | 107 ++++++++++++++++++++++ src/__tests__/hook-dispatch-cli.test.ts | 5 + src/__tests__/hook-handlers.test.ts | 12 +++ src/__tests__/session-id.test.ts | 11 +++ src/__tests__/tool-names.test.ts | 1 + src/__tests__/usage-tracking.test.ts | 32 +++++++ 8 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/__tests__/builtin-hooks.test.ts b/src/__tests__/builtin-hooks.test.ts index 234719adb..99cf5d7b1 100644 --- a/src/__tests__/builtin-hooks.test.ts +++ b/src/__tests__/builtin-hooks.test.ts @@ -13,6 +13,18 @@ describe('builtinHookDefs — unified built-in hook model', () => { ]); }); + it('adds a lifecycle-complete SessionEnd hook only for Copilot', () => { + const defs = builtinHookDefs('copilot'); + expect(defs).toHaveLength(7); + expect(defs.at(-1)).toEqual(expect.objectContaining({ + event: 'SessionEnd', + matcher: '*', + timeout: 15, + command: expect.stringContaining('hook-dispatch session-end --tool copilot'), + })); + expect(builtinHookDefs('claude')).toHaveLength(6); + }); + it('Claude defs carry no timeout; Cursor defs carry per-hook timeouts', () => { expect(builtinHookDefs('claude').every((d) => d.timeout === undefined)).toBe(true); const cursor = builtinHookDefs('cursor'); diff --git a/src/__tests__/copilot-adapter.test.ts b/src/__tests__/copilot-adapter.test.ts index f0f41955a..e3156ca27 100644 --- a/src/__tests__/copilot-adapter.test.ts +++ b/src/__tests__/copilot-adapter.test.ts @@ -224,6 +224,7 @@ describe('GitHub Copilot adapter', () => { expect(parsed.hooks.UserPromptSubmit).toBeDefined(); expect(parsed.hooks.PostToolUse).toBeDefined(); expect(parsed.hooks.Stop).toBeDefined(); + expect(parsed.hooks.SessionEnd).toBeDefined(); expect(parsed.hooks.SessionStart[0]).toEqual(expect.objectContaining({ type: 'command', bash: expect.stringContaining('teamai hook-dispatch session-start --tool copilot'), @@ -232,7 +233,10 @@ describe('GitHub Copilot adapter', () => { timeoutSec: expect.any(Number), })); expect(parsed.hooks.PostToolUse).toEqual(expect.arrayContaining([ - expect.objectContaining({ matcher: 'Skill' }), + expect.objectContaining({ + matcher: 'skill', + command: expect.stringContaining('--matcher Skill'), + }), expect.objectContaining({ matcher: 'TodoWrite' }), ])); expect(parsed.hooks.PostToolUse.some((entry) => entry.matcher === undefined)).toBe(true); diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index eb1d93a46..02eac69c0 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -96,6 +96,93 @@ describe('parseHookEvent', () => { expect(event!.toolName).toBe('Edit'); }); + it('normalizes Copilot lowercase skill tool usage', async () => { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'PostToolUse', + session_id: 'copilot-session', + tool_name: 'skill', + }), 'copilot'); + expect(event?.toolName).toBe('Skill'); + }); + + it('retains only the correction signal from Copilot prompts', async () => { + const sensitivePrompt = 'wrong, use token ghp_private_value instead'; + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'UserPromptSubmit', + session_id: 'copilot-session', + prompt: sensitivePrompt, + }), 'copilot'); + expect(event).toEqual(expect.objectContaining({ + type: 'prompt_submit', + sessionId: 'copilot-session', + correction: true, + })); + expect(event?.promptSummary).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(sensitivePrompt); + expect(JSON.stringify(event)).not.toContain('ghp_private_value'); + }); + + it('reads only final Copilot token totals and redacts transcript content', async () => { + const transcript = path.join(tmpDir, 'copilot-events.jsonl'); + const secret = 'TOP-SECRET-COPILOT-PROMPT'; + fs.writeFileSync(transcript, [ + JSON.stringify({ + type: 'session.usage_checkpoint', + data: { prompt: secret, inputTokens: 999_999, request: { authorization: secret } }, + }), + JSON.stringify({ type: 'assistant.message', data: { content: secret } }), + JSON.stringify({ + type: 'session.shutdown', + data: { + tokenDetails: { + input: { tokenCount: 101 }, + output: { tokenCount: 29 }, + cache_read: { tokenCount: 17 }, + cache_write: { tokenCount: 3 }, + }, + prompt: secret, + }, + }), + ].join('\n')); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + session_id: 'copilot-stable-session', + transcript_path: transcript, + }), 'copilot'); + + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId: 'copilot-stable-session', + tool: 'copilot', + tokens: { input: 101, output: 29, cacheRead: 17, cacheCreation: 3 }, + tokenScope: 'session', + })); + expect(event?.transcriptPath).toBeUndefined(); + expect(event?.stoppedOutput).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(secret); + expect(JSON.stringify(event)).not.toContain(transcript); + expect(JSON.stringify(event)).not.toContain('999999'); + }); + + it('keeps Copilot SessionEnd when final token details are unavailable', async () => { + const transcript = path.join(tmpDir, 'copilot-no-tokens.jsonl'); + fs.writeFileSync(transcript, JSON.stringify({ + type: 'session.shutdown', + data: { reason: 'complete' }, + })); + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId: 'copilot-camel-session', + transcript_path: transcript, + }), 'copilot'); + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId: 'copilot-camel-session', + })); + expect(event?.tokens).toBeUndefined(); + }); + it('parses UserPromptSubmit event with prompt', async () => { const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', @@ -525,6 +612,26 @@ describe('rebuildSessions', () => { expect(sessions[0].status).toBe('waiting_for_input'); }); + it('rebuilds a privacy-safe Copilot lifecycle with final token totals', () => { + const events: DashboardEvent[] = [ + { type: 'session_start', timestamp: now, sessionId: 'copilot-1', tool: 'copilot', cwd: '/proj' }, + { type: 'prompt_submit', timestamp: now, sessionId: 'copilot-1', tool: 'copilot', correction: false }, + { type: 'tool_use', timestamp: now, sessionId: 'copilot-1', tool: 'copilot', toolName: 'Skill' }, + { + type: 'session_end', timestamp: now, sessionId: 'copilot-1', tool: 'copilot', + tokens: { input: 10, output: 4, cacheRead: 2, cacheCreation: 1 }, tokenScope: 'session', + }, + ]; + const sessions = rebuildSessions(events); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toEqual(expect.objectContaining({ + status: 'stopped', + promptCount: 1, + lastTool: 'Skill', + tokens: { input: 10, output: 4, cacheRead: 2, cacheCreation: 1 }, + })); + }); + it('stop then prompt_submit returns to running', () => { const events: DashboardEvent[] = [ { type: 'session_start', timestamp: now, sessionId: 's1', tool: 'claude', cwd: '/proj' }, diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index 50f9b13de..0663bfe2c 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -39,6 +39,11 @@ describe('parseStdin', () => { expect(result.hook_event_name).toBe('SessionStart'); }); + it('maps the Copilot lifecycle alias to SessionEnd', () => { + const result = parseStdin('', 'session-end'); + expect(result.hook_event_name).toBe('SessionEnd'); + }); + it('degrades JSON `null` to {} instead of throwing', () => { // RED BASELINE: before the fix, JSON.parse('null') returns null, and the // subsequent `stdin.hook_event_name` access throws TypeError in ESM strict diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 7b5ab4f14..1037a46e0 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -128,6 +128,18 @@ describe('hook-handlers registry', () => { expect(events).toContain('stop'); expect(events).toContain('post-tool-use'); expect(events).toContain('prompt-submit'); + expect(events).toContain('session-end'); + }); + + it('session-end only records the final dashboard snapshot in the background', () => { + const handlers = buildHandlerRegistry().filter((r) => r.event === 'session-end'); + expect(handlers).toEqual([ + expect.objectContaining({ + matcher: '*', + background: true, + handler: expect.objectContaining({ name: 'dashboard-report' }), + }), + ]); }); it('session-start has pull and dashboard-report handlers', () => { diff --git a/src/__tests__/session-id.test.ts b/src/__tests__/session-id.test.ts index de6feb039..5356b228e 100644 --- a/src/__tests__/session-id.test.ts +++ b/src/__tests__/session-id.test.ts @@ -16,6 +16,17 @@ describe('deriveSessionId', () => { expect(deriveSessionId({ session_id: 'explicit-session' })).toBe('explicit-session'); }); + it('uses Copilot camelCase sessionId when the snake_case field is absent', () => { + expect(deriveSessionId({ sessionId: 'copilot-session' })).toBe('copilot-session'); + }); + + it('prefers canonical snake_case when both session ID forms are present', () => { + expect(deriveSessionId({ + session_id: 'canonical-session', + sessionId: 'copilot-session', + })).toBe('canonical-session'); + }); + it('falls back to CLAUDE_SESSION_ID env var', () => { delete process.env.CLAUDE_SESSION_ID; process.env.CLAUDE_SESSION_ID = 'env-session'; diff --git a/src/__tests__/tool-names.test.ts b/src/__tests__/tool-names.test.ts index 2b7943426..16e615d1f 100644 --- a/src/__tests__/tool-names.test.ts +++ b/src/__tests__/tool-names.test.ts @@ -13,6 +13,7 @@ describe('normalizeToolName', () => { expect(normalizeToolName('web_fetch')).toBe('WebFetch'); expect(normalizeToolName('read_file')).toBe('Read'); expect(normalizeToolName('task')).toBe('Task'); + expect(normalizeToolName('skill')).toBe('Skill'); }); it('passes through CLI-style names unchanged', () => { diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 8b788464e..c196f5203 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -26,6 +26,7 @@ import { updateKnownSkills, readKnownSkills, extractSkillName, + skillExistsOnDisk, } from '../usage-tracker.js'; import { aggregateUsage } from '../stats.js'; import { mergeStats } from '../team-push.js'; @@ -117,6 +118,37 @@ describe('isValidSkillName', () => { }); }); +describe('skillExistsOnDisk — Copilot', () => { + it('finds user skills under a custom COPILOT_HOME', async () => { + const copilotHome = path.join(tmpDir, 'copilot-home'); + const skillDir = path.join(copilotHome, 'skills', 'copilot-review'); + await fse.ensureDir(skillDir); + await fse.writeFile(path.join(skillDir, 'SKILL.md'), '# Copilot review\n'); + const previous = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = copilotHome; + try { + await expect(skillExistsOnDisk('copilot-review')).resolves.toBe(true); + } finally { + if (previous === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = previous; + } + }); + + it('finds project skills under .github/skills', async () => { + const project = path.join(tmpDir, 'project'); + const skillDir = path.join(project, '.github', 'skills', 'copilot-test'); + await fse.ensureDir(skillDir); + await fse.writeFile(path.join(skillDir, 'SKILL.md'), '# Copilot test\n'); + const previousCwd = process.cwd(); + process.chdir(project); + try { + await expect(skillExistsOnDisk('copilot-test')).resolves.toBe(true); + } finally { + process.chdir(previousCwd); + } + }); +}); + describe('appendUsageEvent', () => { it('appends a valid event to JSONL', async () => { const event: UsageEvent = { From c37d249f38868a6711966ab4e5f55cd845775590 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:26:15 +0000 Subject: [PATCH 2/8] feat:copilot-privacy-telemetry --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/dashboard-collector.test.ts | 43 +++++++ src/__tests__/usage-tracking.test.ts | 8 ++ src/builtin-hooks.ts | 13 +- src/dashboard-collector.ts | 143 ++++++++++++++++++---- src/hook-dispatch-cli.ts | 1 + src/hook-handlers.ts | 4 + src/hooks.ts | 8 +- src/types.ts | 2 +- src/usage-tracker.ts | 16 ++- src/utils/session-id.ts | 9 +- src/utils/tool-names.ts | 1 + 13 files changed, 218 insertions(+), 34 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index dc2238e65..a6cb938e8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1444,7 +1444,7 @@ GitHub Copilot CLI is supported for its official custom-instructions, Rules, Ski - **Custom agents.** Team agents become official `.agent.md` profiles under `$COPILOT_HOME/agents/` or `.github/agents/`. TeamAI maps compatible tool names onto Copilot's primary aliases, preserves Copilot-only frontmatter through `tool_extras.copilot`, and removes only profiles that match team agents or the built-in recall profile. User-authored profiles remain untouched. See [GitHub's custom-agent configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration). - **Team Context recall.** The built-in `teamai-recall.agent.md` profile receives only `execute`, `read`, and `search`. It invokes the existing `teamai recall` pipeline, so Copilot can retrieve learnings, codebase evidence, and teamwiki results without copying or creating a second knowledge store. - **Docs and Env.** Team docs sync to the configured local docs directory (`~/.teamai/docs` by default, or the project-relative equivalent in project scope). Team env values sync to the scope's managed `env.sh`; launch Copilot from a shell that has sourced that file. TeamAI does not copy environment values into Copilot configuration. -- **Hooks.** TeamAI writes a dedicated version-1 hook file at `$COPILOT_HOME/hooks/teamai.json` or `.github/hooks/teamai.json`. It uses Copilot's VS Code-compatible PascalCase events (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop`) so hook payloads retain the snake_case fields consumed by TeamAI, and emits `bash`, `powershell`, and fallback `command` fields. The file is reconciled idempotently while preserving unrelated entries. TeamAI never edits Copilot's `settings.json`. +- **Hooks and private telemetry.** TeamAI writes a dedicated version-1 hook file at `$COPILOT_HOME/hooks/teamai.json` or `.github/hooks/teamai.json`. It uses Copilot's VS Code-compatible PascalCase events (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, and `SessionEnd`) so hook payloads retain the snake_case fields consumed by TeamAI, and emits `bash`, `powershell`, and fallback `command` fields. Session IDs, skill usage, prompt counts, lifecycle state, and final token totals feed the local dashboard. Copilot prompt text, assistant output, transcript paths, and request metadata are never stored; if final token counters are absent, the session is still recorded without token data. The file is reconciled idempotently while preserving unrelated entries. TeamAI never edits Copilot's `settings.json`. - **MCP.** `teamai pull` and `teamai mcp inject` merge local and remote servers into `$COPILOT_HOME/mcp-config.json` or `.github/mcp.json` using Copilot's native schema. TeamAI tracks ownership outside the Copilot file, so repeated pulls are idempotent and `mcp remove` or uninstall removes only TeamAI-owned entries. Hand-authored servers and `settings.json` remain unchanged. Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in the team repository and use the normal pull/push workflow. TeamAI does not reverse-import arbitrary native hook entries from a Copilot configuration file. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8af8c6a5f..a47d33cec 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1404,7 +1404,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **自定义 Agents。** 团队 Agents 会转换为 `$COPILOT_HOME/agents/` 或 `.github/agents/` 下的官方 `.agent.md` 配置。TeamAI 将兼容的工具名映射为 Copilot 主别名,通过 `tool_extras.copilot` 保留 Copilot 专属 frontmatter,并且只删除与团队 Agent 或内置 recall 配置匹配的文件;用户自建配置保持不变。详见 [GitHub 自定义 Agent 配置](https://docs.github.com/zh/copilot/reference/custom-agents-configuration)。 - **Team Context recall。** 内置 `teamai-recall.agent.md` 只获得 `execute`、`read` 和 `search`。它调用现有的 `teamai recall` 流程,让 Copilot 检索 learnings、codebase 证据和 teamwiki 结果,而不会复制或创建第二套知识库。 - **Docs 和 Env。** 团队 Docs 同步到配置的本地文档目录(默认 `~/.teamai/docs`;project scope 使用项目内对应路径)。团队环境变量同步到该作用域由 TeamAI 管理的 `env.sh`;请从已 source 此文件的 shell 启动 Copilot。TeamAI 不会把环境变量值复制到 Copilot 配置中。 -- **Hooks。** TeamAI 在 `$COPILOT_HOME/hooks/teamai.json` 或 `.github/hooks/teamai.json` 写入独立的 version-1 Hook 文件,使用 Copilot 与 VS Code 兼容的 PascalCase 事件(`SessionStart`、`UserPromptSubmit`、`PostToolUse` 和 `Stop`),从而保留 TeamAI 所需的 snake_case Hook 负载字段,并生成 `bash`、`powershell` 和后备 `command` 字段。文件会被幂等合并,且保留无关条目。TeamAI 从不修改 Copilot 的 `settings.json`。 +- **Hooks 与隐私遥测。** TeamAI 在 `$COPILOT_HOME/hooks/teamai.json` 或 `.github/hooks/teamai.json` 写入独立的 version-1 Hook 文件,使用 Copilot 与 VS Code 兼容的 PascalCase 事件(`SessionStart`、`UserPromptSubmit`、`PostToolUse`、`Stop` 和 `SessionEnd`),从而保留 TeamAI 所需的 snake_case Hook 负载字段,并生成 `bash`、`powershell` 和后备 `command` 字段。会话 ID、Skill 使用、提示次数、生命周期状态和最终 Token 总数会进入本地 Dashboard;Copilot 提示原文、助手输出、Transcript 路径和请求元数据绝不会被保存。若最终 Token 计数不可用,会话仍会被记录,但不包含 Token 数据。文件会被幂等合并,且保留无关条目。TeamAI 从不修改 Copilot 的 `settings.json`。 - **MCP。** `teamai pull` 和 `teamai mcp inject` 使用 Copilot 原生结构,把本地与远程 Server 合并到 `$COPILOT_HOME/mcp-config.json` 或 `.github/mcp.json`。归属信息保存在 Copilot 文件之外,因此重复 pull 保持幂等,`mcp remove` 或卸载只会移除 TeamAI 管理的条目;手写 Server 与 `settings.json` 均保持不变。 团队 Hooks 仍以团队仓库中的 `hooks/hooks.yaml` 为来源:直接编辑该文件,再使用正常的 pull/push 流程。TeamAI 不会从 Copilot 配置文件反向导入任意原生 Hook 条目。 diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 02eac69c0..ca0c694d6 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -16,6 +16,7 @@ import { dedupeEvents, } from '../dashboard-collector.js'; import type { DashboardEvent } from '../types.js'; +import { _resetState as resetLogger, _setLogFilePath } from '../utils/logger.js'; // ─── Transcript fixtures for intervention scanning ────── const INTERRUPT_LINE = JSON.stringify({ @@ -165,6 +166,25 @@ describe('parseHookEvent', () => { expect(JSON.stringify(event)).not.toContain('999999'); }); + it('skips an incomplete Copilot shutdown record and uses the valid final record', async () => { + const transcript = path.join(tmpDir, 'copilot-in-flight.jsonl'); + fs.writeFileSync(transcript, [ + '{"type":"session.shutdown",', + JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 7 }, output: { tokenCount: 2 } } }, + }), + ].join('\n')); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + session_id: 'copilot-in-flight', + transcript_path: transcript, + }), 'copilot'); + + expect(event?.tokens).toEqual({ input: 7, output: 2, cacheRead: 0, cacheCreation: 0 }); + }); + it('keeps Copilot SessionEnd when final token details are unavailable', async () => { const transcript = path.join(tmpDir, 'copilot-no-tokens.jsonl'); fs.writeFileSync(transcript, JSON.stringify({ @@ -183,6 +203,29 @@ describe('parseHookEvent', () => { expect(event?.tokens).toBeUndefined(); }); + it('keeps a missing Copilot transcript path out of events and debug logs', async () => { + const sensitivePath = path.join(tmpDir, 'private-customer-name.jsonl'); + const debugLog = path.join(tmpDir, 'debug.log'); + _setLogFilePath(debugLog); + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + session_id: 'copilot-missing-transcript', + transcript_path: sensitivePath, + }), 'copilot'); + + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId: 'copilot-missing-transcript', + })); + expect(event?.tokens).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(sensitivePath); + expect(fs.readFileSync(debugLog, 'utf-8')).not.toContain(sensitivePath); + } finally { + resetLogger(); + } + }); + it('parses UserPromptSubmit event with prompt', async () => { const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index c196f5203..1b6ca7e4b 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -147,6 +147,14 @@ describe('skillExistsOnDisk — Copilot', () => { process.chdir(previousCwd); } }); + + it('does not treat the home .github directory as a user skill scope', async () => { + const skillDir = path.join(tmpDir, '.github', 'skills', 'project-only'); + await fse.ensureDir(skillDir); + await fse.writeFile(path.join(skillDir, 'SKILL.md'), '# Project only\n'); + + await expect(skillExistsOnDisk('project-only')).resolves.toBe(false); + }); }); describe('appendUsageEvent', () => { diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index f01672c01..b73841472 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -290,6 +290,14 @@ const BUILTIN_HOOK_SPECS: BuiltinHookSpec[] = [ { key: 'Hook dispatch prompt-submit', event: 'UserPromptSubmit', dispatchEvent: 'prompt-submit', matcher: '*', timeoutSec: 10 }, ]; +const COPILOT_SESSION_END_SPEC: BuiltinHookSpec = { + key: 'Hook dispatch session-end', + event: 'SessionEnd', + dispatchEvent: 'session-end', + matcher: '*', + timeoutSec: 15, +}; + /** * Build the built-in hook definitions for a tool. * @@ -310,7 +318,10 @@ export function builtinHookDefs(tool: string): HookDef[] { const buildCommand = tool === 'zcode' ? getRawDispatchCommand : WRAPPER_TOOLS.has(tool) ? getWrapperDispatchCommand : getDispatchCommand; - return BUILTIN_HOOK_SPECS.map((spec) => ({ + const specs = tool === 'copilot' + ? [...BUILTIN_HOOK_SPECS, COPILOT_SESSION_END_SPEC] + : BUILTIN_HOOK_SPECS; + return specs.map((spec) => ({ source: 'builtin' as const, key: spec.key, event: spec.event, diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index e4501c5b8..94369b178 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -21,6 +21,7 @@ import { TRANSCRIPT_SYSTEM_PREFIXES, stripInjectedPrompt, TRANSCRIPT_REJECT_MARKERS, + COPILOT_TOOL_ID, emptyTokenUsage, addTokenUsage, type DashboardEvent, @@ -535,6 +536,84 @@ async function waitForCodexUsageFlush( return latest; } +const COPILOT_USAGE_TAIL_BYTES = 256 * 1024; +const COPILOT_USAGE_MAX_ATTEMPTS = 8; +const COPILOT_USAGE_RETRY_MS = 250; +const COPILOT_SHUTDOWN_EVENT = 'session.shutdown'; + +interface CopilotUsageObservation { + shutdownObserved: boolean; + tokens?: TokenUsage; +} + +/** Parse only Copilot's final aggregate token counters from a shutdown record. */ +function parseCopilotShutdown(entry: unknown): CopilotUsageObservation { + const record = asRecord(entry); + if (record?.type !== COPILOT_SHUTDOWN_EVENT) return { shutdownObserved: false }; + + const details = asRecord(asRecord(record.data)?.tokenDetails); + if (!details) return { shutdownObserved: true }; + const tokenCount = (bucket: string): number => toNum(asRecord(details[bucket])?.tokenCount); + return { + shutdownObserved: true, + tokens: { + input: tokenCount('input'), + output: tokenCount('output'), + cacheRead: tokenCount('cache_read'), + cacheCreation: tokenCount('cache_write'), + }, + }; +} + +/** Read a bounded tail without retaining any prompt, output, or request fields. */ +async function readLatestCopilotUsageFromTail( + transcriptPath: string, +): Promise { + try { + const stat = await fs.promises.stat(transcriptPath); + if (stat.size === 0) return { shutdownObserved: false }; + const readSize = Math.min(stat.size, COPILOT_USAGE_TAIL_BYTES); + const offset = stat.size - readSize; + const fh = await fs.promises.open(transcriptPath, 'r'); + try { + const buffer = Buffer.alloc(readSize); + await fh.read(buffer, 0, readSize, offset); + const lines = buffer.toString('utf-8').split('\n'); + if (offset > 0) lines.shift(); + let latest: CopilotUsageObservation = { shutdownObserved: false }; + for (const line of lines) { + if (!line.includes(`"${COPILOT_SHUTDOWN_EVENT}"`)) continue; + try { + const observed = parseCopilotShutdown(JSON.parse(line)); + if (observed.shutdownObserved) latest = observed; + } catch { + // The last line may still be in flight; the bounded retry sees it later. + } + } + return latest; + } finally { + await fh.close(); + } + } catch { + log.debug('dashboard: failed to read Copilot usage tail'); + return { shutdownObserved: false }; + } +} + +/** Wait briefly because Copilot can append session.shutdown after SessionEnd. */ +async function waitForCopilotShutdownUsage( + transcriptPath: string, +): Promise { + for (let attempt = 0; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { + const observed = await readLatestCopilotUsageFromTail(transcriptPath); + if (observed.shutdownObserved) return observed; + if (attempt < COPILOT_USAGE_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); + } + } + return { shutdownObserved: false }; +} + /** * Read a CodeBuddy `index.json` transcript once. CodeBuddy's schema differs from * Claude Code: @@ -850,6 +929,9 @@ function mapEventType(hookEventName: string): DashboardEventType | null { case 'SessionStart': case 'sessionStart': return 'session_start'; + case 'SessionEnd': + case 'sessionEnd': + return 'session_end'; case 'PostToolUse': case 'postToolUse': return 'tool_use'; @@ -904,6 +986,7 @@ export async function parseHookEvent( const sessionId = deriveSessionId(hookData, { includeCwd: true }); const cwd = resolveHookCwd(hookData); + const isCopilot = tool.toLowerCase() === COPILOT_TOOL_ID; const event: DashboardEvent = { type: eventType, @@ -940,7 +1023,7 @@ export async function parseHookEvent( const human = stripInjectedPrompt(hookData.prompt); if (!human) return null; // Keep first 200 chars of the genuine prompt as summary. - event.promptSummary = human.slice(0, 200); + if (!isCopilot) event.promptSummary = human.slice(0, 200); // Decide "correction" here, over the full prompt, because only the hook knows // which team (and so which extra keywords) the prompt belongs to. The // machine-level events file mixes sessions from every team. @@ -948,7 +1031,7 @@ export async function parseHookEvent( } // Extract transcript path, AI output and intervention counts from Stop event - if (eventType === 'stop' && typeof hookData.transcript_path === 'string') { + if (eventType === 'stop' && !isCopilot && typeof hookData.transcript_path === 'string') { event.transcriptPath = hookData.transcript_path; const output = await readLastAssistantOutput(hookData.transcript_path); if (output) { @@ -985,6 +1068,17 @@ export async function parseHookEvent( } } + // Copilot's session log contains prompts, tool arguments, assistant output, + // and auth-bearing request metadata. Read only the final shutdown token + // counters and never persist the path or any transcript content. + if (eventType === 'session_end' && isCopilot && typeof hookData.transcript_path === 'string') { + const usage = await waitForCopilotShutdownUsage(hookData.transcript_path); + if (usage.tokens) { + event.tokens = usage.tokens; + event.tokenScope = 'session'; + } + } + return event; } @@ -1159,7 +1253,7 @@ function eventSignature(e: DashboardEvent): string { switch (e.type) { case 'tool_use': return `tool_use\0${e.toolName ?? ''}`; case 'prompt_submit': return `prompt_submit\0${e.promptSummary ?? ''}`; - default: return e.type; // session_start | stop | process_exit + default: return e.type; // session_start | session_end | stop | process_exit } } @@ -1234,7 +1328,7 @@ export async function readEvents(eventsPath?: string): Promise * - tool_use → update lastActivity + lastTool, mark running * - prompt_submit → capture prompt, mark running * - stop → mark as waiting_for_input (LLM finished, user still in session) - * - process_exit → mark as stopped (process truly exited) + * - session_end / process_exit → mark as stopped (process truly exited) * Then apply timeouts: idle after 5 min, remove stale after 30 min. * Stopped sessions are kept for 30 seconds before removal. */ @@ -1300,6 +1394,10 @@ export function rebuildSessions(events: DashboardEvent[]): DashboardSession[] { session.stoppedOutput = event.stoppedOutput; } break; + case 'session_end': + session.status = 'stopped'; + session.stoppedAt = event.timestamp; + break; case 'process_exit': // The AI tool process has truly exited (detected by PID liveness monitor). // This is the real "session ended" signal. @@ -1427,24 +1525,6 @@ export function aggregateSessionMetrics( m.interrupt = event.interventions.interrupt; m.toolReject = event.interventions.toolReject; } - if (event.tokens) { - if (event.tokenScope === 'session') { - setLatestTokenSnapshot(sessionTokens, event.sessionId, event); - } else if (event.tokenScope === 'transcript' && event.transcriptPath) { - let segments = transcriptTokens.get(event.sessionId); - if (!segments) { - segments = new Map(); - transcriptTokens.set(event.sessionId, segments); - } - // A rollout's counter is cumulative within that file. Repeated Stop scans - // replace the same segment; a resumed rollout has a distinct path and adds - // one new segment to the logical session total. - setLatestTokenSnapshot(segments, event.transcriptPath, event); - } else { - // Claude, CodeBuddy, and pre-existing events retain latest-Stop semantics. - setLatestTokenSnapshot(unscopedTokens, event.sessionId, event); - } - } if (typeof event.prompts === 'number') { stopPrompts.set(event.sessionId, event.prompts); } @@ -1463,6 +1543,25 @@ export function aggregateSessionMetrics( lastStopAt.delete(event.sessionId); } } + + if ((event.type === 'stop' || event.type === 'session_end') && event.tokens) { + if (event.tokenScope === 'session') { + setLatestTokenSnapshot(sessionTokens, event.sessionId, event); + } else if (event.tokenScope === 'transcript' && event.transcriptPath) { + let segments = transcriptTokens.get(event.sessionId); + if (!segments) { + segments = new Map(); + transcriptTokens.set(event.sessionId, segments); + } + // A rollout's counter is cumulative within that file. Repeated Stop scans + // replace the same segment; a resumed rollout has a distinct path and adds + // one new segment to the logical session total. + setLatestTokenSnapshot(segments, event.transcriptPath, event); + } else { + // Claude, CodeBuddy, and pre-existing events retain latest-Stop semantics. + setLatestTokenSnapshot(unscopedTokens, event.sessionId, event); + } + } } // Reconcile prompt count: the Stop transcript snapshot is the durable baseline diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 02aa0230e..f49b09146 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -377,6 +377,7 @@ export function parseStdin(raw: string, event: string): Record if (!stdin.hook_event_name) { const EVENT_MAP: Record = { 'session-start': 'SessionStart', + 'session-end': 'SessionEnd', 'stop': 'Stop', 'post-tool-use': 'PostToolUse', 'prompt-submit': 'UserPromptSubmit', diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 030e0a9ee..21a8ccf10 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -550,6 +550,10 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + // Copilot emits SessionEnd after its final turn. Only the dashboard needs + // this lifecycle event; detaching it avoids delaying CLI shutdown. + { event: 'session-end', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + // ─── 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). diff --git a/src/hooks.ts b/src/hooks.ts index d5f8a32b5..5e39b9d7b 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -55,6 +55,7 @@ export const CLAUDE_TO_CURSOR_EVENTS: Record = { */ export const CLAUDE_TO_COPILOT_EVENTS: Record = { SessionStart: 'SessionStart', + SessionEnd: 'SessionEnd', Stop: 'Stop', UserPromptSubmit: 'UserPromptSubmit', PreToolUse: 'PreToolUse', @@ -364,12 +365,17 @@ function copilotPowershellCommand(command: string): string { } function toCopilotEntry(def: HookDef): CopilotHookEntry { + const matcher = def.source === 'builtin' + && def.event === 'PostToolUse' + && def.matcher === 'Skill' + ? 'skill' + : def.matcher; return { type: 'command', bash: def.command, powershell: copilotPowershellCommand(def.command), command: def.command, - ...(def.matcher && def.matcher !== '*' ? { matcher: def.matcher } : {}), + ...(matcher && matcher !== '*' ? { matcher } : {}), ...(def.timeout !== undefined ? { timeoutSec: def.timeout } : {}), }; } diff --git a/src/types.ts b/src/types.ts index 6b5976820..873cd67d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1064,7 +1064,7 @@ export interface SessionMetrics { export type DashboardSessionStatus = 'running' | 'waiting_for_input' | 'error' | 'idle' | 'stopped'; -export type DashboardEventType = 'session_start' | 'tool_use' | 'prompt_submit' | 'stop' | 'process_exit'; +export type DashboardEventType = 'session_start' | 'session_end' | 'tool_use' | 'prompt_submit' | 'stop' | 'process_exit'; export interface DashboardEvent { /** Event type mapped from hook event */ diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index c28910c15..a1cbf794b 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { log } from './utils/logger.js'; import { normalizeToolName } from './utils/tool-names.js'; import { + getCopilotHome, SKILL_NAME_REGEX, type UsageEvent, } from './types.js'; @@ -123,24 +124,29 @@ const SKILL_DIRS = [ '.openclaw/skills', '.hermes/skills', ]; +const PROJECT_SKILL_DIRS = [...SKILL_DIRS, '.github/skills']; /** * Check whether a skill actually exists on disk (has a SKILL.md in any tool's skills directory). * This prevents tracking phantom skills from typos or path inputs like "/data". * - * Performance: Checks at most 12 directories (6 user + 6 project) with a single stat() each — sub-millisecond. + * Performance: Checks a bounded list of user and project directories with one stat() each. */ export async function skillExistsOnDisk(skillName: string): Promise { const home = getUserHome(); + const userSkillDirs = [ + ...SKILL_DIRS.map((dir) => path.join(home, dir)), + path.join(getCopilotHome(), 'skills'), + ]; // Check user-level directories - for (const dir of SKILL_DIRS) { - const skillMd = path.join(home, dir, skillName, 'SKILL.md'); + for (const dir of userSkillDirs) { + const skillMd = path.join(dir, skillName, 'SKILL.md'); if (await pathExists(skillMd)) return true; } // Check project-level directories (cwd) const cwd = process.cwd(); if (path.resolve(cwd) !== path.resolve(home)) { - for (const dir of SKILL_DIRS) { + for (const dir of PROJECT_SKILL_DIRS) { const skillMd = path.join(cwd, dir, skillName, 'SKILL.md'); if (await pathExists(skillMd)) return true; } @@ -447,4 +453,4 @@ export async function trackSlashCommand(toolArg?: string): Promise { await appendUsageEvent(event); await updateKnownSkills(skillName); } -} \ No newline at end of file +} diff --git a/src/utils/session-id.ts b/src/utils/session-id.ts index 2f3868992..effe70b43 100644 --- a/src/utils/session-id.ts +++ b/src/utils/session-id.ts @@ -18,8 +18,9 @@ export interface DeriveSessionIdOptions { * * Priority: * 1. Explicit `session_id` field from the hook payload - * 2. `CLAUDE_SESSION_ID` environment variable - * 3. `pid-${process.ppid ?? process.pid}` (or `pid-${ppid}-${cwd}` when includeCwd is true) + * 2. Explicit `sessionId` field from camelCase hook payloads + * 3. `CLAUDE_SESSION_ID` environment variable + * 4. `pid-${process.ppid ?? process.pid}` (or `pid-${ppid}-${cwd}` when includeCwd is true) */ export function deriveSessionId( data: Record, @@ -29,6 +30,10 @@ export function deriveSessionId( return data.session_id; } + if (typeof data.sessionId === 'string' && data.sessionId) { + return data.sessionId; + } + if (process.env.CLAUDE_SESSION_ID) { return process.env.CLAUDE_SESSION_ID; } diff --git a/src/utils/tool-names.ts b/src/utils/tool-names.ts index e0efe78fa..8c3d53c61 100644 --- a/src/utils/tool-names.ts +++ b/src/utils/tool-names.ts @@ -16,6 +16,7 @@ const IDE_TO_CLI: Record = { web_fetch: 'WebFetch', read_file: 'Read', task: 'Task', + skill: 'Skill', }; export function normalizeToolName(name: string): string { From 60a68a39dd87d2bf5be86fa87d0b646c0f9f65f9 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:46:28 +0000 Subject: [PATCH 3/8] fix:read-real-copilot-usage --- src/__tests__/dashboard-collector.test.ts | 72 +++++++++++++++++++++++ src/dashboard-collector.ts | 42 +++++++++---- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index ca0c694d6..5be30849a 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -166,6 +166,46 @@ describe('parseHookEvent', () => { expect(JSON.stringify(event)).not.toContain('999999'); }); + it('finds Copilot shutdown usage when the real SessionEnd payload omits transcriptPath', async () => { + const sessionId = 'copilot-real-lifecycle'; + const copilotHome = path.join(tmpDir, '.copilot'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, JSON.stringify({ + type: 'session.shutdown', + data: { + tokenDetails: { + input: { tokenCount: 61 }, + output: { tokenCount: 7 }, + cache_read: { tokenCount: 43 }, + cache_write: { tokenCount: 2 }, + }, + }, + })); + process.env.COPILOT_HOME = copilotHome; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + reason: 'complete', + }), 'copilot'); + + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId, + tokens: { input: 61, output: 7, cacheRead: 43, cacheCreation: 2 }, + tokenScope: 'session', + })); + expect(event?.transcriptPath).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(transcript); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + it('skips an incomplete Copilot shutdown record and uses the valid final record', async () => { const transcript = path.join(tmpDir, 'copilot-in-flight.jsonl'); fs.writeFileSync(transcript, [ @@ -492,6 +532,38 @@ describe('parseHookEvent', () => { expect(event!.stoppedOutput).toBe('AI response here'); expect(event!.transcriptPath).toBe(transcriptPath); }); + + it('captures aggregate tokens and request cost metrics from a Claude Stop transcript', async () => { + const transcriptPath = path.join(tmpDir, 'usage-transcript.jsonl'); + fs.writeFileSync(transcriptPath, JSON.stringify({ + type: 'assistant', + timestamp: '2026-09-19T12:00:00Z', + message: { + id: 'usage-message', + model: 'claude-sonnet-5', + usage: { + input_tokens: 13, + output_tokens: 5, + cache_read_input_tokens: 8, + cache_creation_input_tokens: 3, + }, + content: [{ type: 'text', text: 'done' }], + }, + }) + '\n'); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'Stop', + session_id: 'sess-usage', + transcript_path: transcriptPath, + }), 'claude'); + + expect(event?.tokens).toEqual({ input: 13, output: 5, cacheRead: 8, cacheCreation: 3 }); + expect(event?.requestMetrics).toMatchObject({ + pricedRequests: 1, + cacheReadTokens: 8, + cacheEligibleInputTokens: 24, + }); + }); }); describe('local request log', () => { diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 94369b178..f4a98aefe 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -22,6 +22,7 @@ import { stripInjectedPrompt, TRANSCRIPT_REJECT_MARKERS, COPILOT_TOOL_ID, + getCopilotHome, emptyTokenUsage, addTokenUsage, type DashboardEvent, @@ -540,6 +541,9 @@ const COPILOT_USAGE_TAIL_BYTES = 256 * 1024; const COPILOT_USAGE_MAX_ATTEMPTS = 8; const COPILOT_USAGE_RETRY_MS = 250; const COPILOT_SHUTDOWN_EVENT = 'session.shutdown'; +const COPILOT_SESSION_STATE_DIR = 'session-state'; +const COPILOT_SESSION_EVENTS_FILE = 'events.jsonl'; +const COPILOT_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; interface CopilotUsageObservation { shutdownObserved: boolean; @@ -614,6 +618,24 @@ async function waitForCopilotShutdownUsage( return { shutdownObserved: false }; } +/** Resolve Copilot's local event log without accepting path traversal via sessionId. */ +function resolveCopilotUsageTranscript( + hookData: Record, + sessionId: string, +): string | null { + const supplied = typeof hookData.transcript_path === 'string' + ? hookData.transcript_path + : typeof hookData.transcriptPath === 'string' ? hookData.transcriptPath : null; + if (supplied) return supplied; + if (!COPILOT_SESSION_ID_RE.test(sessionId) || sessionId === '.' || sessionId === '..') return null; + return path.join( + getCopilotHome(), + COPILOT_SESSION_STATE_DIR, + sessionId, + COPILOT_SESSION_EVENTS_FILE, + ); +} + /** * Read a CodeBuddy `index.json` transcript once. CodeBuddy's schema differs from * Claude Code: @@ -1005,12 +1027,7 @@ export async function parseHookEvent( if (eventType === 'session_start') { const ppid = process.ppid ?? process.pid; if (ppid > 1) { - try { - event.monitorPid = resolveMonitorPid(ppid); - } catch { - // PID resolution failed — fall back to ppid - event.monitorPid = ppid; - } + event.monitorPid = resolveMonitorPid(ppid); } } @@ -1071,11 +1088,14 @@ export async function parseHookEvent( // Copilot's session log contains prompts, tool arguments, assistant output, // and auth-bearing request metadata. Read only the final shutdown token // counters and never persist the path or any transcript content. - if (eventType === 'session_end' && isCopilot && typeof hookData.transcript_path === 'string') { - const usage = await waitForCopilotShutdownUsage(hookData.transcript_path); - if (usage.tokens) { - event.tokens = usage.tokens; - event.tokenScope = 'session'; + if (eventType === 'session_end' && isCopilot) { + const transcriptPath = resolveCopilotUsageTranscript(hookData, sessionId); + if (transcriptPath) { + const usage = await waitForCopilotShutdownUsage(transcriptPath); + if (usage.tokens) { + event.tokens = usage.tokens; + event.tokenScope = 'session'; + } } } From 557d939cf9326bba1a0ec2256fdbfbdf8824b735 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:58:31 +0000 Subject: [PATCH 4/8] fix:lock-down-copilot-telemetry --- src/__tests__/dashboard-collector.test.ts | 176 +++++++++++++++++----- src/__tests__/hook-dispatch-cli.test.ts | 10 ++ src/dashboard-collector.ts | 29 ++-- src/hook-dispatch-cli.ts | 10 +- 4 files changed, 173 insertions(+), 52 deletions(-) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 5be30849a..ffcb6f2c7 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -124,8 +124,12 @@ describe('parseHookEvent', () => { }); it('reads only final Copilot token totals and redacts transcript content', async () => { - const transcript = path.join(tmpDir, 'copilot-events.jsonl'); + const sessionId = 'copilot-stable-session'; + const copilotHome = path.join(tmpDir, '.copilot-token-totals'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; const secret = 'TOP-SECRET-COPILOT-PROMPT'; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); fs.writeFileSync(transcript, [ JSON.stringify({ type: 'session.usage_checkpoint', @@ -145,25 +149,31 @@ describe('parseHookEvent', () => { }, }), ].join('\n')); + process.env.COPILOT_HOME = copilotHome; - const event = await parseHookEvent(JSON.stringify({ - hook_event_name: 'SessionEnd', - session_id: 'copilot-stable-session', - transcript_path: transcript, - }), 'copilot'); + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + session_id: sessionId, + transcript_path: path.join(tmpDir, 'ignored-supplied-path.jsonl'), + }), 'copilot'); - expect(event).toEqual(expect.objectContaining({ - type: 'session_end', - sessionId: 'copilot-stable-session', - tool: 'copilot', - tokens: { input: 101, output: 29, cacheRead: 17, cacheCreation: 3 }, - tokenScope: 'session', - })); - expect(event?.transcriptPath).toBeUndefined(); - expect(event?.stoppedOutput).toBeUndefined(); - expect(JSON.stringify(event)).not.toContain(secret); - expect(JSON.stringify(event)).not.toContain(transcript); - expect(JSON.stringify(event)).not.toContain('999999'); + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId, + tool: 'copilot', + tokens: { input: 101, output: 29, cacheRead: 17, cacheCreation: 3 }, + tokenScope: 'session', + })); + expect(event?.transcriptPath).toBeUndefined(); + expect(event?.stoppedOutput).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(secret); + expect(JSON.stringify(event)).not.toContain(transcript); + expect(JSON.stringify(event)).not.toContain('999999'); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } }); it('finds Copilot shutdown usage when the real SessionEnd payload omits transcriptPath', async () => { @@ -206,8 +216,63 @@ describe('parseHookEvent', () => { } }); + it('ignores a supplied Copilot transcript outside the validated session-state path', async () => { + const sessionId = 'copilot-contained-session'; + const copilotHome = path.join(tmpDir, '.copilot-contained'); + const safeTranscript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const outsiderTranscript = path.join(tmpDir, 'unrelated-sensitive.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(safeTranscript), { recursive: true }); + fs.writeFileSync(safeTranscript, JSON.stringify({ + type: 'session.shutdown', + data: { reason: 'complete' }, + })); + fs.writeFileSync(outsiderTranscript, JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 999_999 } } }, + })); + process.env.COPILOT_HOME = copilotHome; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + transcript_path: outsiderTranscript, + }), 'copilot'); + + expect(event?.tokens).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(outsiderTranscript); + expect(JSON.stringify(event)).not.toContain('999999'); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not resolve Copilot usage for a traversal session ID', async () => { + const outsiderTranscript = path.join(tmpDir, 'traversal-sensitive.jsonl'); + fs.writeFileSync(outsiderTranscript, JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 999_999 } } }, + })); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId: '../escape', + transcript_path: outsiderTranscript, + }), 'copilot'); + + expect(event?.tokens).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain(outsiderTranscript); + expect(JSON.stringify(event)).not.toContain('999999'); + }); + it('skips an incomplete Copilot shutdown record and uses the valid final record', async () => { - const transcript = path.join(tmpDir, 'copilot-in-flight.jsonl'); + const sessionId = 'copilot-in-flight'; + const copilotHome = path.join(tmpDir, '.copilot-in-flight'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); fs.writeFileSync(transcript, [ '{"type":"session.shutdown",', JSON.stringify({ @@ -215,32 +280,73 @@ describe('parseHookEvent', () => { data: { tokenDetails: { input: { tokenCount: 7 }, output: { tokenCount: 2 } } }, }), ].join('\n')); + process.env.COPILOT_HOME = copilotHome; - const event = await parseHookEvent(JSON.stringify({ - hook_event_name: 'SessionEnd', - session_id: 'copilot-in-flight', - transcript_path: transcript, - }), 'copilot'); + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + session_id: sessionId, + }), 'copilot'); - expect(event?.tokens).toEqual({ input: 7, output: 2, cacheRead: 0, cacheCreation: 0 }); + expect(event?.tokens).toEqual({ input: 7, output: 2, cacheRead: 0, cacheCreation: 0 }); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } }); it('keeps Copilot SessionEnd when final token details are unavailable', async () => { - const transcript = path.join(tmpDir, 'copilot-no-tokens.jsonl'); + const sessionId = 'copilot-camel-session'; + const copilotHome = path.join(tmpDir, '.copilot-no-tokens'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); fs.writeFileSync(transcript, JSON.stringify({ type: 'session.shutdown', data: { reason: 'complete' }, })); - const event = await parseHookEvent(JSON.stringify({ - hook_event_name: 'SessionEnd', - sessionId: 'copilot-camel-session', - transcript_path: transcript, - }), 'copilot'); - expect(event).toEqual(expect.objectContaining({ - type: 'session_end', - sessionId: 'copilot-camel-session', + process.env.COPILOT_HOME = copilotHome; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + expect(event).toEqual(expect.objectContaining({ + type: 'session_end', + sessionId, + })); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not store an all-zero snapshot for empty Copilot token details', async () => { + const sessionId = 'copilot-empty-token-details'; + const copilotHome = path.join(tmpDir, '.copilot-empty-token-details'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: {} }, })); - expect(event?.tokens).toBeUndefined(); + process.env.COPILOT_HOME = copilotHome; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + + expect(event?.tokens).toBeUndefined(); + expect(event?.tokenScope).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } }); it('keeps a missing Copilot transcript path out of events and debug logs', async () => { diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index 0663bfe2c..880531618 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -23,6 +23,16 @@ describe('parseStdin', () => { expect(result.hook_event_name).toBe('Stop'); }); + it('never writes malformed hook body fragments to debug logs', () => { + const secret = 'ghp_sensitive_hook_fragment'; + parseStdin(`{"prompt":"${secret}`, 'user-prompt-submit'); + + const debugOutput = vi.mocked(log.debug).mock.calls.flat().join('\n'); + expect(debugOutput).toContain('failed to parse STDIN JSON'); + expect(debugOutput).not.toContain(secret); + expect(debugOutput).not.toContain('body='); + }); + it('returns an empty object (plus event name) for blank STDIN', () => { const result = parseStdin('', 'stop'); expect(result).toEqual({ hook_event_name: 'Stop' }); diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index f4a98aefe..9e7cb19ea 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -557,14 +557,26 @@ function parseCopilotShutdown(entry: unknown): CopilotUsageObservation { const details = asRecord(asRecord(record.data)?.tokenDetails); if (!details) return { shutdownObserved: true }; - const tokenCount = (bucket: string): number => toNum(asRecord(details[bucket])?.tokenCount); + const tokenCount = (bucket: string): number | undefined => { + const value = asRecord(details[bucket])?.tokenCount; + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : undefined; + }; + const input = tokenCount('input'); + const output = tokenCount('output'); + const cacheRead = tokenCount('cache_read'); + const cacheCreation = tokenCount('cache_write'); + if ([input, output, cacheRead, cacheCreation].every((value) => value === undefined)) { + return { shutdownObserved: true }; + } return { shutdownObserved: true, tokens: { - input: tokenCount('input'), - output: tokenCount('output'), - cacheRead: tokenCount('cache_read'), - cacheCreation: tokenCount('cache_write'), + input: input ?? 0, + output: output ?? 0, + cacheRead: cacheRead ?? 0, + cacheCreation: cacheCreation ?? 0, }, }; } @@ -620,13 +632,8 @@ async function waitForCopilotShutdownUsage( /** Resolve Copilot's local event log without accepting path traversal via sessionId. */ function resolveCopilotUsageTranscript( - hookData: Record, sessionId: string, ): string | null { - const supplied = typeof hookData.transcript_path === 'string' - ? hookData.transcript_path - : typeof hookData.transcriptPath === 'string' ? hookData.transcriptPath : null; - if (supplied) return supplied; if (!COPILOT_SESSION_ID_RE.test(sessionId) || sessionId === '.' || sessionId === '..') return null; return path.join( getCopilotHome(), @@ -1089,7 +1096,7 @@ export async function parseHookEvent( // and auth-bearing request metadata. Read only the final shutdown token // counters and never persist the path or any transcript content. if (eventType === 'session_end' && isCopilot) { - const transcriptPath = resolveCopilotUsageTranscript(hookData, sessionId); + const transcriptPath = resolveCopilotUsageTranscript(sessionId); if (transcriptPath) { const usage = await waitForCopilotShutdownUsage(transcriptPath); if (usage.tokens) { diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index f49b09146..6415d3672 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -348,14 +348,12 @@ export function parseStdin(raw: string, event: string): Record // Degrade instead of short-circuiting: handlers that depend on stdin // fields (votes-sync, contribute-check) self-skip when transcript_path // is absent, while background handlers that don't read stdin - // (version-check, etc.) still get to run. Include a bounded preview so - // concurrent STDIN corruption is diagnosable in debug.log. - const preview = raw.length > 160 - ? `${raw.slice(0, 80)}...${raw.slice(-80)}` - : raw; + // (version-check, etc.) still get to run. Hook payloads can contain + // prompts, credentials, and tool arguments, so diagnostics record only + // structural metadata and never any part of the raw body. log.debug( `hook-dispatch: failed to parse STDIN JSON for event=${event}` + - ` (len=${raw.length}, body=${JSON.stringify(preview)})`, + ` (len=${raw.length})`, ); stdin = salvageStdinFields(raw); } From 205943403829a18b83b3a1a0807ca0a1ca391133 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:14:09 +0000 Subject: [PATCH 5/8] fix: address Copilot telemetry review --- src/__tests__/dashboard-collector.test.ts | 60 +++++++++++++++++++++ src/dashboard-collector.ts | 66 +++++++++++++++++------ 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index ffcb6f2c7..c07af2adc 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -216,6 +216,66 @@ describe('parseHookEvent', () => { } }); + it('waits for a new Copilot shutdown record when a resumed session has an older one', async () => { + const sessionId = 'copilot-resumed-session'; + const copilotHome = path.join(tmpDir, '.copilot-resumed'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const appendDelayMs = 50; + const previousInputTokens = 11; + const currentInputTokens = 37; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: previousInputTokens } } }, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + const appendTimer = setTimeout(() => { + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: currentInputTokens } } }, + })}\n`); + }, appendDelayMs); + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + + expect(event?.tokens).toEqual({ + input: currentInputTokens, + output: 0, + cacheRead: 0, + cacheCreation: 0, + }); + } finally { + clearTimeout(appendTimer); + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not persist a path in Copilot cwd or its fallback session ID', async () => { + const sensitiveCwd = path.join(tmpDir, 'private-customer-project'); + const originalClaudeSessionId = process.env.CLAUDE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', + cwd: sensitiveCwd, + }), 'copilot'); + + expect(event?.cwd).toBeUndefined(); + expect(event?.sessionId).toMatch(/^pid-\d+$/); + expect(JSON.stringify(event)).not.toContain(sensitiveCwd); + } finally { + if (originalClaudeSessionId === undefined) delete process.env.CLAUDE_SESSION_ID; + else process.env.CLAUDE_SESSION_ID = originalClaudeSessionId; + } + }); + it('ignores a supplied Copilot transcript outside the validated session-state path', async () => { const sessionId = 'copilot-contained-session'; const copilotHome = path.join(tmpDir, '.copilot-contained'); diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 9e7cb19ea..ee525a694 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -547,9 +547,22 @@ const COPILOT_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; interface CopilotUsageObservation { shutdownObserved: boolean; + shutdownOffset?: number; tokens?: TokenUsage; } +function copilotUsageEquals( + first: CopilotUsageObservation, + second: CopilotUsageObservation, +): boolean { + if (first.shutdownObserved !== second.shutdownObserved) return false; + if (!first.tokens || !second.tokens) return first.tokens === second.tokens; + return first.tokens.input === second.tokens.input + && first.tokens.output === second.tokens.output + && first.tokens.cacheRead === second.tokens.cacheRead + && first.tokens.cacheCreation === second.tokens.cacheCreation; +} + /** Parse only Copilot's final aggregate token counters from a shutdown record. */ function parseCopilotShutdown(entry: unknown): CopilotUsageObservation { const record = asRecord(entry); @@ -594,17 +607,29 @@ async function readLatestCopilotUsageFromTail( try { const buffer = Buffer.alloc(readSize); await fh.read(buffer, 0, readSize, offset); - const lines = buffer.toString('utf-8').split('\n'); - if (offset > 0) lines.shift(); + let lineStart = 0; + if (offset > 0) { + const partialLineEnd = buffer.indexOf('\n'); + if (partialLineEnd < 0) return { shutdownObserved: false }; + lineStart = partialLineEnd + 1; + } let latest: CopilotUsageObservation = { shutdownObserved: false }; - for (const line of lines) { - if (!line.includes(`"${COPILOT_SHUTDOWN_EVENT}"`)) continue; - try { - const observed = parseCopilotShutdown(JSON.parse(line)); - if (observed.shutdownObserved) latest = observed; - } catch { - // The last line may still be in flight; the bounded retry sees it later. + while (lineStart < buffer.length) { + const newline = buffer.indexOf('\n', lineStart); + const lineEnd = newline < 0 ? buffer.length : newline; + const line = buffer.subarray(lineStart, lineEnd).toString('utf-8'); + if (line.includes(`"${COPILOT_SHUTDOWN_EVENT}"`)) { + try { + const observed = parseCopilotShutdown(JSON.parse(line)); + if (observed.shutdownObserved) { + latest = { ...observed, shutdownOffset: offset + lineEnd }; + } + } catch { + // The last line may still be in flight; the bounded retry sees it later. + } } + if (newline < 0) break; + lineStart = newline + 1; } return latest; } finally { @@ -619,15 +644,21 @@ async function readLatestCopilotUsageFromTail( /** Wait briefly because Copilot can append session.shutdown after SessionEnd. */ async function waitForCopilotShutdownUsage( transcriptPath: string, + initial: CopilotUsageObservation, ): Promise { - for (let attempt = 0; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { + let latest = initial; + for (let attempt = 1; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { + await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); const observed = await readLatestCopilotUsageFromTail(transcriptPath); - if (observed.shutdownObserved) return observed; - if (attempt < COPILOT_USAGE_MAX_ATTEMPTS - 1) { - await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); + if (!observed.shutdownObserved) continue; + latest = observed; + if (!initial.shutdownObserved + || observed.shutdownOffset !== initial.shutdownOffset + || !copilotUsageEquals(initial, observed)) { + return observed; } } - return { shutdownObserved: false }; + return latest; } /** Resolve Copilot's local event log without accepting path traversal via sessionId. */ @@ -1013,9 +1044,9 @@ export async function parseHookEvent( return null; } - const sessionId = deriveSessionId(hookData, { includeCwd: true }); - const cwd = resolveHookCwd(hookData); const isCopilot = tool.toLowerCase() === COPILOT_TOOL_ID; + const sessionId = deriveSessionId(hookData, { includeCwd: !isCopilot }); + const cwd = isCopilot ? undefined : resolveHookCwd(hookData); const event: DashboardEvent = { type: eventType, @@ -1098,7 +1129,8 @@ export async function parseHookEvent( if (eventType === 'session_end' && isCopilot) { const transcriptPath = resolveCopilotUsageTranscript(sessionId); if (transcriptPath) { - const usage = await waitForCopilotShutdownUsage(transcriptPath); + const initial = await readLatestCopilotUsageFromTail(transcriptPath); + const usage = await waitForCopilotShutdownUsage(transcriptPath, initial); if (usage.tokens) { event.tokens = usage.tokens; event.tokenScope = 'session'; From b5ecec933f582f4c79ef64371ca1f62089808b50 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sun, 20 Sep 2026 20:49:54 +0000 Subject: [PATCH 6/8] fix(copilot): keep paths out of session IDs The background dispatcher can inject a cwd-bearing fallback before the collector runs. Keep that fallback path-free for Copilot and reject path-like IDs at persistence. Preserve other providers' existing IDs. Refs #666 --- src/__tests__/dashboard-collector.test.ts | 8 ++++++++ src/__tests__/hook-dispatch-cli.test.ts | 21 ++++++++++++++++++++- src/dashboard-collector.ts | 6 +++++- src/hook-dispatch-cli.ts | 11 ++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index c07af2adc..097866295 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -270,6 +270,14 @@ describe('parseHookEvent', () => { expect(event?.cwd).toBeUndefined(); expect(event?.sessionId).toMatch(/^pid-\d+$/); expect(JSON.stringify(event)).not.toContain(sensitiveCwd); + + const pathIdEvent = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', + sessionId: sensitiveCwd, + cwd: sensitiveCwd, + }), 'copilot'); + expect(pathIdEvent?.sessionId).toMatch(/^pid-\d+$/); + expect(JSON.stringify(pathIdEvent)).not.toContain(sensitiveCwd); } finally { if (originalClaudeSessionId === undefined) delete process.env.CLAUDE_SESSION_ID; else process.env.CLAUDE_SESSION_ID = originalClaudeSessionId; diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index 880531618..9ba796140 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -9,9 +9,28 @@ vi.mock('node:child_process', async (importOriginal) => ({ spawn: mockSpawn, })); -const { parseStdin, trySpawnDetachedViaWmi } = await import('../hook-dispatch-cli.js'); +const { parseStdin, trySpawnDetachedViaWmi, deriveDispatchSessionId } = await import('../hook-dispatch-cli.js'); const { log } = await import('../utils/logger.js'); +describe('deriveDispatchSessionId', () => { + it('keeps a Copilot background fallback ID free of workspace paths', () => { + const cwd = path.join(os.tmpdir(), 'private-customer-project'); + const originalClaudeSessionId = process.env.CLAUDE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + try { + const copilotId = deriveDispatchSessionId({ cwd }, 'copilot'); + expect(copilotId).toMatch(/^pid-\d+$/); + expect(copilotId).not.toContain(cwd); + + // Other providers retain the existing fallback used to distinguish projects. + expect(deriveDispatchSessionId({ cwd }, 'claude')).toContain(cwd); + } finally { + if (originalClaudeSessionId === undefined) delete process.env.CLAUDE_SESSION_ID; + else process.env.CLAUDE_SESSION_ID = originalClaudeSessionId; + } + }); +}); + describe('parseStdin', () => { it('degrades malformed JSON to an empty object instead of null', () => { // RED BASELINE: before the fix this returns null (short-circuiting all diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index ee525a694..b6f0fb0f1 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -1045,7 +1045,11 @@ export async function parseHookEvent( } const isCopilot = tool.toLowerCase() === COPILOT_TOOL_ID; - const sessionId = deriveSessionId(hookData, { includeCwd: !isCopilot }); + const derivedSessionId = deriveSessionId(hookData, { includeCwd: !isCopilot }); + // Copilot IDs are persisted, so reject path-like IDs even when supplied directly. + const sessionId = isCopilot && !COPILOT_SESSION_ID_RE.test(derivedSessionId) + ? `pid-${process.ppid ?? process.pid}` + : derivedSessionId; const cwd = isCopilot ? undefined : resolveHookCwd(hookData); const event: DashboardEvent = { diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 6415d3672..d617f6e80 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -26,6 +26,7 @@ import { buildHandlerRegistry, filterHandlersForConfig } from './hook-handlers.j import { resolveHookCwd } from './utils/hook-cwd.js'; import { log, setStderrOnly } from './utils/logger.js'; import { deriveSessionId } from './utils/session-id.js'; +import { COPILOT_TOOL_ID } from './types.js'; /** * Max time to wait for STDIN EOF before proceeding with whatever was received. @@ -403,6 +404,14 @@ async function runDispatch( return result.output; } +/** Keep the detached Copilot fallback stable without persisting a workspace path. */ +export function deriveDispatchSessionId( + stdin: Record, + tool: string, +): string { + return deriveSessionId(stdin, { includeCwd: tool.toLowerCase() !== COPILOT_TOOL_ID }); +} + /** * Main CLI handler for hook-dispatch. * @@ -455,7 +464,7 @@ export async function hookDispatchCli( // this, hosts that omit session_id produce different PID-based IDs and // the foreground and post-pull paths can claim the same hint twice. if (typeof stdin.session_id !== 'string' || !stdin.session_id) { - stdin.session_id = deriveSessionId(stdin, { includeCwd: true }); + stdin.session_id = deriveDispatchSessionId(stdin, tool); } settling = spawnBackground(event, tool, matcher, JSON.stringify(stdin), cwd); } From 31bcd2846c2cb4eab534ac10ee5a612807a31973 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sun, 20 Sep 2026 21:12:15 +0000 Subject: [PATCH 7/8] fix(copilot): reject stale shutdown totals A resumed session can retain an older shutdown record. When no new record arrives after SessionEnd, leave tokens absent so the existing missing-token fallback applies. Keep SessionStart when PID lookup fails. Refs #666 --- src/__tests__/dashboard-collector.test.ts | 93 +++++++++++++++++------ src/dashboard-collector.ts | 12 ++- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 097866295..0a56e4e3f 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -16,6 +16,7 @@ import { dedupeEvents, } from '../dashboard-collector.js'; import type { DashboardEvent } from '../types.js'; +import * as pidMonitor from '../pid-monitor.js'; import { _resetState as resetLogger, _setLogFilePath } from '../utils/logger.js'; // ─── Transcript fixtures for intervention scanning ────── @@ -57,6 +58,13 @@ const TOOL_ERROR_LINE = JSON.stringify({ let tmpDir: string; let originalHome: string; +/** Simulate Copilot flushing its current shutdown after SessionEnd fires. */ +function appendCopilotShutdownLater(transcript: string, entry: object): NodeJS.Timeout { + return setTimeout(() => { + fs.appendFileSync(transcript, `\n${JSON.stringify(entry)}\n`); + }, 50); +} + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dashboard-test-')); originalHome = process.env.HOME ?? ''; @@ -85,6 +93,21 @@ describe('parseHookEvent', () => { expect(event!.cwd).toBe('/home/jeff/project'); }); + it('keeps SessionStart when monitor PID resolution fails', async () => { + const spy = vi.spyOn(pidMonitor, 'resolveMonitorPid').mockImplementation(() => { + throw new Error('PID lookup failed'); + }); + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', + session_id: 'pid-fallback-session', + }), 'claude'); + expect(event?.monitorPid).toBe(process.ppid); + } finally { + spy.mockRestore(); + } + }); + it('parses PostToolUse event with tool_name', async () => { const raw = JSON.stringify({ hook_event_name: 'PostToolUse', @@ -136,19 +159,19 @@ describe('parseHookEvent', () => { data: { prompt: secret, inputTokens: 999_999, request: { authorization: secret } }, }), JSON.stringify({ type: 'assistant.message', data: { content: secret } }), - JSON.stringify({ - type: 'session.shutdown', - data: { - tokenDetails: { - input: { tokenCount: 101 }, - output: { tokenCount: 29 }, - cache_read: { tokenCount: 17 }, - cache_write: { tokenCount: 3 }, - }, - prompt: secret, - }, - }), ].join('\n')); + const appendTimer = appendCopilotShutdownLater(transcript, { + type: 'session.shutdown', + data: { + tokenDetails: { + input: { tokenCount: 101 }, + output: { tokenCount: 29 }, + cache_read: { tokenCount: 17 }, + cache_write: { tokenCount: 3 }, + }, + prompt: secret, + }, + }); process.env.COPILOT_HOME = copilotHome; try { @@ -171,6 +194,7 @@ describe('parseHookEvent', () => { expect(JSON.stringify(event)).not.toContain(transcript); expect(JSON.stringify(event)).not.toContain('999999'); } finally { + clearTimeout(appendTimer); if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = originalCopilotHome; } @@ -182,7 +206,8 @@ describe('parseHookEvent', () => { const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); const originalCopilotHome = process.env.COPILOT_HOME; fs.mkdirSync(path.dirname(transcript), { recursive: true }); - fs.writeFileSync(transcript, JSON.stringify({ + fs.writeFileSync(transcript, ''); + const appendTimer = appendCopilotShutdownLater(transcript, { type: 'session.shutdown', data: { tokenDetails: { @@ -192,7 +217,7 @@ describe('parseHookEvent', () => { cache_write: { tokenCount: 2 }, }, }, - })); + }); process.env.COPILOT_HOME = copilotHome; try { @@ -211,6 +236,7 @@ describe('parseHookEvent', () => { expect(event?.transcriptPath).toBeUndefined(); expect(JSON.stringify(event)).not.toContain(transcript); } finally { + clearTimeout(appendTimer); if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = originalCopilotHome; } @@ -256,6 +282,30 @@ describe('parseHookEvent', () => { } }); + it('uses the missing-token fallback when no new Copilot shutdown arrives', async () => { + const sessionId = 'copilot-resumed-without-shutdown'; + const copilotHome = path.join(tmpDir, '.copilot-stale'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 11 } } }, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + it('does not persist a path in Copilot cwd or its fallback session ID', async () => { const sensitiveCwd = path.join(tmpDir, 'private-customer-project'); const originalClaudeSessionId = process.env.CLAUDE_SESSION_ID; @@ -341,13 +391,11 @@ describe('parseHookEvent', () => { const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); const originalCopilotHome = process.env.COPILOT_HOME; fs.mkdirSync(path.dirname(transcript), { recursive: true }); - fs.writeFileSync(transcript, [ - '{"type":"session.shutdown",', - JSON.stringify({ - type: 'session.shutdown', - data: { tokenDetails: { input: { tokenCount: 7 }, output: { tokenCount: 2 } } }, - }), - ].join('\n')); + fs.writeFileSync(transcript, '{"type":"session.shutdown",'); + const appendTimer = appendCopilotShutdownLater(transcript, { + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 7 }, output: { tokenCount: 2 } } }, + }); process.env.COPILOT_HOME = copilotHome; try { @@ -358,6 +406,7 @@ describe('parseHookEvent', () => { expect(event?.tokens).toEqual({ input: 7, output: 2, cacheRead: 0, cacheCreation: 0 }); } finally { + clearTimeout(appendTimer); if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = originalCopilotHome; } diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index b6f0fb0f1..d0407708d 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -646,19 +646,18 @@ async function waitForCopilotShutdownUsage( transcriptPath: string, initial: CopilotUsageObservation, ): Promise { - let latest = initial; for (let attempt = 1; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); const observed = await readLatestCopilotUsageFromTail(transcriptPath); if (!observed.shutdownObserved) continue; - latest = observed; if (!initial.shutdownObserved || observed.shutdownOffset !== initial.shutdownOffset || !copilotUsageEquals(initial, observed)) { return observed; } } - return latest; + // A prior shutdown belongs to an earlier run when no new record arrives. + return { shutdownObserved: false }; } /** Resolve Copilot's local event log without accepting path traversal via sessionId. */ @@ -1069,7 +1068,12 @@ export async function parseHookEvent( if (eventType === 'session_start') { const ppid = process.ppid ?? process.pid; if (ppid > 1) { - event.monitorPid = resolveMonitorPid(ppid); + try { + event.monitorPid = resolveMonitorPid(ppid); + } catch { + // PID lookup failure must not discard the session lifecycle event. + event.monitorPid = ppid; + } } } From a74c96234994fd2c6e31d825a47845f3489acc1a Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sun, 20 Sep 2026 21:22:27 +0000 Subject: [PATCH 8/8] fix(copilot): distinguish resumed shutdowns --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/dashboard-collector.test.ts | 670 +++++++++++++++++++++- src/__tests__/hook-dispatch-cli.test.ts | 48 +- src/dashboard-collector.ts | 240 +++++++- src/types.ts | 5 + 6 files changed, 943 insertions(+), 24 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a6cb938e8..34aa99a87 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1444,7 +1444,7 @@ GitHub Copilot CLI is supported for its official custom-instructions, Rules, Ski - **Custom agents.** Team agents become official `.agent.md` profiles under `$COPILOT_HOME/agents/` or `.github/agents/`. TeamAI maps compatible tool names onto Copilot's primary aliases, preserves Copilot-only frontmatter through `tool_extras.copilot`, and removes only profiles that match team agents or the built-in recall profile. User-authored profiles remain untouched. See [GitHub's custom-agent configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration). - **Team Context recall.** The built-in `teamai-recall.agent.md` profile receives only `execute`, `read`, and `search`. It invokes the existing `teamai recall` pipeline, so Copilot can retrieve learnings, codebase evidence, and teamwiki results without copying or creating a second knowledge store. - **Docs and Env.** Team docs sync to the configured local docs directory (`~/.teamai/docs` by default, or the project-relative equivalent in project scope). Team env values sync to the scope's managed `env.sh`; launch Copilot from a shell that has sourced that file. TeamAI does not copy environment values into Copilot configuration. -- **Hooks and private telemetry.** TeamAI writes a dedicated version-1 hook file at `$COPILOT_HOME/hooks/teamai.json` or `.github/hooks/teamai.json`. It uses Copilot's VS Code-compatible PascalCase events (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, and `SessionEnd`) so hook payloads retain the snake_case fields consumed by TeamAI, and emits `bash`, `powershell`, and fallback `command` fields. Session IDs, skill usage, prompt counts, lifecycle state, and final token totals feed the local dashboard. Copilot prompt text, assistant output, transcript paths, and request metadata are never stored; if final token counters are absent, the session is still recorded without token data. The file is reconciled idempotently while preserving unrelated entries. TeamAI never edits Copilot's `settings.json`. +- **Hooks and private telemetry.** TeamAI writes a dedicated version-1 hook file at `$COPILOT_HOME/hooks/teamai.json` or `.github/hooks/teamai.json`. It uses Copilot's VS Code-compatible PascalCase events (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, and `SessionEnd`) so hook payloads retain the snake_case fields consumed by TeamAI, and emits `bash`, `powershell`, and fallback `command` fields. Session IDs, skill usage, prompt counts, lifecycle state, and final token totals feed the local dashboard. Copilot prompt text, assistant output, transcript paths, and request metadata are never stored; if final token counters are absent, the session is still recorded without token data. For resumed sessions, TeamAI records a path-free log byte boundary at SessionStart and captures a marker already present only when it is neither closed nor claimed by the previous run. Shutdown counters must link to that marker or to one written after the boundary. If a marker appears only after SessionStart and SessionEnd has no provider timestamp, its run cannot be proven and the session remains recorded without token data. The file is reconciled idempotently while preserving unrelated entries. TeamAI never edits Copilot's `settings.json`. - **MCP.** `teamai pull` and `teamai mcp inject` merge local and remote servers into `$COPILOT_HOME/mcp-config.json` or `.github/mcp.json` using Copilot's native schema. TeamAI tracks ownership outside the Copilot file, so repeated pulls are idempotent and `mcp remove` or uninstall removes only TeamAI-owned entries. Hand-authored servers and `settings.json` remain unchanged. Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in the team repository and use the normal pull/push workflow. TeamAI does not reverse-import arbitrary native hook entries from a Copilot configuration file. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a47d33cec..9eb3f587b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1404,7 +1404,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **自定义 Agents。** 团队 Agents 会转换为 `$COPILOT_HOME/agents/` 或 `.github/agents/` 下的官方 `.agent.md` 配置。TeamAI 将兼容的工具名映射为 Copilot 主别名,通过 `tool_extras.copilot` 保留 Copilot 专属 frontmatter,并且只删除与团队 Agent 或内置 recall 配置匹配的文件;用户自建配置保持不变。详见 [GitHub 自定义 Agent 配置](https://docs.github.com/zh/copilot/reference/custom-agents-configuration)。 - **Team Context recall。** 内置 `teamai-recall.agent.md` 只获得 `execute`、`read` 和 `search`。它调用现有的 `teamai recall` 流程,让 Copilot 检索 learnings、codebase 证据和 teamwiki 结果,而不会复制或创建第二套知识库。 - **Docs 和 Env。** 团队 Docs 同步到配置的本地文档目录(默认 `~/.teamai/docs`;project scope 使用项目内对应路径)。团队环境变量同步到该作用域由 TeamAI 管理的 `env.sh`;请从已 source 此文件的 shell 启动 Copilot。TeamAI 不会把环境变量值复制到 Copilot 配置中。 -- **Hooks 与隐私遥测。** TeamAI 在 `$COPILOT_HOME/hooks/teamai.json` 或 `.github/hooks/teamai.json` 写入独立的 version-1 Hook 文件,使用 Copilot 与 VS Code 兼容的 PascalCase 事件(`SessionStart`、`UserPromptSubmit`、`PostToolUse`、`Stop` 和 `SessionEnd`),从而保留 TeamAI 所需的 snake_case Hook 负载字段,并生成 `bash`、`powershell` 和后备 `command` 字段。会话 ID、Skill 使用、提示次数、生命周期状态和最终 Token 总数会进入本地 Dashboard;Copilot 提示原文、助手输出、Transcript 路径和请求元数据绝不会被保存。若最终 Token 计数不可用,会话仍会被记录,但不包含 Token 数据。文件会被幂等合并,且保留无关条目。TeamAI 从不修改 Copilot 的 `settings.json`。 +- **Hooks 与隐私遥测。** TeamAI 在 `$COPILOT_HOME/hooks/teamai.json` 或 `.github/hooks/teamai.json` 写入独立的 version-1 Hook 文件,使用 Copilot 与 VS Code 兼容的 PascalCase 事件(`SessionStart`、`UserPromptSubmit`、`PostToolUse`、`Stop` 和 `SessionEnd`),从而保留 TeamAI 所需的 snake_case Hook 负载字段,并生成 `bash`、`powershell` 和后备 `command` 字段。会话 ID、Skill 使用、提示次数、生命周期状态和最终 Token 总数会进入本地 Dashboard;Copilot 提示原文、助手输出、Transcript 路径和请求元数据绝不会被保存。若最终 Token 计数不可用,会话仍会被记录,但不包含 Token 数据。对于恢复的会话,TeamAI 在 SessionStart 时保存不含路径的日志字节边界;只有此前的运行标记尚未关闭、且未被上次运行使用时,才会采纳该标记。关闭计数必须关联这个标记或边界之后写入的标记。若标记仅在 SessionStart 之后出现,而 SessionEnd 没有提供方时间戳,则无法确认它属于本次运行;会话仍会被记录,但不包含 Token 数据。文件会被幂等合并,且保留无关条目。TeamAI 从不修改 Copilot 的 `settings.json`。 - **MCP。** `teamai pull` 和 `teamai mcp inject` 使用 Copilot 原生结构,把本地与远程 Server 合并到 `$COPILOT_HOME/mcp-config.json` 或 `.github/mcp.json`。归属信息保存在 Copilot 文件之外,因此重复 pull 保持幂等,`mcp remove` 或卸载只会移除 TeamAI 管理的条目;手写 Server 与 `settings.json` 均保持不变。 团队 Hooks 仍以团队仓库中的 `hooks/hooks.yaml` 为来源:直接编辑该文件,再使用正常的 pull/push 流程。TeamAI 不会从 Copilot 配置文件反向导入任意原生 Hook 条目。 diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 0a56e4e3f..1a341603d 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -65,6 +65,18 @@ function appendCopilotShutdownLater(transcript: string, entry: object): NodeJS.T }, 50); } +function writeResumedCopilotLog(transcript: string, oldInputTokens: number): void { + fs.writeFileSync(transcript, [ + JSON.stringify({ + type: 'session.shutdown', id: 'old-shutdown', + data: { tokenDetails: { input: { tokenCount: oldInputTokens } } }, + }), + JSON.stringify({ + type: 'session.resume', id: 'current-resume', parentId: 'old-shutdown', + }), + ].join('\n') + '\n'); +} + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dashboard-test-')); originalHome = process.env.HOME ?? ''; @@ -242,23 +254,118 @@ describe('parseHookEvent', () => { } }); + it('keeps token collection for a session started before the collector upgrade', async () => { + const sessionId = 'copilot-preupgrade-start'; + const copilotHome = path.join(tmpDir, '.copilot-preupgrade'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, ''); + process.env.COPILOT_HOME = copilotHome; + await appendEvent({ + type: 'session_start', timestamp: new Date().toISOString(), + sessionId, tool: 'copilot', + }); + const appendTimer = appendCopilotShutdownLater(transcript, { + type: 'session.shutdown', id: 'current-shutdown', + data: { tokenDetails: { input: { tokenCount: 61 } } }, + }); + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(event?.tokens?.input).toBe(61); + } finally { + clearTimeout(appendTimer); + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not reuse unchanged shutdown totals for a pre-upgrade start', async () => { + const sessionId = 'copilot-preupgrade-stale-shutdown'; + const copilotHome = path.join(tmpDir, '.copilot-preupgrade-stale'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'old-shutdown', + data: { tokenDetails: { input: { tokenCount: 11 } } }, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + await appendEvent({ + type: 'session_start', timestamp: new Date().toISOString(), + sessionId, tool: 'copilot', + }); + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(end?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('reads a shutdown after a long private log through the bounded tail', async () => { + const sessionId = 'copilot-large-legacy-tail'; + const copilotHome = path.join(tmpDir, '.copilot-large-tail'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, [ + JSON.stringify({ type: 'session.message', data: { content: 'x'.repeat(300 * 1024) } }), + JSON.stringify({ + type: 'session.shutdown', id: 'old-shutdown', + data: { tokenDetails: { input: { tokenCount: 11 } } }, + }), + ].join('\n') + '\n'); + process.env.COPILOT_HOME = copilotHome; + const appendTimer = appendCopilotShutdownLater(transcript, { + type: 'session.shutdown', id: 'new-shutdown', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }); + + try { + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(end?.tokens?.input).toBe(37); + } finally { + clearTimeout(appendTimer); + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + it('waits for a new Copilot shutdown record when a resumed session has an older one', async () => { const sessionId = 'copilot-resumed-session'; const copilotHome = path.join(tmpDir, '.copilot-resumed'); const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); const originalCopilotHome = process.env.COPILOT_HOME; const appendDelayMs = 50; + const startAt = Date.now() - 1000; + const markerAt = Date.now() - 500; const previousInputTokens = 11; const currentInputTokens = 37; fs.mkdirSync(path.dirname(transcript), { recursive: true }); - fs.writeFileSync(transcript, `${JSON.stringify({ - type: 'session.shutdown', - data: { tokenDetails: { input: { tokenCount: previousInputTokens } } }, - })}\n`); + writeResumedCopilotLog(transcript, previousInputTokens); process.env.COPILOT_HOME = copilotHome; + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: startAt, + }), 'copilot'); + expect(start?.copilotRunStartOffset).toBe(fs.statSync(transcript).size); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.resume', id: 'new-resume', parentId: 'current-resume', + timestamp: markerAt, + })}\n`); const appendTimer = setTimeout(() => { fs.appendFileSync(transcript, `${JSON.stringify({ - type: 'session.shutdown', + type: 'session.shutdown', id: 'current-shutdown', parentId: 'new-resume', data: { tokenDetails: { input: { tokenCount: currentInputTokens } } }, })}\n`); }, appendDelayMs); @@ -266,7 +373,7 @@ describe('parseHookEvent', () => { try { const event = await parseHookEvent(JSON.stringify({ hook_event_name: 'SessionEnd', - sessionId, + sessionId, timestamp: Date.now(), }), 'copilot'); expect(event?.tokens).toEqual({ @@ -282,22 +389,210 @@ describe('parseHookEvent', () => { } }); + it('accepts Copilot shutdown flushed before SessionEnd reads the log', async () => { + const sessionId = 'copilot-preflushed-current'; + const copilotHome = path.join(tmpDir, '.copilot-preflushed'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + writeResumedCopilotLog(transcript, 11); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', + sessionId, + }), 'copilot'); + expect(start?.copilotRunMarkerId).toBe('current-resume'); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'current-resume', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + })}\n`); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + expect(event?.tokens).toEqual({ + input: 37, output: 0, cacheRead: 0, cacheCreation: 0, + }); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + it('uses the missing-token fallback when no new Copilot shutdown arrives', async () => { const sessionId = 'copilot-resumed-without-shutdown'; const copilotHome = path.join(tmpDir, '.copilot-stale'); const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); const originalCopilotHome = process.env.COPILOT_HOME; fs.mkdirSync(path.dirname(transcript), { recursive: true }); + writeResumedCopilotLog(transcript, 11); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + expect(start?.copilotRunStartOffset).toBe(fs.statSync(transcript).size); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.resume', id: 'new-resume', parentId: 'current-resume', + })}\n`); + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, + }), 'copilot'); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not infer the next run from a late marker without provider time', async () => { + const sessionId = 'copilot-out-of-order-end'; + const copilotHome = path.join(tmpDir, '.copilot-out-of-order'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + writeResumedCopilotLog(transcript, 11); + process.env.COPILOT_HOME = copilotHome; + + try { + const oldEndTime = new Date(Date.now() + 1000).toISOString(); + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(start!); + await appendEvent({ + type: 'session_end', timestamp: oldEndTime, sessionId, tool: 'copilot', + }); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.resume', id: 'new-resume', parentId: 'current-resume', + })}\n`); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'new-resume', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + })}\n`); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('rejects a prior unclaimed marker when its shutdown arrives after resume', async () => { + const sessionId = 'copilot-unclaimed-prior-marker'; + const copilotHome = path.join(tmpDir, '.copilot-unclaimed-marker'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'old-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + await appendEvent({ + type: 'session_start', timestamp: new Date(Date.now() - 2000).toISOString(), + sessionId, tool: 'copilot', + }); + await appendEvent({ + type: 'session_end', timestamp: new Date(Date.now() - 1000).toISOString(), + sessionId, tool: 'copilot', + }); + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + expect(start?.copilotRunMarkerId).toBeUndefined(); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'old-shutdown', parentId: 'old-start', + data: { tokenDetails: { input: { tokenCount: 999 } } }, + })}\n`); + + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(end?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('rejects a still-open marker already claimed by the prior run', async () => { + const sessionId = 'copilot-reused-open-marker'; + const copilotHome = path.join(tmpDir, '.copilot-reused-open-marker'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'old-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + await appendEvent({ + type: 'session_start', timestamp: new Date(Date.now() - 2000).toISOString(), + sessionId, tool: 'copilot', + copilotRunMarkerId: 'old-start', copilotRunMarkerOffset: 0, + }); + await appendEvent({ + type: 'session_end', timestamp: new Date(Date.now() - 1000).toISOString(), + sessionId, tool: 'copilot', + }); + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + expect(start?.copilotRunMarkerId).toBeUndefined(); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'old-shutdown', parentId: 'old-start', + data: { tokenDetails: { input: { tokenCount: 999 } } }, + })}\n`); + + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(end?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('ignores an old shutdown line completed after SessionStart', async () => { + const sessionId = 'copilot-partial-old-shutdown'; + const copilotHome = path.join(tmpDir, '.copilot-partial-old'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const line = JSON.stringify({ type: 'session.shutdown', data: { tokenDetails: { input: { tokenCount: 11 } } }, - })}\n`); + }); + const split = Math.floor(line.length / 2); + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, line.slice(0, split)); process.env.COPILOT_HOME = copilotHome; try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + expect(start?.copilotRunMarkerId).toBeUndefined(); + await appendEvent(start!); + fs.appendFileSync(transcript, `${line.slice(split)}\n`); + const event = await parseHookEvent(JSON.stringify({ - hook_event_name: 'SessionEnd', - sessionId, + hook_event_name: 'SessionEnd', sessionId, }), 'copilot'); expect(event?.tokens).toBeUndefined(); } finally { @@ -306,6 +601,363 @@ describe('parseHookEvent', () => { } }); + it('does not attribute a delayed prior-run shutdown to a resumed run', async () => { + const sessionId = 'copilot-delayed-prior-shutdown'; + const copilotHome = path.join(tmpDir, '.copilot-delayed-prior'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, [ + JSON.stringify({ type: 'session.shutdown', id: 'old-shutdown', + data: { tokenDetails: { input: { tokenCount: 11 } } } }), + JSON.stringify({ type: 'session.resume', id: 'current-resume', parentId: 'old-shutdown' }), + ].join('\n') + '\n'); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(start!); + fs.appendFileSync(transcript, `${JSON.stringify({ + type: 'session.shutdown', id: 'old-delayed', parentId: 'old-checkpoint', + data: { tokenDetails: { input: { tokenCount: 999 } } }, + })}\n`); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not assign a later resumed run to an older detached SessionEnd', async () => { + const sessionId = 'copilot-new-run-after-old-end'; + const copilotHome = path.join(tmpDir, '.copilot-new-run'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'old-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + const oldStartAt = new Date(Date.now() - 3000).toISOString(); + const oldEndAt = new Date(Date.now() - 2000).toISOString(); + const newStartAt = new Date().toISOString(); + const oldStart = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: oldStartAt, + }), 'copilot'); + await appendEvent(oldStart!); + const newMarkerOffset = fs.statSync(transcript).size; + fs.appendFileSync(transcript, [ + JSON.stringify({ type: 'session.resume', id: 'new-resume', parentId: 'old-start' }), + JSON.stringify({ + type: 'session.shutdown', id: 'new-shutdown', parentId: 'new-resume', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + await appendEvent({ + type: 'session_start', timestamp: newStartAt, sessionId, tool: 'copilot', + copilotRunMarkerId: 'new-resume', copilotRunMarkerOffset: newMarkerOffset, + }); + + const oldEnd = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: oldEndAt, + }), 'copilot'); + expect(oldEnd?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('drops ambiguous Copilot totals when a delayed End has no provider timestamp', async () => { + const sessionId = 'copilot-delayed-end-no-timestamp'; + const copilotHome = path.join(tmpDir, '.copilot-delayed-end-no-timestamp'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'old-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + const oldStart = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(oldStart!); + fs.appendFileSync(transcript, [ + JSON.stringify({ type: 'session.resume', id: 'new-resume', parentId: 'old-start' }), + JSON.stringify({ + type: 'session.shutdown', id: 'new-shutdown', parentId: 'new-resume', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + const newStart = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(newStart!); + + const delayedOldEnd = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(delayedOldEnd?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('does not claim a later run before its Start handler is recorded', async () => { + const sessionId = 'copilot-next-run-not-yet-started'; + const copilotHome = path.join(tmpDir, '.copilot-next-run'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, ''); + process.env.COPILOT_HOME = copilotHome; + + try { + const oldStart = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(oldStart!); + fs.appendFileSync(transcript, [ + JSON.stringify({ type: 'session.resume', id: 'next-resume', parentId: null }), + JSON.stringify({ + type: 'session.shutdown', id: 'next-shutdown', parentId: 'next-resume', + data: { tokenDetails: { input: { tokenCount: 999 } } }, + }), + ].join('\n') + '\n'); + + const delayedOldEnd = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(delayedOldEnd?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('retains Copilot totals when the current run exceeds the short tail window', async () => { + const sessionId = 'copilot-long-resumed-run'; + const copilotHome = path.join(tmpDir, '.copilot-long-run'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const startAt = Date.now() - 1000; + const markerAt = Date.now() - 500; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, ''); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: startAt, + }), 'copilot'); + await appendEvent(start!); + fs.appendFileSync(transcript, [ + JSON.stringify({ type: 'session.start', id: 'current-start', parentId: null, + timestamp: markerAt }), + JSON.stringify({ type: 'session.message', id: 'large-output', + parentId: 'current-start', data: { content: 'x'.repeat(300 * 1024) } }), + JSON.stringify({ type: 'session.shutdown', id: 'current-shutdown', + parentId: 'large-output', + data: { tokenDetails: { input: { tokenCount: 37 } } } }), + ].join('\n') + '\n'); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: Date.now(), + }), 'copilot'); + expect(event?.tokens?.input).toBe(37); + expect(JSON.stringify(event)).not.toContain('x'.repeat(100)); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('recovers a Copilot marker whose JSONL line was in flight at SessionStart', async () => { + const sessionId = 'copilot-partial-current-marker'; + const copilotHome = path.join(tmpDir, '.copilot-partial-marker'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const startAt = Date.now() - 1000; + const markerAt = Date.now() - 500; + const marker = JSON.stringify({ + type: 'session.start', id: 'current-start', parentId: null, + timestamp: markerAt, + }); + const split = Math.floor(marker.length / 2); + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, marker.slice(0, split)); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: startAt, + }), 'copilot'); + expect(start?.copilotRunStartOffset).toBe(0); + await appendEvent(start!); + fs.appendFileSync(transcript, [ + marker.slice(split), + JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'current-start', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: Date.now(), + }), 'copilot'); + expect(end?.tokens?.input).toBe(37); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it.each(['removed', 'truncated'])('omits tokens when the Copilot log is %s after Start', async (change) => { + const sessionId = `copilot-log-${change}`; + const copilotHome = path.join(tmpDir, `.copilot-log-${change}`); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'current-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: Date.now() - 1000, + }), 'copilot'); + await appendEvent(start!); + if (change === 'removed') fs.unlinkSync(transcript); + else fs.writeFileSync(transcript, ''); + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: Date.now(), + }), 'copilot'); + expect(end?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('skips a malformed Copilot line and keeps the linked shutdown', async () => { + const sessionId = 'copilot-malformed-middle-line'; + const copilotHome = path.join(tmpDir, '.copilot-malformed-line'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'session.start', id: 'current-start', parentId: null, + })}\n`); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, + }), 'copilot'); + await appendEvent(start!); + fs.appendFileSync(transcript, [ + '{malformed-json', + JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'current-start', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, + }), 'copilot'); + expect(end?.tokens?.input).toBe(37); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('recovers a current marker with a numeric provider timestamp', async () => { + const sessionId = 'copilot-numeric-marker-time'; + const copilotHome = path.join(tmpDir, '.copilot-numeric-marker-time'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const startAt = Date.now() - 1000; + const markerAt = Date.now() - 500; + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, ''); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: startAt, + }), 'copilot'); + await appendEvent(start!); + fs.appendFileSync(transcript, [ + JSON.stringify({ + type: 'session.start', id: 'current-start', parentId: null, + timestamp: markerAt, + }), + JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'current-start', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + + const end = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: Date.now(), + }), 'copilot'); + expect(end?.tokens?.input).toBe(37); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + it('recovers the current marker when SessionStart ran before the log write', async () => { + const sessionId = 'copilot-marker-after-hook'; + const copilotHome = path.join(tmpDir, '.copilot-marker-after-hook'); + const transcript = path.join(copilotHome, 'session-state', sessionId, 'events.jsonl'); + const originalCopilotHome = process.env.COPILOT_HOME; + const startAt = new Date(Date.now() - 1000).toISOString(); + const markerAt = new Date(Date.now() - 500).toISOString(); + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, ''); + process.env.COPILOT_HOME = copilotHome; + + try { + const start = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionStart', sessionId, timestamp: startAt, + }), 'copilot'); + expect(start?.copilotRunMarkerId).toBeUndefined(); + await appendEvent(start!); + fs.appendFileSync(transcript, [ + JSON.stringify({ + type: 'session.start', id: 'current-start', parentId: null, timestamp: markerAt, + }), + JSON.stringify({ + type: 'session.shutdown', id: 'current-shutdown', parentId: 'current-start', + data: { tokenDetails: { input: { tokenCount: 37 } } }, + }), + ].join('\n') + '\n'); + + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', sessionId, timestamp: new Date().toISOString(), + }), 'copilot'); + expect(event?.tokens?.input).toBe(37); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + it('does not persist a path in Copilot cwd or its fallback session ID', async () => { const sensitiveCwd = path.join(tmpDir, 'private-customer-project'); const originalClaudeSessionId = process.env.CLAUDE_SESSION_ID; diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index 9ba796140..aed0d2b4c 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -3,13 +3,24 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); +const { mockSpawn, mockDispatcher } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockDispatcher: { + hasBackground: vi.fn(() => true), + dispatch: vi.fn(async () => ({ errors: [], output: null })), + }, +})); +vi.mock('../hook-dispatch.js', async (importOriginal) => ({ + ...(await importOriginal()), + createDispatcher: vi.fn(() => mockDispatcher), +})); vi.mock('node:child_process', async (importOriginal) => ({ ...(await importOriginal()), spawn: mockSpawn, })); -const { parseStdin, trySpawnDetachedViaWmi, deriveDispatchSessionId } = await import('../hook-dispatch-cli.js'); +const { parseStdin, trySpawnDetachedViaWmi, deriveDispatchSessionId, hookDispatchCli } = + await import('../hook-dispatch-cli.js'); const { log } = await import('../utils/logger.js'); describe('deriveDispatchSessionId', () => { @@ -31,6 +42,39 @@ describe('deriveDispatchSessionId', () => { }); }); +describe('hookDispatchCli', () => { + 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(); + const previousClaudeId = process.env.CLAUDE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + fs.writeFileSync(stdinFile, JSON.stringify({ + hook_event_name: 'SessionStart', cwd, + })); + let detachedPayload = ''; + const child = { + on: vi.fn(), + stdin: { on: vi.fn(), end: vi.fn((raw: string, done: () => void) => { + detachedPayload = raw; + done(); + }) }, + unref: vi.fn(), + }; + mockSpawn.mockReturnValue(child); + + try { + await hookDispatchCli('session-start', 'copilot', '*', { stdinFile }); + expect(mockSpawn).toHaveBeenCalled(); + expect(JSON.parse(detachedPayload).session_id).toMatch(/^pid-\d+$/); + expect(JSON.parse(detachedPayload).session_id).not.toContain(cwd); + } finally { + fs.rmSync(stdinFile, { force: true }); + if (previousClaudeId === undefined) delete process.env.CLAUDE_SESSION_ID; + else process.env.CLAUDE_SESSION_ID = previousClaudeId; + } + }); +}); + describe('parseStdin', () => { it('degrades malformed JSON to an empty object instead of null', () => { // RED BASELINE: before the fix this returns null (short-circuiting all diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index d0407708d..9909e6e7d 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -538,6 +538,7 @@ async function waitForCodexUsageFlush( } const COPILOT_USAGE_TAIL_BYTES = 256 * 1024; +const COPILOT_RUN_SCAN_BYTES = 8 * 1024 * 1024; const COPILOT_USAGE_MAX_ATTEMPTS = 8; const COPILOT_USAGE_RETRY_MS = 250; const COPILOT_SHUTDOWN_EVENT = 'session.shutdown'; @@ -646,17 +647,137 @@ async function waitForCopilotShutdownUsage( transcriptPath: string, initial: CopilotUsageObservation, ): Promise { + const isCurrent = (observed: CopilotUsageObservation): boolean => { + if (!observed.shutdownObserved) return false; + // Legacy sessions have no run marker; only a newly observed record is safe. + return !initial.shutdownObserved + || observed.shutdownOffset !== initial.shutdownOffset + || !copilotUsageEquals(initial, observed); + }; + if (isCurrent(initial)) return initial; for (let attempt = 1; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); const observed = await readLatestCopilotUsageFromTail(transcriptPath); - if (!observed.shutdownObserved) continue; - if (!initial.shutdownObserved - || observed.shutdownOffset !== initial.shutdownOffset - || !copilotUsageEquals(initial, observed)) { - return observed; + if (isCurrent(observed)) return observed; + } + return { shutdownObserved: false }; +} + +interface CopilotRunMarker { + id: string; + offset: number; +} + +/** Recover a marker written after SessionStart from a bounded private-log tail. */ +async function findCopilotRunMarkerAfter( + transcriptPath: string, + startOffset: number, + startedAt: number, + endedAt: number, + useProviderTime: boolean, +): Promise { + try { + const stat = await fs.promises.stat(transcriptPath); + if (startOffset > stat.size || stat.size - startOffset > COPILOT_RUN_SCAN_BYTES) { + return undefined; + } + const readSize = stat.size - startOffset; + const offset = startOffset; + const fh = await fs.promises.open(transcriptPath, 'r'); + try { + const buffer = Buffer.alloc(readSize); + await fh.read(buffer, 0, readSize, offset); + let lineStart = 0; + let selected: CopilotRunMarker | undefined; + while (lineStart < buffer.length) { + const newline = buffer.indexOf('\n', lineStart); + const lineEnd = newline < 0 ? buffer.length : newline; + const line = buffer.subarray(lineStart, lineEnd).toString('utf-8'); + if (line.includes('"session.start"') || line.includes('"session.resume"')) { + try { + const record = asRecord(JSON.parse(line)); + const markerTime = record?.timestamp; + const time = typeof markerTime === 'string' || typeof markerTime === 'number' + ? new Date(markerTime).getTime() : NaN; + if ((record?.type === 'session.start' || record?.type === 'session.resume') + && typeof record.id === 'string' + && offset + lineStart >= startOffset + && (!useProviderTime || (Number.isFinite(time) + && time >= startedAt && time <= endedAt))) { + selected = { id: record.id, offset: offset + lineStart }; + } + } catch { + // Partial records provide no safe run linkage. + } + } + if (newline < 0) break; + lineStart = newline + 1; + } + return selected; + } finally { + await fh.close(); + } + } catch { + log.debug('dashboard: failed to recover Copilot run marker'); + return undefined; + } +} + +/** Follow Copilot event parent IDs from the current run marker, discarding content. */ +async function readCopilotUsageForRun( + transcriptPath: string, + marker: CopilotRunMarker, +): Promise { + const descendants = new Set([marker.id]); + let latest: CopilotUsageObservation = { shutdownObserved: false }; + try { + const stat = await fs.promises.stat(transcriptPath); + // Large transcript bodies are private and may be arbitrarily long. Missing + // totals are safer than rereading an unbounded log on every retry. + if (stat.size - marker.offset > COPILOT_RUN_SCAN_BYTES) return latest; + const stream = fs.createReadStream(transcriptPath, { + start: marker.offset, encoding: 'utf-8', + }); + const lines = readline.createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of lines) { + let record: Record | null; + try { + record = asRecord(JSON.parse(line)); + } catch { + continue; + } + if (!record || typeof record.id !== 'string') continue; + if ((record.type === 'session.start' || record.type === 'session.resume') + && record.id !== marker.id) break; + if (typeof record.parentId === 'string' && descendants.has(record.parentId)) { + descendants.add(record.id); + } + if (record.type === COPILOT_SHUTDOWN_EVENT && descendants.has(record.id)) { + latest = parseCopilotShutdown(record); + } + } + } finally { + lines.close(); + stream.destroy(); + } + } catch { + log.debug('dashboard: failed to read Copilot run lineage'); + } + return latest; +} + +async function waitForCopilotRunUsage( + transcriptPath: string, + marker: CopilotRunMarker, +): Promise { + for (let attempt = 0; attempt < COPILOT_USAGE_MAX_ATTEMPTS; attempt++) { + const observed = await readCopilotUsageForRun(transcriptPath, marker); + if (observed.tokens) return observed; + if (attempt < COPILOT_USAGE_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, COPILOT_USAGE_RETRY_MS)); } } - // A prior shutdown belongs to an earlier run when no new record arrives. return { shutdownObserved: false }; } @@ -1051,9 +1172,13 @@ export async function parseHookEvent( : derivedSessionId; const cwd = isCopilot ? undefined : resolveHookCwd(hookData); + const providerTime = isCopilot && (typeof hookData.timestamp === 'string' + || typeof hookData.timestamp === 'number') + ? new Date(hookData.timestamp).getTime() : NaN; const event: DashboardEvent = { type: eventType, - timestamp: new Date().toISOString(), + timestamp: Number.isFinite(providerTime) + ? new Date(providerTime).toISOString() : new Date().toISOString(), sessionId, tool, cwd, @@ -1131,14 +1256,107 @@ export async function parseHookEvent( } } + // SessionStart and SessionEnd run in separate processes. Capture the log + // boundary and an already-written marker only when it has not closed or + // appeared in a previous start event for this session. + if (eventType === 'session_start' && isCopilot) { + const transcriptPath = resolveCopilotUsageTranscript(sessionId); + if (transcriptPath) { + try { + let boundary = (await fs.promises.stat(transcriptPath)).size; + if (boundary > 0) { + const tailSize = Math.min(boundary, COPILOT_USAGE_TAIL_BYTES); + const tail = Buffer.alloc(tailSize); + const fh = await fs.promises.open(transcriptPath, 'r'); + try { + await fh.read(tail, 0, tailSize, boundary - tailSize); + } finally { + await fh.close(); + } + if (tail[tailSize - 1] !== 10) { + const newline = tail.lastIndexOf(10); + if (newline >= 0) boundary = boundary - tailSize + newline + 1; + else if (tailSize === boundary) boundary = 0; + } + } + event.copilotRunStartOffset = boundary; + const candidate = await findCopilotRunMarkerAfter( + transcriptPath, Math.max(0, boundary - COPILOT_USAGE_TAIL_BYTES), + NaN, NaN, false, + ); + if (candidate) { + const shutdown = await readLatestCopilotUsageFromTail(transcriptPath); + const history = await readEventsRaw(getEventsPath()); + const previousStarts = history.filter((entry) => entry.tool === COPILOT_TOOL_ID + && entry.sessionId === sessionId && entry.type === 'session_start'); + const reused = previousStarts.some((entry) => entry.copilotRunMarkerId === candidate.id); + const unclaimedPrior = previousStarts.some( + (entry) => typeof entry.copilotRunMarkerId !== 'string', + ); + const closed = shutdown.shutdownObserved + && typeof shutdown.shutdownOffset === 'number' + && shutdown.shutdownOffset > candidate.offset; + if (!reused && !unclaimedPrior && !closed) { + event.copilotRunMarkerId = candidate.id; + event.copilotRunMarkerOffset = candidate.offset; + } + } + } catch { + event.copilotRunStartOffset = 0; + } + } + } + // Copilot's session log contains prompts, tool arguments, assistant output, - // and auth-bearing request metadata. Read only the final shutdown token - // counters and never persist the path or any transcript content. + // and auth-bearing request metadata. Follow opaque event IDs, extract only + // final shutdown counters, and never persist paths or transcript content. if (eventType === 'session_end' && isCopilot) { const transcriptPath = resolveCopilotUsageTranscript(sessionId); if (transcriptPath) { - const initial = await readLatestCopilotUsageFromTail(transcriptPath); - const usage = await waitForCopilotShutdownUsage(transcriptPath, initial); + const history = await readEventsRaw(getEventsPath()); + const endTime = Date.parse(event.timestamp); + const sessionHistory = history.filter((entry) => entry.tool === COPILOT_TOOL_ID + && entry.sessionId === sessionId); + // Copilot may omit the provider timestamp. A delayed End handler then has + // only its receipt time, so two unmatched Starts make its run ambiguous. + const starts = sessionHistory.filter((entry) => entry.type === 'session_start'); + let lastEnd = -1; + for (let index = 0; index < sessionHistory.length; index++) { + if (sessionHistory[index].type === 'session_end') lastEnd = index; + } + const pendingStarts = sessionHistory.slice(lastEnd + 1) + .filter((entry) => entry.type === 'session_start'); + const start = Number.isFinite(providerTime) + ? [...starts].reverse().find((entry) => Date.parse(entry.timestamp) <= endTime) + : starts.length === 1 ? starts[0] + : pendingStarts.length === 1 ? pendingStarts[0] : undefined; + let usage: CopilotUsageObservation = { shutdownObserved: false }; + const startTime = start ? Date.parse(start.timestamp) : NaN; + const boundary = start?.copilotRunStartOffset; + const recovered = Number.isFinite(providerTime) + && typeof boundary === 'number' && Number.isFinite(boundary) + ? await findCopilotRunMarkerAfter( + transcriptPath, boundary, startTime, endTime, Number.isFinite(providerTime), + ) + : undefined; + const stored = typeof start?.copilotRunMarkerId === 'string' + && typeof start.copilotRunMarkerOffset === 'number' + && Number.isFinite(start.copilotRunMarkerOffset) + ? { id: start.copilotRunMarkerId, offset: start.copilotRunMarkerOffset } + : undefined; + // Without a provider End time, a later marker may belong to the next run + // whose Start handler has not persisted yet. Only the marker claimed at + // this Start can safely supply totals. + const marker = Number.isFinite(providerTime) ? recovered ?? stored : stored; + if (marker) { + usage = await waitForCopilotRunUsage(transcriptPath, marker); + } else if ((start && boundary === undefined && !stored) + || (!start && sessionHistory.every((entry) => entry.type !== 'session_start'))) { + // Pre-upgrade sessions lack a run marker: accept only a new shutdown + // observed during this handler, never an unchanged historical record. + const initial = await readLatestCopilotUsageFromTail(transcriptPath); + usage = await waitForCopilotShutdownUsage(transcriptPath, initial); + } if (usage.tokens) { event.tokens = usage.tokens; event.tokenScope = 'session'; diff --git a/src/types.ts b/src/types.ts index 873cd67d3..38679802b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1097,6 +1097,11 @@ export interface DashboardEvent { transcriptPath?: string; /** Resolved PID of the AI tool main process (for liveness monitoring) */ monitorPid?: number; + /** Byte boundary captured at Copilot SessionStart; private log path is never stored. */ + copilotRunStartOffset?: number; + /** Opaque marker metadata retained for events written by older collector versions. */ + copilotRunMarkerId?: string; + copilotRunMarkerOffset?: number; /** * Cumulative human-intervention counts scanned from the transcript at Stop time. * Full snapshot (idempotent): each Stop event carries the running total for the