@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/package.json +9 -8
- package/src/README.md +583 -0
- package/src/USAGE.md +299 -0
- package/src/claude-code/hook.js +309 -0
- package/src/claude-code/settings.js +155 -0
- package/src/claude-code/transcript.js +259 -0
- package/src/cli-doctor.js +127 -144
- package/src/cli-env.js +134 -0
- package/src/cli-setup.js +171 -121
- package/src/cli.js +115 -70
- package/src/config-templates.js +148 -183
- package/src/enforcement.js +182 -184
- package/src/index.js +4 -0
- package/src/postinstall.js +32 -11
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
|
+
}
|