@kennykeni/agent-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ import { join } from "node:path";
2
+ import type { ChangeSummary } from "./types";
3
+ import { hookCommand, readJson, writeJson } from "./utils";
4
+
5
+ function ensureClaudeCommandGroup(
6
+ hooks: Record<string, unknown>,
7
+ event: string,
8
+ command: string,
9
+ matcher?: string,
10
+ ): void {
11
+ const groups = Array.isArray(hooks[event])
12
+ ? [...(hooks[event] as unknown[])]
13
+ : [];
14
+ let target: Record<string, unknown> | undefined;
15
+
16
+ for (const group of groups) {
17
+ if (!group || typeof group !== "object") continue;
18
+ const candidate = group as Record<string, unknown>;
19
+ const candidateMatcher =
20
+ typeof candidate.matcher === "string" ? candidate.matcher : undefined;
21
+ if ((matcher ?? undefined) === candidateMatcher) {
22
+ target = candidate;
23
+ break;
24
+ }
25
+ }
26
+
27
+ if (!target) {
28
+ target = {};
29
+ if (matcher) target.matcher = matcher;
30
+ target.hooks = [];
31
+ groups.push(target);
32
+ }
33
+
34
+ const inner = Array.isArray(target.hooks)
35
+ ? [...(target.hooks as unknown[])]
36
+ : [];
37
+ const filtered = inner.filter(
38
+ (entry) =>
39
+ !(
40
+ entry &&
41
+ typeof entry === "object" &&
42
+ (entry as Record<string, unknown>).type === "command" &&
43
+ typeof (entry as Record<string, unknown>).command === "string" &&
44
+ ((entry as Record<string, unknown>).command as string).includes(
45
+ "agent-trace",
46
+ )
47
+ ),
48
+ );
49
+ filtered.push({ type: "command", command });
50
+ target.hooks = filtered;
51
+ hooks[event] = groups;
52
+ }
53
+
54
+ export function installClaude(
55
+ targetRoot: string,
56
+ dryRun: boolean,
57
+ pinVersion = true,
58
+ ): ChangeSummary {
59
+ const path = join(targetRoot, ".claude", "settings.json");
60
+ const command = hookCommand("claude", { pinVersion });
61
+
62
+ const config = readJson(path);
63
+ const hooks = (config.hooks ?? {}) as Record<string, unknown>;
64
+
65
+ ensureClaudeCommandGroup(hooks, "SessionStart", command);
66
+ ensureClaudeCommandGroup(hooks, "SessionEnd", command);
67
+ ensureClaudeCommandGroup(hooks, "UserPromptSubmit", command);
68
+ ensureClaudeCommandGroup(hooks, "PostToolUse", command, "Write|Edit");
69
+ ensureClaudeCommandGroup(hooks, "PostToolUse", command, "Bash");
70
+ ensureClaudeCommandGroup(
71
+ hooks,
72
+ "PostToolUseFailure",
73
+ command,
74
+ "Write|Edit|Bash",
75
+ );
76
+
77
+ return writeJson(path, { ...config, hooks }, dryRun);
78
+ }
@@ -0,0 +1,50 @@
1
+ import { join } from "node:path";
2
+ import type { ChangeSummary } from "./types";
3
+ import { hookCommand, readJson, writeJson } from "./utils";
4
+
5
+ export function installCursor(
6
+ targetRoot: string,
7
+ dryRun: boolean,
8
+ pinVersion = true,
9
+ ): ChangeSummary {
10
+ const path = join(targetRoot, ".cursor", "hooks.json");
11
+ const command = hookCommand("cursor", { pinVersion });
12
+
13
+ const config = readJson(path);
14
+ const hooks = (config.hooks ?? {}) as Record<string, unknown>;
15
+ const events = [
16
+ "sessionStart",
17
+ "sessionEnd",
18
+ "beforeSubmitPrompt",
19
+ "afterFileEdit",
20
+ "afterTabFileEdit",
21
+ "afterShellExecution",
22
+ ];
23
+
24
+ for (const event of events) {
25
+ const entries = Array.isArray(hooks[event])
26
+ ? [...(hooks[event] as unknown[])]
27
+ : [];
28
+ const filtered = entries.filter(
29
+ (entry) =>
30
+ !(
31
+ entry &&
32
+ typeof entry === "object" &&
33
+ typeof (entry as Record<string, unknown>).command === "string" &&
34
+ ((entry as Record<string, unknown>).command as string).includes(
35
+ "agent-trace",
36
+ )
37
+ ),
38
+ );
39
+ filtered.push({ command });
40
+ hooks[event] = filtered;
41
+ }
42
+
43
+ const next = {
44
+ ...config,
45
+ version: Number.isInteger(config.version) ? config.version : 1,
46
+ hooks,
47
+ };
48
+
49
+ return writeJson(path, next, dryRun);
50
+ }
@@ -0,0 +1,49 @@
1
+ import { installClaude } from "./claude";
2
+ import { installCursor } from "./cursor";
3
+ import { installOpenCode } from "./opencode";
4
+ import type { ChangeSummary, InstallOptions } from "./types";
5
+
6
+ export { InstallError, parseArgs } from "./args";
7
+ export type { ChangeSummary, InstallOptions } from "./types";
8
+
9
+ export function install(options: InstallOptions): ChangeSummary[] {
10
+ const changes: ChangeSummary[] = [];
11
+
12
+ for (const targetRoot of options.targetRoots) {
13
+ if (options.providers.includes("cursor")) {
14
+ changes.push(
15
+ installCursor(targetRoot, options.dryRun, options.pinVersion),
16
+ );
17
+ }
18
+ if (options.providers.includes("claude")) {
19
+ changes.push(
20
+ installClaude(targetRoot, options.dryRun, options.pinVersion),
21
+ );
22
+ }
23
+ if (options.providers.includes("opencode")) {
24
+ changes.push(
25
+ installOpenCode(targetRoot, options.dryRun, options.pinVersion),
26
+ );
27
+ }
28
+ }
29
+
30
+ return changes;
31
+ }
32
+
33
+ export function printInstallSummary(
34
+ changes: ChangeSummary[],
35
+ targetRoots: string[],
36
+ ): void {
37
+ const summary = changes
38
+ .map((change) =>
39
+ change.note
40
+ ? `${change.status.toUpperCase()}: ${change.file} (${change.note})`
41
+ : `${change.status.toUpperCase()}: ${change.file}`,
42
+ )
43
+ .join("\n");
44
+
45
+ console.log(summary);
46
+ console.log("\nTargets:");
47
+ for (const target of targetRoots) console.log(` ${target}`);
48
+ console.log("\nTrace output: .agent-trace/");
49
+ }
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ChangeSummary } from "./types";
4
+ import { getPackageName, getPackageVersion, writeTextFile } from "./utils";
5
+
6
+ export function installOpenCode(
7
+ targetRoot: string,
8
+ dryRun: boolean,
9
+ pinVersion = true,
10
+ ): ChangeSummary {
11
+ const templatePath = join(import.meta.dir, "templates", "opencode-plugin.ts");
12
+ const raw = readFileSync(templatePath, "utf-8");
13
+ const name = getPackageName();
14
+ const pkg = pinVersion ? `${name}@${getPackageVersion()}` : name;
15
+ const content = raw.replace("__AGENT_TRACE_PKG__", pkg);
16
+ const path = join(targetRoot, ".opencode", "plugins", "agent-trace.ts");
17
+ return writeTextFile(path, content, dryRun);
18
+ }
@@ -0,0 +1,207 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ async function emitToAgentTrace(root: string, payload: unknown): Promise<void> {
4
+ await new Promise<void>((resolve) => {
5
+ const child = spawn(
6
+ "bunx",
7
+ ["__AGENT_TRACE_PKG__", "hook", "--provider", "opencode"],
8
+ {
9
+ cwd: root,
10
+ stdio: ["pipe", "ignore", "ignore"],
11
+ },
12
+ );
13
+ child.on("error", () => resolve());
14
+ child.on("exit", () => resolve());
15
+ child.stdin.end(JSON.stringify(payload));
16
+ });
17
+ }
18
+
19
+ export const AgentTracePlugin = async ({
20
+ worktree,
21
+ directory,
22
+ }: {
23
+ worktree?: string;
24
+ directory?: string;
25
+ }) => {
26
+ const root = worktree || directory || process.cwd();
27
+ const pendingCommands = new Map<string, string>();
28
+ return {
29
+ event: async ({
30
+ event,
31
+ }: {
32
+ event: { type: string; properties: Record<string, unknown> };
33
+ }) => {
34
+ const props = event.properties ?? {};
35
+ const nested = (key: string) => {
36
+ const v = props[key];
37
+ return typeof v === "object" && v !== null
38
+ ? (v as Record<string, unknown>)
39
+ : undefined;
40
+ };
41
+ const info = nested("info");
42
+ const part = nested("part");
43
+
44
+ const str = (v: unknown): string | undefined =>
45
+ typeof v === "string" && v ? v : undefined;
46
+
47
+ const session =
48
+ str(props.sessionID) ??
49
+ str(info?.sessionID) ??
50
+ str(info?.id) ??
51
+ str(part?.sessionID);
52
+
53
+ const filterDiffs = (arr: unknown[]) =>
54
+ arr.filter((d: unknown) => {
55
+ if (!d || typeof d !== "object") return true;
56
+ const file = (d as Record<string, unknown>).file;
57
+ return typeof file !== "string" || !file.startsWith(".agent-trace/");
58
+ });
59
+
60
+ let filtered = props;
61
+ if (Array.isArray(props.diff)) {
62
+ filtered = { ...filtered, diff: filterDiffs(props.diff) };
63
+ }
64
+ if (info?.summary && typeof info.summary === "object") {
65
+ const summary = info.summary as Record<string, unknown>;
66
+ if (Array.isArray(summary.diffs)) {
67
+ filtered = {
68
+ ...filtered,
69
+ info: {
70
+ ...info,
71
+ summary: { ...summary, diffs: filterDiffs(summary.diffs) },
72
+ },
73
+ };
74
+ }
75
+ }
76
+
77
+ await emitToAgentTrace(root, {
78
+ hook_event_name: event.type ?? "event",
79
+ provider: "opencode",
80
+ session_id: session,
81
+ cwd: root,
82
+ event: filtered,
83
+ });
84
+ },
85
+
86
+ "chat.message": async (
87
+ input: {
88
+ sessionID: string;
89
+ agent?: string;
90
+ model?: { providerID: string; modelID: string };
91
+ messageID?: string;
92
+ },
93
+ output: {
94
+ parts?: Array<{ type?: string; text?: string; synthetic?: boolean }>;
95
+ },
96
+ ) => {
97
+ const parts = output?.parts ?? [];
98
+ const content = parts
99
+ .filter(
100
+ (p) =>
101
+ !p.synthetic && p.type === "text" && typeof p.text === "string",
102
+ )
103
+ .map((p) => p.text as string)
104
+ .join("\n");
105
+ if (!content) return;
106
+
107
+ const model =
108
+ input.model?.providerID && input.model?.modelID
109
+ ? `${input.model.providerID}/${input.model.modelID}`
110
+ : input.model?.modelID;
111
+
112
+ await emitToAgentTrace(root, {
113
+ hook_event_name: "hook:chat.message",
114
+ provider: "opencode",
115
+ session_id: input.sessionID,
116
+ cwd: root,
117
+ content,
118
+ role: "user",
119
+ model,
120
+ message_id: input.messageID,
121
+ agent: input.agent,
122
+ });
123
+ },
124
+
125
+ "tool.execute.before": async (
126
+ input: { tool: string; sessionID: string; callID: string },
127
+ output: { args: Record<string, unknown> },
128
+ ) => {
129
+ const shellTools = ["bash", "shell"];
130
+ if (
131
+ shellTools.includes(input.tool) &&
132
+ typeof output?.args?.command === "string"
133
+ ) {
134
+ pendingCommands.set(input.callID, output.args.command);
135
+ }
136
+ },
137
+
138
+ "tool.execute.after": async (
139
+ input: {
140
+ tool: string;
141
+ sessionID: string;
142
+ callID: string;
143
+ },
144
+ output: {
145
+ title?: string;
146
+ output?: string;
147
+ metadata?: {
148
+ files?: Array<{
149
+ filePath?: string;
150
+ before?: string;
151
+ after?: string;
152
+ additions?: number;
153
+ deletions?: number;
154
+ }>;
155
+ };
156
+ },
157
+ ) => {
158
+ const toolName = input?.tool ?? "";
159
+ const shellTools = ["bash", "shell"];
160
+
161
+ if (shellTools.includes(toolName)) {
162
+ const command = pendingCommands.get(input.callID);
163
+ pendingCommands.delete(input.callID);
164
+ await emitToAgentTrace(root, {
165
+ hook_event_name: "hook:tool.execute.after",
166
+ provider: "opencode",
167
+ session_id: input.sessionID,
168
+ cwd: root,
169
+ tool_name: toolName,
170
+ command,
171
+ });
172
+ return;
173
+ }
174
+
175
+ const editTools = ["edit", "apply_patch", "write", "patch"];
176
+ if (!editTools.includes(toolName)) return;
177
+
178
+ const metadata = output?.metadata;
179
+ const files: Array<{ file: string; before?: string; after?: string }> =
180
+ [];
181
+
182
+ if (metadata?.files) {
183
+ for (const f of metadata.files) {
184
+ const fp = f.filePath ?? "";
185
+ if (!fp || fp.startsWith(".agent-trace/")) continue;
186
+ files.push({
187
+ file: fp,
188
+ before: f.before,
189
+ after: f.after,
190
+ });
191
+ }
192
+ }
193
+
194
+ if (files.length === 0) return;
195
+
196
+ await emitToAgentTrace(root, {
197
+ hook_event_name: "hook:tool.execute.after",
198
+ provider: "opencode",
199
+ session_id: input.sessionID,
200
+ cwd: root,
201
+ tool_name: toolName,
202
+ call_id: input.callID,
203
+ files,
204
+ });
205
+ },
206
+ };
207
+ };
@@ -0,0 +1,14 @@
1
+ import type { Provider } from "../providers/types";
2
+
3
+ export interface ChangeSummary {
4
+ file: string;
5
+ status: "created" | "updated" | "unchanged" | "skipped";
6
+ note?: string;
7
+ }
8
+
9
+ export interface InstallOptions {
10
+ providers: Provider[];
11
+ dryRun: boolean;
12
+ pinVersion: boolean;
13
+ targetRoots: string[];
14
+ }
@@ -0,0 +1,78 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { ensureDir } from "../core/utils";
5
+ import type { ChangeSummary } from "./types";
6
+
7
+ export function readJson(path: string): Record<string, unknown> {
8
+ if (!existsSync(path)) return {};
9
+ const raw = readFileSync(path, "utf-8");
10
+ try {
11
+ return JSON.parse(raw) as Record<string, unknown>;
12
+ } catch {
13
+ throw new Error(`Failed to parse JSON at ${path}`);
14
+ }
15
+ }
16
+
17
+ export function writeJson(
18
+ path: string,
19
+ value: unknown,
20
+ dryRun: boolean,
21
+ ): ChangeSummary {
22
+ const next = `${JSON.stringify(value, null, 2)}\n`;
23
+ return writeTextFile(path, next, dryRun);
24
+ }
25
+
26
+ export function writeTextFile(
27
+ path: string,
28
+ content: string,
29
+ dryRun: boolean,
30
+ ): ChangeSummary {
31
+ let previous = "";
32
+ if (existsSync(path)) previous = readFileSync(path, "utf-8");
33
+ const status: ChangeSummary["status"] = existsSync(path)
34
+ ? previous === content
35
+ ? "unchanged"
36
+ : "updated"
37
+ : "created";
38
+ if (!dryRun && status !== "unchanged") {
39
+ ensureDir(dirname(path));
40
+ writeFileSync(path, content, "utf-8");
41
+ }
42
+ return { file: path, status };
43
+ }
44
+
45
+ function findPackageRoot(startDir: string): string {
46
+ let dir = startDir;
47
+ while (true) {
48
+ if (existsSync(join(dir, "package.json"))) return dir;
49
+ const parent = dirname(dir);
50
+ if (parent === dir) break;
51
+ dir = parent;
52
+ }
53
+ return startDir;
54
+ }
55
+
56
+ function readPkg(): { name: string; version: string } {
57
+ const dir = dirname(fileURLToPath(import.meta.url));
58
+ const root = findPackageRoot(dir);
59
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
60
+ }
61
+
62
+ export function getPackageName(): string {
63
+ return readPkg().name;
64
+ }
65
+
66
+ export function getPackageVersion(): string {
67
+ return readPkg().version;
68
+ }
69
+
70
+ export function hookCommand(
71
+ provider: string,
72
+ options?: { pinVersion?: boolean },
73
+ ): string {
74
+ const pkg = getPackageName();
75
+ const pinVersion = options?.pinVersion ?? true;
76
+ const target = pinVersion ? `${pkg}@${getPackageVersion()}` : pkg;
77
+ return `bunx ${target} hook --provider ${provider}`;
78
+ }
@@ -0,0 +1,130 @@
1
+ import type { HookInput, TraceEvent } from "../core/types";
2
+ import { textFromUnknown } from "../core/utils";
3
+ import { normalizeModelId, sessionIdFor } from "./utils";
4
+
5
+ export interface ClaudeHookInput extends HookInput {
6
+ tool_name?: string;
7
+ tool_input?: {
8
+ file_path?: string;
9
+ new_string?: string;
10
+ old_string?: string;
11
+ content?: string;
12
+ command?: string;
13
+ };
14
+ tool_response?: {
15
+ originalFile?: string;
16
+ };
17
+ tool_use_id?: string;
18
+ source?: string;
19
+ reason?: string;
20
+ prompt?: unknown;
21
+ message?: unknown;
22
+ content?: unknown;
23
+ }
24
+
25
+ export { sessionIdFor } from "./utils";
26
+
27
+ export function adapt(input: HookInput): TraceEvent | TraceEvent[] | undefined {
28
+ const ci = input as ClaudeHookInput;
29
+ const sessionId = sessionIdFor(input);
30
+ const model = normalizeModelId(input.model);
31
+
32
+ switch (input.hook_event_name) {
33
+ case "PostToolUse": {
34
+ const toolName = ci.tool_name ?? "";
35
+ const isFileEdit = toolName === "Write" || toolName === "Edit";
36
+ const isBash = toolName === "Bash";
37
+ if (!isFileEdit && !isBash) return undefined;
38
+
39
+ if (isBash) {
40
+ return {
41
+ kind: "shell",
42
+ provider: "claude",
43
+ sessionId,
44
+ model,
45
+ transcript: input.transcript_path,
46
+ meta: {
47
+ session_id: input.session_id,
48
+ tool_name: toolName,
49
+ tool_use_id: ci.tool_use_id,
50
+ command: ci.tool_input?.command,
51
+ },
52
+ };
53
+ }
54
+
55
+ const file = ci.tool_input?.file_path ?? ".unknown";
56
+ const newContent = ci.tool_input?.new_string ?? ci.tool_input?.content;
57
+ const edits = newContent
58
+ ? [
59
+ {
60
+ old_string:
61
+ ci.tool_input?.old_string ??
62
+ ci.tool_response?.originalFile ??
63
+ "",
64
+ new_string: newContent,
65
+ },
66
+ ]
67
+ : [];
68
+ return {
69
+ kind: "file_edit",
70
+ provider: "claude",
71
+ sessionId,
72
+ filePath: file,
73
+ edits,
74
+ model,
75
+ readContent: !!ci.tool_input?.file_path,
76
+ transcript: input.transcript_path,
77
+ eventName: "PostToolUse",
78
+ meta: {
79
+ session_id: input.session_id,
80
+ tool_name: toolName,
81
+ tool_use_id: ci.tool_use_id,
82
+ },
83
+ };
84
+ }
85
+
86
+ case "UserPromptSubmit": {
87
+ const text = textFromUnknown(ci.prompt ?? ci.message ?? ci.content);
88
+ if (!text) return undefined;
89
+ return {
90
+ kind: "message",
91
+ provider: "claude",
92
+ sessionId,
93
+ role: "user",
94
+ content: text,
95
+ eventName: "UserPromptSubmit",
96
+ model,
97
+ meta: {},
98
+ };
99
+ }
100
+
101
+ case "SessionStart": {
102
+ return {
103
+ kind: "session_start",
104
+ provider: "claude",
105
+ sessionId,
106
+ model,
107
+ meta: {
108
+ session_id: input.session_id,
109
+ source: ci.source,
110
+ },
111
+ };
112
+ }
113
+
114
+ case "SessionEnd": {
115
+ return {
116
+ kind: "session_end",
117
+ provider: "claude",
118
+ sessionId,
119
+ model,
120
+ meta: {
121
+ session_id: input.session_id,
122
+ reason: ci.reason,
123
+ },
124
+ };
125
+ }
126
+
127
+ default:
128
+ return undefined;
129
+ }
130
+ }