diff --git a/docs/usage-guide.md b/docs/usage-guide.md index dc2238e65..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.** 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. 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 8af8c6a5f..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`),从而保留 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 在 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__/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..1a341603d 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,8 @@ 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 ────── const INTERRUPT_LINE = JSON.stringify({ @@ -56,6 +58,25 @@ 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); +} + +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 ?? ''; @@ -84,6 +105,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', @@ -96,6 +132,1015 @@ 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 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', + data: { prompt: secret, inputTokens: 999_999, request: { authorization: secret } }, + }), + JSON.stringify({ type: 'assistant.message', data: { content: 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 { + 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, + 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 { + clearTimeout(appendTimer); + 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 () => { + 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, ''); + const appendTimer = appendCopilotShutdownLater(transcript, { + 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 { + clearTimeout(appendTimer); + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + 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 }); + 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', id: 'current-shutdown', parentId: 'new-resume', + data: { tokenDetails: { input: { tokenCount: currentInputTokens } } }, + })}\n`); + }, appendDelayMs); + + try { + const event = await parseHookEvent(JSON.stringify({ + hook_event_name: 'SessionEnd', + sessionId, timestamp: Date.now(), + }), '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('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 } } }, + }); + 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, + }), 'copilot'); + expect(event?.tokens).toBeUndefined(); + } finally { + if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = originalCopilotHome; + } + }); + + 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; + 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); + + 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; + } + }); + + 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 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",'); + const appendTimer = appendCopilotShutdownLater(transcript, { + type: 'session.shutdown', + data: { tokenDetails: { input: { tokenCount: 7 }, output: { tokenCount: 2 } } }, + }); + process.env.COPILOT_HOME = copilotHome; + + 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 }); + } finally { + clearTimeout(appendTimer); + 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 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' }, + })); + 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: {} }, + })); + 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 () => { + 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', @@ -362,6 +1407,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', () => { @@ -525,6 +1602,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..aed0d2b4c 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -3,15 +3,78 @@ 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 } = 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', () => { + 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('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 @@ -23,6 +86,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' }); @@ -39,6 +112,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..1b6ca7e4b 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,45 @@ 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); + } + }); + + 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', () => { it('appends a valid event to JSONL', async () => { const event: UsageEvent = { 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..9909e6e7d 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -21,6 +21,8 @@ import { TRANSCRIPT_SYSTEM_PREFIXES, stripInjectedPrompt, TRANSCRIPT_REJECT_MARKERS, + COPILOT_TOOL_ID, + getCopilotHome, emptyTokenUsage, addTokenUsage, type DashboardEvent, @@ -535,6 +537,263 @@ async function waitForCodexUsageFlush( return latest; } +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'; +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; + 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); + 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 | 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: input ?? 0, + output: output ?? 0, + cacheRead: cacheRead ?? 0, + cacheCreation: cacheCreation ?? 0, + }, + }; +} + +/** 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); + 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 }; + 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 { + 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, + 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 (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)); + } + } + return { shutdownObserved: false }; +} + +/** Resolve Copilot's local event log without accepting path traversal via sessionId. */ +function resolveCopilotUsageTranscript( + sessionId: string, +): string | null { + 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: @@ -850,6 +1109,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'; @@ -902,12 +1164,21 @@ export async function parseHookEvent( return null; } - const sessionId = deriveSessionId(hookData, { includeCwd: true }); - const cwd = resolveHookCwd(hookData); - + const isCopilot = tool.toLowerCase() === COPILOT_TOOL_ID; + 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 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, @@ -925,7 +1196,7 @@ export async function parseHookEvent( try { event.monitorPid = resolveMonitorPid(ppid); } catch { - // PID resolution failed — fall back to ppid + // PID lookup failure must not discard the session lifecycle event. event.monitorPid = ppid; } } @@ -940,7 +1211,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 +1219,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 +1256,114 @@ 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. 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 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'; + } + } + } + return event; } @@ -1159,7 +1538,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 +1613,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 +1679,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 +1810,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 +1828,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..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. @@ -348,14 +349,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); } @@ -377,6 +376,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', @@ -404,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. * @@ -456,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); } 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..38679802b 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 */ @@ -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 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 {