@intelli-1113/stratos-sdk 1.3.5 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/USAGE.md ADDED
@@ -0,0 +1,299 @@
1
+ # How to use the Stratos SDK
2
+
3
+ `@intelli-1113/stratos-sdk` connects things that use AI to **Stratos**, so their activity (models, tokens, tools) shows up in the Stratos dashboard.
4
+
5
+ Pick the section that matches you:
6
+
7
+ | You are… | What you connect | Go to |
8
+ |---|---|---|
9
+ | A team running a **Node.js AI agent** (LangChain, OpenAI, Anthropic, Vercel AI, …) | Your agent process | [For agents](#1-for-agents) |
10
+ | A **developer using Claude Code** (terminal, desktop app or IDE) | Your own Claude Code usage | [For developers using Claude Code](#2-for-developers-using-claude-code) |
11
+ | A developer using **MCP tools** in Claude Desktop, Cursor or VS Code | Tool calls to an MCP server | [MCP tools](#3-for-developers-using-mcp-tools) |
12
+ | Someone **working on this SDK** | The SDK code itself | [Contributing](#4-for-developers-working-on-this-sdk) |
13
+
14
+ **Before you start, you need:**
15
+ - **Node.js 20.12 or newer**: check with `node --version`.
16
+ - **A token from Stratos**: in Stratos, open **Add agent** and copy the token. The server decides the token format; use it exactly as given.
17
+ - **The Stratos URL**, e.g. `https://stratos.example.com`. For a local Stratos it is `http://localhost:4000`, which is the default.
18
+
19
+ Treat the token like a password: don't commit it or paste it into chats.
20
+
21
+ ---
22
+
23
+ ## 1. For agents
24
+
25
+ This is for an AI agent written in Node.js that runs as a server, worker or script.
26
+
27
+ ### Step 1: Install
28
+
29
+ In your agent's project folder:
30
+
31
+ ```bash
32
+ npm install @intelli-1113/stratos-sdk
33
+ ```
34
+
35
+ ### Step 2: Add the token
36
+
37
+ Create a `.env` file in the project folder, or set the same variables in your deployment:
38
+
39
+ ```env
40
+ STRATOS_TOKEN=<your-token>
41
+ STRATOS_URL=https://stratos.example.com
42
+ STRATOS_APP_NAME=checkout-agent
43
+ ```
44
+
45
+ `STRATOS_APP_NAME` is the name shown in the dashboard.
46
+
47
+ ### Step 3: Start your agent with the SDK loaded
48
+
49
+ **Recommended: no code changes.** Add `--import` to the command that starts your agent:
50
+
51
+ ```bash
52
+ node --import @intelli-1113/stratos-sdk/register server.js
53
+ ```
54
+
55
+ Or in `package.json`:
56
+
57
+ ```json
58
+ {
59
+ "scripts": {
60
+ "start": "node --import @intelli-1113/stratos-sdk/register server.js"
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Other options**, if you can't change the start command:
66
+
67
+ ```js
68
+ // Option B: the very first line of your entry file
69
+ import "@intelli-1113/stratos-sdk/register";
70
+ ```
71
+
72
+ ```js
73
+ // Option C: start it yourself
74
+ import { start } from "@intelli-1113/stratos-sdk";
75
+ start({ token: process.env.STRATOS_TOKEN, url: process.env.STRATOS_URL, appName: "checkout-agent" });
76
+ ```
77
+
78
+ > **Using the Vercel AI SDK (`ai` package)?** Use the `--import` option. With options B and C, the `ai` package can load before the SDK and its calls won't be tracked.
79
+
80
+ ### Step 4: Check it works
81
+
82
+ When the agent starts you should see:
83
+
84
+ ```text
85
+ [stratos] telemetry → https://stratos.example.com/api/ingest (heartbeat 30000ms, framework=langchain)
86
+ ```
87
+
88
+ Within about 30 seconds the agent shows as **online** in Stratos. After its first AI call, the model, tokens and tools appear.
89
+
90
+ ### What gets tracked
91
+
92
+ | Works automatically | Notes |
93
+ |---|---|
94
+ | LangChain / LangGraph | Needs `@langchain/core` 1.x; run `npx stratos-inspect` to check |
95
+ | OpenAI, Anthropic, Google GenAI, Bedrock, Vertex, Together, LlamaIndex SDKs | Through OpenLLMetry (Traceloop) |
96
+ | Vercel AI SDK (`ai`) | Only when started with `--import` |
97
+ | Raw `fetch` calls to an AI API, Python agents | **Not tracked**: only the heartbeat shows |
98
+
99
+ ### Keeping prompts private
100
+
101
+ By default, prompt and answer text is sent with each call. To send only model, token and tool data:
102
+
103
+ ```env
104
+ STRATOS_RECORD_INPUTS=0
105
+ STRATOS_RECORD_OUTPUTS=0
106
+ ```
107
+
108
+ ### When Stratos blocks your agent
109
+
110
+ If your agent goes over budget or is disabled in the Stratos Control Tower, the SDK learns it from its next heartbeat, within about 30 seconds. From then on, calls to AI providers fail with a `StratosBudgetBlockedError`, and the log shows:
111
+
112
+ ```text
113
+ [stratos] BLOCKED: <reason> — outbound calls to LLM providers will be refused until this clears.
114
+ ```
115
+
116
+ Calls resume by themselves once Stratos unblocks the agent. Catch `StratosBudgetBlockedError` (it has `err.stratosBlocked === true`) if your agent should handle this gracefully.
117
+
118
+ ### Useful settings
119
+
120
+ | Variable | Default | What it does |
121
+ |---|---|---|
122
+ | `STRATOS_TOKEN` | (required) | Token from Stratos |
123
+ | `STRATOS_URL` | `http://localhost:4000` | Stratos server |
124
+ | `STRATOS_APP_NAME` | package name | Name in the dashboard |
125
+ | `STRATOS_HEARTBEAT_MS` | `30000` | How often to check in (minimum 1000) |
126
+ | `STRATOS_FRAMEWORK` | auto-detected | Force the framework label, e.g. `vercel-ai` |
127
+ | `STRATOS_MODEL` | auto-detected | Model label shown on the heartbeat |
128
+ | `STRATOS_BLOCK_HOSTS` | (none) | Extra AI hostnames to block when Stratos blocks the agent, comma-separated |
129
+ | `STRATOS_VERCEL_AI` | on | `0` turns off the Vercel AI wrapper |
130
+ | `STRATOS_DEBUG` | off | `1` prints detailed logs |
131
+
132
+ The full list is in [README.md](README.md#environment-reference).
133
+
134
+ ### Troubleshooting
135
+
136
+ | You see | Fix |
137
+ |---|---|
138
+ | `STRATOS_TOKEN not set — telemetry disabled` | Put the token in `.env` in the folder you start the agent from, or set it in the environment |
139
+ | Agent online but no models or tokens | Start with `--import`, and check your framework is in the table above |
140
+ | LangChain warning at start | Run `npx stratos-inspect`, then `npx stratos-inspect --fix` |
141
+ | Nothing in Stratos | Check `STRATOS_URL`, and that the agent's machine can reach it |
142
+
143
+ ---
144
+
145
+ ## 2. For developers using Claude Code
146
+
147
+ This tracks **your own Claude Code usage** (terminal, desktop app or IDE extension): which models you use, how many tokens, and which tools and subagents ran. There's nothing to change in your projects.
148
+
149
+ It works through small **hooks** that Claude Code runs itself. After each answer, a hook reads Claude Code's session log, sends the new usage to Stratos, and exits. Nothing keeps running in the background, so restarting your laptop doesn't stop it.
150
+
151
+ ### Step 1: Install the CLI
152
+
153
+ ```bash
154
+ npm install -g @intelli-1113/stratos-sdk
155
+ ```
156
+
157
+ ### Step 2: Connect Claude Code
158
+
159
+ ```bash
160
+ stratos setup claude-code --token <your-token> --url https://stratos.example.com
161
+ ```
162
+
163
+ This command:
164
+ - saves the token and URL in `~/.stratos/config.json`, which only you can read;
165
+ - adds four hooks to `~/.claude/settings.json`, keeping your existing settings and saving a backup first;
166
+ - runs `stratos doctor` to check everything.
167
+
168
+ ### Step 3: Use Claude Code as normal
169
+
170
+ Start a **new** Claude Code session; sessions that were already open don't pick up the hooks. Your usage appears in Stratos after each answer.
171
+
172
+ ### Check or fix your setup
173
+
174
+ ```bash
175
+ stratos doctor
176
+ ```
177
+
178
+ It checks the config, the hooks, that `node` is on your PATH, and that Stratos accepts your token. It also shows when usage was last sent.
179
+
180
+ ### What is sent, and what isn't
181
+
182
+ | Sent | Not sent |
183
+ |---|---|
184
+ | Model name for each request | Your prompts |
185
+ | Input, output and cache tokens | Claude's answers |
186
+ | Tool names, how long they took, success or failure (including MCP tools) | Commands, file paths, file contents |
187
+ | Subagents used | Your token (it never goes into `settings.json`) |
188
+
189
+ To also send prompt and answer text, for example for a team that needs audit logs, re-run setup with `--record-prompts`.
190
+
191
+ ### If Stratos is unreachable
192
+
193
+ Usage is saved in `~/.stratos/outbox/` and sent automatically by the next hook once Stratos is reachable again. Claude Code is never slowed down or blocked. Any problems are written to `~/.stratos/hook.log`.
194
+
195
+ ### Remove it
196
+
197
+ ```bash
198
+ stratos setup claude-code --remove
199
+ ```
200
+
201
+ ### For admins: roll out to everyone
202
+
203
+ ```bash
204
+ stratos setup claude-code --print-managed
205
+ ```
206
+
207
+ This prints a hooks block for Claude Code's `managed-settings.json`. Paths are `C:\Program Files\ClaudeCode\` on Windows, `/Library/Application Support/ClaudeCode/` on macOS and `/etc/claude-code/` on Linux. Each developer still needs their own token, which they add with:
208
+
209
+ ```bash
210
+ stratos setup claude-code --config-only --token <their-token> --url <url>
211
+ ```
212
+
213
+ ---
214
+
215
+ ## 3. For developers using MCP tools
216
+
217
+ Claude Desktop, Cursor and VS Code start MCP servers themselves. Wrap the server's command with `stratos-mcp-proxy` to report each tool call to Stratos.
218
+
219
+ ```bash
220
+ npm install -g @intelli-1113/stratos-sdk
221
+ ```
222
+
223
+ In the app's MCP config, put `stratos-mcp-proxy --` in front of the original command:
224
+
225
+ ```json
226
+ {
227
+ "mcpServers": {
228
+ "weather": {
229
+ "command": "stratos-mcp-proxy",
230
+ "args": ["--", "npx", "-y", "@scope/weather-mcp@latest"],
231
+ "env": {
232
+ "STRATOS_TOKEN": "<your-token>",
233
+ "STRATOS_URL": "https://stratos.example.com",
234
+ "STRATOS_APP_NAME": "weather"
235
+ }
236
+ }
237
+ }
238
+ }
239
+ ```
240
+
241
+ The proxy passes everything through unchanged. If Stratos blocks this agent, tool calls get an error instead of running.
242
+
243
+ > **Using Claude Code?** Its MCP tool calls are already tracked by the Claude Code hooks in section 2, so you don't need the proxy there.
244
+
245
+ ---
246
+
247
+ ## 4. For developers working on this SDK
248
+
249
+ ### Set up
250
+
251
+ ```bash
252
+ git clone https://github.com/intelli-123/stratos-sdk.git
253
+ cd stratos-sdk
254
+ npm install
255
+ npm link # makes the stratos commands use this folder; edits apply immediately
256
+ npm test # 17 tests, about 1s
257
+ ```
258
+
259
+ `npm unlink -g @intelli-1113/stratos-sdk` undoes the link.
260
+
261
+ ### Where things are
262
+
263
+ | Path | What it does |
264
+ |---|---|
265
+ | `src/register.js` | `--import` entry: loads `.env`, installs the network guard and the Vercel AI loader, then calls `start()` |
266
+ | `src/index.js` | `start()`: OpenLLMetry (Traceloop) setup, framework and model detection, heartbeat |
267
+ | `src/enforcement.js` | Network guard: refuses calls to AI providers while Stratos says the agent is blocked |
268
+ | `src/vercel-ai*.js` | Wraps the `ai` package so Vercel AI calls are tracked |
269
+ | `src/mcp-proxy.js` | `stratos-mcp-proxy` |
270
+ | `src/inspect-langchain.js`, `src/cli-inspect.js`, `src/postinstall.js` | LangChain version check and fix (`stratos-inspect`) |
271
+ | `src/cli.js`, `src/cli-setup.js`, `src/cli-doctor.js` | `stratos` CLI: `setup`, `doctor`, `hook` |
272
+ | `src/claude-code/` | Claude Code integration: `transcript.js` turns the session log into spans, `hook.js` runs the hooks, `settings.js` handles config and the settings merge |
273
+ | `src/config-templates.js` | Config bundles for rolling out to many developers |
274
+ | `test/` | `node:test` tests and a sample transcript |
275
+ | `docs/` | Plain-language reviews: `STRATOS-SDK-REVIEW.*`, `CLAUDE-CODE-INTEGRATION.*` |
276
+
277
+ ### Try the Claude Code hooks without touching your real setup
278
+
279
+ ```bash
280
+ export STRATOS_HOME=/tmp/stratos CLAUDE_CONFIG_DIR=/tmp/claude
281
+ stratos setup claude-code --token test --url http://127.0.0.1:4999
282
+ claude -p "hello" --settings /tmp/claude/settings.json
283
+ ```
284
+
285
+ Point `--url` at any small local HTTP server that logs `POST /api/heartbeat` and `POST /api/ingest` to see exactly what is sent.
286
+
287
+ ### Rules worth keeping
288
+
289
+ - **Hooks must never get in Claude Code's way:** they print nothing to stdout, always exit 0, and log problems to `~/.stratos/hook.log`.
290
+ - **Don't validate the token format.** The Stratos server owns it; ask the server (`/api/heartbeat`) instead.
291
+ - **No secrets in `~/.claude/settings.json`.** Its `env` section is passed to every command Claude runs.
292
+ - **Text is off by default** for Claude Code: only names, counts and timings unless someone opts in.
293
+
294
+ ### Release
295
+
296
+ 1. Bump `version` in `package.json`.
297
+ 2. `npm test`
298
+ 3. `npm login`
299
+ 4. `npm publish --access public`
@@ -0,0 +1,309 @@
1
+ // `stratos hook <event>` — short-lived Claude Code hook handler.
2
+ //
3
+ // Claude Code runs this at SessionStart / Stop / SubagentStop / SessionEnd and
4
+ // passes the event as JSON on stdin. It reads only the transcript lines added
5
+ // since the last run, turns them into spans, posts them to Stratos and exits.
6
+ // Nothing keeps running between hooks — developer machines restart, so there
7
+ // is deliberately no background process.
8
+ //
9
+ // Rules: never write to stdout, always exit 0. A hook that fails must not get
10
+ // in the way of Claude Code; problems go to ~/.stratos/hook.log instead.
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ renameSync,
18
+ rmSync,
19
+ readdirSync,
20
+ statSync,
21
+ openSync,
22
+ readSync,
23
+ closeSync,
24
+ appendFileSync,
25
+ } from "node:fs";
26
+ import { basename, dirname, join } from "node:path";
27
+ import { parseLines, buildSpans, toOtlpPayload } from "./transcript.js";
28
+ import { stratosDir, loadConfig } from "./settings.js";
29
+
30
+ const MAX_IDS = 5000;
31
+ const OUTBOX_MAX_FILES = 200;
32
+ const OUTBOX_MAX_BYTES = 20 * 1024 * 1024;
33
+ const LOG_MAX_BYTES = 1024 * 1024;
34
+ const SPANS_PER_POST = 200;
35
+ const LOCK_STALE_MS = 60_000;
36
+
37
+ const paths = () => {
38
+ const root = stratosDir();
39
+ return {
40
+ root,
41
+ state: join(root, "state"),
42
+ outbox: join(root, "outbox"),
43
+ log: join(root, "hook.log"),
44
+ status: join(root, "status.json"),
45
+ };
46
+ };
47
+
48
+ export function log(msg) {
49
+ try {
50
+ const p = paths();
51
+ mkdirSync(p.root, { recursive: true });
52
+ if (existsSync(p.log) && statSync(p.log).size > LOG_MAX_BYTES) renameSync(p.log, p.log + ".1");
53
+ appendFileSync(p.log, `${new Date().toISOString()} ${msg}\n`);
54
+ } catch {
55
+ /* logging must never fail the hook */
56
+ }
57
+ }
58
+
59
+ function writeJsonAtomic(file, data) {
60
+ mkdirSync(dirname(file), { recursive: true });
61
+ const tmp = `${file}.${process.pid}.tmp`;
62
+ writeFileSync(tmp, JSON.stringify(data));
63
+ renameSync(tmp, file);
64
+ }
65
+
66
+ function readJson(file, fallback) {
67
+ try {
68
+ return JSON.parse(readFileSync(file, "utf8"));
69
+ } catch {
70
+ return fallback;
71
+ }
72
+ }
73
+
74
+ function updateStatus(patch) {
75
+ const p = paths();
76
+ writeJsonAtomic(p.status, { ...readJson(p.status, {}), ...patch });
77
+ }
78
+
79
+ async function post(cfg, path, body) {
80
+ const res = await fetch(`${cfg.url}${path}`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json", "x-stratos-token": cfg.token },
83
+ body: typeof body === "string" ? body : JSON.stringify(body),
84
+ signal: AbortSignal.timeout(5000),
85
+ });
86
+ if (!res.ok) throw new Error(`${path} returned ${res.status}`);
87
+ return res;
88
+ }
89
+
90
+ // ---- outbox: payloads that could not be delivered, retried by later hooks ----
91
+
92
+ function saveToOutbox(payload) {
93
+ const { outbox } = paths();
94
+ mkdirSync(outbox, { recursive: true });
95
+ const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`;
96
+ writeFileSync(join(outbox, name), JSON.stringify(payload));
97
+ // Keep the outbox bounded: drop the oldest first.
98
+ const files = readdirSync(outbox).filter((f) => f.endsWith(".json")).sort();
99
+ let total = files.reduce((n, f) => n + statSync(join(outbox, f)).size, 0);
100
+ while (files.length > OUTBOX_MAX_FILES || total > OUTBOX_MAX_BYTES) {
101
+ const f = files.shift();
102
+ total -= statSync(join(outbox, f)).size;
103
+ rmSync(join(outbox, f), { force: true });
104
+ log(`outbox full — dropped ${f}`);
105
+ }
106
+ }
107
+
108
+ export async function flushOutbox(cfg) {
109
+ const { outbox } = paths();
110
+ if (!existsSync(outbox)) return 0;
111
+ let sent = 0;
112
+ for (const f of readdirSync(outbox).filter((x) => x.endsWith(".json")).sort()) {
113
+ const file = join(outbox, f);
114
+ try {
115
+ await post(cfg, "/api/ingest", readFileSync(file, "utf8"));
116
+ rmSync(file, { force: true });
117
+ sent++;
118
+ } catch (err) {
119
+ log(`outbox retry failed (${err.message}) — will try again next hook`);
120
+ break; // Stratos still unreachable; stop for now
121
+ }
122
+ }
123
+ if (sent) updateStatus({ lastSendAt: new Date().toISOString(), lastError: null });
124
+ return sent;
125
+ }
126
+
127
+ async function deliver(cfg, spans, meta) {
128
+ for (let i = 0; i < spans.length; i += SPANS_PER_POST) {
129
+ const payload = toOtlpPayload(spans.slice(i, i + SPANS_PER_POST), meta);
130
+ try {
131
+ await post(cfg, "/api/ingest", payload);
132
+ updateStatus({ lastSendAt: new Date().toISOString(), lastError: null });
133
+ } catch (err) {
134
+ log(`ingest failed (${err.message}) — saved to outbox`);
135
+ updateStatus({ lastError: `${new Date().toISOString()} ${err.message}` });
136
+ saveToOutbox(payload);
137
+ }
138
+ }
139
+ }
140
+
141
+ // ---- transcript reading ----
142
+
143
+ /** Read complete lines added since `offset`. Returns { text, offset }. */
144
+ function readNew(file, offset) {
145
+ const size = statSync(file).size;
146
+ if (size < offset) offset = 0; // file was rewritten
147
+ if (size === offset) return { text: "", offset };
148
+ const fd = openSync(file, "r");
149
+ try {
150
+ const buf = Buffer.alloc(size - offset);
151
+ readSync(fd, buf, 0, buf.length, offset);
152
+ const lastNl = buf.lastIndexOf(0x0a);
153
+ if (lastNl === -1) return { text: "", offset }; // line still being written
154
+ return { text: buf.subarray(0, lastNl + 1).toString("utf8"), offset: offset + lastNl + 1 };
155
+ } finally {
156
+ closeSync(fd);
157
+ }
158
+ }
159
+
160
+ function transcriptFiles(input) {
161
+ const files = [];
162
+ const main = input.transcript_path;
163
+ if (main && existsSync(main)) files.push({ file: main });
164
+ const subDir = main ? join(main.replace(/\.jsonl$/i, ""), "subagents") : null;
165
+ const seen = new Set(files.map((f) => f.file));
166
+ const addSub = (file) => {
167
+ if (!file || seen.has(file) || !existsSync(file)) return;
168
+ seen.add(file);
169
+ const meta = readJson(file.replace(/\.jsonl$/i, ".meta.json"), {});
170
+ const id = basename(file).replace(/^agent-/, "").replace(/\.jsonl$/i, "");
171
+ files.push({ file, agent: { id, type: meta.agentType, parentToolUseId: meta.toolUseId } });
172
+ };
173
+ if (subDir && existsSync(subDir)) {
174
+ for (const f of readdirSync(subDir)) if (f.endsWith(".jsonl")) addSub(join(subDir, f));
175
+ }
176
+ addSub(input.agent_transcript_path);
177
+ return files;
178
+ }
179
+
180
+ function acquireLock(file) {
181
+ mkdirSync(dirname(file), { recursive: true });
182
+ try {
183
+ closeSync(openSync(file, "wx"));
184
+ return true;
185
+ } catch {
186
+ try {
187
+ if (Date.now() - statSync(file).mtimeMs > LOCK_STALE_MS) {
188
+ rmSync(file, { force: true });
189
+ closeSync(openSync(file, "wx"));
190
+ return true;
191
+ }
192
+ } catch {
193
+ /* someone else got it */
194
+ }
195
+ return false;
196
+ }
197
+ }
198
+
199
+ async function reportTranscripts(cfg, input, { final = false } = {}) {
200
+ const sessionId = input.session_id;
201
+ if (!sessionId || !input.transcript_path) return;
202
+ const p = paths();
203
+ const safeId = String(sessionId).replace(/[^\w.-]/g, "_");
204
+ const stateFile = join(p.state, `${safeId}.json`);
205
+ const lockFile = join(p.state, `${safeId}.lock`);
206
+
207
+ // Stop and SubagentStop can overlap; whoever loses simply leaves the new
208
+ // lines for the next hook, since the cursor has not moved.
209
+ if (!acquireLock(lockFile)) {
210
+ log(`session ${safeId} busy — skipping, next hook catches up`);
211
+ return;
212
+ }
213
+ try {
214
+ const state = readJson(stateFile, { files: {}, sentMessages: [], sentTools: [] });
215
+ const sentMessages = new Set(state.sentMessages);
216
+ const sentTools = new Set(state.sentTools);
217
+ const allSpans = [];
218
+ let version;
219
+
220
+ for (const { file, agent } of transcriptFiles(input)) {
221
+ const fs = state.files[file] || { offset: 0, pending: {} };
222
+ const { text, offset } = readNew(file, fs.offset);
223
+ if (!text && !final) continue;
224
+ const entries = parseLines(text);
225
+ version = version || entries.find((e) => e?.version)?.version;
226
+ const r = buildSpans(entries, {
227
+ sessionId,
228
+ agent,
229
+ sentMessages,
230
+ sentTools,
231
+ pending: fs.pending,
232
+ recordInputs: cfg.recordInputs,
233
+ recordOutputs: cfg.recordOutputs,
234
+ });
235
+ r.messageIds.forEach((id) => sentMessages.add(id));
236
+ r.toolIds.forEach((id) => sentTools.add(id));
237
+ allSpans.push(...r.spans);
238
+ state.files[file] = { offset, pending: r.pending };
239
+ }
240
+
241
+ if (allSpans.length) await deliver(cfg, allSpans, { serviceName: cfg.appName, version });
242
+
243
+ if (final) {
244
+ rmSync(stateFile, { force: true });
245
+ } else {
246
+ state.sentMessages = [...sentMessages].slice(-MAX_IDS);
247
+ state.sentTools = [...sentTools].slice(-MAX_IDS);
248
+ writeJsonAtomic(stateFile, state);
249
+ }
250
+ log(`${input.hook_event_name || "hook"} session=${safeId} spans=${allSpans.length}`);
251
+ } finally {
252
+ rmSync(lockFile, { force: true });
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Entry point used by `stratos hook <event>`.
258
+ * @param {"session-start"|"stop"|"subagent-stop"|"session-end"} event
259
+ * @param {string} stdinText
260
+ */
261
+ export async function runHook(event, stdinText) {
262
+ try {
263
+ const cfg = loadConfig();
264
+ if (!cfg.token) {
265
+ log(`${event}: no token configured — run "stratos setup claude-code"`);
266
+ return;
267
+ }
268
+ let input = {};
269
+ try {
270
+ input = JSON.parse(stdinText || "{}");
271
+ } catch {
272
+ log(`${event}: could not parse hook input`);
273
+ return;
274
+ }
275
+
276
+ await flushOutbox(cfg);
277
+
278
+ if (event === "session-start") {
279
+ await post(cfg, "/api/heartbeat", {
280
+ framework: "claude-code",
281
+ model: input.model || null,
282
+ tools: [],
283
+ frameworkSupported: true,
284
+ }).catch((err) => log(`heartbeat failed: ${err.message}`));
285
+ return;
286
+ }
287
+
288
+ if (event === "stop" || event === "subagent-stop" || event === "session-end") {
289
+ await reportTranscripts(cfg, input, { final: event === "session-end" });
290
+ return;
291
+ }
292
+
293
+ log(`unknown hook event "${event}"`);
294
+ } catch (err) {
295
+ log(`${event}: ${err?.stack || err}`);
296
+ }
297
+ }
298
+
299
+ /** Read all of stdin (Claude Code closes it after writing the event). */
300
+ export function readStdin() {
301
+ return new Promise((resolve) => {
302
+ if (process.stdin.isTTY) return resolve("");
303
+ let data = "";
304
+ process.stdin.setEncoding("utf8");
305
+ process.stdin.on("data", (c) => (data += c));
306
+ process.stdin.on("end", () => resolve(data));
307
+ process.stdin.on("error", () => resolve(data));
308
+ });
309
+ }