diff --git a/package-lock.json b/package-lock.json index 8fd2d1a..19d14d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,15 @@ "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", - "uuidv7": "1.2.1" + "uuidv7": "1.2.1", + "weave": "^0.16.3" }, "bin": { "weave-claude-code": "dist/cli.js" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" @@ -29,6 +27,177 @@ "node": ">=18.19.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dev": true, + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", + "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", + "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", + "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", + "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", + "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", + "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", + "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", + "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -471,43 +640,76 @@ "node": ">=18" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "peer": true, "engines": { - "node": ">=8.0.0" + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", - "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", - "license": "Apache-2.0", + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@opentelemetry/api": "^1.3.0" + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", - "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "node": ">=8.0.0" } }, "node_modules/@opentelemetry/core": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -519,65 +721,11 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", - "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-exporter-base": "0.219.0", - "@opentelemetry/otlp-transformer": "0.219.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", - "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-transformer": "0.219.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", - "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-logs": "0.219.0", - "@opentelemetry/sdk-metrics": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -590,44 +738,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", - "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", - "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -641,99 +756,1585 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", - "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.8.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "engines": { + "node": ">=0.4.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "peer": true, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -753,6 +2354,41 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -771,9 +2407,19 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuidv7": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.2.1.tgz", @@ -782,6 +2428,386 @@ "bin": { "uuidv7": "cli.js" } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/weave": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/weave/-/weave-0.16.3.tgz", + "integrity": "sha512-8sPJfXDKh25YAHmhnJ4qN1IuHGKLHVuvCwhEZo25g6/qxV0ddfujimUXt9797Z3Mt/zMFSy+kVBL6T9UXUbIoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-proto": "^0.53.0", + "@opentelemetry/resources": "^1.26.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "cli-progress": "^3.12.0", + "cross-spawn": "^7.0.5", + "form-data": "^4.0.4", + "import-in-the-middle": "^1.13.2", + "ini": "^5.0.0", + "module-details-from-path": "^1.0.4", + "semifies": "^1.0.0", + "uuidv7": "^1.0.1" + } + }, + "node_modules/weave/node_modules/@opentelemetry/api-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/core": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.53.0.tgz", + "integrity": "sha512-T/bdXslwRKj23S96qbvGtaYOdfyew3TjPEKOk5mHjkCmkVl1O9C/YMdejwSsdLdOq2YW30KjR9kVi0YMxZushQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.26.0.tgz", + "integrity": "sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", + "integrity": "sha512-UCWPreGQEhD6FjBaeDuXhiMf6kkBODF0ZQzrk/tuQcaVDJ+dDQ/xhJp192H9yWnKxVpEjFrSSLnpqmX4VwX+eA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-transformer": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.53.0.tgz", + "integrity": "sha512-rM0sDA9HD8dluwuBxLetUmoqGJKSAbWenwD65KY9iZhUxdBHRLrIdrABfNDP7aiTjcgK8XFyTn5fhDz7N+W6DA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-logs": "0.53.0", + "@opentelemetry/sdk-metrics": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.26.0.tgz", + "integrity": "sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.53.0.tgz", + "integrity": "sha512-dhSisnEgIj/vJZXZV6f6KcTnyLDx/VuQ6l3ejuZpMpPlh9S1qMHiZU9NMmOkVkwwHkMy3G6mEBwdP23vUZVr4g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.26.0.tgz", + "integrity": "sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 487c372..fc8c19a 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", - "uuidv7": "1.2.1" + "uuidv7": "1.2.1", + "weave": "^0.16.3" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" diff --git a/src/cli.ts b/src/cli.ts index d19ed0e..673b8ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,6 @@ import { CONFIG_DIR, SETTINGS_FILE, MARKETPLACE_NAME, - PLUGIN_NAME, VERSION, InstallSource, MarketplaceStatus, @@ -28,7 +27,17 @@ import { type PluginSource, } from './setup.js'; import { prompt, sendToSocket, requestFromSocket, probeUnixSocket, SocketState } from './utils.js'; -import { runDaemon, resolveDaemonConfig, daemonConfigFingerprint } from './daemon.js'; +import { runDaemon } from './daemon.js'; +import { + resolveProject, + resolveApiKey, + resolveAgentName, + resolveDaemonConfig, + daemonConfigFingerprint, + missingConfig, + WeaveProjectSource, + ApiKeySource, +} from './config.js'; import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; // --------------------------------------------------------------------------- @@ -145,8 +154,8 @@ async function cmdInstall( process.exit(1); } - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const effectiveProject = resolveProject(settings).value; + const effectiveApiKey = resolveApiKey(settings).value; if (nonInteractive) { console.log('\n- Non-interactive install: skipping setup prompts'); @@ -220,29 +229,6 @@ function maskSecret(value: string): string { return `${value.slice(0, 4)}…`; } -/** Where the effective agent name came from. Parallels `WeaveProjectSource` / - * `ApiKeySource`; has no `NotSet` member because agent_name always resolves - * to the built-in default. */ -enum AgentNameSource { - EnvVar = 'WEAVE_AGENT_NAME env var', - Settings = 'settings.json', - Default = 'default', -} - -/** - * Resolve the effective top-level agent name and where it came from. Mirrors - * the env-over-settings precedence used for `weave_project`, with the - * hardcoded `DEFAULT_AGENT_NAME` as the final fallback. Shared by - * `config show` and `config get` so both report the same value. - */ -function resolveAgentName(settings: Settings): { value: string; source: AgentNameSource } { - const fromEnv = process.env['WEAVE_AGENT_NAME']?.trim(); - if (fromEnv) return { value: fromEnv, source: AgentNameSource.EnvVar }; - const fromSettings = settings.agent_name?.trim(); - if (fromSettings) return { value: fromSettings, source: AgentNameSource.Settings }; - return { value: DEFAULT_AGENT_NAME, source: AgentNameSource.Default }; -} - async function cmdConfig(args: string[]): Promise { const action = args[0]; @@ -255,19 +241,8 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const projectSource: WeaveProjectSource = process.env['WEAVE_PROJECT'] - ? WeaveProjectSource.EnvVar - : settings.weave_project - ? WeaveProjectSource.Settings - : WeaveProjectSource.NotSet; - - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - const apiKeySource: ApiKeySource = process.env['WANDB_API_KEY'] - ? ApiKeySource.EnvVar - : settings.wandb_api_key - ? ApiKeySource.Settings - : ApiKeySource.NotSet; + const { value: effectiveProject, source: projectSource } = resolveProject(settings); + const { value: effectiveApiKey, source: apiKeySource } = resolveApiKey(settings); const apiKeyDisplay = effectiveApiKey ? `${maskSecret(effectiveApiKey)} [${apiKeySource}]` : `(not set)`; console.log('Current configuration:'); @@ -309,11 +284,9 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } if (key === 'weave_project') { - const effective = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - console.log(effective ?? '(not set)'); + console.log(resolveProject(settings).value ?? '(not set)'); } else if (key === 'wandb_api_key') { - const effective = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - console.log(effective ?? '(not set)'); + console.log(resolveApiKey(settings).value ?? '(not set)'); } else { console.log(value ?? '(not set)'); } @@ -328,11 +301,12 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const writableKeys = ['weave_project', 'wandb_api_key', 'agent_name', 'daemon_socket', 'debug']; - if (!writableKeys.includes(key)) { + const writableKeys: readonly (keyof Settings)[] = ['weave_project', 'wandb_api_key', 'agent_name', 'daemon_socket', 'debug']; + if (!writableKeys.includes(key as keyof Settings)) { console.error(`Cannot set '${key}'. Writable keys: ${writableKeys.join(', ')}`); process.exit(1); } + const writableKey = key as keyof Settings; if (key === 'weave_project' && !value.includes('/')) { console.error(`Invalid format for weave_project: '${value}'\nExpected: entity/project (e.g. my-entity/my-project)`); @@ -352,12 +326,16 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const coerced = key === 'debug' ? value === 'true' : value; - (settings as unknown as Record)[key] = coerced; + // `debug` is the only boolean Settings field; every other writable key is a + // string. Split the assignment so each branch's value type matches the + // narrowed property type (no whole-object cast needed). + if (writableKey === 'debug') { + settings.debug = value === 'true'; + } else { + settings[writableKey] = value; + } saveSettings(settings); - const displayValue = key === 'wandb_api_key' && typeof coerced === 'string' - ? maskSecret(coerced) - : coerced; + const displayValue = writableKey === 'wandb_api_key' ? maskSecret(value) : value; console.log(`✓ Set ${key} = ${displayValue}`); return; } @@ -370,18 +348,6 @@ async function cmdConfig(args: string[]): Promise { // status // --------------------------------------------------------------------------- -/** Where a configured value (project, API key) came from at gather time. */ -export enum WeaveProjectSource { - EnvVar = 'WEAVE_PROJECT env var', - Settings = 'settings.json', - NotSet = 'not set', -} -export enum ApiKeySource { - EnvVar = 'WANDB_API_KEY env var', - Settings = 'settings.json', - NotSet = 'not set', -} - /** Whether settings.json could be read at gather time. */ export enum ConfigState { Ok = 'ok', @@ -480,18 +446,17 @@ async function gatherStatus(): Promise { return snap; } - // Env vars take precedence over settings.json for both project and key. - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; + const { value: effectiveProject, source: projectSource } = resolveProject(settings); if (effectiveProject) { report.weave_project = effectiveProject; - report.weave_project_source = process.env['WEAVE_PROJECT'] ? WeaveProjectSource.EnvVar : WeaveProjectSource.Settings; + report.weave_project_source = projectSource; } - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const { value: effectiveApiKey, source: apiKeySource } = resolveApiKey(settings); if (effectiveApiKey) { report.api_key_configured = true; snap.api_key_masked = maskSecret(effectiveApiKey); - snap.api_key_source = process.env['WANDB_API_KEY'] ? ApiKeySource.EnvVar : ApiKeySource.Settings; + snap.api_key_source = apiKeySource; } report.agent_name = resolveAgentName(settings).value; @@ -582,10 +547,7 @@ function printPrettyStatus(snap: StatusSnapshot): void { } else if (socketState === SocketState.Stale) { console.log('Weave Claude Code — daemon socket stale (auto-recovers next session)'); } else { - const missing = [ - !report.weave_project && 'weave_project', - !report.api_key_configured && 'wandb_api_key', - ].filter(Boolean).join(', '); + const missing = missingConfig(!!report.weave_project, report.api_key_configured, 'wandb_api_key'); console.log('Weave Claude Code — configuration incomplete'); if (missing) console.log(` Set ${missing} to start tracing`); } @@ -832,10 +794,10 @@ async function cmdRestart(): Promise { } // Don't spawn a daemon that would just exit for lack of config (mirrors runDaemon). - const project = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const apiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const project = resolveProject(settings).value; + const apiKey = resolveApiKey(settings).value; if (!project || !apiKey) { - const missing = [!project && 'weave_project', !apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); + const missing = missingConfig(!!project, !!apiKey, 'WANDB_API_KEY'); console.error(`⚠ Not starting daemon, missing configuration: ${missing}`); console.error(' Set it with: weave-claude-code config set weave_project ENTITY/PROJECT'); process.exit(1); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..572e43c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Config resolution shared by the CLI and the daemon (env over +// settings.json). Lives here so both use one implementation without an +// import cycle (cli.ts imports the daemon entry point). + +import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; +import { sha256Hex } from './utils.js'; +import type { Settings } from './setup.js'; + +/** Where a resolved value came from, for user-facing "source" reporting. */ +export enum WeaveProjectSource { + EnvVar = 'WEAVE_PROJECT env var', + Settings = 'settings.json', + NotSet = 'not set', +} +export enum ApiKeySource { + EnvVar = 'WANDB_API_KEY env var', + Settings = 'settings.json', + NotSet = 'not set', +} +/** No `NotSet`: agent_name always resolves to the built-in default. */ +export enum AgentNameSource { + EnvVar = 'WEAVE_AGENT_NAME env var', + Settings = 'settings.json', + Default = 'default', +} + +/** Env-over-settings resolution shared by the project and API-key resolvers: + * a non-empty env value wins, then a non-empty settings value, else null. + * `sources` supplies the per-field labels for the matching branch. */ +function resolveFromEnvOrSettings( + envValue: string | undefined, + settingsValue: string | null | undefined, + sources: { env: S; settings: S; notSet: S }, +): { value: string | null; source: S } { + if (envValue) return { value: envValue, source: sources.env }; + if (settingsValue) return { value: settingsValue, source: sources.settings }; + return { value: null, source: sources.notSet }; +} + +/** Resolve the effective Weave project (WEAVE_PROJECT env beats + * settings.weave_project) and where it came from. */ +export function resolveProject( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string | null; source: WeaveProjectSource } { + return resolveFromEnvOrSettings(env['WEAVE_PROJECT'], settings.weave_project, { + env: WeaveProjectSource.EnvVar, + settings: WeaveProjectSource.Settings, + notSet: WeaveProjectSource.NotSet, + }); +} + +/** Resolve the effective W&B API key (WANDB_API_KEY env beats + * settings.wandb_api_key) and where it came from. */ +export function resolveApiKey( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string | null; source: ApiKeySource } { + return resolveFromEnvOrSettings(env['WANDB_API_KEY'], settings.wandb_api_key, { + env: ApiKeySource.EnvVar, + settings: ApiKeySource.Settings, + notSet: ApiKeySource.NotSet, + }); +} + +/** Resolve the effective top-level agent name (WEAVE_AGENT_NAME env beats + * settings.agent_name), falling back to `DEFAULT_AGENT_NAME`. */ +export function resolveAgentName( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string; source: AgentNameSource } { + const fromEnv = env['WEAVE_AGENT_NAME']?.trim(); + if (fromEnv) return { value: fromEnv, source: AgentNameSource.EnvVar }; + const fromSettings = settings.agent_name?.trim(); + if (fromSettings) return { value: fromSettings, source: AgentNameSource.Settings }; + return { value: DEFAULT_AGENT_NAME, source: AgentNameSource.Default }; +} + +/** The config the daemon loads at startup and holds for its lifetime. */ +export type DaemonConfig = { + weaveProject: string | null; + apiKey: string | null; + baseUrl: string; + agentName: string; + debug: boolean; +}; + +/** Resolve the daemon config from settings + env, reusing the per-field + * resolvers so the env-over-settings precedence is defined once. */ +export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): DaemonConfig { + return { + weaveProject: resolveProject(settings, env).value, + apiKey: resolveApiKey(settings, env).value, + baseUrl: resolveTraceBaseUrl(env), + agentName: resolveAgentName(settings, env).value, + debug: !!env['WEAVE_CLAUDE_DEBUG'] || settings.debug === true, + }; +} + +/** SaaS trace-ingest host: the default OTLP target, and what the routeless + * SaaS API host remaps to. */ +const DEFAULT_TRACE_BASE_URL = 'https://trace.wandb.ai'; + +/** Resolve the Weave trace server base URL for OTLP export. `WF_TRACE_SERVER_URL` + * wins when set. Otherwise `WANDB_BASE_URL` is used, but SaaS `api.wandb.ai` is + * the wandb API host with no OTLP route, so it maps to `trace.wandb.ai`; a + * self-hosted `WANDB_BASE_URL` passes through unchanged. */ +function resolveTraceBaseUrl(env: NodeJS.ProcessEnv): string { + const explicit = env['WF_TRACE_SERVER_URL']?.trim(); + if (explicit) return explicit.replace(/\/+$/, ''); + const base = (env['WANDB_BASE_URL'] ?? DEFAULT_TRACE_BASE_URL).replace(/\/+$/, ''); + return /^https?:\/\/api\.wandb\.ai$/i.test(base) ? DEFAULT_TRACE_BASE_URL : base; +} + +/** Comma-joined list of missing required config, for the "incomplete" + * status/startup messages. `apiKeyLabel` differs by call site + * (`wandb_api_key` for config-oriented messages, `WANDB_API_KEY` for + * env-oriented ones). */ +export function missingConfig(hasProject: boolean, hasApiKey: boolean, apiKeyLabel: string): string { + return [!hasProject && 'weave_project', !hasApiKey && apiKeyLabel].filter(Boolean).join(', '); +} + +/** Hex chars kept from the config hash. 16 (64 bits) is ample to detect a + * config change while staying compact for logs and the socket reply. */ +const CONFIG_FINGERPRINT_LENGTH = 16; + +/** Short, stable hash of a daemon config. The API key is hashed, not exposed, + * so the fingerprint is safe to send over the socket. */ +export function daemonConfigFingerprint(c: DaemonConfig): string { + return sha256Hex(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) + .slice(0, CONFIG_FINGERPRINT_LENGTH); +} diff --git a/src/daemon.ts b/src/daemon.ts index f2c7d25..1868719 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,64 +5,72 @@ import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; -import { createHash } from 'crypto'; -import { - Baggage, - Span, - SpanStatusCode, - Tracer, - context as otelContext, - propagation, - diag, - DiagConsoleLogger, - DiagLogLevel, -} from '@opentelemetry/api'; -import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { resourceFromAttributes } from '@opentelemetry/resources'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; -import { loadSettings, VERSION, type Settings } from './setup.js'; +import { diag, DiagLogLevel } from '@opentelemetry/api'; +import type { Attributes } from '@opentelemetry/api'; +import type { + HookInput, + SessionStartHookInput, + InstructionsLoadedHookInput, + UserPromptSubmitHookInput, + PreToolUseHookInput, + PostToolUseHookInput, + PostToolUseFailureHookInput, + PermissionRequestHookInput, + SubagentStartHookInput, + SubagentStopHookInput, + TeammateIdleHookInput, + PreCompactHookInput, + StopHookInput, + SessionEndHookInput, +} from '@anthropic-ai/claude-agent-sdk'; +import * as weave from 'weave'; +import { loadSettings, VERSION } from './setup.js'; import { appendToLog, deepEqual } from './utils.js'; -import { - parseSessionFd, - extractAssistantTextBlocks, - isTextBlock, - isThinkingBlock, - isRedactedThinkingBlock, -} from './parser.js'; +import { parseSessionFd, isToolUseBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { ATTR, - DEFAULT_AGENT_NAME, CompactionAttrs, - IntegrationBaggageSpanProcessor, - createIntegrationBaggage, - startTurnSpan, - startToolSpan, - startInvokeAgentSpan, - startChatSpan, - finalizeChatSpan, - emitAssistantTextSpan, - emitThinkingSpan, - emitChatSpansFromAssistantCalls, addPermissionRequestEvent, - addPermissionResolvedEvent, setCompactionAttrs, toolDisplayName, - promptSnippet, - jsonStr, + assistantOutputMessages, + buildUsage, + contentBlocksToParts, + providerFromModel, parseTimestamp, + snippet, + jsonStr, } from './genaiSpans.js'; +import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; +import type { DaemonConfig } from './config.js'; +import { + resolvePermissionIfPending, + hashPrompt, + computeSubagentTranscriptPath, + subagentsDirFor, + extractUserMessageContent, + lastAssistantTextEndsWith, + readSubagentFirstLineWithRetry, + newSessionState, + upsertInstruction, +} from './sessionState.js'; +import type { + PendingToolCall, + SubagentTracker, + TeamMember, + SessionState, + LoadedInstruction, +} from './sessionState.js'; import type { AssistantCallDetail, ParsedSession } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── -/** Inbound control message sent directly to the socket (not a hook event). - * `shutdown` stops the daemon; `config-hash` asks it to reply with the - * fingerprint of the config it loaded (used by `status` for drift detection) - * plus the daemon's runtime identity (pid, version, entry path). */ +/** Socket control message (not a hook event): `shutdown` stops the daemon; + * `config-hash` replies with the loaded config's fingerprint (drift + * detection) plus the daemon's identity (pid, version, entry path). */ type ControlMessage = { command: 'shutdown' | 'config-hash'; } @@ -76,436 +84,78 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ -type PendingToolCall = { - span: Span; - toolName: string; - toolInput: Record; - /** True once a PermissionRequest event has been emitted for this tool. */ - permissionRequested?: boolean; -} - -/** Tracks the chat span currently open for a single assistant API response. - * Tool spans for that response parent here so the trace tree shows the - * model's interleaved text → tool_use → text order. The response's - * text/thinking children are emitted when the span is finalized (at the next - * response transition or at Stop), once all its split transcript lines are - * present. */ -type ActiveChatSpan = { - /** Response key (Anthropic `message.id`, or index fallback) this chat span - * represents; see `chatMessageKey`. */ - responseKey: string; - span: Span; -} - -/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ -function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { - if (!pending.permissionRequested) return; - addPermissionResolvedEvent(pending.span, { - approved, - timestamp: new Date(), - }); +/** Real path of the daemon's entry script (npm bin symlink resolved), so + * `status` can report which build is actually running. */ +function daemonEntryPath(): string { + const entry = process.argv[1] ?? ''; + try { + return fs.realpathSync(entry); + } catch { + return entry; + } } -/** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with - * the subagent's SubagentStart by matching transcript content. */ -function hashPrompt(prompt: string): string { - return createHash('sha256').update(prompt, 'utf8').digest('hex'); -} +// ───────────────────────────────────────────────────────────────────────────── +// Chat-span helpers (over parsed assistant responses) +// ───────────────────────────────────────────────────────────────────────────── -/** Stable identity for an assistant API call within a turn. Anthropic returns - * a `message.id` on every response; that's the primary key. When it's - * missing (legacy transcripts), fall back to the index, which is stable - * within a single parse + turn. */ +/** Stable identity for a response within a turn: `message.id`, or the index + * for legacy transcripts without ids. */ function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { return call.responseId ?? `idx:${callIdx}`; } -/** All calls belonging to one assistant API response, in transcript order. - * Claude Code splits a single response's thinking / text / tool_use blocks - * across separate transcript lines that share a `message.id`; the parser maps - * each line to its own `AssistantCallDetail`, so this regroups them by key. */ -function callsForResponseKey( - calls: AssistantCallDetail[], - key: string, -): AssistantCallDetail[] { - const group: AssistantCallDetail[] = []; - for (let i = 0; i < calls.length; i++) { - if (chatMessageKey(calls[i], i) === key) group.push(calls[i]); - } - return group; -} - -/** Find the response key of the assistant call whose content contains a - * `tool_use` block with `toolUseId`, or undefined if not found (transcript - * not flushed yet, or unknown id). */ -function findToolUseResponseKey( - calls: AssistantCallDetail[], - toolUseId: string, -): string | undefined { - for (let ci = 0; ci < calls.length; ci++) { - for (const raw of calls[ci].contentBlocks) { - const b = raw as Record | undefined; - if (b && typeof b === 'object' && b['type'] === 'tool_use' && b['id'] === toolUseId) { - return chatMessageKey(calls[ci], ci); - } - } - } - return undefined; -} - function parseIsoOrNow(ts: string | undefined): Date { return parseTimestamp(ts) ?? new Date(); } -/** - * Map a parent transcript path + subagent agent_id to the subagent's transcript - * file. Claude Code writes subagent transcripts as siblings of the parent in a - * `/subagents/` subdirectory: - * parent: /.jsonl - * subagent: //subagents/agent-.jsonl - */ -function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { - const projectDir = path.dirname(parentTranscriptPath); - const sessionDirName = path.basename(parentTranscriptPath, '.jsonl'); - return path.join(projectDir, sessionDirName, 'subagents', `agent-${agentId}.jsonl`); -} - -/** Pull the user-message content out of a transcript line. Returns the prompt - * string for `{type: 'user', message: {content: string|Array}}` lines, else - * undefined. Array-form content is joined across text blocks. */ -function extractUserMessageContent(line: Record | undefined): string | undefined { - if (!line || line['type'] !== 'user') return undefined; - const msg = line['message']; - if (!msg || typeof msg !== 'object') return undefined; - const content = (msg as Record)['content']; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content) { - if (block && typeof block === 'object' && (block as Record)['type'] === 'text') { - const t = (block as Record)['text']; - if (typeof t === 'string') parts.push(t); - } - } - return parts.length > 0 ? parts.join('') : undefined; - } - return undefined; -} - -/** True if the last assistant call's joined text ends with `suffix`, - * ignoring trailing whitespace on either side. */ -function lastAssistantTextEndsWith( - result: NonNullable>, - suffix: string, -): boolean { - const call = result.turns.at(-1)?.assistantCalls().at(-1); - // Turn exists but parser saw no assistant calls (writer mid-flush). - if (!call) return false; - return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); -} - -/** Read the subagent transcript's first line, retrying briefly because Claude - * Code may not have flushed it yet when SubagentStart fires. Total wait - * bounded by the sum of `RETRY_DELAYS_MS`. */ -const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; -async function readSubagentFirstLineWithRetry( - transcriptPath: string, -): Promise | undefined> { - for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { - if (delay > 0) await new Promise(r => setTimeout(r, delay)); - const line = readFirstTranscriptLine(transcriptPath); - if (line && line['type'] === 'user') return line; - } - return undefined; -} - -/** - * Tracks a subagent across hook events. Two shapes: - * (a) Matched — created at PreToolUse when an Agent tool with subagent_type - * is detected; carries `toolUseId`, `promptHash`, and a reference to - * the subagent's `invoke_agent` span. `agentId` is filled in at - * SubagentStart via content-based correlation: sha256(firing prompt) + - * subagent_type. - * (b) Orphan — created at SubagentStart when no tracker matches the firing - * prompt (the parent's Agent PreToolUse never fired, or its prompt - * differs from the subagent transcript's line 1). The `invoke_agent` - * span is created at SubagentStart with the current turn span as - * parent and no input messages (the firing prompt is unavailable). - * - * The subagent is its own `invoke_agent ` span, child of the - * parent turn's `invoke_agent claude-code` span. Per the Weave Agents chat - * view (`weave/trace_server/agents/chat_view.py`), nested `invoke_agent` - * spans render as an `agent_start` lifecycle marker with the inner agent's - * own assistant text — distinct from an `execute_tool` tool-call event. - * The Agent tool call does NOT emit an `execute_tool` span; it emits this - * `invoke_agent` span directly. - */ -type SubagentTracker = { - subagentType: string; - detectedAt: Date; - toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - invokeAgentSpan?: Span; // subagent's `invoke_agent` span; subagent chat/tool spans parent here - agentId?: string; - /** sha256 of the prompt passed to the Agent tool; matched against the - * subagent's transcript line-1 user message at SubagentStart. */ - promptHash?: string; - /** True once the invoke_agent span has been ended. Guards against - * double-end when PostToolUse and SubagentStop both try to close it. */ - ended?: boolean; - /** Subagent transcript path — stored at SubagentStart so TeammateIdle can - * read all turns without relying on the payload's transcript_path (which - * CC sets to the coordinator's path, not the subagent's). */ - transcriptPath?: string; - /** Set on orphan trackers when SubagentStop fires before TeammateIdle. - * Suppresses span closure at SubagentStop so TeammateIdle can close it - * with full all-turns content. */ - pendingTeammateIdle?: boolean; - /** Set when this Agent tool spawn carried a `team_name` (agent-teams model). - * The teammate runs in its OWN session, so its TeammateIdle fires under a - * different session_id and the per-session lookup misses. The invoke_agent - * span is registered in GlobalDaemon.teamMembers and closed there (at the - * teammate's TeammateIdle), NOT at the coordinator's PostToolUse(Agent). */ - teamName?: string; -} - -/** Cross-session team correlation. In agent-teams (TeamCreate) a teammate is an - * independent Claude session whose TeammateIdle fires under the teammate's own - * session_id, not the coordinator's — so the per-session SubagentTracking - * lookup misses. The coordinator's PreToolUse(Agent, team_name) is the one - * reliable anchor; we record its invoke_agent span here keyed by - * `${team_name}::${name}`. - * - * Entries are stored as a FIFO queue per key (not a single value) because the - * SAME `${team}::${name}` can be spawned more than once in a run — e.g. the - * TARS triage flow re-spawns a specialist (Sonnet→Opus) for deeper work. Each - * spawn pushes its own TeamMember; each teammate's TeammateIdle consumes the - * oldest not-yet-emitted entry (FIFO), so re-spawns never overwrite a live span - * (which would leak it and mis-attribute the first teammate's transcript). This - * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ -type TeamMember = { - invokeAgentSpan: Span; - conversationId: string; - coordinatorTranscriptPath: string; - emitted: boolean; -} - -/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated - * per session (deduped by path) and stamped as `gen_ai.system_instructions` on - * each turn root. */ -type LoadedInstruction = { filePath: string; content: string }; - -/** Append `item` to `list` in place, replacing any existing entry with the same - * filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than - * duplicates. Preserves each file's first-seen position. */ -function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { - const idx = list.findIndex((i) => i.filePath === item.filePath); - if (idx >= 0) list[idx] = item; - else list.push(item); +/** Open a chat (LLM) span under a turn or subagent, backdated to the request + * start. Undefined when the response has no model yet (LLMInit requires one); + * callers fall back to the turn and emit the span later. */ +function openChat(parent: weave.Turn | weave.SubAgent, call: AssistantCallDetail): weave.LLM | undefined { + if (!call.model) return undefined; + const provider = providerFromModel(call.model); + return parent.startLLM({ + model: call.model, + ...(provider ? { providerName: provider } : {}), + startTime: parseIsoOrNow(call.prevTimestamp ?? call.timestamp), + }); } -type SessionState = { - sessionId: string; - /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed - * turns stitch with their pre-resume turns server-side. Equals `sessionId` - * for fresh (non-forked) sessions. Resolved once at SessionStart by - * walking `forkedFrom.sessionId` pointers across transcript files. */ - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel?: string; - /** Integration identity (name, version, meta.*) as OTel Baggage, built once - * at SessionStart. Activated for every event in `routeEvent` so each span - * inherits it via `IntegrationBaggageSpanProcessor`. */ - integrationBaggage: Baggage; - - currentTurnSpan?: Span; - - turnNumber: number; - totalToolCalls: number; - turnToolCalls: number; - toolCounts: Record; - - pendingToolCalls: Map; - subagents: SubagentTracking; - - /** Chat span currently open for an in-progress assistant API call. Tool - * spans from PreToolUse parent here; finalized at Stop or on transition - * to the next API call. Cleared at Stop. */ - activeChatSpan?: ActiveChatSpan; - /** Response keys (see `chatMessageKey`) in the current turn for which a chat - * span has been opened (open or already finalized). Stop uses this to - * identify responses that need a chat span emitted from scratch (responses - * with no tool_use blocks never triggered PreToolUse). Reset per turn. */ - emittedChatSpanResponseKeys: Set; - - /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ - pendingCompaction?: CompactionAttrs; - - /** Instruction files (global/project CLAUDE.md, .claude/rules, @-imports) - * captured from InstructionsLoaded, in load order, deduped by path. Stamped - * on every turn root as `gen_ai.system_instructions`. */ - systemInstructions: LoadedInstruction[]; - +/** Populate a chat span from one assistant response, then end it. `agentName` + * keeps a subagent's/teammate's calls queryable by agent; conversation.id is + * inherited from the parent handle chain. */ +function recordChat(llm: weave.LLM, call: AssistantCallDetail, agentName?: string): void { + const parts = contentBlocksToParts(call.contentBlocks); + llm.record({ + ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), + usage: buildUsage(call.usage, call.reasoningTokens), + outputType: 'text', + ...(call.responseId ? { responseId: call.responseId } : {}), + ...(call.finishReason ? { finishReasons: [call.finishReason] } : {}), + }); + // agent.name isn't on record()'s surface — set directly. + if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); + llm.end({ endTime: parseIsoOrNow(call.timestamp) }); } // ───────────────────────────────────────────────────────────────────────────── // GlobalDaemon // ───────────────────────────────────────────────────────────────────────────── -// How long the daemon stays alive with no hook events before self-reaping. It -// only fires when nothing is in flight (the INFLIGHT_HOLD_MAX_MS guards keep an -// active turn/tool/team alive regardless), so this window purely governs how -// long an idle daemon stays warm for the next prompt. Set to 120 min so gaps in -// a working session (a long build, a meeting, lunch) don't reap the daemon and -// strand the resumed session on a fresh, amnesiac one, the dominant source of -// "Unknown session" drops. Longer idle gaps still reap; session reconstruction -// then recovers those. Override with WEAVE_INACTIVITY_MS. +// Idle window before self-reap; fires only with nothing in flight. 120 min so +// think-time gaps in a working session don't strand it on a fresh, amnesiac +// daemon ("Unknown session" drops); reconstruction covers longer gaps. +// Override with WEAVE_INACTIVITY_MS. const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes -// Absolute ceiling for holding the daemon open past the normal inactivity -// timeout while work is still in flight — either cross-session team -// correlation (hasUnemittedTeamMembers) or an ordinary open turn / pending -// tool / tracked subagent (hasInFlightWork); see checkInactivity. Bounds the -// pathological case (a teammate that never emits TeammateIdle, or a stuck -// session) so an in-flight entry can't pin the daemon forever. +// Ceiling for holding past the idle window while work is in flight (open +// turn/tool/subagent, or a teammate that hasn't reported) so a stuck entry +// can't pin the daemon forever. See checkInactivity. const INFLIGHT_HOLD_MAX_MS = 60 * 60 * 1_000; // 60 minutes const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message -/** - * Per-session container that tracks subagents from PreToolUse (when an Agent - * tool with subagent_type is detected) through SubagentStop. Single source of - * truth for the tracker list, with intent-revealing lookup methods. - */ -class SubagentTracking { - private trackers: SubagentTracker[] = []; - - /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ - add(tracker: SubagentTracker): void { - this.trackers.push(tracker); - } - - /** - * Find the unmatched tracker (no agent_id yet) matching `(promptHash, - * subagentType)`. FIFO across ties: the oldest pending tracker wins, so two - * back-to-back identical Agent calls still correlate in dispatch order. - * Returns undefined if no candidate qualifies. - */ - findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (t.agentId) continue; - if (t.promptHash !== promptHash) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - byAgentId(agentId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.agentId === agentId); - } - - /** Find a tracker awaiting TeammateIdle by its subagentType. Used to - * correlate TeammateIdle(teammate_name) with the orphan tracker created - * at SubagentStart. Returns the oldest pending match (FIFO). */ - findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (!t.pendingTeammateIdle) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - /** Lookup by spawning Agent tool's tool_use_id. Used at PostToolUse to find - * the subagent's `invoke_agent` span when the matching toolUseId is not - * in `pendingToolCalls` (because the Agent tool emits an invoke_agent - * span instead of an execute_tool span). */ - byToolUseId(toolUseId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.toolUseId === toolUseId); - } - - remove(tracker: SubagentTracker): void { - const idx = this.trackers.indexOf(tracker); - if (idx >= 0) this.trackers.splice(idx, 1); - } - - size(): number { - return this.trackers.length; - } - - all(): SubagentTracker[] { - return [...this.trackers]; - } -} - -/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 - * for a brand-new session, or the number of turns already on disk when - * reconstructing a session lost across a daemon restart (so the resumed turn - * keeps counting up instead of resetting to 1). */ -type NewSessionStateOptions = { - sessionId: string; - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel: string | undefined; - turnNumber: number; -}; - -/** Build a fresh SessionState. */ -function newSessionState(options: NewSessionStateOptions): SessionState { - const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = - options; - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet, the meta key is simply omitted. Built - // here (not at the SessionStart call site) so a session reconstructed after - // a daemon restart carries the same integration identity on its spans. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const version = headLine?.['version']; - const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; - const integrationBaggage = createIntegrationBaggage({ - version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, - }); - - return { - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - integrationBaggage, - turnNumber, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - systemInstructions: [], - }; -} - -/** Absolute real path of the daemon's own entry script, resolving the npm bin - * symlink to the actual dist/cli.js (or src/cli.ts under tsx). Lets `status` - * report which build the running daemon is executing. Falls back to the raw - * argv path if it can't be resolved. */ -function daemonEntryPath(): string { - const entry = process.argv[1] ?? ''; - try { - return fs.realpathSync(entry); - } catch { - return entry; - } -} - export class GlobalDaemon { private server?: net.Server; private running = false; @@ -519,44 +169,35 @@ export class GlobalDaemon { * hook can fire before SessionStart). Keyed by session_id; drained into the * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ private pendingInstructions = new Map(); - private provider: NodeTracerProvider | null = null; - private tracer: Tracer | null = null; - /** Cross-session team correlation, keyed by `${team_name}::${name}`. Bridges - * the coordinator's PreToolUse(Agent) to each teammate's TeammateIdle. The - * value is a FIFO queue: a re-spawned `${team}::${name}` appends rather than - * overwriting, so two live spans for the same name never collide. */ + /** True once `weave.init` has completed. All span emission is gated on it. */ + private tracingEnabled = false; + /** Cross-session team correlation (coordinator's PreToolUse(Agent) → the + * teammate's TeammateIdle), keyed `${team_name}::${name}`. FIFO queue per + * key so a re-spawned name never overwrites a live span. */ private teamMembers = new Map(); constructor( private readonly socketPath: string, private readonly logFile: string, - private readonly weaveProject: string | null, - private readonly apiKey: string | null, - private readonly baseUrl: string, - private readonly debugEnabled: boolean, - private readonly agentName: string, + private readonly config: DaemonConfig, ) {} async start(): Promise { - // Initialize the OTel tracer if Weave is configured - if (this.weaveProject && this.apiKey) { + if (this.config.weaveProject && this.config.apiKey) { try { - this.initTracer(); - this.log('INFO', `OTel tracer initialized — project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`); - this.log('INFO', `View traces: https://wandb.ai/${this.weaveProject}/weave/agents`); + await this.initWeave(); + this.log('INFO', `OTel tracer initialized — project=${this.config.weaveProject}, endpoint=${this.config.baseUrl}/agents/otel/v1/traces`); + this.log('INFO', `View traces: https://wandb.ai/${this.config.weaveProject}/weave/agents`); } catch (err) { this.log('ERROR', `Failed to initialize OTel tracer: ${err} — continuing without tracing`); - this.provider = null; - this.tracer = null; + this.tracingEnabled = false; } } else { this.log('INFO', 'No weave_project / API key configured — tracing disabled'); } - // Bind the socket, exiting cleanly if another daemon already owns it. - // Concurrent hook invocations can each cold-start a daemon, but only one - // can bind; the losers exit (process.exit(0)) and their hook still reaches - // the winner over the socket. See bindSocketWithHerdProtection. + // Concurrent hook invocations can each cold-start a daemon; only one + // binds, the losers exit and their event reaches the winner over the socket. await this.bindSocketWithHerdProtection(); this.running = true; @@ -564,13 +205,11 @@ export class GlobalDaemon { process.on('SIGTERM', () => void this.shutdown('SIGTERM')); process.on('SIGINT', () => void this.shutdown('SIGINT')); - // Without SIGHUP, Node terminates the process on terminal close with no JS - // handler — leaving the socket inode behind for the next hook event to - // mistake for a live daemon. Routing SIGHUP through shutdown() unlinks it. + // Without a SIGHUP handler, terminal close kills the process and leaves a + // stale socket inode behind; routing it through shutdown() unlinks it. process.on('SIGHUP', () => void this.shutdown('SIGHUP')); - // Belt-and-suspenders: catch any non-signal exit (uncaught exception, - // process.exit from elsewhere) and remove the inode. Does NOT cover SIGKILL - // or OOM — the hook handler's probe handles those at next event. + // Non-signal exits (uncaught exception, process.exit) also unlink. SIGKILL + // and OOM are not coverable — the hook handler's probe recovers those. process.on('exit', () => { try { if (fs.existsSync(this.socketPath)) fs.unlinkSync(this.socketPath); } catch { /* nothing more we can do */ } }); @@ -614,12 +253,9 @@ export class GlobalDaemon { }); } - /** - * Bind the daemon socket, tolerant of a herd of concurrent starts. Listen; on - * EADDRINUSE/EEXIST, re-probe: a live listener means another daemon won → exit - * 0; a stale inode is unlinked and retried. Only a confirmed-stale socket is - * ever unlinked, so a late starter can't delete the winner's live socket. - */ + /** Bind the socket, tolerant of a start herd: on EADDRINUSE/EEXIST, a live + * listener means another daemon won (exit 0); only a confirmed-stale inode + * is unlinked and retried, so a late starter can't delete the winner's. */ private async bindSocketWithHerdProtection(): Promise { const MAX_RECLAIM_ATTEMPTS = 5; for (let attempt = 0; ; attempt++) { @@ -642,43 +278,33 @@ export class GlobalDaemon { // ── tracer initialization ─────────────────────────────────────────────── - private initTracer(): void { - if (!this.weaveProject) throw new Error('weaveProject required to init tracer'); - if (!this.apiKey) throw new Error('apiKey required to init tracer'); + private async initWeave(): Promise { + if (!this.config.weaveProject) throw new Error('weaveProject required to init tracer'); + if (!this.config.apiKey) throw new Error('apiKey required to init tracer'); - const [entity, project] = this.weaveProject.split('/', 2); + const [entity, project] = this.config.weaveProject.split('/', 2); if (!entity || !project) { - throw new Error(`Invalid weave_project format: '${this.weaveProject}' (expected entity/project)`); - } - - // Route OTel diagnostics into the daemon log so exporter errors surface. - if (this.debugEnabled) { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN); - } - - const resource = resourceFromAttributes({ - // service.name has always mirrored the agent name; keep that coupling - // so a custom agent_name renames the OTel service too. - 'service.name': this.agentName, - 'service.version': VERSION, - 'wandb.entity': entity, - 'wandb.project': project, - }); - - const exporter = new OTLPTraceExporter({ - url: `${this.baseUrl}/agents/otel/v1/traces`, - headers: { 'wandb-api-key': this.apiKey }, - }); + throw new Error(`Invalid weave_project format: '${this.config.weaveProject}' (expected entity/project)`); + } + + // The SDK has no programmatic apiKey/host (login() writes netrc — wrong + // for a daemon); it reads env. WF_TRACE_SERVER_URL aims the exporter at + // our trace server. WANDB_BASE_URL is deliberately NOT set: weave treats + // it as the API host and would derive a wrong trace URL from it. + process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; + process.env['WANDB_API_KEY'] = this.config.apiKey; + + // Route OTel diag warnings/errors into the daemon log — the batch exporter + // otherwise drops every span silently on a bad key or unreachable host. + const otelDiag = (message: string, ...args: unknown[]) => + this.log('ERROR', `otel: ${message}${args.length ? ` ${args.map(String).join(' ')}` : ''}`); + diag.setLogger( + { verbose: otelDiag, debug: otelDiag, info: otelDiag, warn: otelDiag, error: otelDiag }, + DiagLogLevel.WARN, + ); - this.provider = new NodeTracerProvider({ - resource, - // IntegrationBaggageSpanProcessor runs first so it stamps the integration - // identity (from the active session baggage) onto every span before the - // batch processor exports it. - spanProcessors: [new IntegrationBaggageSpanProcessor(), new BatchSpanProcessor(exporter)], - }); - this.provider.register(); - this.tracer = this.provider.getTracer('weave-claude-code', VERSION); + await weave.init(this.config.weaveProject); + this.tracingEnabled = true; } // ── connection handling ─────────────────────────────────────────────────── @@ -731,12 +357,8 @@ export class GlobalDaemon { if (isControlMessage(payload)) { if (payload.command === 'config-hash') { - // Reply carries the config fingerprint (for drift detection) plus the - // daemon's runtime identity, so `status` can show which build is - // actually running (pid + version + resolved entry script) rather than - // just where the CLI symlink currently points. socket.end(JSON.stringify({ - config_hash: this.configFingerprint(), + config_hash: daemonConfigFingerprint(this.config), pid: process.pid, version: VERSION, path: daemonEntryPath(), @@ -769,96 +391,82 @@ export class GlobalDaemon { // ── event routing ───────────────────────────────────────────────────────── private async routeEvent(payload: HookPayload): Promise { - const eventName = payload['hook_event_name'] as string | undefined; - const sessionId = payload['session_id'] as string | undefined; - const agentId = payload['agent_id'] as string | undefined; - + // Trust the raw hook JSON against the SDK's schema once here so dispatch + // and handlers work with typed, discriminated inputs. + const input = payload as HookInput; + const sessionId = input.session_id; if (!sessionId) { this.log('ERROR', 'Missing session_id in payload'); return; } - this.log('INFO', `${eventName ?? 'unknown'} session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`); + this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); - // Activate the session's integration baggage for the whole event so every - // span created while handling it inherits the integration identity (copied - // on by IntegrationBaggageSpanProcessor). The session and its baggage don't - // exist until SessionStart runs, so that one event dispatches unwrapped — it - // creates no spans anyway. - const session = this.sessions.get(sessionId); - const eventContext = session - ? propagation.setBaggage(otelContext.active(), session.integrationBaggage) - : otelContext.active(); - await otelContext.with(eventContext, () => - this.dispatchEvent(eventName, sessionId, agentId, payload), - ); + // Fresh frame per event so the SDK's single-active guards never trip + // across concurrent sessions. Identity rides the held handles + // (conversation → turn → children), not the frame. + await weave.runIsolated(() => this.dispatchEvent(input, sessionId)); } - /** Run the handler for a single hook event. Split out from `routeEvent` so - * the latter can run it inside the session's baggage context. */ - private async dispatchEvent( - eventName: string | undefined, - sessionId: string, - agentId: string | undefined, - payload: HookPayload, - ): Promise { + /** Narrow `input` via the discriminant and run its handler (inside the + * per-event frame `routeEvent` installs). */ + private async dispatchEvent(input: HookInput, sessionId: string): Promise { try { - switch (eventName) { + switch (input.hook_event_name) { case 'SessionStart': - await this.handleSessionStart(sessionId, payload); + await this.handleSessionStart(sessionId, input); break; case 'InstructionsLoaded': - // Reads the instruction file synchronously off the hook's file_path; - // no async work to await. - this.handleInstructionsLoaded(sessionId, payload); + // Synchronous: reads the instruction file inline; nothing to await. + this.handleInstructionsLoaded(sessionId, input); break; case 'UserPromptSubmit': - await this.handleUserPromptSubmit(sessionId, payload); + await this.handleUserPromptSubmit(sessionId, input); break; case 'PreToolUse': - await this.handlePreToolUse(sessionId, agentId, payload); + await this.handlePreToolUse(sessionId, input); break; case 'PermissionRequest': - await this.handlePermissionRequest(sessionId, payload); + await this.handlePermissionRequest(sessionId, input); break; case 'PostToolUse': - await this.handlePostToolUse(sessionId, payload); + await this.handlePostToolUse(sessionId, input); break; case 'PostToolUseFailure': - await this.handlePostToolUseFailure(sessionId, payload); + await this.handlePostToolUseFailure(sessionId, input); break; case 'SubagentStart': - await this.handleSubagentStart(sessionId, payload); + await this.handleSubagentStart(sessionId, input); break; case 'SubagentStop': - await this.handleSubagentStop(sessionId, payload); + await this.handleSubagentStop(sessionId, input); break; case 'TeammateIdle': - await this.handleTeammateIdle(sessionId, payload); + await this.handleTeammateIdle(sessionId, input); break; case 'PreCompact': - await this.handlePreCompact(sessionId, payload); + await this.handlePreCompact(sessionId, input); break; case 'Stop': - await this.handleStop(sessionId, payload); + await this.handleStop(sessionId, input); break; case 'SessionEnd': - await this.handleSessionEnd(sessionId, payload); + await this.handleSessionEnd(sessionId, input); break; default: break; } } catch (err) { - this.log('ERROR', `Error handling ${eventName ?? 'unknown'}: ${err}`); + this.log('ERROR', `Error handling ${input.hook_event_name}: ${err}`); } } // ── event handlers ──────────────────────────────────────────────────────── - private async handleSessionStart(sessionId: string, payload: HookPayload): Promise { + private async handleSessionStart(sessionId: string, input: SessionStartHookInput): Promise { if (this.sessions.has(sessionId)) return; // idempotent - const rawPath = payload['transcript_path'] as string | undefined; + const rawPath = input.transcript_path; if (!rawPath) { this.log('ERROR', `Missing transcript_path for session ${sessionId}`); return; @@ -872,9 +480,9 @@ export class GlobalDaemon { return; } - const source = (payload['source'] as string | undefined) ?? 'unknown'; - const initialRequestModel = payload['model'] as string | undefined; - const cwd = (payload['cwd'] as string | undefined) ?? ''; + const source = input.source; + const initialRequestModel = input.model; + const cwd = input.cwd; const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); @@ -886,6 +494,8 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: 0, + agentName: this.config.agentName, + tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); this.drainPendingInstructions(session); @@ -899,20 +509,12 @@ export class GlobalDaemon { } /** - * Resolve the canonical `gen_ai.conversation.id` for a session by walking - * the `forkedFrom.sessionId` chain to the root. Returns `sessionId` itself - * for fresh (non-forked) sessions, or when the chain can't be resolved. - * - * `claude --continue` / `claude --resume ` produce a new process-level - * session_id but stamp every transcript line with `forkedFrom.sessionId` - * pointing at the immediate parent. Each transcript file is named after - * its session id and lives in the same project directory, so walking the - * chain is just sibling-file reads. - * - * SessionStart fires roughly when Claude Code flushes the first transcript - * line, so we retry briefly if the file isn't readable yet. The hard cap - * on chain depth is a sanity guard against pathological forking, not a - * real limit. + * Canonical `gen_ai.conversation.id`: the root of the `forkedFrom.sessionId` + * chain. `--continue`/`--resume` mint a new session_id but stamp the parent + * on every transcript line, and ancestors are sibling files named by session + * id, so the walk is plain file reads. Returns `sessionId` when there is no + * chain. The first head read retries briefly (SessionStart can beat the + * transcript flush); the depth cap is a pathological-forking guard. */ private async resolveConversationId( sessionId: string, @@ -949,9 +551,8 @@ export class GlobalDaemon { const parentPath = path.join(transcriptDir, `${parent}.jsonl`); current = parent; if (!fs.existsSync(parentPath)) { - // Parent transcript not on disk (e.g., resumed across machines). - // Stop here — the recorded parent id is still the best stitching - // key we have, even though we can't verify if IT was a fork too. + // Parent transcript not on disk (e.g. resumed across machines): stop — + // the recorded parent id is still the best stitching key we have. this.log( 'DEBUG', `resolveConversationId: parent transcript not on disk: ${parentPath} — stopping chain walk at ${parent}`, @@ -962,9 +563,8 @@ export class GlobalDaemon { } if (current !== sessionId && source !== 'resume') { - // Fork detected but `source` doesn't say resume — log so the mismatch - // is visible. We still stitch by the chain root because that's the - // correct behavior; this just surfaces an unexpected hook payload. + // Unexpected payload (fork found but source isn't 'resume') — still + // stitch by the chain root; just surface the mismatch. this.log( 'DEBUG', `resolveConversationId: forkedFrom chain found but source='${source}' (expected 'resume') session=${sessionId} root=${current}`, @@ -974,22 +574,19 @@ export class GlobalDaemon { } /** - * Return the tracked session, reconstructing it from the event's - * `transcript_path` when this daemon never saw its SessionStart. The daemon - * idles out after a short quiet window and keeps all session state in memory; - * Claude Code only emits SessionStart on startup/resume/clear/compact, so a - * session that outlives a daemon restart would otherwise be permanently - * untraced (the "Unknown session" errors). Every hook event carries - * `transcript_path`, which is enough to rebuild state and resume tracing. + * Return the tracked session, rebuilding it from the event's + * `transcript_path` when this daemon never saw its SessionStart — sessions + * outlive daemon restarts, and Claude Code re-emits SessionStart only on + * startup/resume/clear/compact. */ private async getOrReconstructSession( sessionId: string, - payload: HookPayload, + input: HookInput, ): Promise { const existing = this.sessions.get(sessionId); if (existing) return existing; - const rawPath = payload['transcript_path'] as string | undefined; + const rawPath = input.transcript_path; if (!rawPath) return undefined; let transcript: TranscriptFile; @@ -1000,9 +597,12 @@ export class GlobalDaemon { return undefined; } - const source = (payload['source'] as string | undefined) ?? 'reconstructed'; - const cwd = (payload['cwd'] as string | undefined) ?? ''; - const initialRequestModel = payload['model'] as string | undefined; + // source/model aren't on every hook variant (this reconstructs from a + // UserPromptSubmit), so read them best-effort off the raw record. + const raw = input as Record; + const source = (raw['source'] as string | undefined) ?? 'reconstructed'; + const cwd = input.cwd; + const initialRequestModel = raw['model'] as string | undefined; const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); // Seed the turn counter from the turns already on disk so numbering @@ -1022,6 +622,8 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: priorTurns, + agentName: this.config.agentName, + tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); this.drainPendingInstructions(session); @@ -1033,31 +635,18 @@ export class GlobalDaemon { } /** - * Capture one instruction file (global/project CLAUDE.md, .claude/rules, - * @-import) from InstructionsLoaded for `gen_ai.system_instructions`. - * - * The hook is observability-only and gives `file_path`, not contents, so we - * read the file ourselves. The read is synchronous to keep a session-start - * burst in load order (async reads could reorder); the files are small and - * local, off Claude Code's hot path. - * - * The hook's order vs SessionStart is not guaranteed (a file can load first), - * so instructions arriving before the session exists are buffered and drained - * on creation. We do not reconstruct here: handleSessionStart is idempotent, - * so a premature reconstruct would no-op the real one and lose its source/model. - * - * Only files loaded while this daemon runs are captured; a session - * reconstructed after a restart starts empty and picks up only files that - * (re)load afterward (e.g. load_reason=compact). + * Capture one instruction file (CLAUDE.md, .claude/rules, @-imports) for + * `gen_ai.system_instructions`. The hook carries only file_path, so read the + * file ourselves — synchronously, to keep a session-start burst in load + * order. Files can load BEFORE SessionStart, so instructions for unknown + * sessions are buffered and drained at session creation. (No reconstruct + * here: it would no-op the real SessionStart and lose its source/model.) */ - private handleInstructionsLoaded(sessionId: string, payload: HookPayload): void { - const filePath = payload['file_path'] as string | undefined; - const loadReason = (payload['load_reason'] as string | undefined) ?? 'unknown'; - if (!filePath) { - this.log('DEBUG', `InstructionsLoaded ignored (missing file_path): session=${sessionId}`); - return; - } - + private handleInstructionsLoaded(sessionId: string, input: InstructionsLoadedHookInput): void { + // Without tracing there is no turn to stamp these on — skip the file reads + // rather than buffer content that nothing will ever consume. + if (!this.tracingEnabled) return; + const filePath = input.file_path; let content: string; try { content = fs.readFileSync(filePath, 'utf8'); @@ -1078,7 +667,7 @@ export class GlobalDaemon { } this.log( 'DEBUG', - `InstructionsLoaded: session=${sessionId} reason=${loadReason} file=${path.basename(filePath)} bytes=${content.length}${session ? '' : ' (buffered)'}`, + `InstructionsLoaded: session=${sessionId} reason=${input.load_reason} file=${path.basename(filePath)} bytes=${content.length}${session ? '' : ' (buffered)'}`, ); } @@ -1092,123 +681,118 @@ export class GlobalDaemon { this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); } - private async handleUserPromptSubmit(sessionId: string, payload: HookPayload): Promise { - // Reconstruct the session if this daemon never saw its SessionStart (e.g. it - // idled out mid-session and a fresh daemon took over) so the rest of the - // session stays traced instead of dropping with "Unknown session". - const session = await this.getOrReconstructSession(sessionId, payload); + /** Open a turn under the session's conversation and stamp per-turn session + * metadata (queryable without a session-level span). Each turn roots its + * own trace; the conversation handle seeds conversation.id, agent identity, + * and integration attrs onto the whole subtree. */ + private startSessionTurn(session: SessionState, displayName: string, userMessage?: string): weave.Turn | undefined { + if (!session.conversation) return undefined; + const turn = session.conversation.startTurn({ + agentVersion: VERSION, + model: session.initialRequestModel, + userMessage, + systemInstructions: session.systemInstructions.map((i) => i.content), + startTime: new Date(), + }); + turn.setAttributes({ + [ATTR.WEAVE_SESSION_ID]: session.sessionId, + [ATTR.WEAVE_CWD]: session.cwd, + [ATTR.WEAVE_SOURCE]: session.source, + [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, + [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, + [ATTR.WEAVE_DISPLAY_NAME]: displayName, + }); + session.currentTurn = turn; + return turn; + } + + private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { + const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); return; } - if (!this.tracer) return; + if (!this.tracingEnabled) return; - const prompt = (payload['prompt'] as string | undefined) ?? ''; + const prompt = input.prompt; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn_span=${session.currentTurnSpan ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${promptSnippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${snippet(prompt, 120)}`, ); + // A user interrupt ends a turn with no Stop hook, so the previous turn (and + // its chat span) can still be open here. Close it as superseded before the + // new turn overwrites the handle, or its root span would never export. + this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); + session.turnNumber += 1; session.turnToolCalls = 0; session.emittedChatSpanResponseKeys.clear(); - const turnSpan = startTurnSpan(this.tracer, { - sessionId: session.sessionId, - conversationId: session.conversationId, - turnNumber: session.turnNumber, - prompt, - cwd: session.cwd, - source: session.source, - pluginVersion: VERSION, - agentName: this.agentName, - requestModel: session.initialRequestModel, - displayName: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, - systemInstructions: session.systemInstructions.map((i) => i.content), - }); - session.currentTurnSpan = turnSpan; + const turn = this.startSessionTurn(session, `Turn ${session.turnNumber}: ${snippet(prompt)}`, prompt); + if (!turn) return; // Drain compaction attrs buffered while no turn was open. if (session.pendingCompaction) { - setCompactionAttrs(turnSpan, session.pendingCompaction); + setCompactionAttrs(turn, session.pendingCompaction); session.pendingCompaction = undefined; } - - this.log( - 'INFO', - `Created turn span (turn ${session.turnNumber}) trace_id=${turnSpan.spanContext().traceId}`, - ); + this.log('INFO', `Created turn span (turn ${session.turnNumber})`); } - private async handlePreToolUse(sessionId: string, agentId: string | undefined, payload: HookPayload): Promise { + private async handlePreToolUse(sessionId: string, input: PreToolUseHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; + if (!session || !this.tracingEnabled) return; - const toolUseId = payload['tool_use_id'] as string | undefined; - const toolName = payload['tool_name'] as string | undefined; + const agentId = input.agent_id; + const toolUseId = input.tool_use_id; + const toolName = input.tool_name; if (!toolUseId || !toolName) return; - const toolInput = (payload['tool_input'] ?? {}) as Record; - - // Parent: subagent's invoke_agent span if this PreToolUse comes from inside - // a subagent, else the current turn span. - const parentSpan = agentId - ? session.subagents.byAgentId(agentId)?.invokeAgentSpan ?? session.currentTurnSpan - : session.currentTurnSpan; - if (!parentSpan) { - this.log('ERROR', `PreToolUse: no parent span for session=${sessionId} tool=${toolName}`); - return; - } - - // For the main agent, parent the tool span under its assistant response's - // chat span; subagents keep flat parenting under their invoke_agent span. - const toolParent = this.resolveMainAgentToolParent(session, agentId, toolUseId, parentSpan); - - // Agent tool with subagent_type → emit a nested `invoke_agent ` - // span, NOT an `execute_tool Agent` span. The Weave Agents chat view renders - // nested invoke_agent spans as their own `agent_start` lifecycle marker; an - // execute_tool wrapper here would mis-render the subagent dispatch as a - // generic tool call. The Agent tool's PostToolUse closes this span with the - // subagent's final return as `gen_ai.output.messages`. - // - // `promptHash` lets SubagentStart correlate this tracker to the right - // subagent deterministically by reading the subagent transcript's line 1 - // (the firing prompt) and matching by sha256 + subagent_type. - if (!agentId && toolName === 'Agent' && toolInput['subagent_type']) { + // tool_input is per-tool JSON the SDK types as `unknown`; narrow to index it. + const toolInput = (input.tool_input ?? {}) as Record; + + // Agent tool with subagent_type → a nested `invoke_agent` marker, NOT an + // `execute_tool Agent` span: the chat view renders nested invoke_agent as + // an `agent_start` lifecycle event, and a tool wrapper would mis-render the + // dispatch as a generic tool call. PostToolUse(Agent) closes the marker. + // `promptHash` lets SubagentStart correlate deterministically: sha256 of + // the firing prompt (the subagent transcript's line 1) + subagent_type. + // A dispatch from within a subagent (agent_id set) nests under the + // spawner's own marker, so recursive spawns keep their depth. + if (toolName === 'Agent' && toolInput['subagent_type']) { + const spawner = agentId ? session.subagents.byAgentId(agentId)?.subAgent : session.currentTurn; + if (!spawner) { + this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`); + return; + } const subagentType = toolInput['subagent_type'] as string; - const prompt = typeof toolInput['prompt'] === 'string' ? (toolInput['prompt'] as string) : ''; - const invokeAgentSpan = startInvokeAgentSpan(this.tracer, toolParent, { - agentType: subagentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - inputMessages: prompt ? [{ role: 'user', content: prompt }] : undefined, - spawningToolCallId: toolUseId, - displayName: toolDisplayName(toolName, toolInput), - }); - // Agent-teams: when the Agent tool carries a `team_name`, the teammate - // runs as its own session and TeammateIdle fires under the teammate's - // session_id. Register the invoke_agent span in the cross-session team - // map so TeammateIdle can find it regardless of which session fires it. - const teamName = typeof toolInput['team_name'] === 'string' ? (toolInput['team_name'] as string) : undefined; - const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? (toolInput['name'] as string) : subagentType; + const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; + const subAgent = spawner.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); + const subAttrs: Attributes = { + [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, + [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), + }; + if (prompt) subAttrs[ATTR.INPUT_MESSAGES] = jsonStr([{ role: 'user', content: prompt }]); + subAgent.setAttributes(subAttrs); + // A team_name spawn runs as its own session; register in the + // cross-session map so its TeammateIdle finds the span from any session. + const teamName = typeof toolInput['team_name'] === 'string' ? toolInput['team_name'] : undefined; + const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? toolInput['name'] : subagentType; session.subagents.add({ toolUseId, subagentType, detectedAt: new Date(), - invokeAgentSpan, + subAgent, promptHash: hashPrompt(prompt), teamName, }); - if (teamName) { - // Append to the per-key FIFO queue (do NOT overwrite): the same - // `${team}::${name}` may be spawned again later in the run (e.g. TARS - // re-spawns a specialist Sonnet→Opus). Overwriting would orphan the - // first, still-open span and mis-route its teammate's transcript. + if (teamName && session.conversation) { const key = `${teamName}::${memberName}`; const queue = this.teamMembers.get(key) ?? []; queue.push({ - invokeAgentSpan, - conversationId: session.conversationId, + subAgent, + conversation: session.conversation, coordinatorTranscriptPath: session.transcript.resolvedPath, emitted: false, }); @@ -1218,58 +802,46 @@ export class GlobalDaemon { return; } - const toolSpan = startToolSpan(this.tracer, toolParent, { - toolName, - toolUseId, - toolInput, - displayName: toolDisplayName(toolName, toolInput), + // Parent for the tool span. A subagent's tools nest under its own + // `invoke_agent` marker, tagged with the subagent's `gen_ai.agent.name` so + // they also stay queryable by agent (falling back to the turn if the marker + // is missing). For the main agent, nest under the active response's chat + // span (advanced from the transcript), falling back to the turn when the + // machine can't advance yet. + const tracker = agentId ? session.subagents.byAgentId(agentId) : undefined; + const parent: weave.Turn | weave.SubAgent | weave.LLM | undefined = agentId + ? tracker?.subAgent ?? session.currentTurn + : this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; + if (!parent) { + this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); + return; + } + + const tool = parent.startTool({ + name: toolName, + args: jsonStr(toolInput), + toolCallId: toolUseId, + startTime: new Date(), }); - session.pendingToolCalls.set(toolUseId, { span: toolSpan, toolName, toolInput }); + const toolAttrs: Attributes = { [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput) }; + if (tracker) toolAttrs[ATTR.AGENT_NAME] = tracker.subagentType; + tool.setAttributes(toolAttrs); + session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } /** - * Open (or reuse) the chat span for the assistant API response that produced - * `toolUseId`, so the tool span can parent under it. Reads the transcript to - * find which response the tool_use belongs to; on a transition to a new - * response, finalizes the previous chat span first. Returns the chat span, - * or `undefined` if the transcript can't be located / parsed yet (caller - * falls back to the turn span). - * - * Text/thinking children are NOT emitted here; they're emitted when the - * chat span is finalized (next transition or Stop), once all of the - * response's split transcript lines are present and can be stamped with - * their real timestamps. (Claude Code writes each content block as its own - * transcript line sharing a `message.id`; at PreToolUse the trailing lines - * may not be flushed yet.) - */ - /** - * Parent span for a tool span emitted from PreToolUse. For the main agent, - * advance the chat-span machine — find the assistant response containing this - * tool_use, ensure its `chat` span is open, and return it so the tool span - * nests under it. (The response's text/thinking children are emitted when the chat - * span is finalized, not here.) Falls back to `fallback` (the turn span) when - * the machine can't advance yet, e.g. the transcript writer hasn't flushed - * the assistant message. Subagents keep flat parenting under their - * invoke_agent span — their chat spans are emitted at SubagentStop / - * TeammateIdle, where the full transcript is available — so they return - * `fallback` unchanged. + * Chat-span state machine for the main agent: map `toolUseId` to its + * assistant response via the transcript, finalize the previous response's + * span on transition, and return the open one so the tool nests under it. + * Undefined when the transcript or the response's model hasn't flushed yet; + * the caller falls back to the turn. Output parts land at finalize time, + * once all of the response's split lines are on disk. */ - private resolveMainAgentToolParent( - session: SessionState, - agentId: string | undefined, - toolUseId: string, - fallback: Span, - ): Span { - if (agentId) return fallback; - return this.advanceMainAgentChatSpan(session, toolUseId) ?? fallback; - } - - private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): Span | undefined { - if (!this.tracer || !session.currentTurnSpan) return undefined; + private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { + if (!session.currentTurn) return undefined; - // Re-parses the whole transcript per main-agent PreToolUse: O(size) per - // tool call. Off CC's critical path (async daemon), so no editor latency; - // parse the current turn's tail instead if it shows up in profiling. + // O(transcript) re-parse per tool call — off CC's critical path (async + // daemon); parse the turn's tail instead if it shows up in profiling. let fd: number; try { fd = session.transcript.getFd(); @@ -1278,124 +850,75 @@ export class GlobalDaemon { } const parsed = parseSessionFd(fd); if (!parsed) return undefined; - const lastTurn = parsed.turns[parsed.turns.length - 1]; + const lastTurn = parsed.turns.at(-1); if (!lastTurn) return undefined; const calls = lastTurn.assistantCalls(); - const key = findToolUseResponseKey(calls, toolUseId); - if (!key) { - // Transcript writer hasn't flushed the assistant message yet. Fall back - // to the turn span. - return undefined; - } + const idx = calls.findIndex(c => c.contentBlocks.some(b => isToolUseBlock(b) && b.id === toolUseId)); + if (idx < 0) return undefined; // writer hasn't flushed the assistant message yet + const key = chatMessageKey(calls[idx], idx); // Transition to a new API response: finalize the previous chat span first. - if (session.activeChatSpan && session.activeChatSpan.responseKey !== key) { + if (session.activeChat && session.activeChat.responseKey !== key) { this.finalizeActiveChatSpan(session, calls); } - if (!session.activeChatSpan) { - // key came from findToolUseResponseKey above, so the group is non-empty. - const group = callsForResponseKey(calls, key); - const first = group[0]; - const span = startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model: group.map(c => c.model).find(Boolean), - startedAt: parseIsoOrNow(first.prevTimestamp ?? first.timestamp), - }); - session.activeChatSpan = { responseKey: key, span }; + if (!session.activeChat) { + const llm = openChat(session.currentTurn, calls[idx]); + if (!llm) return undefined; // model not flushed yet — emitted at Stop instead + session.activeChat = { responseKey: key, llm }; session.emittedChatSpanResponseKeys.add(key); } - return session.activeChatSpan.span; + return session.activeChat.llm; } - /** Finalize `session.activeChatSpan` from the current transcript and clear it. */ + /** Finalize `session.activeChat` from the current transcript and clear it. */ private finalizeActiveChatSpan(session: SessionState, calls: AssistantCallDetail[]): void { - const active = session.activeChatSpan; + const active = session.activeChat; if (!active) return; - this.emitChatSpanForResponse(session, calls, active.responseKey, active.span); - session.activeChatSpan = undefined; + this.emitChatSpanForResponse(session, calls, active.responseKey, active.llm); + session.activeChat = undefined; } - /** - * Emit a complete chat span for one assistant API response `key`. Emits each - * of the response's split transcript lines' text/thinking blocks as children - * stamped with that line's timestamp, so they sort into transcript order - * among the sibling `execute_tool` spans, which carry live PreToolUse times - * on the same wall clock. Usage is taken once from the response (the split - * lines duplicate the message usage, so it must not be summed), then the span - * is ended. Reuses `existingSpan` when the span was already opened during - * PreToolUse; otherwise opens a fresh one under the turn span. - */ + /** Emit a complete chat span for response `key`: its ordered text/thinking/ + * tool_use blocks become `gen_ai.output.messages` parts (tool calls nest + * under it as children). Reuses `existingLlm` when PreToolUse already + * opened the span; otherwise opens a fresh one under the turn. */ private emitChatSpanForResponse( session: SessionState, calls: AssistantCallDetail[], key: string, - existingSpan?: Span, + existingLlm?: weave.LLM, ): void { - if (!this.tracer || !session.currentTurnSpan) return; - // `key` always comes from a real call (findToolUseResponseKey / - // chatMessageKey) and transcripts are append-only, so the group is never - // empty. - const group = callsForResponseKey(calls, key); - - const model = group.map(c => c.model).find(Boolean); - const span = existingSpan ?? startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model, - startedAt: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), - }); - - for (const call of group) { - this.emitContentBlocks(span, call.contentBlocks, parseIsoOrNow(call.timestamp), session.conversationId); + if (!session.currentTurn) return; + const call = calls.find((c, i) => chatMessageKey(c, i) === key); + // No match: `key` is stale relative to `calls` — an interrupted turn's + // activeChat finalized against the next turn's parse. Close bare rather + // than fabricate content. + if (!call) { + existingLlm?.end(); + return; } - - // Usage is identical across the response's split lines (each carries the - // full message usage), so take it from the last line, which also carries - // the stop_reason, rather than summing. - const last = group[group.length - 1]; - const finishReason = group.map(c => c.finishReason).find(Boolean); - const parts = group.flatMap(c => c.contentBlocks); - finalizeChatSpan(span, { - usage: last.usage, - reasoningTokens: last.reasoningTokens, - responseId: last.responseId, - finishReasons: finishReason ? [finishReason] : undefined, - model, - outputMessages: parts.length ? [{ role: 'assistant', parts }] : undefined, - endedAt: parseIsoOrNow(last.timestamp), - }); + const llm = existingLlm ?? openChat(session.currentTurn, call); + if (!llm) { + this.log('DEBUG', `Chat span skipped (no model flushed for response ${key}); usage not recorded`); + return; + } + recordChat(llm, call); session.emittedChatSpanResponseKeys.add(key); } - /** Emit `assistant_text` / `thinking` spans for the text/thinking blocks in - * `blocks`, each stamped at `ts`. tool_use blocks are skipped; they render - * as their own live `execute_tool` spans. */ - private emitContentBlocks( - parent: Span, - blocks: unknown[], - ts: Date, - conversationId: string, - ): void { - if (!this.tracer) return; - for (const block of blocks) { - if (isTextBlock(block) && block.text.trim()) { - emitAssistantTextSpan(this.tracer, parent, { conversationId, text: block.text, startedAt: ts, endedAt: ts }); - } else if (isThinkingBlock(block) && block.thinking.trim()) { - emitThinkingSpan(this.tracer, parent, { conversationId, text: block.thinking, startedAt: ts, endedAt: ts }); - } else if (isRedactedThinkingBlock(block)) { - // Reasoning withheld by safety filtering: the `data` blob is encrypted, - // so surface a placeholder thinking span to keep it in transcript order. - emitThinkingSpan(this.tracer, parent, { conversationId, text: '[redacted]', startedAt: ts, endedAt: ts }); - } - } + private countToolCall(session: SessionState, toolName: string): void { + session.totalToolCalls += 1; + session.turnToolCalls += 1; + session.toolCounts[toolName] = (session.toolCounts[toolName] ?? 0) + 1; } - private async handlePermissionRequest(sessionId: string, payload: HookPayload): Promise { + private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolName = payload['tool_name'] as string | undefined; + const toolName = input.tool_name; if (!toolName) return; // Correlate to a pending tool call by tool_name + tool_input. Record the @@ -1403,7 +926,7 @@ export class GlobalDaemon { // once we know whether it was approved. let pending: PendingToolCall | undefined; for (const call of session.pendingToolCalls.values()) { - if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, payload['tool_input'])) { + if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, input.tool_input)) { pending = call; break; } @@ -1414,147 +937,117 @@ export class GlobalDaemon { } pending.permissionRequested = true; - addPermissionRequestEvent(pending.span, { - suggestions: payload['permission_suggestions'], + addPermissionRequestEvent(pending.tool, { + suggestions: input.permission_suggestions, timestamp: new Date(), }); this.log('DEBUG', `Permission request recorded for ${toolName}`); } - private async handlePostToolUse(sessionId: string, payload: HookPayload): Promise { + /** + * Settle an Agent-dispatch tracker at PostToolUse[Failure]: its span is the + * subagent's `invoke_agent` marker, closed here with the tool's canonical + * return. Team spawns stay open (the Agent tool returns immediately; the + * team map owns the marker until the teammate's TeammateIdle) — only the + * tracker drops. Returns true when `toolUseId` was a subagent dispatch. + */ + private settleSubagentDispatch( + session: SessionState, + toolUseId: string, + output: unknown, + failure: boolean, + ): boolean { + const tracker = session.subagents.byToolUseId(toolUseId); + if (!tracker?.subAgent) return false; + if (!tracker.teamName) this.closeSubagent(tracker, output, failure); + session.subagents.remove(tracker); + this.countToolCall(session, 'Agent'); + return true; + } + + private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolUseId = payload['tool_use_id'] as string | undefined; + const toolUseId = input.tool_use_id; if (!toolUseId) return; - // Subagent dispatch: the matching span is the subagent's invoke_agent - // span (not a pendingToolCall), so we close it here with the subagent's - // final assistant text as `gen_ai.output.messages`. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { - if (subagentTracker.teamName) { - // Agent-teams: the Agent tool returns immediately (teammate runs async - // in its own session). Do NOT close the invoke_agent span — it would - // end empty before the teammate works. The team map owns it now. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, payload['tool_response'], /*failure*/ false); - session.subagents.remove(subagentTracker); - } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; - return; - } + if (this.settleSubagentDispatch(session, toolUseId, input.tool_response, /*failure*/ false)) return; const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; resolvePermissionIfPending(pending, true); - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(payload['tool_response'])); - pending.span.end(); + pending.tool.result = jsonStr(input.tool_response); + pending.tool.end(); session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; + this.countToolCall(session, pending.toolName); } - private async handlePostToolUseFailure(sessionId: string, payload: HookPayload): Promise { + private async handlePostToolUseFailure(sessionId: string, input: PostToolUseFailureHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolUseId = payload['tool_use_id'] as string | undefined; + const toolUseId = input.tool_use_id; if (!toolUseId) return; - const error = payload['error'] ?? payload['tool_response']; - - // Subagent dispatch failed (rare). Close the invoke_agent span with ERROR - // status; subagent chat spans, if any reached SubagentStop, are already - // attached as children. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { - if (subagentTracker.teamName) { - // Agent-teams: the team map owns this span (closed at the teammate's - // TeammateIdle, cross-session). Closing it here would end it early and - // then double-end when TeammateIdle fires. Mirror handlePostToolUse: - // just drop the per-session tracker; the queue entry lives on. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, error, /*failure*/ true); - session.subagents.remove(subagentTracker); - } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; - return; - } + const error = input.error; + + if (this.settleSubagentDispatch(session, toolUseId, error, /*failure*/ true)) return; const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; resolvePermissionIfPending(pending, false); - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(error)); - pending.span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(error)); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: typeof error === 'string' ? error : 'tool failed' }); - pending.span.end(); + pending.tool.result = error; + pending.tool.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(error) }); + // The SDK records the exception + ERROR status from `error`. + pending.tool.end({ error: new Error(error) }); session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; + this.countToolCall(session, pending.toolName); } - /** - * Close a subagent's `invoke_agent` span. Idempotent — guarded by - * `tracker.ended` so PostToolUse and SubagentStop can both safely call this - * regardless of order. Sets `gen_ai.output.messages` from the canonical - * tool return string when available; marks the span ERROR on failure. - */ - private closeSubagentInvokeAgentSpan( + /** Close a subagent's `invoke_agent` marker with its output; ERROR on + * failure. Idempotent via `tracker.ended`, so PostToolUse and SubagentStop + * can both call it regardless of order. */ + private closeSubagent( tracker: SubagentTracker, output: unknown, failure: boolean, ): void { - const span = tracker.invokeAgentSpan; - if (!span || tracker.ended) return; + const sub = tracker.subAgent; + if (!sub || tracker.ended) return; if (output !== undefined && output !== null && output !== '') { const outputText = typeof output === 'string' ? output : jsonStr(output); - span.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr([{ role: 'assistant', content: outputText }]), - ); + sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([outputText]) }); } if (failure) { - span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(output)); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: typeof output === 'string' ? output : 'subagent failed', - }); + sub.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(output) }); + sub.end({ error: new Error(typeof output === 'string' ? output : 'subagent failed') }); + } else { + sub.end(); } - span.end(); tracker.ended = true; } - private async handleSubagentStart(sessionId: string, payload: HookPayload): Promise { + private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; + if (!session || !this.tracingEnabled) return; - const agentId = payload['agent_id'] as string | undefined; + const agentId = input.agent_id; if (!agentId) return; - const agentType = (payload['agent_type'] as string | undefined) ?? 'unknown'; + const agentType = input.agent_type; - // Content-based deterministic correlation: SubagentStart carries no - // pointer back to the parent's `tool_use_id`, so we read the subagent's - // transcript line 1 (the firing user prompt — byte-identical to the - // parent Agent tool's `tool_input.prompt`) and match by sha256 of that - // string plus the subagent_type. No temporal window. + // SubagentStart carries no pointer to the spawning tool_use_id — match + // deterministically by sha256 of the firing prompt (the subagent + // transcript's line 1, byte-identical to the Agent tool's prompt) + type. const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); const firstLine = await readSubagentFirstLineWithRetry(subagentPath); const firingPrompt = extractUserMessageContent(firstLine); @@ -1566,14 +1059,10 @@ export class GlobalDaemon { const matched = !!bestTracker; if (!bestTracker) { - // No matching Agent tool call — either the parent's PreToolUse never - // fired, or the firing prompt couldn't be read from the subagent - // transcript. Create an orphan tracker AND an orphan `invoke_agent` - // span so the subagent still produces a valid nested agent invocation - // in the chat view. The span parents under the current turn (no - // spawning tool_use_id) and has no input_messages (firing prompt - // unavailable). Closed at SubagentStop since there will be no - // PostToolUse for it. + // No matching Agent tool call (the parent's PreToolUse never fired, or + // the firing prompt couldn't be read). Create an orphan tracker + marker + // so the subagent still renders as a nested invocation; closed at + // SubagentStop since no PostToolUse will come for it. const reason = firingPrompt === undefined ? 'transcript line 1 missing or non-user' : `no tracker matches (promptHash, type=${agentType})`; @@ -1584,83 +1073,60 @@ export class GlobalDaemon { transcriptPath: subagentPath, pendingTeammateIdle: true, }; - if (session.currentTurnSpan) { - bestTracker.invokeAgentSpan = startInvokeAgentSpan(this.tracer, session.currentTurnSpan, { - agentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - displayName: `Agent: ${agentType}`, - }); - bestTracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, reason); + if (session.currentTurn) { + bestTracker.subAgent = this.startOrphanSubagent(session.currentTurn, agentType, reason); } session.subagents.add(bestTracker); } bestTracker.agentId = agentId; - if (bestTracker.invokeAgentSpan) { - // Stamp the runtime agent_id on the subagent's invoke_agent span — the - // chat view uses `gen_ai.agent.id` to label the subagent's subtree. - bestTracker.invokeAgentSpan.setAttribute(ATTR.AGENT_ID, agentId); + if (bestTracker.subAgent) { + // The chat view uses gen_ai.agent.id to label the subagent's subtree. + bestTracker.subAgent.record({ agentId }); } this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); } - /** The session's open turn span, opening a fresh one if a restart left the - * session without a turn, so a subagent recovered at SubagentStop has a parent. */ - private getOrReconstructTurnSpan(session: SessionState): Span | undefined { - if (session.currentTurnSpan) return session.currentTurnSpan; - if (!this.tracer) return undefined; - const turnNumber = session.turnNumber || 1; - const turnSpan = startTurnSpan(this.tracer, { - sessionId: session.sessionId, - conversationId: session.conversationId, - turnNumber, - prompt: '', - cwd: session.cwd, - source: session.source, - pluginVersion: VERSION, - agentName: this.agentName, - requestModel: session.initialRequestModel, - displayName: `Turn ${turnNumber} (reconstructed)`, + /** Open an orphan `invoke_agent` marker under `turn` for a subagent with no + * matched Agent tool call, stamping why it exists outside the normal path. */ + private startOrphanSubagent(turn: weave.Turn, agentType: string, orphanReason: string): weave.SubAgent { + const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + subAgent.setAttributes({ + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: orphanReason, }); - session.currentTurnSpan = turnSpan; - this.log( - 'INFO', - `Reconstructed turn span (turn ${turnNumber}) after restart trace_id=${turnSpan.spanContext().traceId}`, - ); - return turnSpan; + return subAgent; + } + + /** The session's open turn, opening a fresh one if a restart left the + * session without a turn, so a subagent recovered at SubagentStop has a parent. */ + private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { + if (session.currentTurn) return session.currentTurn; + session.turnNumber ||= 1; + const turn = this.startSessionTurn(session, `Turn ${session.turnNumber} (reconstructed)`); + if (turn) this.log('INFO', `Reconstructed turn span (turn ${session.turnNumber}) after restart`); + return turn; } - /** Rebuild a subagent tracker when SubagentStop finds none: the subagent started - * under a daemon that has since restarted. Opens an invoke_agent span under the - * turn so the normal emit path records it instead of dropping it. */ + /** Rebuild a subagent tracker when SubagentStop finds none: the subagent + * started under a daemon that has since restarted. Opens an invoke_agent + * span under the turn so the normal emit path records it instead of + * dropping it. */ private recoverSubagentTracker( session: SessionState, agentId: string, - payload: HookPayload, + agentType: string, ): SubagentTracker | undefined { - if (!this.tracer) return undefined; - const turnSpan = this.getOrReconstructTurnSpan(session); - if (!turnSpan) return undefined; - // Type comes from the SubagentStop payload's agent_type; 'unknown' if absent. - const agentType = (payload['agent_type'] as string | undefined) ?? 'unknown'; - const invokeAgentSpan = startInvokeAgentSpan(this.tracer, turnSpan, { - agentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - displayName: `Agent: ${agentType}`, - }); - invokeAgentSpan.setAttribute(ATTR.AGENT_ID, agentId); - invokeAgentSpan.setAttribute( - ATTR.WEAVE_ORPHAN_REASON, - 'recovered at SubagentStop after daemon restart (no tracker)', - ); + const turn = this.getOrReconstructTurn(session); + if (!turn) return undefined; + const subAgent = this.startOrphanSubagent(turn, agentType, 'recovered at SubagentStop after daemon restart (no tracker)'); + subAgent.record({ agentId }); const tracker: SubagentTracker = { subagentType: agentType, detectedAt: new Date(), agentId, - invokeAgentSpan, + subAgent, transcriptPath: computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId), }; session.subagents.add(tracker); @@ -1668,30 +1134,29 @@ export class GlobalDaemon { return tracker; } - private async handleSubagentStop(sessionId: string, payload: HookPayload): Promise { + private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { // Reconstruct the session if a restart lost it (see getOrReconstructSession). - const session = await this.getOrReconstructSession(sessionId, payload); - if (!session || !this.tracer) return; + const session = await this.getOrReconstructSession(sessionId, input); + if (!session || !this.tracingEnabled) return; - const agentId = payload['agent_id'] as string | undefined; + const agentId = input.agent_id; if (!agentId) return; // No tracker: the subagent started under a since-restarted daemon. Recover it. const tracker = session.subagents.byAgentId(agentId) - ?? this.recoverSubagentTracker(session, agentId, payload); + ?? this.recoverSubagentTracker(session, agentId, input.agent_type); if (!tracker) { this.log('ERROR', `SubagentStop: no tracker for agentId=${agentId} and none recoverable`); return; } - // Chat spans for the subagent's LLM calls parent under the subagent's - // own invoke_agent span. For orphan trackers without an invoke_agent - // span (no current turn at SubagentStart), fall back to the turn span. - const chatParent = tracker.invokeAgentSpan ?? session.currentTurnSpan; + // Chats nest under the subagent's marker; orphans without one fall back to + // the turn. The agent.name tag keeps them queryable either way. + const chatParent = tracker.subAgent ?? session.currentTurn; // Fall back to the stored or agentId-derived path when the payload omits it. const agentTranscriptPath = - (payload['agent_transcript_path'] as string | undefined) ?? + input.agent_transcript_path ?? tracker.transcriptPath ?? computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); let model: string | undefined; @@ -1701,23 +1166,19 @@ export class GlobalDaemon { try { agentTranscript = new TranscriptFile(agentTranscriptPath); const parsed = parseSessionFd(agentTranscript.getFd()); - // Use the last turn only. Subagent transcripts are almost always - // single-turn; the rare 2-turn case occurs when the parent agent's - // prior assistant message is carried in as pre-context on line 0 - // and the user prompt that fires the subagent appears on line 1. - // Emitting chat spans from earlier turns would mis-attribute the - // parent's LLM call to this subagent invocation. - const lastTurn = parsed?.turns[parsed.turns.length - 1]; + // Last turn only: a subagent transcript occasionally carries the + // parent's prior assistant message as pre-context (a 2-turn parse); + // emitting earlier turns would mis-attribute the parent's LLM call + // to this subagent invocation. + const lastTurn = parsed?.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); - if (lastTurn) { - emitChatSpansFromAssistantCalls( - this.tracer, - chatParent, - session.conversationId, - lastTurn.assistantCalls(), - ); + // pendingTeammateIdle: TeammateIdle will emit the FULL transcript as + // the teammate's own turn trace; emitting the last turn here too would + // double-count its chat spans (and their token usage). + if (lastTurn && !tracker.pendingTeammateIdle) { + this.emitChatSpans(chatParent, lastTurn.assistantCalls(), tracker.subagentType); } } catch (err) { this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); @@ -1726,21 +1187,17 @@ export class GlobalDaemon { } } - if (tracker.invokeAgentSpan) { + if (tracker.subAgent) { // Stamp the model the subagent actually ran on (Claude Code's // SubagentStart payload doesn't carry the model; the transcript does). if (model) { - tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); + tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); } - // Orphan path: no PostToolUse will fire, so close the invoke_agent - // span here — unless TeammateIdle is expected to follow (FleetView/ - // Teammate pattern). In that case, keep the span open so TeammateIdle - // can emit all-turns content and close it correctly. - // Matched path: leave the span open for PostToolUse to close with the - // canonical tool_response and remove the tracker; if we removed the - // tracker here, byToolUseId at PostToolUse would miss it. + // Close only plain orphans (no PostToolUse will fire for them). Matched + // trackers wait for PostToolUse's canonical tool_response; orphans + // awaiting TeammateIdle stay open so it can emit all-turns content. if (!tracker.ended && !tracker.toolUseId && !tracker.pendingTeammateIdle) { - this.closeSubagentInvokeAgentSpan(tracker, lastAssistantText, /*failure*/ false); + this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); } } @@ -1757,50 +1214,35 @@ export class GlobalDaemon { } } - private async handleTeammateIdle(sessionId: string, payload: HookPayload): Promise { - if (!this.tracer) return; - // FAIL-OPEN on a missing session: in the agent-teams model this hook fires - // under the TEAMMATE's session_id, which may not be registered with this - // daemon (only the coordinator is). `session` is therefore OPTIONAL for the - // cross-session team path and only REQUIRED for the per-session fallback. - // Do NOT early-return on a missing session — that would silently drop - // cross-session nesting, the whole point of this handler. + private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { + if (!this.tracingEnabled) return; + // Do NOT early-return on a missing session: this hook fires under the + // TEAMMATE's session_id, which the daemon usually doesn't track. `session` + // is optional for the cross-session path, required only for the fallback. const session = this.sessions.get(sessionId); - // TeammateIdle payload (actual schema, confirmed from live TARS triage): - // session_id — the teammate's session UUID (NOT the coordinator's) - // teammate_name — agent name, e.g. "cks-specialist". INVARIANT: must equal - // the `name` the coordinator passed to the Agent tool (in - // TARS, name === subagent_type), else the lookup misses. - // team_name — team name, e.g. "triage-supp-25017" - // transcript_path — CC sets this to the coordinator's transcript (not the - // teammate's), so we ignore it and use the path stored - // at SubagentStart instead. - // - // Note: CC docs incorrectly listed agent_id / agent_type — those fields do - // not appear in practice. - const agentType = (payload['teammate_name'] as string | undefined) ?? 'teammate'; - const teamName = (payload['team_name'] as string | undefined) ?? '?'; - - // ── Cross-session team path (agent-teams / TeamCreate model) ───────── - // The coordinator's PreToolUse(Agent, team_name) registered the invoke_agent - // span in teamMembers under `${team_name}::${name}` (a FIFO queue). Consume - // the OLDEST not-yet-emitted entry, so re-spawns of the same name match in - // dispatch order instead of overwriting each other. + // Payload (verified against live TARS runs; CC docs wrongly list + // agent_id/agent_type): teammate_name must equal the Agent tool's `name` + // or the lookup misses, and transcript_path is the COORDINATOR's — the + // teammate's own path is resolved separately. + const agentType = input.teammate_name; + const teamName = input.team_name; + + // Cross-session team path: consume the oldest not-yet-emitted queue entry + // for `${team}::${name}`, so re-spawns match in dispatch order. const key = `${teamName}::${agentType}`; const queue = this.teamMembers.get(key); if (queue && queue.length) { const member = queue.find(m => !m.emitted); if (!member) { - // All queued spans for this key already emitted — duplicate (repeat) - // TeammateIdle. Expected; nothing to do. + // Duplicate (repeat) TeammateIdle — expected; nothing to do. this.log('DEBUG', `TeammateIdle: ${key} all ${queue.length} entries already emitted — skipping duplicate idle`); return; } member.emitted = true; - const idleTranscript = session?.transcript.resolvedPath ?? (payload['transcript_path'] as string | undefined); + const idleTranscript = session?.transcript.resolvedPath ?? input.transcript_path; const teammateTranscriptPath = this.resolveTeammateTranscript(member.coordinatorTranscriptPath, agentType, idleTranscript); - this.emitTeammateTranscript(member.invokeAgentSpan, member.conversationId, teammateTranscriptPath); + this.emitTeammateTurnTrace(member.subAgent, member.conversation, agentType, teammateTranscriptPath); // Remove the consumed entry; drop the key once its queue drains. const idx = queue.indexOf(member); if (idx >= 0) queue.splice(idx, 1); @@ -1809,92 +1251,48 @@ export class GlobalDaemon { return; } - // No team entry for this key. If OTHER team keys ARE registered, this most - // likely means the teammate_name ≠ Agent.name invariant was violated — log - // it loudly (not silently) so it is debuggable, then try the per-session path. + // Other team keys registered but not this one: most likely the + // teammate_name ≠ Agent.name invariant broke — log it, then fall through. if (this.teamMembers.size > 0) { this.log('INFO', `TeammateIdle: no team entry for ${key} (registered: ${[...this.teamMembers.keys()].join(', ')}) — check teammate_name === Agent.name`); } - // ── Per-session path (individual Agent calls without team_name) ────── - // Requires the firing session to be known to this daemon. Find the orphan - // tracker created at SubagentStart; SubagentStop left its invoke_agent span - // open specifically so we can close it here with full all-turns content. + // Per-session path (Agent calls without team_name): find the orphan + // tracker whose span SubagentStop left open for us to close with content. if (!session) { this.log('DEBUG', `TeammateIdle: session ${sessionId} unknown and no team entry for ${key} — skipping`); return; } const tracker = session.subagents.findPendingTeammateIdle(agentType); - if (!tracker?.invokeAgentSpan) { + if (!tracker?.subAgent) { this.log('DEBUG', `TeammateIdle: no pending tracker for ${agentType} team=${teamName} — skipping`); return; } - // Use the transcript path stored at SubagentStart — more reliable than - // the payload's transcript_path which CC sets to the coordinator's path. - const transcriptPath = tracker.transcriptPath; + const transcriptPath = tracker.transcriptPath; // payload's is the coordinator's this.log('DEBUG', `TeammateIdle: agent=${agentType} team=${teamName} transcript=${transcriptPath ?? '(none)'}`); - let model: string | undefined; - let lastAssistantText: string | undefined; - let agentTranscript: TranscriptFile | undefined; - try { - if (!transcriptPath) throw new Error('no transcript path stored at SubagentStart'); - agentTranscript = new TranscriptFile(transcriptPath); - const parsed = parseSessionFd(agentTranscript.getFd()); - if (parsed) { - // Emit chat spans for ALL turns. Teammates are independent top-level - // sessions — every turn is their own work. SubagentStop only emitted - // the last turn; we replace that with full coverage here. - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls( - this.tracer, - tracker.invokeAgentSpan, - session.conversationId, - turn.assistantCalls(), - ); - } - const lastTurn = parsed.turns[parsed.turns.length - 1]; - model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); - } - } catch (err) { - this.log('DEBUG', `TeammateIdle: could not parse transcript ${transcriptPath}: ${err}`); - } finally { - agentTranscript?.close(); - } - - if (model) tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); - if (lastAssistantText) { - tracker.invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); - } - - this.closeSubagentInvokeAgentSpan(tracker, lastAssistantText, /*failure*/ false); + if (!session.conversation) return; + const model = this.emitTeammateTurnTrace(tracker.subAgent, session.conversation, agentType, transcriptPath); + tracker.ended = true; session.subagents.remove(tracker); this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${transcriptPath ?? '(no transcript)'}`); } - /** Resolve a teammate's OWN transcript. TeammateIdle.session_id is unreliable - * (sometimes the teammate's, sometimes the coordinator's), so the idle - * session's transcript may be the coordinator's. The authoritative source is - * `/subagents/agent-.jsonl` paired with a sibling - * `agent-.meta.json` carrying `{"agentType": }`. Match by - * agentType === teammateName; pick the most-recently-modified if re-spawned. */ + /** Resolve a teammate's OWN transcript: the coordinator's subagents dir + * holds `agent-.jsonl` + `agent-.meta.json` ({"agentType": name}); + * match by agentType, newest mtime wins (re-spawns). Falls back to the idle + * session's transcript (TeammateIdle.session_id is unreliable). */ private resolveTeammateTranscript( coordinatorTranscriptPath: string, teammateName: string, idleTranscriptPath: string | undefined, ): string | undefined { try { - const projectDir = path.dirname(coordinatorTranscriptPath); - const sessionDirName = path.basename(coordinatorTranscriptPath, '.jsonl'); - const subagentsDir = path.join(projectDir, sessionDirName, 'subagents'); + const subagentsDir = subagentsDirFor(coordinatorTranscriptPath); if (fs.existsSync(subagentsDir)) { let best: { p: string; mtime: number } | undefined; for (const meta of fs.readdirSync(subagentsDir).filter(f => f.endsWith('.meta.json'))) { @@ -1915,13 +1313,33 @@ export class GlobalDaemon { return idleTranscriptPath; } - /** Parse a teammate's transcript and emit its chat spans under the given - * invoke_agent span, then end it. Used by the cross-session team path. */ - private emitTeammateTranscript( - invokeAgentSpan: Span, - conversationId: string, - transcriptPath: string | undefined, + /** Emit one chat span per assistant response under `parent`, reconstructed + * from transcript data (backdated times, usage, ordered output parts). */ + private emitChatSpans( + parent: weave.Turn | weave.SubAgent, + calls: AssistantCallDetail[], + agentName?: string, ): void { + for (const call of calls) { + const llm = openChat(parent, call); + if (llm) recordChat(llm, call, agentName); + } + } + + /** + * Emit a teammate's whole transcript as its OWN turn trace (the spawning + * coordinator turn has long closed), then close the SubAgent marker. The + * coordinator's Conversation handle seeds conversation.id + integration + * identity, neither of which inherits cross-session; the turn is backdated + * to span the transcript so its chat children stay inside its window. + * Returns the teammate's model, if known. + */ + private emitTeammateTurnTrace( + subAgent: weave.SubAgent, + conversation: weave.Conversation, + agentType: string, + transcriptPath: string | undefined, + ): string | undefined { let model: string | undefined; let lastAssistantText: string | undefined; let t: TranscriptFile | undefined; @@ -1929,41 +1347,60 @@ export class GlobalDaemon { if (!transcriptPath) throw new Error('no teammate transcript path'); t = new TranscriptFile(transcriptPath); const parsed = parseSessionFd(t.getFd()); - if (parsed && this.tracer) { - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(this.tracer, invokeAgentSpan, conversationId, turn.assistantCalls()); + if (parsed?.turns.length) { + const allCalls = parsed.turns.flatMap((pt) => pt.assistantCalls()); + const first = allCalls[0]; + const turn = conversation.startTurn({ + agentName: agentType, + agentVersion: VERSION, + startTime: parseIsoOrNow(first?.prevTimestamp ?? first?.timestamp), + }); + turn.setAttributes({ [ATTR.WEAVE_DISPLAY_NAME]: `Teammate: ${agentType}` }); + try { + for (const parsedTurn of parsed.turns) { + this.emitChatSpans(turn, parsedTurn.assistantCalls(), agentType); + } + } finally { + // In a finally so a mid-emit throw can't leak the root un-exported. + turn.end({ endTime: parseIsoOrNow(allCalls.at(-1)?.timestamp) }); } - const lastTurn = parsed.turns[parsed.turns.length - 1]; + const lastTurn = parsed.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); } } catch (err) { - this.log('DEBUG', `emitTeammateTranscript: could not parse ${transcriptPath}: ${err}`); + this.log('DEBUG', `emitTeammateTurnTrace: could not parse ${transcriptPath}: ${err}`); } finally { t?.close(); } - if (model) invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); + if (model) subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); if (lastAssistantText) { - invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); + subAgent.setAttributes({ + [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([lastAssistantText]), + }); } - invokeAgentSpan.end(); + subAgent.end(); + return model; } - private async handlePreCompact(sessionId: string, payload: HookPayload): Promise { + private async handlePreCompact(sessionId: string, input: PreCompactHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; + // Live CC payloads carry a summary + item counts the SDK type doesn't + // declare — read them off the raw record. + const raw = input as Record; + const summary = raw['summary'] ?? raw['compaction_summary']; + const itemsBefore = raw['items_before']; + const itemsAfter = raw['items_after']; const attrs: CompactionAttrs = { - summary: (payload['summary'] as string | undefined) ?? (payload['compaction_summary'] as string | undefined), - itemsBefore: typeof payload['items_before'] === 'number' ? (payload['items_before'] as number) : undefined, - itemsAfter: typeof payload['items_after'] === 'number' ? (payload['items_after'] as number) : undefined, + summary: typeof summary === 'string' ? summary : undefined, + itemsBefore: typeof itemsBefore === 'number' ? itemsBefore : undefined, + itemsAfter: typeof itemsAfter === 'number' ? itemsAfter : undefined, }; - if (session.currentTurnSpan) { - setCompactionAttrs(session.currentTurnSpan, attrs); + if (session.currentTurn) { + setCompactionAttrs(session.currentTurn, attrs); this.log('INFO', `PreCompact attached to active turn ${session.turnNumber} (session ${sessionId})`); } else { // Buffer until the next UserPromptSubmit opens a turn span. @@ -1972,75 +1409,70 @@ export class GlobalDaemon { } } - private async handleStop(sessionId: string, payload: HookPayload): Promise { + private async handleStop(sessionId: string, input: StopHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session?.currentTurnSpan || !this.tracer) return; + if (!session?.currentTurn) return; - // Pass last_assistant_message so the retry waits for the synthesis to - // flush — otherwise the final chat span drops when the read races the writer. - const rawFinalMessage = payload['last_assistant_message']; - const finalAssistantMessage = typeof rawFinalMessage === 'string' ? rawFinalMessage : undefined; + // The retry waits for the final synthesis to flush; otherwise the last + // chat span drops when the read races the writer. + const finalAssistantMessage = input.last_assistant_message; const parsedSession = await this.parseSessionFileWithRetry( session.transcript, finalAssistantMessage, ); - const currentTurn = parsedSession?.turns[parsedSession.turns.length - 1]; + const currentTurn = parsedSession?.turns.at(-1); const model = currentTurn?.primaryModel(); const transcriptTurns = parsedSession?.turns.length ?? 0; this.log( 'DEBUG', - `Stop: session=${sessionId} trace_id=${session.currentTurnSpan.spanContext().traceId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(payload['last_assistant_message'])}`, + `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); - // Finalize the chat-span state machine for this turn. - // - The active chat span (open during PreToolUse) gets its trailing - // text/thinking children plus its usage attrs, then ends. - // - Assistant calls that never triggered a PreToolUse (final text-only - // message, or any other tool-less call) get a fresh chat span emitted - // here with their full content as children. + // Finalize the active chat span (open since PreToolUse), then emit fresh + // spans for responses that never opened one (tool-less responses). if (currentTurn) { const calls = currentTurn.assistantCalls(); - if (session.activeChatSpan) { + if (session.activeChat) { this.finalizeActiveChatSpan(session, calls); } - // Emit a chat span for every response that never opened one during - // PreToolUse (tool-less responses, or any not yet emitted). for (let i = 0; i < calls.length; i++) { const key = chatMessageKey(calls[i], i); if (session.emittedChatSpanResponseKeys.has(key)) continue; this.emitChatSpanForResponse(session, calls, key); } + } else if (session.activeChat) { + // Parse failed (retry budget exhausted): close the chat span bare rather + // than leak it un-ended and leave a stale key for the next turn. + session.activeChat.llm.end(); + session.activeChat = undefined; } const parsedTexts = currentTurn?.textBlocks() ?? []; - const lastMessage = (payload['last_assistant_message'] as string | undefined) ?? ''; + const lastMessage = input.last_assistant_message ?? ''; const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); + const turnAttrs: Attributes = { [ATTR.WEAVE_TURN_TOOL_COUNT]: session.turnToolCalls }; if (assistantMessages.length) { - session.currentTurnSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))), - ); + turnAttrs[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(assistantMessages); } - - // Aggregate finish reasons from per-call detail const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); if (finishReasons?.length) { - session.currentTurnSpan.setAttribute(ATTR.RESPONSE_FINISH_REASONS, finishReasons); + turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; } - + session.currentTurn.setAttributes(turnAttrs); + // Through record(), not setAttributes: Turn.end() re-emits + // gen_ai.request.model from its internal field, so a raw attribute write + // of the parsed model would be clobbered by the initial-request model. if (model) { - session.currentTurnSpan.setAttribute(ATTR.REQUEST_MODEL, model); + session.currentTurn.record({ model }); } - - session.currentTurnSpan.setAttribute(ATTR.WEAVE_TURN_TOOL_COUNT, session.turnToolCalls); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; + session.currentTurn.end(); + session.currentTurn = undefined; this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`); } - private async handleSessionEnd(sessionId: string, payload: HookPayload): Promise { + private async handleSessionEnd(sessionId: string, input: SessionEndHookInput): Promise { // Discard any never-drained instruction buffer (e.g. a session that emitted // InstructionsLoaded but never SessionStart) so the map can't leak. this.pendingInstructions.delete(sessionId); @@ -2049,7 +1481,7 @@ export class GlobalDaemon { this.log( 'DEBUG', - `SessionEnd: session=${sessionId} reason=${(payload['reason'] as string | undefined) ?? 'unknown'} transcript_path=${session.transcript.resolvedPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, + `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, ); this.finalizeSession(session, 'session_ended'); @@ -2076,60 +1508,66 @@ export class GlobalDaemon { * per span — each builder ends at most once. */ private finalizeSession(session: SessionState, orphanReason: string): void { - // Close any pending tool calls that were never completed + this.finalizeOpenTurn(session, orphanReason); + + // Close any subagent invoke_agent spans that didn't receive PostToolUse + // or SubagentStop. Without this they'd leak open and never export. + for (const tracker of session.subagents.all()) { + if (tracker.subAgent && !tracker.ended) { + tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); + tracker.ended = true; + } + this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + } + } + + /** + * Close everything still open on the current turn (pending tools, active + * chat, the root span), stamping `orphanReason`. The chat span is finalized + * from the transcript like Stop does; only a failed parse closes bare. + * Callers: finalizeSession, and handleUserPromptSubmit for turns a user + * interrupt ended without a Stop hook (the interrupt also kills in-flight + * tools) — otherwise the next turn would overwrite the handle and leak the + * root unexported. + */ + private finalizeOpenTurn(session: SessionState, orphanReason: string): void { for (const [toolUseId, pending] of session.pendingToolCalls) { resolvePermissionIfPending(pending, false); - pending.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: 'tool did not complete before shutdown' }); - pending.span.end(); + pending.tool.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + pending.tool.end({ error: new Error(`tool did not complete (${orphanReason})`) }); this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); } session.pendingToolCalls.clear(); - // Finalize a chat span left open mid-turn (Stop never fired) from the - // now-flushed transcript, like Stop does, so its text + usage aren't lost. - // Bare orphan close only if the parse fails or the turn span is gone. - if (session.activeChatSpan) { + if (session.activeChat) { let finalized = false; - if (session.currentTurnSpan) { + if (session.currentTurn) { let parsed: ParsedSession | null = null; try { parsed = parseSessionFd(session.transcript.getFd()); } catch { parsed = null; } - const lastTurn = parsed?.turns[parsed.turns.length - 1]; + const lastTurn = parsed?.turns.at(-1); if (lastTurn) { this.finalizeActiveChatSpan(session, lastTurn.assistantCalls()); finalized = true; } } - if (session.activeChatSpan) { - session.activeChatSpan.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.activeChatSpan.span.end(); - session.activeChatSpan = undefined; + if (session.activeChat) { + session.activeChat.llm.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.activeChat.llm.end(); + session.activeChat = undefined; } this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); } - // Close the current turn (root) span if still open - if (session.currentTurnSpan) { - session.currentTurnSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; - this.log('DEBUG', `Closed orphaned turn span`); - } - - // Close any subagent invoke_agent spans that didn't receive PostToolUse - // or SubagentStop. Without this they'd leak open and never export. - for (const tracker of session.subagents.all()) { - if (tracker.invokeAgentSpan && !tracker.ended) { - tracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - tracker.invokeAgentSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'subagent did not complete before shutdown' }); - tracker.invokeAgentSpan.end(); - tracker.ended = true; - } - this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + if (session.currentTurn) { + session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.currentTurn.end(); + session.currentTurn = undefined; + this.log('DEBUG', `Closed orphaned turn span (${orphanReason})`); } } @@ -2138,23 +1576,15 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; - // Do NOT shut down while cross-session team correlation is in flight: a - // shutdown wipes the in-memory teamMembers map and breaks nesting for every - // still-open specialist span. Agent-teams runs have quiet windows (engineer - // think-time; gaps between spawn and first teammate report) that would - // otherwise trip the 10-min timeout mid-triage. Hold open until the team - // work drains, bounded by INFLIGHT_HOLD_MAX_MS so a crashed teammate that - // never emits TeammateIdle can't pin the daemon indefinitely. + // A shutdown wipes the in-memory teamMembers map and breaks nesting for + // every still-open specialist span; agent-teams runs have long quiet + // windows, so hold open (bounded) until the team work drains. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasUnemittedTeamMembers()) { this.log('DEBUG', 'Inactivity timeout reached but team correlation in flight — staying up'); return; } - // Also hold open while ordinary work is in flight: an open turn span, a - // pending tool call, or a tracked subagent. A long-running tool or turn - // (longer than the timeout, with no other session active) would otherwise - // trip the timeout mid-flight — dropping the still-open spans and forcing - // the resumed work onto a fresh, amnesiac daemon. Same INFLIGHT_HOLD_MAX_MS - // ceiling so a stuck session can't pin the daemon indefinitely. + // Same for ordinary in-flight work: a tool or turn running longer than + // the timeout would otherwise be cut off mid-flight. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasInFlightWork()) { this.log('DEBUG', 'Inactivity timeout reached but work in flight — staying up'); return; @@ -2163,8 +1593,7 @@ export class GlobalDaemon { void this.shutdown('inactivity'); } - /** True if any registered team member still awaits its TeammateIdle. Used to - * keep the daemon alive across an agent-teams run's quiet windows. */ + /** True if any registered team member still awaits its TeammateIdle. */ private hasUnemittedTeamMembers(): boolean { for (const queue of this.teamMembers.values()) { if (queue.some(m => !m.emitted)) return true; @@ -2172,12 +1601,10 @@ export class GlobalDaemon { return false; } - /** True if any session has work in flight: an open turn span, a pending tool - * call, or a tracked subagent. Keeps the daemon alive across the inactivity - * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ + /** True if any session has an open turn, a pending tool, or a tracked subagent. */ private hasInFlightWork(): boolean { for (const s of this.sessions.values()) { - if (s.currentTurnSpan) return true; + if (s.currentTurn) return true; if (s.pendingToolCalls.size > 0) return true; if (s.subagents.size() > 0) return true; } @@ -2192,37 +1619,38 @@ export class GlobalDaemon { } /** - * Everything a shutdown does except the final `process.exit`: end in-flight - * spans, flush the exporter, and release the socket. Split out from - * `shutdown` so it can be exercised in tests without terminating the process. - * - * Order matters: open sessions are finalized (their root turn spans ended) - * BEFORE `provider.shutdown()` flushes, so those roots make the final export - * batch instead of being dropped — the fix for rootless traces left behind - * when the daemon exits mid-turn. + * Everything shutdown does except `process.exit` (testable in-process). + * Order matters: sessions finalize BEFORE `weave.flushOTel()`, so roots + * ended here make the final export batch instead of leaking rootless traces. */ private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); this.server?.close(); - // Backstop: close any queued team-member invoke_agent spans whose teammate - // never emitted a TeammateIdle (e.g. teammate crashed, or daemon exits - // mid-triage) so they flush as ended spans instead of leaking. + // Close queued team-member spans whose teammate never reported (crashed, + // or the daemon exits mid-triage) as orphaned instead of leaking them. for (const [, queue] of this.teamMembers) { for (const m of queue) { - if (!m.emitted) { try { m.invokeAgentSpan.end(); } catch { /* best effort */ } } + if (m.emitted) continue; + try { + m.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: 'daemon_shutdown' }); + m.subAgent.end({ error: new Error('teammate did not complete before shutdown') }); + } catch { /* best effort */ } } } this.teamMembers.clear(); - // Finalize every live session's still-open spans (turn root, active chat, - // pending tools, subagents) so an interrupted turn keeps its exported root. + // Per-session try: one bad session must not abort the flush below. for (const session of this.sessions.values()) { - this.finalizeSession(session, 'daemon_shutdown'); + try { + this.finalizeSession(session, 'daemon_shutdown'); + } catch (err) { + this.log('ERROR', `Error finalizing session ${session.sessionId} at shutdown: ${err}`); + } } - if (this.provider) { + if (this.tracingEnabled) { try { - await this.provider.shutdown(); + await weave.flushOTel(); } catch (err) { - this.log('ERROR', `Error shutting down OTel provider: ${err}`); + this.log('ERROR', `Error flushing Weave SDK: ${err}`); } } for (const session of this.sessions.values()) { @@ -2235,10 +1663,9 @@ export class GlobalDaemon { // ── helpers ─────────────────────────────────────────────────────────────── - /** Retry parseSessionFile while the transcript writer catches up to Stop. - * If `finalAssistantMessage` is set, require the last assistant call's - * text to end with it (mod trailing whitespace) — guards against reading - * before the synthesis line lands. Default budget: 5 × 200ms = 1s. */ + /** Retry the transcript parse while the writer catches up to Stop; when + * `finalAssistantMessage` is set, require the last assistant text to end + * with it. Budget: 5 × 200ms. */ private async parseSessionFileWithRetry( transcript: TranscriptFile, finalAssistantMessage?: string, @@ -2256,12 +1683,9 @@ export class GlobalDaemon { let result: ReturnType = null; for (let i = 0; i < attempts; i++) { result = parseSessionFd(fd); - // Writer caught up: parsed at least one turn AND (no synthesis to verify, - // OR the last assistant call ends with it). if (result?.turns.length && (!expected || lastAssistantTextEndsWith(result, expected))) { return result; } - // No next parse to wait for on the last iteration, so skip the sleep. if (i < attempts - 1) await new Promise(r => setTimeout(r, delayMs)); } return result; @@ -2289,74 +1713,12 @@ export class GlobalDaemon { return 'tool_error'; } - /** Fingerprint of the config this daemon loaded at startup. Held in memory; - * replied over the socket for the `config-hash` control message. */ - private configFingerprint(): string { - return daemonConfigFingerprint({ - weaveProject: this.weaveProject, - apiKey: this.apiKey, - baseUrl: this.baseUrl, - agentName: this.agentName, - debug: this.debugEnabled, - }); - } - private log(level: 'DEBUG' | 'INFO' | 'ERROR', msg: string): void { - if (level === 'DEBUG' && !this.debugEnabled) return; + if (level === 'DEBUG' && !this.config.debug) return; appendToLog(this.logFile, level, msg); } } -// ───────────────────────────────────────────────────────────────────────────── -// Config resolution and fingerprinting -// ───────────────────────────────────────────────────────────────────────────── - -/** The config the daemon loads at startup and holds for its lifetime. */ -type DaemonConfig = { - weaveProject: string | null; - apiKey: string | null; - baseUrl: string; - agentName: string; - debug: boolean; -} - -/** Resolve the effective daemon config from settings + env, applying the same - * env-over-settings precedence the daemon uses at startup. */ -export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): DaemonConfig { - return { - weaveProject: env['WEAVE_PROJECT'] ?? settings.weave_project ?? null, - apiKey: env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null, - baseUrl: resolveTraceBaseUrl(env), - // `||` (not `??`) so an empty/whitespace value falls through to the default - // rather than producing a blank `invoke_agent ` span name. - agentName: env['WEAVE_AGENT_NAME']?.trim() || settings.agent_name?.trim() || DEFAULT_AGENT_NAME, - debug: !!env['WEAVE_CLAUDE_DEBUG'] || settings.debug === true, - }; -} - -/** Resolve the Weave trace server base URL for OTLP export. `WF_TRACE_SERVER_URL` - * wins when set. Otherwise `WANDB_BASE_URL` is used, but SaaS `api.wandb.ai` is - * the wandb API host with no OTLP route, so it maps to `trace.wandb.ai`; a - * self-hosted `WANDB_BASE_URL` passes through unchanged. */ -function resolveTraceBaseUrl(env: NodeJS.ProcessEnv): string { - const explicit = env['WF_TRACE_SERVER_URL']?.trim(); - if (explicit) return explicit.replace(/\/+$/, ''); - const base = (env['WANDB_BASE_URL'] ?? 'https://trace.wandb.ai').replace(/\/+$/, ''); - return /^https?:\/\/api\.wandb\.ai$/i.test(base) ? 'https://trace.wandb.ai' : base; -} - -/** Hex chars kept from the config hash. 16 (64 bits) is ample to detect a - * config change while keeping the value compact for logs and the socket reply. */ -const CONFIG_FINGERPRINT_LENGTH = 16; - -/** Short, stable hash of a daemon config. The API key is hashed, not exposed, - * so the fingerprint is safe to send over the socket. */ -export function daemonConfigFingerprint(c: DaemonConfig): string { - return createHash('sha256') - .update(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) - .digest('hex') - .slice(0, CONFIG_FINGERPRINT_LENGTH); -} // ───────────────────────────────────────────────────────────────────────────── // Entry point (invoked by `weave-claude-code daemon`) @@ -2368,18 +1730,15 @@ export async function runDaemon(): Promise { fs.mkdirSync(path.dirname(logFile), { recursive: true }); - const { weaveProject, apiKey, baseUrl, agentName, debug } = resolveDaemonConfig(settings, process.env); + const config = resolveDaemonConfig(settings, process.env); - if (!weaveProject || !apiKey) { - const missing = [!weaveProject && 'weave_project', !apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); + if (!config.weaveProject || !config.apiKey) { + const missing = missingConfig(!!config.weaveProject, !!config.apiKey, 'WANDB_API_KEY'); appendToLog(logFile, 'INFO', `Daemon not started — missing configuration: ${missing}`); process.exit(0); } - // Ensure downstream tooling (e.g. wandb settings) still sees the API key. - process.env['WANDB_API_KEY'] = apiKey; - - const daemon = new GlobalDaemon(socketPath, logFile, weaveProject, apiKey, baseUrl, debug, agentName); + const daemon = new GlobalDaemon(socketPath, logFile, config); try { await daemon.start(); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 9d4813d..a73339b 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -2,73 +2,49 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -import { - Attributes, - Baggage, - Span, - SpanKind, - Tracer, - Context, - TimeInput, - context as otelContext, - propagation, - trace, -} from '@opentelemetry/api'; -import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { extractAssistantTextBlocks } from './parser.js'; -import type { AssistantCallDetail, UsageSummary } from './parser.js'; +// Attribute-key constants and formatting helpers. Span construction lives +// with the SDK handles: conversations start in sessionState.ts, everything +// else in daemon.ts. -// ───────────────────────────────────────────────────────────────────────────── -// Attribute keys -// -// Canonical `gen_ai.*` keys come from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai). `weave.*` keys are -// Claude-Code-specific extensions with no semconv equivalent. Compaction keys -// (`weave.compaction.*`) match the Weave Agents backend's semconv exactly — -// the backend extracts them into dedicated span columns. -// ───────────────────────────────────────────────────────────────────────────── +import type { Attributes } from '@opentelemetry/api'; +import type { MessagePart, Tool, Turn, Usage } from 'weave'; +import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; +import type { UsageSummary } from './parser.js'; + +// Attribute keys: `gen_ai.*` from the OTel GenAI semconv +// (https://github.com/open-telemetry/semantic-conventions-genai); `weave.*` +// are Claude-Code-specific extensions the backend routes into its queryable +// custom-attribute maps (compaction keys get dedicated columns). export const ATTR = { - // GenAI semconv — classification + // GenAI semconv - classification OPERATION_NAME: 'gen_ai.operation.name', - PROVIDER_NAME: 'gen_ai.provider.name', - // GenAI semconv — agent + // GenAI semconv - agent AGENT_NAME: 'gen_ai.agent.name', AGENT_ID: 'gen_ai.agent.id', - AGENT_DESCRIPTION: 'gen_ai.agent.description', - AGENT_VERSION: 'gen_ai.agent.version', CONVERSATION_ID: 'gen_ai.conversation.id', - // GenAI semconv — model - REQUEST_MODEL: 'gen_ai.request.model', + // GenAI semconv - model RESPONSE_MODEL: 'gen_ai.response.model', RESPONSE_ID: 'gen_ai.response.id', RESPONSE_FINISH_REASONS: 'gen_ai.response.finish_reasons', - // GenAI semconv — usage + // GenAI semconv - usage USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens', USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens', - USAGE_REASONING_TOKENS: 'gen_ai.usage.reasoning_tokens', USAGE_CACHE_READ_INPUT_TOKENS: 'gen_ai.usage.cache_read.input_tokens', USAGE_CACHE_CREATION_INPUT_TOKENS: 'gen_ai.usage.cache_creation.input_tokens', - // GenAI semconv — tool - TOOL_NAME: 'gen_ai.tool.name', - TOOL_CALL_ID: 'gen_ai.tool.call.id', - TOOL_CALL_ARGUMENTS: 'gen_ai.tool.call.arguments', - TOOL_CALL_RESULT: 'gen_ai.tool.call.result', - - // GenAI semconv — messages + // GenAI semconv - messages INPUT_MESSAGES: 'gen_ai.input.messages', OUTPUT_MESSAGES: 'gen_ai.output.messages', SYSTEM_INSTRUCTIONS: 'gen_ai.system_instructions', - OUTPUT_TYPE: 'gen_ai.output.type', - // GenAI semconv — errors + // GenAI semconv - errors ERROR_TYPE: 'error.type', - // Weave extensions — claude_code per-turn metadata + // Weave extensions - claude_code per-turn metadata WEAVE_SESSION_ID: 'weave.claude_code.session.id', WEAVE_CWD: 'weave.claude_code.cwd', WEAVE_SOURCE: 'weave.claude_code.source', @@ -78,24 +54,18 @@ export const ATTR = { WEAVE_ORPHAN_REASON: 'weave.claude_code.orphan_reason', WEAVE_DISPLAY_NAME: 'weave.claude_code.display_name', - // Integration identity — attributes the trace to the emitting integration - // (this plugin) so the Weave Agents backend can group/filter by integration - // alongside peers (weave-openclaw, the playground's `weave.source`). Distinct - // from `gen_ai.agent.name`, which is user-overridable and changes per - // subagent. These are non-semconv `weave.*` keys, so the backend routes them - // into its queryable custom-attribute maps. Stamped on the turn (invoke_agent) - // root; `meta.*` keys (built with WEAVE_INTEGRATION_META_PREFIX) carry - // free-form per-session context. + // Integration identity: attributes the trace to this plugin, alongside + // peers like weave-openclaw. Unlike gen_ai.agent.name it is not + // user-overridable and never changes per subagent. Set on the session's + // conversation so the SDK copies it onto every span. WEAVE_INTEGRATION_NAME: 'weave.integration.name', WEAVE_INTEGRATION_VERSION: 'weave.integration.version', - // Back-pointer from a subagent `invoke_agent` span to the parent agent's - // `Agent` tool call that spawned it. Set on the inner `invoke_agent` span - // so queries can correlate the subagent invocation with the spawning - // tool_use_id without walking the span tree. + // Back-pointer from a subagent's invoke_agent span to the tool_use_id of + // the Agent call that spawned it (correlation without walking the tree). WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID: 'weave.claude_code.subagent.spawning_tool_call_id', - // Weave Agents backend — compaction (set as span attributes on the turn span; + // Weave Agents backend - compaction (set as span attributes on the turn span; // the backend extracts these into dedicated columns) COMPACTION_SUMMARY: 'weave.compaction.summary', COMPACTION_ITEMS_BEFORE: 'weave.compaction.items_before', @@ -108,98 +78,15 @@ export const ATTR = { EVT_PERMISSION_SUGGESTIONS: 'weave.permission.suggestions', } as const; -/** - * Default name for the top-level agent: the value shown in Weave's Agents - * view and stamped as `gen_ai.agent.name` on every turn span. Users can - * override it (settings `agent_name` / `WEAVE_AGENT_NAME`); this is the - * fallback when neither is set. - */ +/** Top-level `gen_ai.agent.name` fallback; users override via settings + * `agent_name` / `WEAVE_AGENT_NAME`. */ export const DEFAULT_AGENT_NAME = 'claude-code'; -/** - * Stable identifier for this integration, stamped as `weave.integration.name` - * on every turn span. Unlike the agent name (`gen_ai.agent.name`), it is not - * user-overridable and does not change for subagents, so it's a reliable - * dimension for "which integration produced this trace" in the Weave Agents - * backend. - */ -export const INTEGRATION_NAME = 'weave-claude-code'; - -/** - * Prefix for free-form integration metadata. Each entry of a turn's - * `integrationMeta` is stamped as `weave.integration.meta.`, so new - * fields (e.g. `claude_code_app_version`) need no new attribute constant. - */ -export const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; - -/** Common prefix for all integration-identity attributes. The span processor - * copies baggage entries under this prefix onto each span. */ -export const WEAVE_INTEGRATION_PREFIX = 'weave.integration.'; - -/** - * Build the per-session integration Baggage. `name` is the fixed integration - * id; `version` is the plugin version; `meta` is free-form per-session context - * flattened to `weave.integration.meta.` (falsy values skipped). The - * daemon activates this baggage for each session event so - * `IntegrationBaggageSpanProcessor` stamps it onto every span the event emits. - */ -export function createIntegrationBaggage(args: { - version: string; - meta?: Record; -}): Baggage { - const entries: Record = { - [ATTR.WEAVE_INTEGRATION_NAME]: { value: INTEGRATION_NAME }, - [ATTR.WEAVE_INTEGRATION_VERSION]: { value: args.version }, - }; - if (args.meta) { - for (const [key, value] of Object.entries(args.meta)) { - if (value) entries[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = { value }; - } - } - return propagation.createBaggage(entries); -} - -/** - * Copies `weave.integration.*` baggage entries off the active context onto each - * span at start. This is how integration identity reaches every span (turn - * root and all children) from a single per-session baggage attribution, instead - * of stamping each builder. Runs at `onStart` because attributes set after a - * span ends are dropped; the copy is a one-time snapshot (baggage is static per - * session). Baggage itself is never exported, only the copied attributes. - */ -export class IntegrationBaggageSpanProcessor implements SpanProcessor { - onStart(span: SdkSpan, parentContext: Context): void { - const baggage = propagation.getBaggage(parentContext); - if (!baggage) return; - for (const [key, entry] of baggage.getAllEntries()) { - if (key.startsWith(WEAVE_INTEGRATION_PREFIX)) { - span.setAttribute(key, entry.value); - } - } - } - onEnd(_span: ReadableSpan): void {} - forceFlush(): Promise { - return Promise.resolve(); - } - shutdown(): Promise { - return Promise.resolve(); - } -} +const INTEGRATION_NAME = 'weave-claude-code'; -// Values for `gen_ai.operation.name`. `invoke_agent`, `chat`, and -// `execute_tool` are well-known values from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md#gen-ai-operation-name); -// the spec mandates the well-known value whenever one applies. `assistant_text` -// and `thinking` have no well-known equivalent, so they're spec-permitted custom -// values — the model's natural-language output and its private reasoning, each -// emitted as a `chat` child so they interleave with sibling `execute_tool` spans. -export const OP = { - INVOKE_AGENT: 'invoke_agent', - CHAT: 'chat', - EXECUTE_TOOL: 'execute_tool', - ASSISTANT_TEXT: 'assistant_text', - THINKING: 'thinking', -} as const; +/** Free-form integration metadata prefix: new fields (e.g. + * `claude_code_app_version`) need no new attribute constant. */ +const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -207,7 +94,7 @@ export const OP = { /** * Derive `gen_ai.provider.name` from a model id. Returns undefined when the - * routing layer is ambiguous — better to omit than guess. + * routing layer is ambiguous - better to omit than guess. */ export function providerFromModel(model: string | undefined): string | undefined { if (!model) return undefined; @@ -227,441 +114,103 @@ export function jsonStr(v: unknown): string { } } -/** - * Context carrying `parent` as the active span for child-span creation. - * Builds on `context.active()` (not `ROOT_CONTEXT`) so baggage on the active - * context propagates to children — relevant if we ever wire baggage for - * cross-process trace continuity. - */ -export function ctxWithParent(parent: Span): Context { - return trace.setSpan(otelContext.active(), parent); +/** `gen_ai.output.messages` JSON for plain assistant text(s) — the shape used + * on turn and subagent `invoke_agent` spans (chat spans carry parts instead). */ +export function assistantOutputMessages(texts: string[]): string { + return jsonStr(texts.map((content) => ({ role: 'assistant', content }))); } -// ───────────────────────────────────────────────────────────────────────────── -// Span builders -// ───────────────────────────────────────────────────────────────────────────── - -type TurnSpanArgs = { - /** Current process's Claude Code session id — stamped on the span as a - * debug breadcrumb (`weave.claude_code.session.id`). Per resume, this - * changes; the conversation id does not. */ - sessionId: string; - /** Stitching key for the multi-turn conversation. For resumed sessions, - * this is the root ancestor's session id (so turns from before and after - * resume share `gen_ai.conversation.id`). For fresh sessions, equals - * `sessionId`. */ - conversationId: string; - turnNumber: number; - prompt: string; - cwd: string; - source: string; - pluginVersion: string; - /** Top-level agent name; becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. Defaults to `DEFAULT_AGENT_NAME`; - * the daemon resolves any user override before calling. */ - agentName: string; - requestModel?: string; - displayName?: string; - /** Loaded instruction-file contents (global/project CLAUDE.md, .claude/rules, - * @-imports) in load order, stamped as `gen_ai.system_instructions` (one text - * part per file) when non-empty. */ - systemInstructions?: string[]; -}; - -/** - * Start a turn span. Each turn is the root of its own trace; the Weave Agents - * backend stitches turns into a conversation via `gen_ai.conversation.id`. - * Session-level metadata (cwd, source, plugin.version) is stamped on every - * turn span so it's queryable without a separate session-level span. - */ -export function startTurnSpan(tracer: Tracer, args: TurnSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentName, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.WEAVE_SESSION_ID]: args.sessionId, - [ATTR.WEAVE_CWD]: args.cwd, - [ATTR.WEAVE_SOURCE]: args.source, - [ATTR.WEAVE_PLUGIN_VERSION]: args.pluginVersion, - [ATTR.WEAVE_TURN_NUMBER]: args.turnNumber, - [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: args.prompt }]), - }; - if (args.requestModel) attrs[ATTR.REQUEST_MODEL] = args.requestModel; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - if (args.systemInstructions?.length) { - attrs[ATTR.SYSTEM_INSTRUCTIONS] = jsonStr( - args.systemInstructions.map((content) => ({ type: 'text', content })), - ); - } - - // `weave.integration.*` is not set here — it rides the active session baggage - // and is stamped on this span (and all children) by - // IntegrationBaggageSpanProcessor at onStart. - // - // No parent span in context — turn spans are roots, one trace per turn. (The - // active context carries integration baggage but no span, so this stays a - // root.) - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ); +/** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ +export function parseTimestamp(ts: string | undefined): Date | undefined { + if (!ts) return undefined; + const d = new Date(ts); + return Number.isFinite(d.getTime()) ? d : undefined; } -type InvokeAgentSpanArgs = { - /** Agent type label — becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. For Claude Code subagents this is the - * `subagent_type` from the spawning `Agent` tool call (e.g. "Explore", - * "general-purpose"). */ - agentType: string; - /** Stitching key inherited from the parent turn span. */ - conversationId: string; - /** Plugin version, stamped as `gen_ai.agent.version` for parity with the - * outer turn span. */ - pluginVersion: string; - /** Initial input passed to the agent — typically the firing prompt from - * the parent agent's `Agent` tool call. Stamped as - * `gen_ai.input.messages`. */ - inputMessages?: unknown; - /** tool_use_id of the parent's `Agent` tool call. Stamped as a - * back-pointer attribute so queries can correlate the subagent - * invocation with the spawning tool call. */ - spawningToolCallId?: string; - displayName?: string; -}; - -/** - * Start a nested `invoke_agent` span — used for subagents Claude Code - * dispatches via the `Agent` tool. Child of the parent turn (or, for nested - * subagent calls, of the spawning subagent's invoke_agent span). Subagent - * `chat` spans and any tool calls the subagent runs parent under this span, - * which the Weave Agents chat view renders as an `agent_start` lifecycle - * marker followed by the subagent's own assistant text. - */ -export function startInvokeAgentSpan( - tracer: Tracer, - parentSpan: Span, - args: InvokeAgentSpanArgs, -): Span { +/** Per-session integration attributes; `meta` flattens to + * `weave.integration.meta.` (falsy values skipped). */ +export function buildIntegrationAttrs(args: { + version: string; + meta?: Record; +}): Attributes { const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentType, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, + [ATTR.WEAVE_INTEGRATION_NAME]: INTEGRATION_NAME, + [ATTR.WEAVE_INTEGRATION_VERSION]: args.version, }; - if (args.inputMessages !== undefined) { - attrs[ATTR.INPUT_MESSAGES] = jsonStr(args.inputMessages); - } - if (args.spawningToolCallId) { - attrs[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] = args.spawningToolCallId; + if (args.meta) { + for (const [key, value] of Object.entries(args.meta)) { + if (value) attrs[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = value; + } } - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentType}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ToolSpanArgs = { - toolName: string; - toolUseId: string; - toolInput: Record; - displayName?: string; -}; - -export function startToolSpan(tracer: Tracer, parentSpan: Span, args: ToolSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.EXECUTE_TOOL, - [ATTR.TOOL_NAME]: args.toolName, - [ATTR.TOOL_CALL_ID]: args.toolUseId, - [ATTR.TOOL_CALL_ARGUMENTS]: jsonStr(args.toolInput), - }; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.EXECUTE_TOOL} ${args.toolName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ChatSpanArgs = { - /** Stitching key — same value as the parent turn span's - * `gen_ai.conversation.id`. For subagent chats this is suffixed with - * `:${agent_id}` upstream so the subagent's calls form their own - * conversation under the spawning tool span. */ - conversationId: string; - model: string; - startedAt: TimeInput; - endedAt: TimeInput; - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; -}; - -/** - * Emit a chat span as a child of `parentSpan`. The span is started AND ended - * inside this helper. Used by code paths that construct the chat span from - * transcript data after the fact (SubagentStop, TeammateIdle). For the main - * agent path — where the chat span parents the assistant_text / thinking / - * execute_tool spans that occur during the API call — use `startChatSpan` / - * `finalizeChatSpan` instead. - */ -export function emitChatSpan( - tracer: Tracer, - parentSpan: Span, - args: ChatSpanArgs, -): void { - const span = startChatSpan(tracer, parentSpan, { - conversationId: args.conversationId, - model: args.model, - startedAt: args.startedAt, - }); - finalizeChatSpan(span, { - usage: args.usage, - reasoningTokens: args.reasoningTokens, - responseId: args.responseId, - finishReasons: args.finishReasons, - inputMessages: args.inputMessages, - outputMessages: args.outputMessages, - endedAt: args.endedAt, - }); + return attrs; } -type StartChatSpanArgs = { - conversationId: string; - model?: string; - startedAt: TimeInput; -}; - -/** - * Start a chat span (open). Caller is responsible for emitting any child - * spans and calling `finalizeChatSpan` with the usage data and end time. - * - * `model` is optional at open time — Anthropic returns it in the response, so - * it may not be known until the assistant message is parsed. When omitted, - * the span name uses a placeholder; `finalizeChatSpan` overwrites the name - * with the actual model once it's known. - */ -export function startChatSpan( - tracer: Tracer, - parentSpan: Span, - args: StartChatSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.CHAT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_TYPE]: 'text', - }; - if (args.model) { - attrs[ATTR.REQUEST_MODEL] = args.model; - const provider = providerFromModel(args.model); - if (provider) attrs[ATTR.PROVIDER_NAME] = provider; +/** Map assistant content blocks to ordered `MessagePart`s (text → text, + * thinking/redacted → reasoning, tool_use → tool_call; empties skipped), so + * the model's natural interleave survives into `gen_ai.output.messages`. */ +export function contentBlocksToParts(blocks: unknown[]): MessagePart[] { + const parts: MessagePart[] = []; + for (const block of blocks) { + if (isTextBlock(block)) { + if (block.text.trim()) parts.push({ type: 'text', content: block.text }); + } else if (isThinkingBlock(block)) { + if (block.thinking.trim()) parts.push({ type: 'reasoning', content: block.thinking }); + } else if (isRedactedThinkingBlock(block)) { + // Reasoning withheld by safety filtering: the `data` blob is encrypted, + // so surface a placeholder so the part stays in transcript order. + parts.push({ type: 'reasoning', content: '[redacted]' }); + } else if (isToolUseBlock(block)) { + parts.push({ + type: 'tool_call', + toolCallId: block.id, + toolName: block.name, + arguments: jsonStr(block.input), + }); + } } - const name = args.model ? `${OP.CHAT} ${args.model}` : OP.CHAT; - return tracer.startSpan( - name, - { kind: SpanKind.CLIENT, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); + return parts; } -type FinalizeChatSpanArgs = { - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; - /** If set and the span was opened without a model, attaches the model - * attribute and updates the span name. */ - model?: string; - endedAt?: TimeInput; -}; - -/** Stamp usage / response attrs on an open chat span and end it. */ -export function finalizeChatSpan(span: Span, args: FinalizeChatSpanArgs): void { - // OTel `input_tokens` is the total prompt; Anthropic splits it into three - // disjoint fields (uncached + cache_read + cache_creation), so sum them. - // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - const totalInputTokens = - args.usage.input_tokens - + (args.usage.cache_read_input_tokens ?? 0) - + (args.usage.cache_creation_input_tokens ?? 0); - - span.setAttribute(ATTR.USAGE_INPUT_TOKENS, totalInputTokens); - span.setAttribute(ATTR.USAGE_OUTPUT_TOKENS, args.usage.output_tokens); - if (args.usage.cache_read_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_READ_INPUT_TOKENS, args.usage.cache_read_input_tokens); - } - if (args.usage.cache_creation_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS, args.usage.cache_creation_input_tokens); - } - if (args.reasoningTokens !== undefined && args.reasoningTokens > 0) { - span.setAttribute(ATTR.USAGE_REASONING_TOKENS, args.reasoningTokens); - } - if (args.responseId) { - span.setAttribute(ATTR.RESPONSE_ID, args.responseId); - } - if (args.finishReasons?.length) { - span.setAttribute(ATTR.RESPONSE_FINISH_REASONS, args.finishReasons); - } - if (args.inputMessages !== undefined) { - span.setAttribute(ATTR.INPUT_MESSAGES, jsonStr(args.inputMessages)); - } - if (args.outputMessages !== undefined) { - span.setAttribute(ATTR.OUTPUT_MESSAGES, jsonStr(args.outputMessages)); +/** Anthropic usage → `weave.Usage`. OTel inputTokens is the TOTAL prompt, so + * sum Anthropic's three disjoint fields (uncached + cache_read + + * cache_creation); optional fields are set only when present. + * https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md */ +export function buildUsage(usage: UsageSummary, reasoningTokens?: number): Usage { + const out: Usage = { + inputTokens: + usage.input_tokens + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0), + outputTokens: usage.output_tokens, + }; + if (usage.cache_read_input_tokens !== undefined) { + out.cacheReadInputTokens = usage.cache_read_input_tokens; } - if (args.model) { - span.setAttribute(ATTR.REQUEST_MODEL, args.model); - const provider = providerFromModel(args.model); - if (provider) span.setAttribute(ATTR.PROVIDER_NAME, provider); - span.updateName(`${OP.CHAT} ${args.model}`); + if (usage.cache_creation_input_tokens !== undefined) { + out.cacheCreationInputTokens = usage.cache_creation_input_tokens; } - span.end(args.endedAt); -} - -type AssistantTextSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one text content block from an assistant message. - * Renders in the trace tree between sibling `execute_tool` spans so the - * model's natural interleave (say something → call tool → say something → - * call tool) is visible. Carries the text on `gen_ai.output.messages` so - * Weave's UI shows the content; no token attributes — tokens live on the - * parent chat span. - */ -export function emitAssistantTextSpan( - tracer: Tracer, - parentSpan: Span, - args: AssistantTextSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.ASSISTANT_TEXT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'text', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.ASSISTANT_TEXT, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -type ThinkingSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one thinking content block. Like - * `emitAssistantTextSpan` but for `{type: 'thinking'}` blocks — Claude's - * private reasoning surfaced in its content stream. Kept distinct so callers - * can hide thinking spans in the UI without hiding ordinary assistant text. - */ -export function emitThinkingSpan( - tracer: Tracer, - parentSpan: Span, - args: ThinkingSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.THINKING, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'thinking', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.THINKING, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -/** - * Walk a parsed list of per-message details and emit one chat span per - * assistant message. `parentSpan` is the turn-level span (for the main agent) - * or the spawning Agent tool span (for a subagent). - */ -export function emitChatSpansFromAssistantCalls( - tracer: Tracer, - parentSpan: Span, - conversationId: string, - calls: AssistantCallDetail[], -): void { - for (const c of calls) { - if (!c.model) continue; - const startedAt = parseTimestamp(c.prevTimestamp) ?? parseTimestamp(c.timestamp) ?? new Date(); - const endedAt = parseTimestamp(c.timestamp) ?? new Date(); - emitChatSpan(tracer, parentSpan, { - conversationId, - model: c.model, - startedAt, - endedAt, - usage: c.usage, - reasoningTokens: c.reasoningTokens, - responseId: c.responseId, - finishReasons: c.finishReason ? [c.finishReason] : undefined, - outputMessages: c.contentBlocks.length - ? [{ role: 'assistant', content: assistantBlocksToText(c.contentBlocks), parts: c.contentBlocks }] - : undefined, - }); + if (reasoningTokens !== undefined && reasoningTokens > 0) { + out.reasoningTokens = reasoningTokens; } -} - -/** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ -export function parseTimestamp(ts: string | undefined): Date | undefined { - if (!ts) return undefined; - const d = new Date(ts); - return Number.isFinite(d.getTime()) ? d : undefined; -} - -function assistantBlocksToText(blocks: unknown[]): string { - return extractAssistantTextBlocks(blocks).join('\n'); + return out; } // ───────────────────────────────────────────────────────────────────────────── // Span events // ───────────────────────────────────────────────────────────────────────────── -export interface PermissionRequestEventArgs { - suggestions?: unknown; - timestamp: Date; -} - /** Added at PermissionRequest time. Records that the request happened. */ -export function addPermissionRequestEvent(toolSpan: Span, args: PermissionRequestEventArgs): void { +export function addPermissionRequestEvent(tool: Tool, args: { suggestions?: unknown; timestamp: Date }): void { const attrs: Attributes = {}; if (args.suggestions !== undefined) { attrs[ATTR.EVT_PERMISSION_SUGGESTIONS] = jsonStr(args.suggestions); } - toolSpan.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); -} - -export interface PermissionResolvedEventArgs { - approved: boolean; - timestamp: Date; + tool.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); } /** Added at PostToolUse[Failure]. Records the request outcome. */ -export function addPermissionResolvedEvent(toolSpan: Span, args: PermissionResolvedEventArgs): void { - toolSpan.addEvent( +export function addPermissionResolvedEvent(tool: Tool, args: { approved: boolean; timestamp: Date }): void { + tool.addEvent( ATTR.EVT_PERMISSION_RESOLVED, { [ATTR.EVT_PERMISSION_APPROVED]: args.approved }, args.timestamp, @@ -674,27 +223,23 @@ export interface CompactionAttrs { itemsAfter?: number; } -/** - * Stamp `weave.compaction.*` attributes onto a turn span. The Weave Agents - * backend extracts these into dedicated columns (`compaction_summary`, - * `compaction_items_before`, `compaction_items_after`) and renders a - * "context_compacted" card in the chat view. - * - * Compaction is a session-level event, but with no session span it attaches - * to the turn span that's open when the compaction fires — or to the next - * turn span, if compaction fires between turns. - */ -export function setCompactionAttrs(turnSpan: Span, attrs: CompactionAttrs): void { - if (attrs.summary !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_SUMMARY, attrs.summary); - if (attrs.itemsBefore !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_BEFORE, attrs.itemsBefore); - if (attrs.itemsAfter !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_AFTER, attrs.itemsAfter); +/** Stamp `weave.compaction.*` onto a turn (the backend renders a + * context_compacted card). Compaction is session-level, but with no session + * span it rides the open turn — or the next one, between turns. */ +export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void { + const out: Attributes = {}; + if (attrs.summary !== undefined) out[ATTR.COMPACTION_SUMMARY] = attrs.summary; + if (attrs.itemsBefore !== undefined) out[ATTR.COMPACTION_ITEMS_BEFORE] = attrs.itemsBefore; + if (attrs.itemsAfter !== undefined) out[ATTR.COMPACTION_ITEMS_AFTER] = attrs.itemsAfter; + if (Object.keys(out).length) turn.setAttributes(out); } // ───────────────────────────────────────────────────────────────────────────── // Display-name helpers // ───────────────────────────────────────────────────────────────────────────── -function snippet(value: unknown, maxLen = 60): string { +/** Single-line preview of a value: whitespace collapsed, truncated with `…`. */ +export function snippet(value: unknown, maxLen = 60): string { const s = String(value ?? '').replace(/\s+/g, ' ').trim(); return s.length <= maxLen ? s : s.slice(0, maxLen - 1) + '…'; } @@ -718,12 +263,8 @@ export function toolDisplayName(toolName: string, input: Record case 'WebSearch': return `WebSearch: ${snippet(input['query'])}`; default: { - const first = Object.values(input).find((v) => typeof v === 'string') as string | undefined; + const first = Object.values(input).find((v): v is string => typeof v === 'string'); return first ? `${toolName}: ${snippet(first)}` : toolName; } } } - -export function promptSnippet(prompt: string, maxLen = 60): string { - return snippet(prompt, maxLen); -} diff --git a/src/parser.ts b/src/parser.ts index 0b06ae7..08fb5a1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,23 +12,22 @@ export interface UsageSummary { } /** - * Per-API-call detail for a single assistant message in the transcript. - * Each entry corresponds to one LLM invocation within a turn, used to emit - * one `chat ` span per call at Stop time. + * One assistant API response within a turn. Claude Code splits a response's + * thinking / text / tool_use blocks across transcript lines sharing a + * `message.id`; `buildTurn` folds those back into a single entry. */ export interface AssistantCallDetail { - timestamp: string; // ISO timestamp of the assistant message - prevTimestamp?: string; // ISO timestamp of preceding transcript line (proxy for "request started") + timestamp: string; // ISO timestamp of the response's last transcript line + prevTimestamp?: string; // ISO timestamp of the line preceding it (proxy for "request started") model?: string; - usage: UsageSummary; // per-call usage + usage: UsageSummary; reasoningTokens?: number; // reasoning/thinking tokens, if any - contentBlocks: unknown[]; // raw assistant content blocks (text, tool_use, thinking, ...) + contentBlocks: unknown[]; // raw content blocks in transcript order (text, tool_use, thinking, ...) responseId?: string; // provider message id finishReason?: string; // stop_reason / finish_reason if present } export interface Turn { - totalUsage(): UsageSummary; primaryModel(): string | undefined; textBlocks(): string[]; assistantCalls(): AssistantCallDetail[]; @@ -38,7 +37,7 @@ export interface ParsedSession { turns: Turn[]; } -export function rawToUsageSummary(raw: Record): UsageSummary { +function rawToUsageSummary(raw: Record): UsageSummary { return { input_tokens: raw['input_tokens'] ?? 0, output_tokens: raw['output_tokens'] ?? 0, @@ -47,15 +46,6 @@ export function rawToUsageSummary(raw: Record): UsageSummary { }; } -export function addUsage(a: UsageSummary, b: UsageSummary): UsageSummary { - return { - input_tokens: a.input_tokens + b.input_tokens, - output_tokens: a.output_tokens + b.output_tokens, - cache_read_input_tokens: (a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0), - cache_creation_input_tokens: (a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0), - }; -} - export function parseSessionFile(filePath: string): ParsedSession | null { return parseSessionReader(() => fs.readFileSync(filePath, 'utf8')); } @@ -107,20 +97,19 @@ function buildSession(lines: unknown[]): ParsedSession { let prevTimestamp: string | undefined; for (const line of lines) { - const entry = line as Record; - const message = entry['message'] as Record | undefined; - const type = entry['type'] as string | undefined; - const role = (message?.['role'] as string | undefined) ?? type; - const timestamp = entry['timestamp'] as string | undefined; + const { message, type, role: rawRole, timestamp } = readTranscriptLine(line); + const role = rawRole ?? type; if (role === 'assistant') { - currentAssistantLines.push({ line: entry, prevTimestamp }); + // `role === 'assistant'` implies the line is an object (it carried either + // a `message.role` or a top-level `type`), so the {} fallback is unreachable. + currentAssistantLines.push({ line: isObject(line) ? line : {}, prevTimestamp }); } else if (role === 'user') { const rawContent = message?.['content']; - const content = Array.isArray(rawContent) ? rawContent as Array> : []; // A user message with text content marks the end of the previous turn. - const hasText = typeof rawContent === 'string' || content.some(block => block['type'] === 'text'); + const hasText = typeof rawContent === 'string' + || (Array.isArray(rawContent) ? rawContent : []).some(isTextBlock); if (hasText && currentAssistantLines.length > 0) { turns.push(buildTurn(currentAssistantLines)); currentAssistantLines = []; @@ -138,17 +127,16 @@ function buildSession(lines: unknown[]): ParsedSession { } function buildTurn(assistantLines: AssistantLine[]): Turn { - const calls: AssistantCallDetail[] = assistantLines.map(({ line, prevTimestamp }) => { - const message = line['message'] as Record | undefined; + const calls: AssistantCallDetail[] = []; + for (const { line, prevTimestamp } of assistantLines) { + const { message } = readTranscriptLine(line); const rawUsage = (message?.['usage'] ?? line['usage'] ?? {}) as Record; const usage = rawToUsageSummary(rawUsage); const reasoningTokens = typeof rawUsage['reasoning_tokens'] === 'number' ? rawUsage['reasoning_tokens'] : undefined; const model = (message?.['model'] ?? line['model']) as string | undefined; const rawContent = message?.['content']; - // `content` is either an array of blocks (the common assistant shape), a - // bare string (legacy single-text format), or missing. Fall back to [] for - // the missing / unknown case so downstream code sees a well-typed empty - // list instead of `undefined`. + // A bare-string `content` is the legacy single-text format; synthesize a + // text block so downstream sees a uniform block list (missing/other → []). const contentBlocks: unknown[] = Array.isArray(rawContent) ? (rawContent as unknown[]) : typeof rawContent === 'string' @@ -158,29 +146,27 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { const stopReason = (message?.['stop_reason'] ?? message?.['finish_reason']) as string | undefined; const timestamp = (line['timestamp'] as string | undefined) ?? ''; - return { - timestamp, - prevTimestamp, - model, - usage, - reasoningTokens, - contentBlocks, - responseId, - finishReason: stopReason, - }; - }); - - const totalUsageValue = calls.reduce( - (acc, call) => addUsage(acc, call.usage), - { input_tokens: 0, output_tokens: 0 }, - ); - - const model = calls.map(call => call.model).filter(Boolean).pop(); + // Fold split lines (shared message.id) into one call per API response. + // Split lines duplicate the response usage — keep the last line's, which + // accompanies stop_reason; keep the first line's prevTimestamp as start. + const prev = calls.at(-1); + if (responseId && prev?.responseId === responseId) { + prev.contentBlocks.push(...contentBlocks); + prev.timestamp = timestamp; + prev.usage = usage; + prev.reasoningTokens = reasoningTokens ?? prev.reasoningTokens; + prev.model ??= model; + prev.finishReason ??= stopReason; + continue; + } + calls.push({ timestamp, prevTimestamp, model, usage, reasoningTokens, contentBlocks, responseId, finishReason: stopReason }); + } + + const model = calls.filter(call => call.model).at(-1)?.model; const texts = calls.flatMap(call => extractAssistantTextBlocks(call.contentBlocks)); return { - totalUsage: () => totalUsageValue, primaryModel: () => model, textBlocks: () => texts, assistantCalls: () => calls, @@ -194,11 +180,37 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { type TextBlock = { type: 'text'; text: string }; type ThinkingBlock = { type: 'thinking'; thinking: string }; type RedactedThinkingBlock = { type: 'redacted_thinking'; data?: string }; +type ToolUseBlock = { type: 'tool_use'; id: string; name: string; input?: unknown }; function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null; } +/** Structural shape of a single JSONL transcript line we care about. */ +type TranscriptLine = { + message?: Record; + type?: string; + role?: string; + timestamp?: string; +}; + +/** + * Decode one raw JSONL transcript line into the fields the parser reads, + * narrowing each with a runtime check instead of an `as` cast. `role` is the + * raw `message.role` (callers fall back to `type` for lines that carry only a + * top-level `type`). Fields absent or of the wrong type come back undefined. + */ +function readTranscriptLine(line: unknown): TranscriptLine { + if (!isObject(line)) return {}; + const message = isObject(line['message']) ? line['message'] : undefined; + return { + message, + type: typeof line['type'] === 'string' ? line['type'] : undefined, + role: typeof message?.['role'] === 'string' ? message['role'] : undefined, + timestamp: typeof line['timestamp'] === 'string' ? line['timestamp'] : undefined, + }; +} + export function isTextBlock(block: unknown): block is TextBlock { return isObject(block) && block['type'] === 'text' && typeof block['text'] === 'string'; } @@ -211,6 +223,11 @@ export function isRedactedThinkingBlock(block: unknown): block is RedactedThinki return isObject(block) && block['type'] === 'redacted_thinking'; } +export function isToolUseBlock(block: unknown): block is ToolUseBlock { + return isObject(block) && block['type'] === 'tool_use' + && typeof block['id'] === 'string' && typeof block['name'] === 'string'; +} + /** * Pull human-readable text out of assistant `content` blocks. Accepts the raw * union (string entries, `{type: 'text', text}` objects, etc.) and returns diff --git a/src/sessionState.ts b/src/sessionState.ts new file mode 100644 index 0000000..931b347 --- /dev/null +++ b/src/sessionState.ts @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import * as path from 'path'; +import * as weave from 'weave'; +import { VERSION } from './setup.js'; +import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; +import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { sha256Hex } from './utils.js'; +import { buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; +import type { CompactionAttrs } from './genaiSpans.js'; + +/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ +export type PendingToolCall = { + tool: weave.Tool; + toolName: string; + toolInput: Record; + /** True once a PermissionRequest event has been emitted for this tool. */ + permissionRequested?: boolean; +} + +/** The chat span (LLM) open for the in-flight assistant response; its tool + * spans parent here. Content lands when it is finalized (next response + * transition, or Stop), once all its transcript lines are flushed. */ +type ActiveChat = { + /** Response key (Anthropic `message.id`, or index fallback) this chat span + * represents; see `chatMessageKey`. */ + responseKey: string; + llm: weave.LLM; +} + +/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ +export function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { + if (!pending.permissionRequested) return; + addPermissionResolvedEvent(pending.tool, { + approved, + timestamp: new Date(), + }); +} + +/** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with + * the subagent's SubagentStart by matching transcript content. */ +export function hashPrompt(prompt: string): string { + return sha256Hex(prompt); +} + +/** + * Directory holding a session's subagent transcripts. Claude Code writes them + * as siblings of the session transcript in a `/subagents/` + * subdirectory: + * session: /.jsonl + * subagent: //subagents/agent-.jsonl + */ +export function subagentsDirFor(sessionTranscriptPath: string): string { + const projectDir = path.dirname(sessionTranscriptPath); + const sessionDirName = path.basename(sessionTranscriptPath, '.jsonl'); + return path.join(projectDir, sessionDirName, 'subagents'); +} + +/** Map a parent transcript path + subagent agent_id to the subagent's transcript file. */ +export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { + return path.join(subagentsDirFor(parentTranscriptPath), `agent-${agentId}.jsonl`); +} + +/** Pull the user-message content out of a transcript line. Returns the prompt + * string for `{type: 'user', message: {content: string|Array}}` lines, else + * undefined. Array-form content is joined across text blocks. */ +export function extractUserMessageContent(line: Record | undefined): string | undefined { + if (!line || line['type'] !== 'user') return undefined; + const msg = line['message']; + if (!msg || typeof msg !== 'object') return undefined; + const content = (msg as Record)['content']; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + // Keep every text block's text verbatim (including empties) and join with + // '' (this differs from extractAssistantTextBlocks, which drops empties). + const parts = content.filter(isTextBlock).map(block => block.text); + return parts.length > 0 ? parts.join('') : undefined; + } + return undefined; +} + +/** True if the last assistant call's joined text ends with `suffix`, + * ignoring trailing whitespace on either side. */ +export function lastAssistantTextEndsWith( + result: NonNullable>, + suffix: string, +): boolean { + const call = result.turns.at(-1)?.assistantCalls().at(-1); + // Turn exists but parser saw no assistant calls (writer mid-flush). + if (!call) return false; + return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); +} + +/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated + * per session (deduped by path) and stamped as `gen_ai.system_instructions` on + * each turn root. */ +export type LoadedInstruction = { filePath: string; content: string }; + +/** Append `item` to `list` in place, replacing any existing entry with the same + * filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than + * duplicates. Preserves each file's first-seen position. */ +export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { + const idx = list.findIndex((i) => i.filePath === item.filePath); + if (idx >= 0) list[idx] = item; + else list.push(item); +} + +/** Read the subagent transcript's first line, retrying briefly because Claude + * Code may not have flushed it yet when SubagentStart fires. Total wait + * bounded by the sum of `RETRY_DELAYS_MS`. */ +const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; +export async function readSubagentFirstLineWithRetry( + transcriptPath: string, +): Promise | undefined> { + for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + const line = readFirstTranscriptLine(transcriptPath); + if (line && line['type'] === 'user') return line; + } + return undefined; +} + +/** + * Tracks a subagent across hook events. Matched trackers are created at + * PreToolUse(Agent) and correlated to an agent_id at SubagentStart by + * sha256(firing prompt) + type; orphans are created at SubagentStart when + * nothing matches. Either way the subagent is its own `invoke_agent` span + * under the turn, with its chat/tool spans nested beneath it (why a marker + * and not `execute_tool`: see handlePreToolUse's Agent-dispatch branch). + */ +export type SubagentTracker = { + subagentType: string; + detectedAt: Date; + toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) + subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span; its chat/tool spans nest here + agentId?: string; + /** sha256 of the prompt passed to the Agent tool; matched against the + * subagent's transcript line-1 user message at SubagentStart. */ + promptHash?: string; + /** True once the invoke_agent span has been ended. Guards against + * double-end when PostToolUse and SubagentStop both try to close it. */ + ended?: boolean; + /** Stored at SubagentStart; TeammateIdle's own transcript_path is the + * coordinator's, so this is the reliable copy. */ + transcriptPath?: string; + /** Orphan awaiting TeammateIdle: SubagentStop leaves the span open so + * TeammateIdle can close it with full all-turns content. */ + pendingTeammateIdle?: boolean; + /** Set for `team_name` spawns: the marker is owned by + * GlobalDaemon.teamMembers and closed at the teammate's TeammateIdle, + * NOT at the coordinator's PostToolUse(Agent). */ + teamName?: string; +} + +/** One queued team-member spawn. A teammate is an independent session whose + * TeammateIdle fires under its OWN session_id, so the coordinator's + * PreToolUse(Agent, team_name) is the only reliable anchor: it queues the + * marker in GlobalDaemon.teamMembers (FIFO per `${team}::${name}` — the same + * name can be re-spawned; overwriting would leak the first, still-open span). */ +export type TeamMember = { + subAgent: weave.SubAgent; + /** Coordinator's Conversation handle. The teammate's own turn trace starts + * from it so the coordinator's conversation.id and integration identity + * (which don't inherit cross-session) seed the teammate's span subtree. */ + conversation: weave.Conversation; + coordinatorTranscriptPath: string; + emitted: boolean; +} + +export type SessionState = { + sessionId: string; + /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed + * turns stitch with their pre-resume turns server-side. Equals `sessionId` + * for fresh (non-forked) sessions. Resolved once at SessionStart by + * walking `forkedFrom.sessionId` pointers across transcript files. */ + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel?: string; + + /** Conversation handle: seeds conversation.id, agent identity, and the + * integration attributes onto every turn (and, via the handle chain, all + * children) regardless of runIsolated frame. Unset when tracing is off. */ + conversation?: weave.Conversation; + + currentTurn?: weave.Turn; + + turnNumber: number; + totalToolCalls: number; + turnToolCalls: number; + toolCounts: Record; + + pendingToolCalls: Map; + subagents: SubagentTracking; + + /** Chat span (LLM) currently open for an in-progress assistant API call. + * Tool spans from PreToolUse parent here; finalized at Stop or on transition + * to the next API call. Cleared at Stop. */ + activeChat?: ActiveChat; + /** Response keys with a chat span already opened this turn; Stop emits + * fresh spans for the rest (tool-less responses never hit PreToolUse). */ + emittedChatSpanResponseKeys: Set; + + /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ + pendingCompaction?: CompactionAttrs; + + /** Instruction files (global/project CLAUDE.md, .claude/rules, @-imports) + * captured from InstructionsLoaded, in load order, deduped by path. Stamped + * on every turn root as `gen_ai.system_instructions`. */ + systemInstructions: LoadedInstruction[]; +} + +/** + * Per-session container that tracks subagents from PreToolUse (when an Agent + * tool with subagent_type is detected) through SubagentStop. Single source of + * truth for the tracker list, with intent-revealing lookup methods. + */ +export class SubagentTracking { + private trackers: SubagentTracker[] = []; + + /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ + add(tracker: SubagentTracker): void { + this.trackers.push(tracker); + } + + /** Oldest unmatched tracker (no agent_id yet) for `(promptHash, type)` — + * FIFO so back-to-back identical Agent calls correlate in dispatch order. */ + findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (t.agentId) continue; + if (t.promptHash !== promptHash) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + byAgentId(agentId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.agentId === agentId); + } + + /** Oldest tracker awaiting TeammateIdle for this subagentType (FIFO). */ + findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (!t.pendingTeammateIdle) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + /** Lookup by the spawning Agent tool's tool_use_id (PostToolUse settle). */ + byToolUseId(toolUseId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.toolUseId === toolUseId); + } + + remove(tracker: SubagentTracker): void { + const idx = this.trackers.indexOf(tracker); + if (idx >= 0) this.trackers.splice(idx, 1); + } + + size(): number { + return this.trackers.length; + } + + all(): SubagentTracker[] { + return [...this.trackers]; + } +} + +/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 + * for a brand-new session, or the number of turns already on disk when + * reconstructing a session lost across a daemon restart (so the resumed turn + * keeps counting up instead of resetting to 1). */ +type NewSessionStateOptions = { + sessionId: string; + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel: string | undefined; + turnNumber: number; + /** The top-level agent name the conversation (and thus every turn) carries. */ + agentName: string; + /** When false (tracing disabled), no Conversation handle is created. */ + tracingEnabled: boolean; +}; + +/** Build a fresh SessionState, starting its Conversation when tracing is on. */ +export function newSessionState(options: NewSessionStateOptions): SessionState { + const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = + options; + // Best-effort CC CLI version from the transcript head line; built here so a + // reconstructed session carries the same integration identity. + const headLine = readFirstTranscriptLine(transcript.resolvedPath); + const version = headLine?.['version']; + const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; + const integrationAttrs = buildIntegrationAttrs({ + version: VERSION, + meta: { claude_code_app_version: claudeCodeAppVersion }, + }); + const conversation = options.tracingEnabled + ? weave.startConversation({ conversationId, agentName: options.agentName, attributes: integrationAttrs }) + : undefined; + + return { + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + conversation, + turnNumber, + totalToolCalls: 0, + turnToolCalls: 0, + toolCounts: {}, + pendingToolCalls: new Map(), + subagents: new SubagentTracking(), + emittedChatSpanResponseKeys: new Set(), + systemInstructions: [], + }; +} diff --git a/src/setup.ts b/src/setup.ts index 969c25d..1a0cf5c 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -5,12 +5,42 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { spawnSync } from 'child_process'; +import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'child_process'; import { findClaudeCLI, appendToLog } from './utils.js'; import { VERSION } from './version.mjs'; export { VERSION }; +// Shared spawnSync options for every `claude plugin ...` invocation: capture +// utf8 stdout/stderr through pipes so we can inspect the output for status. +const CLAUDE_SPAWN_OPTS: SpawnSyncOptionsWithStringEncoding = { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], +}; + +/** Concatenate a spawnSync result's stderr and stdout (either may be null). */ +function combinedOutput(result: SpawnSyncReturns): string { + return (result.stderr ?? '') + (result.stdout ?? ''); +} + +/** + * Throw (and log) `Failed to ${action}: ${output}` when a `claude plugin ...` + * command exited non-zero, unless `alreadyOk` (a matched "already + * registered/installed" message) makes the non-zero exit benign. + */ +function failIfError( + result: SpawnSyncReturns, + alreadyOk: boolean, + logFile: string, + action: string, +): void { + if (result.status === 0 || alreadyOk) return; + const output = combinedOutput(result).trim(); + const msg = `Failed to ${action}: ${output}`; + appendToLog(logFile, 'ERROR', msg); + throw new Error(msg); +} + export interface Settings { log_file: string; weave_project: string | null; @@ -177,7 +207,7 @@ export type PluginSource = * unparseable. Caller is responsible for shape validation on the returned * value (typed as `unknown`). */ -function readJsonFile(filePath: string): unknown | null { +function readJsonFile(filePath: string): unknown { if (!fs.existsSync(filePath)) return null; try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -192,17 +222,10 @@ function readJsonFile(filePath: string): unknown | null { * version for directory-source registrations. */ function readPackageVersion(dir: string): string | null { - const pkg = readJsonFile(path.join(dir, 'package.json')) as { version?: unknown } | null; + const pkg = readJsonFile(path.join(dir, 'package.json')) as { version?: unknown }; return typeof pkg?.version === 'string' ? pkg.version : null; } -/** - * Read and normalize the source spec Claude Code has registered for the given - * marketplace, or null if the marketplace isn't registered or the on-disk - * shape is unrecognized. Unknown shapes are treated as null rather than - * throwing so a future Claude Code schema change degrades to "Source: not - * registered" rather than crashing status. - */ /** * Shape of a github-source entry inside known_marketplaces.json. `ref` is * optional because pre-v0.2 marketplace registrations didn't pin to a tag. @@ -220,6 +243,13 @@ function isRawDirectorySource(s: Record): s is RawDirectorySour return s['source'] === 'directory' && typeof s['path'] === 'string'; } +/** + * Read and normalize the source spec Claude Code has registered for the given + * marketplace, or null if the marketplace isn't registered or the on-disk + * shape is unrecognized. Unknown shapes are treated as null rather than + * throwing so a future Claude Code schema change degrades to "Source: not + * registered" rather than crashing status. + */ export function readRegisteredPluginSource(marketplaceName: string): PluginSource | null { const knownPath = path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'); const raw = readJsonFile(knownPath); @@ -303,15 +333,10 @@ export function registerPlugin( const mktResult = spawnSync( claudePath, ['plugin', 'marketplace', 'add', marketplaceArg], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - const mktAlready = /already/i.test((mktResult.stderr ?? '') + (mktResult.stdout ?? '')); - if (mktResult.status !== 0 && !mktAlready) { - const output = ((mktResult.stderr ?? '') + (mktResult.stdout ?? '')).trim(); - const msg = `Failed to register marketplace '${marketplaceArg}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + const mktAlready = /already/i.test(combinedOutput(mktResult)); + failIfError(mktResult, mktAlready, logFile, `register marketplace '${marketplaceArg}'`); const refAfter = readRegisteredMarketplaceRef(MARKETPLACE_NAME); // Drift detection compares marketplace refs (version tags). Local sources @@ -323,15 +348,10 @@ export function registerPlugin( const pluginResult = spawnSync( claudePath, ['plugin', 'install', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - const pluginAlready = /already/i.test((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')); - if (pluginResult.status !== 0 && !pluginAlready) { - const output = ((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')).trim(); - const msg = `Failed to install plugin '${PLUGIN_NAME}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + const pluginAlready = /already/i.test(combinedOutput(pluginResult)); + failIfError(pluginResult, pluginAlready, logFile, `install plugin '${PLUGIN_NAME}'`); const { updated: pluginUpdated } = maybeUpdateOutdatedPlugin(claudePath, logFile, refDrifted, pluginAlready); @@ -364,14 +384,9 @@ function maybeUpdateOutdatedPlugin( const updateResult = spawnSync( claudePath, ['plugin', 'update', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - if (updateResult.status !== 0) { - const output = ((updateResult.stderr ?? '') + (updateResult.stdout ?? '')).trim(); - const msg = `Failed to update plugin '${PLUGIN_NAME}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + failIfError(updateResult, /*alreadyOk*/ false, logFile, `update plugin '${PLUGIN_NAME}'`); return { updated: true }; } @@ -399,15 +414,15 @@ export function unregisterPlugin(): UninstallResult { const pluginResult = spawnSync( claudePath, ['plugin', 'uninstall', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); const pluginAlreadyAbsent = /not installed|not found|unknown plugin|no installed plugin/i - .test((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')); + .test(combinedOutput(pluginResult)); if (pluginResult.status !== 0) { if (pluginAlreadyAbsent) { pluginStatus = RemovalStatus.AlreadyAbsent; } else { - const output = ((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')).trim(); + const output = combinedOutput(pluginResult).trim(); pluginStatus = RemovalStatus.Failed; pluginError = `Failed to uninstall plugin '${PLUGIN_NAME}': ${output}`; } @@ -421,15 +436,15 @@ export function unregisterPlugin(): UninstallResult { const mktResult = spawnSync( claudePath, ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); const marketplaceAlreadyAbsent = /not found|unknown marketplace|no configured marketplace/i - .test((mktResult.stderr ?? '') + (mktResult.stdout ?? '')); + .test(combinedOutput(mktResult)); if (mktResult.status !== 0) { if (marketplaceAlreadyAbsent) { marketplaceStatus = RemovalStatus.AlreadyAbsent; } else { - const output = ((mktResult.stderr ?? '') + (mktResult.stdout ?? '')).trim(); + const output = combinedOutput(mktResult).trim(); marketplaceStatus = RemovalStatus.Failed; marketplaceError = `Failed to remove marketplace '${MARKETPLACE_NAME}': ${output}`; } diff --git a/src/utils.ts b/src/utils.ts index e2fa616..2765fea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,12 @@ import * as net from 'net'; import * as path from 'path'; import * as readline from 'readline'; import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; + +/** Hex-encoded sha256 of `input`. */ +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex'); +} export function prompt(question: string): Promise { return new Promise((resolve) => { @@ -95,10 +101,12 @@ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; if (Array.isArray(a) !== Array.isArray(b)) return false; - const keysA = Object.keys(a as object); - const keysB = Object.keys(b as object); + const ao = a as Record; + const bo = b as Record; + const keysA = Object.keys(ao); + const keysB = Object.keys(bo); if (keysA.length !== keysB.length) return false; - return keysA.every(k => deepEqual((a as Record)[k], (b as Record)[k])); + return keysA.every(k => deepEqual(ao[k], bo[k])); } /** diff --git a/tests/config-drift.test.ts b/tests/config-drift.test.ts index bec83e2..46ee18a 100644 --- a/tests/config-drift.test.ts +++ b/tests/config-drift.test.ts @@ -16,7 +16,7 @@ import * as net from 'node:net'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveDaemonConfig, daemonConfigFingerprint } from '../src/daemon.ts'; +import { resolveDaemonConfig, daemonConfigFingerprint } from '../src/config.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts index bd2032f..ee3d26e 100644 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ b/tests/daemon-shutdown-finalizes-turn.test.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// A turn's root span (`invoke_agent claude-code`) is created at -// UserPromptSubmit and only ended at Stop or SessionEnd. When the daemon exits -// for any other reason — inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a -// restart control message — its already-ended children (completed tool spans, -// finalized chat spans, closed subagent spans) have been exported, but the -// still-open root had not. The result was a rootless trace: tool spans with no -// user turn to attribute them to. +// A turn's root span (`invoke_agent`) is created at UserPromptSubmit and only +// ended at Stop or SessionEnd. When the daemon exits for any other reason +// (inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a restart control message), its +// already-ended children (completed tool spans, finalized chat spans, closed +// subagent spans) have been exported, but the still-open root had not. The +// result was a rootless trace: tool spans with no user turn to attribute them +// to. // // The fix finalizes every live session (ending its turn root) inside the // shutdown drain, before the exporter is flushed. These tests drive a turn to a @@ -19,19 +19,8 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; @@ -53,52 +42,36 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; } -interface Harness { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, a: string | undefined, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; - drain(reason: string): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Harness { - const logFile = path.join(os.tmpdir(), `wcp-shutdown-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-shutdown.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Harness; -} - /** Drive a session to a mid-turn state: turn open, one tool completed. */ async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'reading' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Read', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); } test('daemon shutdown mid-turn exports the turn root span (children are not left rootless)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-shutdown'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { await openTurnWithOneCompletedTool(d, sid, append, file); // Neither Stop nor SessionEnd fired: the daemon exits (inactivity / signal // / restart). The drain must finalize the open turn before flushing. await d.drain('inactivity'); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); - const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === OP.EXECUTE_TOOL); + const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); assert.ok(tool, 'the completed tool span exported as a child'); - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); + const root = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); assert.ok(root, 'the turn root span must be exported on shutdown, not leaked'); assert.equal(root!.attributes[ATTR.AGENT_NAME], 'claude-code'); assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); @@ -112,29 +85,32 @@ test('daemon shutdown mid-turn exports the turn root span (children are not left }); test('daemon shutdown ends an open subagent invoke_agent span under the same trace', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-shutdown-subagent'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'spawn a reviewer' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'spawn a reviewer' }); - // Agent tool with subagent_type opens a nested invoke_agent span that a - // mid-flight shutdown would otherwise leave open. + // Agent tool with subagent_type opens a nested invoke_agent span (SubAgent) + // that a mid-flight shutdown would otherwise leave open. append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); await d.drain('SIGTERM'); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); - const sub = spans.find(s => s.name === `${OP.INVOKE_AGENT} code-reviewer`); + const invokeAgents = spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + const root = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const sub = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'code-reviewer'); assert.ok(root, 'turn root exported'); assert.ok(sub, 'open subagent invoke_agent span exported on shutdown'); assert.equal(sub!.spanContext().traceId, root!.spanContext().traceId, 'subagent nests under the same trace as the root'); + assert.equal(spanParentId(sub!), root!.spanContext().spanId, 'subagent parents under the turn root'); assert.equal(sub!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -142,16 +118,17 @@ test('daemon shutdown ends an open subagent invoke_agent span under the same tra }); test('SessionEnd still exports the turn root span after the finalize refactor', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-sessionend'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { await openTurnWithOneCompletedTool(d, sid, append, file); - await d.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); - const root = exporter.getFinishedSpans().find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); + const root = exporter.getFinishedSpans().find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); assert.ok(root, 'SessionEnd exports the turn root'); assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); } finally { diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts index 7980d67..dc95dd0 100644 --- a/tests/daemon-subagent-recovery.test.ts +++ b/tests/daemon-subagent-recovery.test.ts @@ -12,61 +12,32 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; - -// Production registers this via NodeTracerProvider.register(); the test injects -// a BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-subrecover-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-subrecover.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as { routeEvent(p: Record): Promise }; -} - -function userLine(text: string): string { - return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }); -} -function assistantLine(text: string, usage: Record): string { - return JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', model: 'claude-opus-4-8', id: 'm1', usage, stop_reason: 'end_turn', content: [{ type: 'text', text }] }, - }); -} - -test('SubagentStop with no tracker (post-restart) recovers the subagent: turn -> invoke_agent -> chat with tokens', async () => { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; + +test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_agent + chat with tokens', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover-')); const sid = 'sub-recover-001'; const agentId = 'a1234567890abcdef'; // Main transcript: the in-progress turn the subagent ran under, already on disk. const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, userLine('spawn a subagent') + '\n' + assistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); + fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); // Subagent transcript where the daemon derives it (agentId-based sibling dir). const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('do the subtask') + '\n' + assistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); + fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { // Fresh daemon that only sees the subagent's completion, not its start. await d.routeEvent({ @@ -79,7 +50,7 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent: turn -> }); // SessionEnd closes the reconstructed turn so it exports. await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); const names = spans.map((s) => `${s.name}[${s.attributes['gen_ai.agent.name']}]`).join(', '); @@ -89,37 +60,40 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent: turn -> ); assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); + // The subagent's chat spans nest under its invoke_agent marker — and carry + // its tokens. const chat = spans.find( - (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.parentSpanContext?.spanId === subInvoke.spanContext().spanId, + (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', ); - assert.ok(chat, `expected the subagent chat span nested under the subagent invoke_agent span; got: ${names}`); + assert.ok(chat, `expected the subagent chat span; got: ${names}`); assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); // Recovery reconstructs the turn; the subagent nests under it. const turn = spans.find( - (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude code', + (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code', ); assert.ok(turn, `expected a reconstructed turn span to parent the subagent; got: ${names}`); - assert.equal(subInvoke.parentSpanContext?.spanId, turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); } finally { fs.rmSync(dir, { recursive: true, force: true }); - await provider.shutdown(); } }); test('recovery reuses an already-open turn span instead of creating a spurious second turn', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover2-')); const sid = 'sub-recover-002'; const agentId = 'b1234567890abcdef'; const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, userLine('start') + '\n'); + fs.writeFileSync(mainPath, transcriptUserLine('start') + '\n'); const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('subtask') + '\n' + assistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); + fs.writeFileSync(subPath, transcriptUserLine('subtask') + '\n' + transcriptAssistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { // UserPromptSubmit reconstructs the session and opens a turn first; recovery // must nest under that existing turn, not create a second one. @@ -129,16 +103,15 @@ test('recovery reuses an already-open turn span instead of creating a spurious s agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore', }); await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); - const turns = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude code'); + const turns = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code'); assert.equal(turns.length, 1, `exactly one turn span expected, no spurious reconstructed turn; got ${turns.length}`); - const subInvoke = spans.find((s) => s.attributes['gen_ai.agent.name'] === 'Explore'); + const subInvoke = spans.find((s) => s.attributes['gen_ai.agent.name'] === 'Explore' && s.attributes['gen_ai.operation.name'] === 'invoke_agent'); assert.ok(subInvoke, 'recovered subagent invoke_agent span present'); - assert.equal(subInvoke.parentSpanContext?.spanId, turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); + assert.equal(spanParentId(subInvoke), turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); } finally { fs.rmSync(dir, { recursive: true, force: true }); - await provider.shutdown(); } }); diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts index 2b9c188..e82c368 100644 --- a/tests/genai-span-usage-tokens.test.ts +++ b/tests/genai-span-usage-tokens.test.ts @@ -5,9 +5,9 @@ // Regression test for the cache-hit-rate bug (Weave UI showed >100%). // // Anthropic's API splits prompt usage into three disjoint fields: -// input_tokens — new (uncached) prompt tokens -// cache_read_input_tokens — tokens served from prompt cache -// cache_creation_input_tokens — tokens written to prompt cache +// input_tokens - new (uncached) prompt tokens +// cache_read_input_tokens - tokens served from prompt cache +// cache_creation_input_tokens - tokens written to prompt cache // // OTel GenAI semconv requires `gen_ai.usage.input_tokens` to be the TOTAL // prompt size (including cache reads and writes). When the plugin forwarded @@ -15,52 +15,63 @@ // `cache_read / input_tokens` produced rates greater than 100% (the cache // portion was larger than the uncached portion). Spec ref: // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md +// +// Driven end-to-end through the daemon so the assertion is on the exported +// `chat` span's attributes (the public contract), not an internal helper. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { emitChatSpan, ATTR } from '../src/genaiSpans.ts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +function aLine(id: string, ts: string, text: string, usage: Record) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-7', content: [{ type: 'text', text }], usage, stop_reason: 'end_turn' }, + }; +} +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; } -test('emitChatSpan: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); - - const startedAt = new Date('2026-01-01T00:00:00Z'); - const endedAt = new Date('2026-01-01T00:00:01Z'); +/** Drive one turn whose single tool-less assistant response carries `usage`, + * and return the exported `chat` span. */ +async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usage: Record): Promise { + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-usage-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), + JSON.stringify(aLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), + ].join('\n') + '\n'); + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + const chat = exporter.getFinishedSpans().find(s => s.name === 'chat'); + assert.ok(chat, 'chat span should be emitted'); + return chat; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} - emitChatSpan(tracer, parent, { - conversationId: 'conv-1', - model: 'claude-opus-4-7', - startedAt, - endedAt, - usage: { - input_tokens: 7600, - output_tokens: 528, - cache_read_input_tokens: 36500, - cache_creation_input_tokens: 4100, - }, +test('chat span: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-1', { + input_tokens: 7600, + output_tokens: 528, + cache_read_input_tokens: 36500, + cache_creation_input_tokens: 4100, }); - parent.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-7'); - assert.ok(chatSpan, 'chat span should be emitted'); - // Total prompt = 7600 + 36500 + 4100 = 48200. // Without this fix the value was 7600, making cache_read/input_tokens = 480%. assert.equal( @@ -68,30 +79,16 @@ test('emitChatSpan: input_tokens includes cache_read + cache_creation (OTel semc 48200, 'gen_ai.usage.input_tokens must include cache_read and cache_creation per OTel semconv', ); - // Cache fields are reported separately and unchanged. assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], 36500); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], 4100); assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 528); }); -test('emitChatSpan: input_tokens unchanged when no cache fields present', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); - - emitChatSpan(tracer, parent, { - conversationId: 'conv-2', - model: 'claude-haiku-4-5', - startedAt: new Date(), - endedAt: new Date(), - usage: { input_tokens: 1000, output_tokens: 200 }, - }); - - parent.end(); - await provider.forceFlush(); +test('chat span: input_tokens unchanged when no cache fields present', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-2', { input_tokens: 1000, output_tokens: 200 }); - const chatSpan = exporter.getFinishedSpans().find(s => s.name === 'chat claude-haiku-4-5'); - assert.ok(chatSpan); assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], undefined); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], undefined); diff --git a/tests/helpers.ts b/tests/helpers.ts index 2e36691..9511bf9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -12,8 +12,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import * as weave from 'weave'; -import { MARKETPLACE_NAME } from '../src/setup.ts'; +import { MARKETPLACE_NAME, type Settings } from '../src/setup.ts'; +import { GlobalDaemon } from '../src/daemon.ts'; +import { resolveApiKey, resolveProject } from '../src/config.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); @@ -103,6 +107,115 @@ export function writeKnownMarketplace(home: string, source: Record { + if (!genaiExporter) { + // weave.init() resolves a key from WANDB_API_KEY/~/.netrc and throws without + // one, even offline. Resolve project + key the way the daemon does (fake + // creds so the bridge stays hermetic on CI) and export the key for init. + const settings: Settings = { + log_file: '', daemon_socket: '', weave_project: 'e/p', wandb_api_key: 'fake-key-for-test', + agent_name: null, debug: false, installed_at: '', version: '0.0.0-test', + }; + process.env.WANDB_API_KEY = resolveApiKey(settings).value ?? ''; + genaiExporter = new InMemorySpanExporter(); + await weave.init(resolveProject(settings).value ?? 'e/p', { + genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) }, + }); + } + return genaiExporter; +} + +/** The daemon surface the genai tests drive: the (private) routeEvent entry + * point production feeds from the socket, plus drain for shutdown tests. */ +export type DaemonDriver = { + routeEvent(p: Record): Promise; + drain(reason: string): Promise; +}; + +/** Construct a GlobalDaemon with tracing marked enabled (the SDK is already + * initialised via `initWeaveInMemory`), skipping the real socket/`start()`, + * viewed through the {@link DaemonDriver} test seam. */ +export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { + const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); + const d = new GlobalDaemon('/tmp/unused.sock', logFile, { + weaveProject: 'e/p', apiKey: 'k', baseUrl: 'https://x', agentName, debug: false, + }); + (d as unknown as { tracingEnabled: boolean }).tracingEnabled = true; + return d as unknown as DaemonDriver; +} + +/** One JSONL transcript line for a user text message. `version` mirrors the + * CC CLI version stamp real transcripts carry on their head line. */ +export function transcriptUserLine(text: string, opts: { version?: string; timestamp?: string } = {}): string { + return JSON.stringify({ + type: 'user', + ...(opts.version ? { version: opts.version } : {}), + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { role: 'user', content: [{ type: 'text', text }] }, + }); +} + +/** One JSONL transcript line for a single-text assistant response. */ +export function transcriptAssistantLine( + text: string, + usage: Record, + opts: { id?: string; model?: string; timestamp?: string } = {}, +): string { + return JSON.stringify({ + type: 'assistant', + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { + role: 'assistant', + id: opts.id ?? 'm1', + model: opts.model ?? 'claude-opus-4-8', + usage, + stop_reason: 'end_turn', + content: [{ type: 'text', text }], + }, + }); +} + +/** Flush any spans buffered in the SDK so the in-memory exporter has them. */ +export function flushWeave(): Promise { + return weave.flushOTel(); +} + +/** Parent span id of an exported span. weave's BasicTracerProvider exposes it + * as the `parentSpanId` string (older node providers used `parentSpanContext`), + * so read whichever is present. */ +export function spanParentId(s: ReadableSpan): string | undefined { + return (s as unknown as { parentSpanId?: string }).parentSpanId ?? s.parentSpanContext?.spanId; +} + +/** Direct children of `parent`, ordered by span start time. */ +export function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { + const parentId = parent.spanContext().spanId; + return spans + .filter(s => spanParentId(s) === parentId) + .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); +} + +function hrToNs(t: [number, number]): number { + return t[0] * 1e9 + t[1]; +} + function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts index f58276e..6631ff6 100644 --- a/tests/interleave-handlers.test.ts +++ b/tests/interleave-handlers.test.ts @@ -2,31 +2,24 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Chat-span state machine, driven through the real hook handlers (not by -// calling emitChatSpanForResponse directly): chat span opened at PreToolUse + -// tool parenting, response transitions, the dedup (no double chat span at -// Stop), and SessionEnd finalizing a still-open span. The other interleave -// tests cover only the helpers in isolation. +// Chat-span state machine, driven through the real hook handlers (via +// routeEvent, so the ambient conversation is installed): chat span opened at +// PreToolUse + tool parenting, response transitions, the dedup (no double chat +// span at Stop), and SessionEnd finalizing a still-open span. +// +// Post-SDK-migration: a response's text/thinking blocks are ORDERED +// `gen_ai.output.messages` parts on its single `chat` span (not separate child +// spans). The tools the model called still nest under that chat span as +// `execute_tool` children. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - type ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; @@ -62,54 +55,37 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno }; } -interface Handlers { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, a: string | undefined, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleStop(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Handlers { - const logFile = path.join(os.tmpdir(), `wcp-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Handlers; -} - -function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { - return spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); -} function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { - return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === OP.CHAT && s.attributes[ATTR.RESPONSE_ID] === id); + return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.RESPONSE_ID] === id); +} +function partsOf(span: ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; } test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool interleave, usage once, no double-emit', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-A'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); // Response msgA: text then tool_use (split lines, shared id), flushed // before the tool's PreToolUse fires. append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); - // msgB: text-only (no tool_use → no PreToolUse; back-filled at Stop). + // msgB: text-only (no tool_use -> no PreToolUse; back-filled at Stop). append(aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn')); - await d.handleStop(sid, {}); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); @@ -118,45 +94,53 @@ test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool inte assert.equal(chatByResponse(spans, 'msgB').length, 1, 'one chat span for msgB'); const chatA = chatByResponse(spans, 'msgA')[0]; + // msgA text + tool_use are ordered output parts on the chat span. + assert.deepEqual(partsOf(chatA), [ + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'msgA: text then tool_call, in transcript order, as output parts'); + // The tool the model called nests under the chat span as an execute_tool child. const aKids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(aKids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], 'msgA: text then tool, both parented under the chat span'); + assert.deepEqual(aKids, ['execute_tool'], 'msgA: the execute_tool span nests under the chat span'); // Usage counted once for the split response. assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); const chatB = chatByResponse(spans, 'msgB')[0]; - assert.deepEqual(childrenOf(spans, chatB).map(s => s.attributes[ATTR.OPERATION_NAME]), [OP.ASSISTANT_TEXT]); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + assert.equal(childrenOf(spans, chatB).length, 0, 'tool-less msgB has no execute_tool children'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('handlers: a new response transitions and finalizes the previous chat span', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-B'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do two things')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do two things' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do two things' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing A' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_A', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_A', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_A', tool_response: 'ok' }); - // Second response with its own tool_use → PreToolUse(tool_B) must finalize + // Second response with its own tool_use -> PreToolUse(tool_B) must finalize // msgA's chat span (transition) before opening msgB's. append(aLine('msgB', '2026-01-01T00:00:05.000Z', { type: 'text', text: 'editing B' })); append(aLine('msgB', '2026-01-01T00:00:06.000Z', { type: 'tool_use', id: 'tool_B', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_B', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_B', tool_response: 'ok' }); - await d.handleStop(sid, {}); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA finalized exactly once at the transition'); @@ -164,45 +148,44 @@ test('handlers: a new response transitions and finalizes the previous chat span' for (const id of ['msgA', 'msgB']) { const chat = chatByResponse(spans, id)[0]; + const parts = partsOf(chat).map(p => p['type']); + assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); const kids = childrenOf(spans, chat).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(kids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], `${id}: text + tool under its chat span`); + assert.deepEqual(kids, ['execute_tool'], `${id}: execute_tool nests under its chat span`); } } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); -test('handlers: SessionEnd finalizes a still-open chat span with its text + usage (not an empty orphan)', async () => { +test('handlers: SessionEnd finalizes a still-open chat span with its output + usage (not an empty orphan)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-C'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); - // No Stop — session ends mid-turn with the chat span still open. - await d.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); + // No Stop - session ends mid-turn with the chat span still open. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); const spans = exporter.getFinishedSpans(); const chatA = chatByResponse(spans, 'msgA')[0]; assert.ok(chatA, 'chat span for msgA was finalized at SessionEnd (has a response id)'); - // Finalized, not an empty orphan: usage + text child are present. + // Finalized, not an empty orphan: usage + text output part are present. assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); - const kids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.ok(kids.includes(OP.ASSISTANT_TEXT), 'assistant_text child recovered at SessionEnd'); + const types = partsOf(chatA).map(p => p['type']); + assert.ok(types.includes('text'), 'assistant text output part recovered at SessionEnd'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts index 1165e0d..f0ba825 100644 --- a/tests/interleave-split-lines.test.ts +++ b/tests/interleave-split-lines.test.ts @@ -9,36 +9,22 @@ // `message.id`, and the parser maps each line to its own AssistantCallDetail. // An earlier version walked `blockIdx` within a single call's contentBlocks, // assuming all blocks lived together; against real (split) transcripts that -// emitted nothing for the text/thinking blocks (they were dropped) and lumped -// any surviving text at the end with emission-time timestamps. +// emitted nothing for the text/thinking blocks (they were dropped). // -// This drives the actual reconstruction (GlobalDaemon.emitChatSpanForResponse, -// reached the same way the Stop handler reaches it) against a realistic -// split-line transcript and asserts: text/thinking are NOT dropped, each lands -// on a span stamped with its transcript timestamp (so it sorts into order -// among the live tool spans), and the duplicated per-line usage is counted -// once per response. +// Post-SDK-migration each assistant response is a single `chat` span whose +// ordered `gen_ai.output.messages` parts carry the response's blocks in +// transcript order. This drives the actual reconstruction through the Stop +// handler and asserts: thinking / redacted_thinking / text / tool_use are NOT +// dropped, appear in order as parts, and the duplicated per-line usage is +// counted once per response. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - type ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { parseSessionFile } from '../src/parser.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; /** One assistant transcript line carrying a single content block, mirroring how * Claude Code splits a response. `usage` is the FULL message usage, duplicated @@ -62,105 +48,60 @@ function userText(ts: string, text: string) { return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; } -function writeTranscript(lines: unknown[]): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wcp-interleave-')); - const file = path.join(dir, 'session.jsonl'); - fs.writeFileSync(file, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); - return file; -} - -function makeDaemon(tracer: ReturnType['tracer']): GlobalDaemon { - const d = new GlobalDaemon('/tmp/unused.sock', '/tmp/unused.log', 'e/p', 'k', 'https://x', false); - // Inject the in-memory tracer (normally created by initTracer at startup). - (d as unknown as { tracer: unknown }).tracer = tracer; - return d; +function partsOf(span: import('@opentelemetry/sdk-trace-base').ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; } -test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave, none dropped, usage counted once', async () => { +test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave as ordered parts, none dropped, usage once', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); // One turn: // response msgA: thinking, redacted_thinking, text, tool_use (4 split lines, shared id) // response msgB: text-only (no tool_use) - const file = writeTranscript([ + const sid = 'sess-split'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-splitlines-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ userText('2026-01-01T00:00:00.000Z', 'do the thing'), aLine('msgA', '2026-01-01T00:00:01.000Z', { type: 'thinking', thinking: 'let me think' }), aLine('msgA', '2026-01-01T00:00:01.500Z', { type: 'redacted_thinking', data: 'ENCRYPTED' }), aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' }), aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use'), aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), - ]); + ].map(l => JSON.stringify(l)).join('\n') + '\n'); - const { tracer, exporter, provider } = setupTracer(); + const d = makeGenaiDaemon(); try { - const parsed = parseSessionFile(file); - assert.ok(parsed); - const calls = parsed.turns[parsed.turns.length - 1].assistantCalls(); - // Sanity: the parser really does split one response across lines. - assert.equal(calls.filter(c => c.responseId === 'msgA').length, 4, 'msgA is 4 split lines'); - - const daemon = makeDaemon(tracer); - const turn = tracer.startSpan('invoke_agent claude-code'); - const session = { - conversationId: 'conv-1', - currentTurnSpan: turn, - emittedChatSpanResponseKeys: new Set(), - activeChatSpan: undefined, - }; - - // Reach the real reconstruction the same way the Stop handler does. - const emit = (key: string) => - (daemon as unknown as { emitChatSpanForResponse: (s: unknown, c: unknown, k: string) => void }) - .emitChatSpanForResponse(session, calls, key); - emit('msgA'); - emit('msgB'); - - turn.end(); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); + // No PreToolUse fires here; both responses are back-filled at Stop, which + // is the reconstruction path this test exercises. + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); assert.ok(chatA, 'chat span for msgA emitted'); - const childrenOf = (parent: ReadableSpan) => - spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); - - const aChildren = childrenOf(chatA); - // thinking, redacted_thinking, and text are NOT dropped, and appear in - // transcript order. redacted_thinking has no readable content, so it - // surfaces as a placeholder thinking span rather than being dropped. - assert.deepEqual( - aChildren.map(s => s.attributes[ATTR.OPERATION_NAME]), - [OP.THINKING, OP.THINKING, OP.ASSISTANT_TEXT], - 'thinking, redacted placeholder, text — all present, in order', - ); - assert.deepEqual( - JSON.parse(aChildren[1].attributes[ATTR.OUTPUT_MESSAGES] as string), - [{ role: 'assistant', parts: [{ type: 'thinking', content: '[redacted]' }] }], - 'redacted_thinking renders a [redacted] placeholder', - ); - // Each child is stamped with its transcript line timestamp (so it sorts - // before the tool_use of the same response, whose live span starts later). - assert.equal(isoOf(aChildren[0].startTime), '2026-01-01T00:00:01.000Z'); - assert.equal(isoOf(aChildren[1].startTime), '2026-01-01T00:00:01.500Z'); - assert.equal(isoOf(aChildren[2].startTime), '2026-01-01T00:00:02.000Z'); + // thinking, redacted_thinking (as a [redacted] reasoning part), text, and + // tool_use are NOT dropped and appear in transcript order as parts. + assert.deepEqual(partsOf(chatA), [ + { type: 'reasoning', content: 'let me think' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'thinking, redacted placeholder, text, tool_call: all present, in order'); - // Usage counted ONCE for the response (not 3x for the 3 split lines). + // Usage counted ONCE for the response (not 4x for the 4 split lines). assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); - // The tool-less final message still renders, after msgA. + // The tool-less final message still renders as its own chat span. const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); assert.ok(chatB, 'chat span for tool-less msgB emitted'); - assert.equal(childrenOf(chatB).map(s => s.attributes[ATTR.OPERATION_NAME])[0], OP.ASSISTANT_TEXT); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); } finally { - fs.rmSync(path.dirname(file), { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); } }); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} -function isoOf(t: [number, number]): string { - return new Date(t[0] * 1000 + t[1] / 1e6).toISOString(); -} diff --git a/tests/interleaved-assistant-spans.test.ts b/tests/interleaved-assistant-spans.test.ts index fb9cf95..da52450 100644 --- a/tests/interleaved-assistant-spans.test.ts +++ b/tests/interleaved-assistant-spans.test.ts @@ -2,192 +2,57 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Regression test for the "final assistant message contains the interstitial -// Claude text that should appear between tool calls" bug. +// Unit test for `contentBlocksToParts`: the formatting layer that turns a +// Claude assistant message's content blocks into ordered `MessagePart`s for a +// chat span's `gen_ai.output.messages`. // -// The old emitChatSpansFromAssistantCalls path emitted one chat span per -// assistant API call as a sibling of tool spans, with text content joined and -// tool_use position info dropped. With parallel tool calls, the Weave UI -// rendered all tool spans first and a single chat span at the bottom holding -// every interstitial utterance smushed together. -// -// New behavior: each assistant API call gets a chat span that PARENTS the -// tool spans AND per-block assistant_text / thinking spans that occur during -// that call, in transcript order. Token usage stays on the chat span (where -// it accurately represents one API invocation). +// Post-SDK-migration, an assistant response's interleave (text -> tool_use -> +// text) is no longer separate child spans; it is the ordered `parts` array on +// the single `chat` span. This test pins the block -> part mapping and order. +// The end-to-end interleave behavior (parts on the chat span, tools nested +// under it) is covered by interleave-handlers / interleave-split-lines. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { - startChatSpan, - finalizeChatSpan, - startToolSpan, - emitAssistantTextSpan, - emitThinkingSpan, - OP, - ATTR, -} from '../src/genaiSpans.ts'; - -function setupTracer(): { - tracer: ReturnType; - exporter: InMemorySpanExporter; - provider: BasicTracerProvider; -} { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function opName(span: ReadableSpan): string { - return span.attributes[ATTR.OPERATION_NAME] as string; -} - -test('chat span parents per-block assistant_text and execute_tool children in transcript order', async () => { - const { tracer, exporter, provider } = setupTracer(); - const turn = tracer.startSpan('invoke_agent claude-code'); - - // Simulated assistant API call: - // text "Now let me add the method" - // tool_use Edit - // text "Now let me add the test" - // tool_use Edit - // text "All done" - const chat = startChatSpan(tracer, turn, { - conversationId: 'conv-1', - model: 'claude-opus-4-7', - startedAt: new Date('2026-01-01T00:00:00Z'), - }); - - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'Now let me add the method', - }); - const t1 = startToolSpan(tracer, chat, { - toolName: 'Edit', - toolUseId: 'toolu_01', - toolInput: { file_path: '/foo.ts' }, - }); - t1.end(); - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'Now let me add the test', - }); - const t2 = startToolSpan(tracer, chat, { - toolName: 'Edit', - toolUseId: 'toolu_02', - toolInput: { file_path: '/foo.test.ts' }, - }); - t2.end(); - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'All done', - }); - - finalizeChatSpan(chat, { - usage: { input_tokens: 1000, output_tokens: 200 }, - endedAt: new Date('2026-01-01T00:00:05Z'), - }); - turn.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const turnSpan = spans.find((s) => s.name === 'invoke_agent claude-code'); - const chatSpan = spans.find((s) => s.name === 'chat claude-opus-4-7'); - assert.ok(turnSpan, 'turn span emitted'); - assert.ok(chatSpan, 'chat span emitted with model in name'); - - // Chat span parents under the turn. - assert.equal(chatSpan.parentSpanContext?.spanId, turnSpan.spanContext().spanId); - - // Every assistant_text + execute_tool span parents under the chat span. - const chatChildren = spans.filter( - (s) => s.parentSpanContext?.spanId === chatSpan.spanContext().spanId, - ); - // Order in `chatChildren` reflects end-time ordering (SimpleSpanProcessor - // exports on span.end). Synchronous zero-duration emits in code order - // produce monotonic timestamps, so the assertion holds. - const childOps = chatChildren.map(opName); - assert.deepEqual( - childOps, - [ - OP.ASSISTANT_TEXT, - OP.EXECUTE_TOOL, - OP.ASSISTANT_TEXT, - OP.EXECUTE_TOOL, - OP.ASSISTANT_TEXT, - ], - 'children appear in interleaved transcript order', - ); - - // Token usage lives on the chat span, not on the per-block spans. - assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); - assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); - for (const child of chatChildren) { - assert.equal(child.attributes[ATTR.USAGE_INPUT_TOKENS], undefined); - assert.equal(child.attributes[ATTR.USAGE_OUTPUT_TOKENS], undefined); - } +import { contentBlocksToParts } from '../src/genaiSpans.ts'; + +test('contentBlocksToParts: interleaved text and tool_use map to ordered parts', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: 'Now let me add the method' }, + { type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: '/foo.ts' } }, + { type: 'text', text: 'Now let me add the test' }, + { type: 'tool_use', id: 'toolu_02', name: 'Edit', input: { file_path: '/foo.test.ts' } }, + { type: 'text', text: 'All done' }, + ]); - // assistant_text content lands on gen_ai.output.messages as a text part. - assert.deepEqual( - JSON.parse(chatChildren[0].attributes[ATTR.OUTPUT_MESSAGES] as string), - [{ role: 'assistant', parts: [{ type: 'text', content: 'Now let me add the method' }] }], - ); + assert.deepEqual(parts, [ + { type: 'text', content: 'Now let me add the method' }, + { type: 'tool_call', toolCallId: 'toolu_01', toolName: 'Edit', arguments: '{"file_path":"/foo.ts"}' }, + { type: 'text', content: 'Now let me add the test' }, + { type: 'tool_call', toolCallId: 'toolu_02', toolName: 'Edit', arguments: '{"file_path":"/foo.test.ts"}' }, + { type: 'text', content: 'All done' }, + ]); }); -test('emitThinkingSpan: thinking content lands as a thinking part on its own span', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('chat'); - - emitThinkingSpan(tracer, parent, { - conversationId: 'conv-1', - text: 'Let me reason about this...', - }); - - parent.end(); - await provider.forceFlush(); +test('contentBlocksToParts: thinking maps to a reasoning part; redacted_thinking to a placeholder', () => { + const parts = contentBlocksToParts([ + { type: 'thinking', thinking: 'Let me reason about this...' }, + { type: 'redacted_thinking', data: 'ENCRYPTED' }, + { type: 'text', text: 'answer' }, + ]); - const span = exporter - .getFinishedSpans() - .find((s) => s.attributes[ATTR.OPERATION_NAME] === OP.THINKING); - assert.ok(span); - assert.equal(span.name, OP.THINKING); - const messages = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string); - assert.deepEqual(messages, [ - { - role: 'assistant', - parts: [{ type: 'thinking', content: 'Let me reason about this...' }], - }, + assert.deepEqual(parts, [ + { type: 'reasoning', content: 'Let me reason about this...' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'answer' }, ]); }); -test('startChatSpan without model: finalizeChatSpan stamps the model and updates the name', async () => { - const { tracer, exporter, provider } = setupTracer(); - const turn = tracer.startSpan('invoke_agent claude-code'); - - const chat = startChatSpan(tracer, turn, { - conversationId: 'conv-1', - startedAt: new Date(), - }); - finalizeChatSpan(chat, { - usage: { input_tokens: 1, output_tokens: 1 }, - model: 'claude-haiku-4-5', - }); - turn.end(); - await provider.forceFlush(); - - const chatSpan = exporter - .getFinishedSpans() - .find((s) => s.attributes[ATTR.OPERATION_NAME] === OP.CHAT); - assert.ok(chatSpan); - assert.equal(chatSpan.name, 'chat claude-haiku-4-5'); - assert.equal(chatSpan.attributes[ATTR.REQUEST_MODEL], 'claude-haiku-4-5'); - assert.equal(chatSpan.attributes[ATTR.PROVIDER_NAME], 'anthropic'); +test('contentBlocksToParts: empty text and empty thinking are skipped', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: ' ' }, + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'kept' }, + ]); + assert.deepEqual(parts, [{ type: 'text', content: 'kept' }]); }); diff --git a/tests/interrupted-turn.test.ts b/tests/interrupted-turn.test.ts new file mode 100644 index 0000000..31676f8 --- /dev/null +++ b/tests/interrupted-turn.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// A user interrupt ends a turn WITHOUT a Stop hook, so the next +// UserPromptSubmit arrives with the previous turn (and possibly its chat span) +// still open. Regression coverage for two bugs in that window: +// 1. the open turn's handle was silently overwritten, leaking its root span +// un-exported (rootless trace); +// 2. the stale activeChat's response key, finalized against the NEXT turn's +// transcript, produced an empty call group and crashed recordChat — +// killing tool tracing for the rest of the session. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + transcriptUserLine, +} from './helpers.ts'; + +function assistantToolUseLine(msgId: string, toolUseId: string, ts: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id: msgId, + model: 'claude-opus-4-8', + usage: { input_tokens: 100, output_tokens: 10 }, + stop_reason: 'tool_use', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 999' } }], + }, + }); +} + +test('interrupted turn: next prompt closes the open turn and tool tracing survives', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-interrupt'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-interrupt-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, transcriptUserLine('turn one', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn one' }); + + // Turn 1's response msgA starts a tool; the user interrupts before it + // completes, so neither PostToolUse nor Stop ever fires. + fs.appendFileSync(file, assistantToolUseLine('msgA', 'tool_1', '2026-01-01T00:00:02.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + + // Turn 2 begins: the transcript's new user message starts a new parsed turn, + // making turn 1's msgA key stale relative to the latest parse. + fs.appendFileSync(file, transcriptUserLine('turn two', { timestamp: '2026-01-01T00:00:10.000Z' }) + '\n'); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); + + // Tool tracing in turn 2 must still work (this crashed on the stale key). + fs.appendFileSync(file, assistantToolUseLine('msgB', 'tool_2', '2026-01-01T00:00:12.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_2', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_2', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.equal(turns.length, 2, 'both turn roots exported (interrupted turn not leaked)'); + + const superseded = turns.find((s) => s.attributes[ATTR.WEAVE_ORPHAN_REASON] === 'superseded_by_next_prompt'); + assert.ok(superseded, 'interrupted turn closed with the superseded orphan reason'); + + const tools = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.equal(tools.length, 2, 'tool spans from both turns exported (turn 2 tracing survived)'); + + // The interrupted turn's chat span is finalized from the transcript with + // its real usage, not dropped. + const chats = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.ok(chats.some((c) => c.attributes[ATTR.RESPONSE_ID] === 'msgA'), 'interrupted chat span exported'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts new file mode 100644 index 0000000..dced089 --- /dev/null +++ b/tests/subagent-nesting.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// The matched subagent path end-to-end: PreToolUse(Agent) opens the +// invoke_agent marker under the turn, SubagentStart correlates the agent_id by +// firing-prompt hash, the subagent's own tools and chat spans nest under the +// marker (weave 0.16.3 SubAgent parents children), and PostToolUse(Agent) +// closes the marker with the tool's canonical return. Conversation id and +// integration identity must reach every nested span through the handle chain. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; + +const userLine = (text: string): string => + transcriptUserLine(text, { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }); +const assistantLine = (text: string, usage: Record): string => + transcriptAssistantLine(text, usage, { timestamp: '2026-01-01T00:00:05.000Z' }); + +test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-001'; + const agentId = 'nest-agent-1'; + const firingPrompt = 'find the flaky test'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + + // Subagent transcript at the derived path; line 1 is the firing prompt + // (byte-identical to the Agent tool's prompt) for content-based correlation. + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-agent', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + // The subagent runs its own tool. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_response: 'contents' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent', tool_response: 'found the flaky test' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(subInvoke, 'subagent invoke_agent marker exported'); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); + assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); + assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); + assert.equal( + subInvoke.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'found the flaky test' }]), + 'PostToolUse(Agent) closes the marker with the canonical tool return', + ); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'subagent tool span exported'); + assert.equal(spanParentId(readTool), subInvoke.spanContext().spanId, 'subagent tool nests under the marker'); + assert.equal(readTool.attributes[ATTR.AGENT_NAME], 'Explore', 'subagent tool tagged with the subagent name'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'subagent chat span exported'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); + assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); + + // Identity flows through the handle chain to every nested span. + for (const s of [subInvoke, readTool, chat]) { + assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('recursive dispatch: a subagent spawning a subagent nests the child under its own marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-002'; + const outerPrompt = 'do the outer task'; + const innerPrompt = 'do the inner task'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest2-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + for (const [agentId, prompt] of [['outer-1', outerPrompt], ['inner-1', innerPrompt]] as const) { + const p = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, userLine(prompt) + '\n' + assistantLine('done', { input_tokens: 10, output_tokens: 5 }) + '\n'); + } + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + // Main agent dispatches the outer subagent. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-outer', + tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: outerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'outer-1', agent_type: 'general-purpose' }); + // The OUTER subagent dispatches the inner one (agent_id set on the event). + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: innerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'inner-1', agent_type: 'Explore' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/f.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'inner-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-inner-1.jsonl'), agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', tool_response: 'inner done' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'outer-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-outer-1.jsonl'), agent_type: 'general-purpose' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-outer', tool_response: 'outer done' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const outer = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'general-purpose'); + const inner = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(turn && outer && inner, 'turn + both markers exported'); + assert.equal(spanParentId(outer), turn.spanContext().spanId, 'outer marker nests under the turn'); + assert.equal(spanParentId(inner), outer.spanContext().spanId, 'inner marker nests under the OUTER marker'); + assert.equal(inner.attributes[ATTR.AGENT_ID], 'inner-1', 'inner marker matched (not an orphan)'); + assert.equal(inner.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined, 'no orphan fallback'); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'inner tool exported'); + assert.equal(spanParentId(readTool), inner.spanContext().spanId, 'inner tool nests under the inner marker'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/system-instructions-integration.test.ts b/tests/system-instructions-integration.test.ts index cc72f81..09c84e9 100644 --- a/tests/system-instructions-integration.test.ts +++ b/tests/system-instructions-integration.test.ts @@ -18,43 +18,16 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; - -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-sysinstr-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-sysinstr.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as { routeEvent(p: Record): Promise }; -} +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, transcriptUserLine } from './helpers.ts'; /** Seed a transcript file with a single user line (the first line carries the * CC CLI version, as real transcripts do) and return its path. */ function seedTranscript(sid: string): { dir: string; file: string } { const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-sysinstr-')); const file = path.join(dir, `${sid}.jsonl`); - const userLine = { type: 'user', version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }; - fs.writeFileSync(file, JSON.stringify(userLine) + '\n'); + fs.writeFileSync(file, transcriptUserLine('hi', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); return { dir, file }; } @@ -75,10 +48,11 @@ function turnRoots(spans: ReadableSpan[]): ReadableSpan[] { } test('buffers InstructionsLoaded fired before SessionStart, then accumulates in load order', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-order'; const { dir, file } = seedTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); // Global CLAUDE.md loads BEFORE SessionStart (the real, non-deterministic order). @@ -88,7 +62,7 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); @@ -105,10 +79,11 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in }); test('re-loading the same file replaces its content rather than duplicating', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-dedup'; const { dir, file } = seedTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); @@ -117,7 +92,7 @@ test('re-loading the same file replaces its content rather than duplicating', as await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'V2', 'compact')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); @@ -131,10 +106,11 @@ test('re-loading the same file replaces its content rather than duplicating', as }); test('stamps system instructions on every turn root (no session span to hang them on)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-multiturn'; const { dir, file } = seedTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); @@ -143,7 +119,7 @@ test('stamps system instructions on every turn root (no session span to hang the await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const turns = turnRoots(exporter.getFinishedSpans()); assert.equal(turns.length, 2, 'both turn roots exported'); @@ -157,15 +133,16 @@ test('stamps system instructions on every turn root (no session span to hang the }); test('omits gen_ai.system_instructions when no instructions were loaded', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-none'; const { dir, file } = seedTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts index 0203390..553043a 100644 --- a/tests/teammate-idle.test.ts +++ b/tests/teammate-idle.test.ts @@ -25,30 +25,13 @@ import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; import { parseSessionFile } from '../src/parser.ts'; -import { startInvokeAgentSpan, emitChatSpansFromAssistantCalls, ATTR } from '../src/genaiSpans.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; // ── helpers ────────────────────────────────────────────────────────────────── -function setupTracer(): { - tracer: ReturnType; - exporter: InMemorySpanExporter; - provider: BasicTracerProvider; -} { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; -} - /** Write a fake teammate transcript to a temp file and return its path. * * readFirstTranscriptLine requires the path to be within os.homedir() (security @@ -142,57 +125,56 @@ test('parseSessionFile: skips agent-setting lines, parses LLM calls from teammat } }); -test('TeammateIdle span tree: invoke_agent span contains chat child', async () => { - const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); - const { tracer, exporter, provider } = setupTracer(); - +test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagged by agent name', async () => { + // Drive the per-session teammate path end-to-end in-process: SubagentStart + // (orphan) creates the SubAgent marker; SubagentStop keeps it open; + // TeammateIdle emits the teammate's chat spans under a fresh teammate turn + // (the SubAgent is a leaf and can't parent them). Each teammate chat span is + // tagged with `gen_ai.agent.name` so the Agents view groups it. + const exporter = await initWeaveInMemory(); + exporter.reset(); + + const home = os.homedir(); + const coordSid = 'coord-span-001'; + const coordDir = fs.mkdtempSync(path.join(home, '.weave-tmspan-')); + const coordPath = path.join(coordDir, `${coordSid}.jsonl`); + fs.writeFileSync(coordPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // Subagent transcript at the path the daemon derives: + // //subagents/agent-.jsonl + const agentId = 'agent-span-abc'; + const subDir = path.join(coordDir, coordSid, 'subagents'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), + [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon(); try { - const parsed = parseSessionFile(filePath); - assert.ok(parsed); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); + // Orphan SubagentStart (no matching PreToolUse tracker). + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: coordSid, agent_id: agentId, agent_type: 'cks-specialist' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: coordSid, agent_id: agentId }); + await d.routeEvent({ hook_event_name: 'TeammateIdle', session_id: coordSid, teammate_name: 'cks-specialist', team_name: 'triage-span' }); + await flushWeave(); - // Mimic what handleTeammateIdle does: agent type comes from payload['teammate_name'], - // transcript path comes from payload['transcript_path']. - const agentType = 'cks-specialist'; // from payload['teammate_name'] - const parentSpan = tracer.startSpan('turn'); - const invokeSpan = startInvokeAgentSpan(tracer, parentSpan, { - agentType, - conversationId: 'conv-1', - pluginVersion: '0.2.6', - displayName: `Agent: ${agentType}`, - }); + const spans = exporter.getFinishedSpans(); - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(tracer, invokeSpan, 'conv-1', turn.assistantCalls()); - } - invokeSpan.end(); - parentSpan.end(); + // The teammate's own turn root (fresh trace), tagged with the teammate name. + const teammateTurn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'cks-specialist'); + assert.ok(teammateTurn, 'teammate turn span exists tagged with gen_ai.agent.name'); - await provider.forceFlush(); + // The teammate chat span nests under the teammate turn and is tagged too. + const chatKids = childrenOf(spans, teammateTurn).filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.equal(chatKids.length, 1, 'one chat span under the teammate turn'); + const chatSpan = chatKids[0]; + assert.equal(chatSpan.attributes[ATTR.AGENT_NAME], 'cks-specialist', 'chat span tagged with the teammate name'); - const spans = exporter.getFinishedSpans(); - const invokeAgentSpan = spans.find(s => s.name === 'invoke_agent cks-specialist'); - assert.ok(invokeAgentSpan, 'invoke_agent span should exist'); - assert.equal( - invokeAgentSpan.attributes[ATTR.AGENT_NAME], - 'cks-specialist', - 'gen_ai.agent.name should be set', - ); - - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-8'); - assert.ok(chatSpan, 'chat span should exist'); - - // Chat span should be a child of the invoke_agent span. - assert.equal( - chatSpan.parentSpanContext?.spanId, - invokeAgentSpan.spanContext().spanId, - 'chat span should be child of invoke_agent span', - ); - - // Token counts should be correct (cache-inclusive total for input). + // Token counts are correct (cache-inclusive total for input). assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1500, 'input_tokens = 1000 + 500 cache_read'); assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); } finally { - fs.rmSync(path.dirname(filePath), { recursive: true }); + fs.rmSync(coordDir, { recursive: true, force: true }); } }); diff --git a/tests/trace-base-url.test.ts b/tests/trace-base-url.test.ts index 6354303..bd31b13 100644 --- a/tests/trace-base-url.test.ts +++ b/tests/trace-base-url.test.ts @@ -8,7 +8,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resolveDaemonConfig } from '../src/daemon.ts'; +import { resolveDaemonConfig } from '../src/config.ts'; const SETTINGS = { weave_project: 'e/p', wandb_api_key: 'k' }; const baseUrlFor = (env: Record): string => diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts index 2bdcb60..989d971 100644 --- a/tests/turn-span-agent-name.test.ts +++ b/tests/turn-span-agent-name.test.ts @@ -3,54 +3,47 @@ // SPDX-PackageName: weave-claude-code // The top-level agent name is user-customizable (settings `agent_name` / -// `WEAVE_AGENT_NAME`). The daemon resolves the effective value and passes it -// to startTurnSpan, which must stamp it on BOTH the span name (`invoke_agent -// `, which drives Weave's Agents-view grouping) and the -// `gen_ai.agent.name` attribute. +// `WEAVE_AGENT_NAME`). The daemon resolves the effective value and passes it to +// `weave.startTurn`, which the SDK stamps on the `gen_ai.agent.name` attribute +// that drives Weave's Agents-view grouping. (The SDK always names the span +// `invoke_agent`; the agent name lives in the attribute, not the span name.) import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { startTurnSpan, ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +function writeTranscript(sessionId: string, text: string): { file: string; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-agentname-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n'); + return { file, dir }; } -function baseArgs(agentName: string) { - return { - sessionId: 'sess-1', - conversationId: 'conv-1', - turnNumber: 1, - prompt: 'hello', - cwd: '/tmp', - source: 'startup', - pluginVersion: '0.0.0-test', - agentName, - }; -} - -test('startTurnSpan: agentName drives the span name and gen_ai.agent.name', async () => { - const { tracer, exporter, provider } = setupTracer(); +test('turn span: agentName drives gen_ai.agent.name', async () => { + const exporter = await initWeaveInMemory(); // A custom name and the default both flow through identically. for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - startTurnSpan(tracer, baseArgs(name)).end(); - } - await provider.forceFlush(); + exporter.reset(); + const sid = `sess-${name}`; + const { file, dir } = writeTranscript(sid, 'hello'); + const d = makeGenaiDaemon(name); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/tmp' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'hello' }); + // The turn span only exports on end; SessionEnd finalizes an open turn. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); - for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - const span = exporter.getFinishedSpans().find(s => s.name === `invoke_agent ${name}`); - assert.ok(span, `span name must embed the agent name "${name}"`); - assert.equal(span.attributes[ATTR.AGENT_NAME], name); + const turnSpans = exporter.getFinishedSpans().filter(s => s.name === 'invoke_agent'); + assert.equal(turnSpans.length, 1, 'exactly one turn span'); + assert.equal(turnSpans[0].attributes[ATTR.AGENT_NAME], name, `gen_ai.agent.name must be "${name}"`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } }); diff --git a/tests/turn-span-integration.test.ts b/tests/turn-span-integration.test.ts index 3c49d22..293b589 100644 --- a/tests/turn-span-integration.test.ts +++ b/tests/turn-span-integration.test.ts @@ -2,46 +2,25 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Integration identity rides OTel Baggage onto EVERY span, not just the turn -// root. The daemon stashes per-session baggage at SessionStart and activates it -// for each event (in routeEvent); IntegrationBaggageSpanProcessor copies the -// `weave.integration.*` entries onto every span at onStart. So a chat or -// execute_tool span deep in a turn is filterable by integration just like the +// Integration identity rides onto EVERY span (turn root and all children), not +// just the turn root. The daemon builds per-session integration attributes at +// SessionStart and installs them on the session's Conversation; the SDK copies +// them onto every span it emits, and routeEvent re-installs the conversation for +// each event (each runIsolated frame starts with fresh ambient state). So a chat +// or execute_tool span deep in a turn is filterable by integration just like the // root. Assertions use the literal wire keys — those strings are the contract // the Weave backend reads into its queryable custom-attribute maps. // -// This drives the real routeEvent entry point (not the handlers directly) so -// the baggage context.with wrapping is exercised, and registers an -// AsyncLocalStorage context manager the way production's provider.register() -// does, so context.active() propagates across the handlers' awaits. +// Driven through the real routeEvent entry point so the per-event conversation +// re-install is exercised. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; import { VERSION } from '../src/setup.ts'; - -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; @@ -64,22 +43,16 @@ function aLine(id: string, ts: string, block: Record, stop?: st }; } -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-integ-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-integ.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as { routeEvent(p: Record): Promise }; -} - -test('integration baggage stamps weave.integration.* on every span (turn, chat, tool, text)', async () => { +test('integration identity stamps weave.integration.* on every span (turn, chat, tool)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-bag'; const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-integ-')); const file = path.join(dir, `${sid}.jsonl`); // First transcript line carries the CC CLI version (real CC transcripts do). fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); @@ -90,7 +63,7 @@ test('integration baggage stamps weave.integration.* on every span (turn, chat, await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); const ops = new Set(spans.map((s) => s.attributes['gen_ai.operation.name'])); @@ -98,11 +71,11 @@ test('integration baggage stamps weave.integration.* on every span (turn, chat, assert.ok(ops.has('chat'), 'chat span present'); assert.ok(ops.has('execute_tool'), 'tool span present'); - // The baggage context.with wrapping must not disturb the trace tree: the + // The per-event conversation re-install must not disturb the trace tree: the // turn is still the root (no parent) and every span lives in its trace. const turn = spans.find((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent'); assert.ok(turn, 'turn span present'); - assert.equal(turn.parentSpanContext, undefined, 'turn span is a trace root'); + assert.equal(spanParentId(turn), undefined, 'turn span is a trace root'); for (const s of spans) { assert.equal(s.spanContext().traceId, turn.spanContext().traceId, `${s.name} shares the turn trace`); } diff --git a/tests/turn-span-system-instructions.test.ts b/tests/turn-span-system-instructions.test.ts deleted file mode 100644 index fe6d6a0..0000000 --- a/tests/turn-span-system-instructions.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// The loaded instruction files (global/project CLAUDE.md, .claude/rules, -// @-imports) surfaced by the InstructionsLoaded hook are stamped on every turn -// root as `gen_ai.system_instructions` — one OTel text part per file, in load -// order. The base Claude Code system prompt is never exposed to hooks, so this -// captures only the user/project instructions appended to it. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { startTurnSpan, ATTR } from '../src/genaiSpans.ts'; - -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; -} - -function baseArgs(systemInstructions?: string[]) { - return { - sessionId: 'sess-1', - conversationId: 'conv-1', - turnNumber: 1, - prompt: 'hello', - cwd: '/tmp', - source: 'startup', - pluginVersion: '0.0.0-test', - agentName: 'claude-code', - systemInstructions, - }; -} - -test('startTurnSpan: stamps gen_ai.system_instructions as ordered text parts', async () => { - const { tracer, exporter, provider } = setupTracer(); - - startTurnSpan(tracer, baseArgs(['GLOBAL', 'PROJECT'])).end(); - await provider.forceFlush(); - - const span = exporter.getFinishedSpans()[0]; - assert.ok(span, 'turn span exported'); - assert.equal( - span.attributes[ATTR.SYSTEM_INSTRUCTIONS], - JSON.stringify([ - { type: 'text', content: 'GLOBAL' }, - { type: 'text', content: 'PROJECT' }, - ]), - ); -}); - -test('startTurnSpan: omits gen_ai.system_instructions when there are none', async () => { - const { tracer, exporter, provider } = setupTracer(); - - // Both undefined and empty-array (the daemon passes [] for a session with no - // loaded instructions) must leave the attribute unset — not "[]". - startTurnSpan(tracer, baseArgs(undefined)).end(); - startTurnSpan(tracer, baseArgs([])).end(); - await provider.forceFlush(); - - for (const span of exporter.getFinishedSpans()) { - assert.equal(span.attributes[ATTR.SYSTEM_INSTRUCTIONS], undefined); - } -});