@tjamescouch/niki 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/bin/niki +337 -0
- package/niki.png +0 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="niki.png" width="200" alt="niki — blue and gold macaw" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">niki</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Deterministic process supervisor for AI agents.<br/>
|
|
9
|
+
Token budgets, rate limits, and abort control.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Niki wraps any AI agent command and enforces hard limits. When the agent exceeds its budget, timeout, or rate limit, niki kills it. No negotiation.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @tjamescouch/niki
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
niki [options] -- <command> [args...]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The `--` separator is required. Everything before it is niki config, everything after is the command to supervise.
|
|
29
|
+
|
|
30
|
+
### Examples
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Basic: 500k token budget, 1 hour timeout
|
|
34
|
+
niki --budget 500000 --timeout 3600 -- claude -p "your prompt" --verbose
|
|
35
|
+
|
|
36
|
+
# Strict: rate-limit sends and tool calls
|
|
37
|
+
niki --budget 1000000 --max-sends 5 --max-tool-calls 20 -- claude -p "..." --verbose
|
|
38
|
+
|
|
39
|
+
# With external abort file (touch this file to kill the agent)
|
|
40
|
+
niki --budget 500000 --abort-file /tmp/niki-12345.abort -- claude -p "..." --verbose
|
|
41
|
+
|
|
42
|
+
# With logging and state output
|
|
43
|
+
niki --budget 500000 --log /tmp/niki.log --state /tmp/niki-state.json -- claude -p "..."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Flag | Default | Description |
|
|
49
|
+
|------|---------|-------------|
|
|
50
|
+
| `--budget <tokens>` | `1000000` | Max total tokens (input+output) before SIGTERM |
|
|
51
|
+
| `--timeout <seconds>` | `3600` | Max wall-clock runtime before SIGTERM |
|
|
52
|
+
| `--max-sends <n>` | `10` | Max `agentchat_send` calls per minute |
|
|
53
|
+
| `--max-tool-calls <n>` | `30` | Max total tool calls per minute |
|
|
54
|
+
| `--log <file>` | none | Append diagnostics to file |
|
|
55
|
+
| `--state <file>` | none | Write exit-state JSON on completion |
|
|
56
|
+
| `--cooldown <seconds>` | `5` | Grace period after SIGTERM before SIGKILL |
|
|
57
|
+
| `--abort-file <path>` | none | Poll this file for external abort signal |
|
|
58
|
+
| `--poll-interval <ms>` | `1000` | Base poll interval for abort file (±30% jitter) |
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
1. Niki spawns the child command, inheriting stdin and stdout
|
|
63
|
+
2. Stderr is captured and parsed for token counts and tool calls
|
|
64
|
+
3. Token usage is tracked via high-water-mark (monotonically increasing)
|
|
65
|
+
4. Tool calls and sends are rate-limited with a sliding 60-second window
|
|
66
|
+
5. When any limit is exceeded, niki sends SIGTERM, waits the cooldown period, then SIGKILL
|
|
67
|
+
6. On exit, niki writes a state summary and exits with the child's exit code
|
|
68
|
+
|
|
69
|
+
## State file
|
|
70
|
+
|
|
71
|
+
When `--state` is provided, niki writes a JSON snapshot on exit:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"startedAt": "2026-02-09T12:00:00.000Z",
|
|
76
|
+
"pid": 12345,
|
|
77
|
+
"tokensIn": 45000,
|
|
78
|
+
"tokensOut": 12000,
|
|
79
|
+
"tokensTotal": 57000,
|
|
80
|
+
"toolCalls": 42,
|
|
81
|
+
"sendCalls": 8,
|
|
82
|
+
"exitCode": 0,
|
|
83
|
+
"killedBy": null,
|
|
84
|
+
"duration": 1234
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`killedBy` is one of: `"budget"`, `"timeout"`, `"rate-sends"`, `"rate-tools"`, `"abort"`, or `null` (clean exit).
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
- Never logs API tokens, environment variables, or message content
|
|
93
|
+
- Diagnostics contain only counters and timestamps
|
|
94
|
+
- Credentials flow through inherited env, never in CLI args
|
|
95
|
+
- State file contains only operational metrics
|
|
96
|
+
|
|
97
|
+
## Kill reasons
|
|
98
|
+
|
|
99
|
+
| Reason | Trigger |
|
|
100
|
+
|--------|---------|
|
|
101
|
+
| `budget` | `tokensTotal > --budget` |
|
|
102
|
+
| `timeout` | Wall-clock time exceeds `--timeout` |
|
|
103
|
+
| `rate-sends` | More than `--max-sends` agentchat_send calls in 60s |
|
|
104
|
+
| `rate-tools` | More than `--max-tool-calls` tool calls in 60s |
|
|
105
|
+
| `abort` | External abort file detected at `--abort-file` path |
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
*Photo: [Unsplash](https://unsplash.com) (stylized)*
|
package/bin/niki
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* niki — Deterministic process supervisor for AI agents.
|
|
5
|
+
*
|
|
6
|
+
* Wraps a child command (e.g. `claude -p`) and enforces:
|
|
7
|
+
* - Token budget (kill if exceeded)
|
|
8
|
+
* - Wall-clock timeout (kill if exceeded)
|
|
9
|
+
* - Tool-call rate limiting (kill if agent floods)
|
|
10
|
+
* - Diagnostics logging
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* niki [options] -- <command> [args...]
|
|
14
|
+
* niki --budget 500000 --timeout 3600 -- claude -p "..." --verbose
|
|
15
|
+
*
|
|
16
|
+
* Security:
|
|
17
|
+
* - Never logs or exposes API tokens
|
|
18
|
+
* - Inherits env from parent (tokens stay in env, never in CLI args)
|
|
19
|
+
* - Diagnostics only contain counters, never message content
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { createWriteStream, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import { parseArgs } from 'node:util';
|
|
26
|
+
|
|
27
|
+
// --- Argument parsing ---
|
|
28
|
+
|
|
29
|
+
const SEPARATOR = process.argv.indexOf('--');
|
|
30
|
+
if (SEPARATOR === -1 || SEPARATOR === process.argv.length - 1) {
|
|
31
|
+
console.error(`niki — deterministic agent supervisor
|
|
32
|
+
|
|
33
|
+
Usage: niki [options] -- <command> [args...]
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--budget <tokens> Max total tokens (input+output) before SIGTERM (default: 1000000)
|
|
37
|
+
--timeout <seconds> Max wall-clock runtime before SIGTERM (default: 3600)
|
|
38
|
+
--max-sends <n> Max agentchat_send calls per minute (default: 10)
|
|
39
|
+
--max-tool-calls <n> Max total tool calls per minute (default: 30)
|
|
40
|
+
--log <file> Write diagnostics log to file
|
|
41
|
+
--state <file> Write state JSON on exit (budget used, reason, etc.)
|
|
42
|
+
--cooldown <seconds> Grace period after SIGTERM before SIGKILL (default: 5)
|
|
43
|
+
--abort-file <path> Poll this file for external abort signal
|
|
44
|
+
--poll-interval <ms> Base poll interval in ms for abort file (default: 1000)
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
niki --budget 500000 -- claude -p "your prompt" --verbose
|
|
48
|
+
niki --timeout 1800 --max-sends 5 -- claude -p "..." --model sonnet --verbose`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const nikiArgs = process.argv.slice(2, SEPARATOR);
|
|
53
|
+
const childCmd = process.argv[SEPARATOR + 1];
|
|
54
|
+
const childArgs = process.argv.slice(SEPARATOR + 2);
|
|
55
|
+
|
|
56
|
+
const { values: opts } = parseArgs({
|
|
57
|
+
args: nikiArgs,
|
|
58
|
+
options: {
|
|
59
|
+
budget: { type: 'string', default: '1000000' },
|
|
60
|
+
timeout: { type: 'string', default: '3600' },
|
|
61
|
+
'max-sends': { type: 'string', default: '10' },
|
|
62
|
+
'max-tool-calls': { type: 'string', default: '30' },
|
|
63
|
+
log: { type: 'string' },
|
|
64
|
+
state: { type: 'string' },
|
|
65
|
+
cooldown: { type: 'string', default: '5' },
|
|
66
|
+
'abort-file': { type: 'string' },
|
|
67
|
+
'poll-interval': { type: 'string', default: '1000' },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const BUDGET = parseInt(opts.budget, 10);
|
|
72
|
+
const TIMEOUT_S = parseInt(opts.timeout, 10);
|
|
73
|
+
const MAX_SENDS = parseInt(opts['max-sends'], 10);
|
|
74
|
+
const MAX_TOOL_CALLS = parseInt(opts['max-tool-calls'], 10);
|
|
75
|
+
const COOLDOWN_S = parseInt(opts.cooldown, 10);
|
|
76
|
+
const ABORT_FILE = opts['abort-file'] ? resolve(opts['abort-file']) : null;
|
|
77
|
+
const POLL_INTERVAL = parseInt(opts['poll-interval'], 10);
|
|
78
|
+
const LOG_FILE = opts.log;
|
|
79
|
+
const STATE_FILE = opts.state;
|
|
80
|
+
|
|
81
|
+
// --- State ---
|
|
82
|
+
|
|
83
|
+
const state = {
|
|
84
|
+
startedAt: new Date().toISOString(),
|
|
85
|
+
pid: null,
|
|
86
|
+
tokensIn: 0,
|
|
87
|
+
tokensOut: 0,
|
|
88
|
+
tokensTotal: 0,
|
|
89
|
+
toolCalls: 0,
|
|
90
|
+
sendCalls: 0,
|
|
91
|
+
toolCallsThisMinute: 0,
|
|
92
|
+
sendCallsThisMinute: 0,
|
|
93
|
+
exitCode: null,
|
|
94
|
+
exitSignal: null,
|
|
95
|
+
killedBy: null, // 'budget' | 'timeout' | 'rate-sends' | 'rate-tools' | 'abort' | null
|
|
96
|
+
duration: 0,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Sliding window for per-minute rate limiting
|
|
100
|
+
const toolCallTimestamps = [];
|
|
101
|
+
const sendCallTimestamps = [];
|
|
102
|
+
|
|
103
|
+
// --- Logging ---
|
|
104
|
+
|
|
105
|
+
let logStream = null;
|
|
106
|
+
if (LOG_FILE) {
|
|
107
|
+
mkdirSync(dirname(resolve(LOG_FILE)), { recursive: true });
|
|
108
|
+
logStream = createWriteStream(resolve(LOG_FILE), { flags: 'a' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function log(msg) {
|
|
112
|
+
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
113
|
+
if (logStream) logStream.write(line + '\n');
|
|
114
|
+
// Also write to stderr so supervisor can capture it
|
|
115
|
+
process.stderr.write(`[niki] ${line}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeState() {
|
|
119
|
+
if (!STATE_FILE) return;
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dirname(resolve(STATE_FILE)), { recursive: true });
|
|
122
|
+
// Never include env, tokens, or message content — only counters
|
|
123
|
+
writeFileSync(resolve(STATE_FILE), JSON.stringify(state, null, 2) + '\n');
|
|
124
|
+
} catch {
|
|
125
|
+
// Best effort
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Token parsing from stderr ---
|
|
130
|
+
|
|
131
|
+
// Claude --verbose outputs token usage in stderr. Patterns vary by version.
|
|
132
|
+
// We look for common patterns and extract numbers.
|
|
133
|
+
//
|
|
134
|
+
// Known patterns:
|
|
135
|
+
// "input_tokens": 1234
|
|
136
|
+
// "output_tokens": 567
|
|
137
|
+
// tokens: { input: 1234, output: 567 }
|
|
138
|
+
// Input tokens: 1234
|
|
139
|
+
// Output tokens: 567
|
|
140
|
+
|
|
141
|
+
const TOKEN_PATTERNS = [
|
|
142
|
+
// JSON-style: "input_tokens": 1234
|
|
143
|
+
{ regex: /"input_tokens"\s*:\s*(\d+)/g, field: 'in' },
|
|
144
|
+
{ regex: /"output_tokens"\s*:\s*(\d+)/g, field: 'out' },
|
|
145
|
+
// Human-readable: Input tokens: 1234
|
|
146
|
+
{ regex: /Input tokens:\s*(\d+)/gi, field: 'in' },
|
|
147
|
+
{ regex: /Output tokens:\s*(\d+)/gi, field: 'out' },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
function parseTokens(line) {
|
|
151
|
+
for (const { regex, field } of TOKEN_PATTERNS) {
|
|
152
|
+
regex.lastIndex = 0;
|
|
153
|
+
let match;
|
|
154
|
+
while ((match = regex.exec(line)) !== null) {
|
|
155
|
+
const count = parseInt(match[1], 10);
|
|
156
|
+
if (isNaN(count) || count <= 0) continue;
|
|
157
|
+
if (field === 'in') {
|
|
158
|
+
state.tokensIn = Math.max(state.tokensIn, count);
|
|
159
|
+
} else {
|
|
160
|
+
state.tokensOut = Math.max(state.tokensOut, count);
|
|
161
|
+
}
|
|
162
|
+
state.tokensTotal = state.tokensIn + state.tokensOut;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Tool call detection from stderr ---
|
|
168
|
+
|
|
169
|
+
// Claude --verbose logs tool calls. We detect sends specifically.
|
|
170
|
+
const TOOL_CALL_PATTERN = /(?:Using tool|Tool call|tool_use).*?(\w+)/i;
|
|
171
|
+
const SEND_PATTERN = /agentchat_send/i;
|
|
172
|
+
|
|
173
|
+
function parseToolCall(line) {
|
|
174
|
+
if (TOOL_CALL_PATTERN.test(line)) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
state.toolCalls++;
|
|
177
|
+
toolCallTimestamps.push(now);
|
|
178
|
+
|
|
179
|
+
if (SEND_PATTERN.test(line)) {
|
|
180
|
+
state.sendCalls++;
|
|
181
|
+
sendCallTimestamps.push(now);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Rate limit checking ---
|
|
187
|
+
|
|
188
|
+
function pruneWindow(timestamps) {
|
|
189
|
+
const cutoff = Date.now() - 60_000; // 1 minute window
|
|
190
|
+
while (timestamps.length > 0 && timestamps[0] < cutoff) {
|
|
191
|
+
timestamps.shift();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function checkRateLimits() {
|
|
196
|
+
pruneWindow(toolCallTimestamps);
|
|
197
|
+
pruneWindow(sendCallTimestamps);
|
|
198
|
+
|
|
199
|
+
state.toolCallsThisMinute = toolCallTimestamps.length;
|
|
200
|
+
state.sendCallsThisMinute = sendCallTimestamps.length;
|
|
201
|
+
|
|
202
|
+
if (sendCallTimestamps.length > MAX_SENDS) {
|
|
203
|
+
return 'rate-sends';
|
|
204
|
+
}
|
|
205
|
+
if (toolCallTimestamps.length > MAX_TOOL_CALLS) {
|
|
206
|
+
return 'rate-tools';
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Kill logic ---
|
|
212
|
+
|
|
213
|
+
let child = null;
|
|
214
|
+
let killed = false;
|
|
215
|
+
|
|
216
|
+
function killChild(reason) {
|
|
217
|
+
if (killed || !child) return;
|
|
218
|
+
killed = true;
|
|
219
|
+
state.killedBy = reason;
|
|
220
|
+
log(`KILL — reason: ${reason} | tokens: ${state.tokensTotal}/${BUDGET} | sends: ${state.sendCallsThisMinute}/min | tools: ${state.toolCallsThisMinute}/min`);
|
|
221
|
+
|
|
222
|
+
child.kill('SIGTERM');
|
|
223
|
+
|
|
224
|
+
// Grace period, then SIGKILL
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
try {
|
|
227
|
+
child.kill('SIGKILL');
|
|
228
|
+
} catch {
|
|
229
|
+
// Already dead
|
|
230
|
+
}
|
|
231
|
+
}, COOLDOWN_S * 1000);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Spawn child process ---
|
|
235
|
+
|
|
236
|
+
log(`Starting: ${childCmd} ${childArgs.join(' ').substring(0, 100)}...`);
|
|
237
|
+
log(`Budget: ${BUDGET} tokens | Timeout: ${TIMEOUT_S}s | Max sends: ${MAX_SENDS}/min | Max tools: ${MAX_TOOL_CALLS}/min`);
|
|
238
|
+
|
|
239
|
+
child = spawn(childCmd, childArgs, {
|
|
240
|
+
stdio: ['inherit', 'inherit', 'pipe'], // stdin/stdout pass through, stderr captured
|
|
241
|
+
env: process.env, // Inherit env (tokens stay in env, never logged)
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
state.pid = child.pid;
|
|
245
|
+
|
|
246
|
+
// --- Monitor stderr ---
|
|
247
|
+
|
|
248
|
+
let stderrBuffer = '';
|
|
249
|
+
|
|
250
|
+
child.stderr.on('data', (chunk) => {
|
|
251
|
+
const text = chunk.toString();
|
|
252
|
+
|
|
253
|
+
// Always forward stderr to our stderr (so supervisor captures it)
|
|
254
|
+
process.stderr.write(chunk);
|
|
255
|
+
|
|
256
|
+
// Buffer and parse line by line
|
|
257
|
+
stderrBuffer += text;
|
|
258
|
+
const lines = stderrBuffer.split('\n');
|
|
259
|
+
stderrBuffer = lines.pop(); // Keep incomplete last line in buffer
|
|
260
|
+
|
|
261
|
+
for (const line of lines) {
|
|
262
|
+
parseTokens(line);
|
|
263
|
+
parseToolCall(line);
|
|
264
|
+
|
|
265
|
+
// Check budget
|
|
266
|
+
if (state.tokensTotal > BUDGET) {
|
|
267
|
+
killChild('budget');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check rate limits
|
|
272
|
+
const rateViolation = checkRateLimits();
|
|
273
|
+
if (rateViolation) {
|
|
274
|
+
killChild(rateViolation);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// --- Abort file polling (with jitter) ---
|
|
281
|
+
|
|
282
|
+
let abortPollId = null;
|
|
283
|
+
|
|
284
|
+
function jitteredDelay(base) {
|
|
285
|
+
// ±30% jitter
|
|
286
|
+
const jitter = base * 0.3;
|
|
287
|
+
return base + (Math.random() * 2 * jitter - jitter);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function scheduleAbortPoll() {
|
|
291
|
+
if (!ABORT_FILE || killed) return;
|
|
292
|
+
abortPollId = setTimeout(() => {
|
|
293
|
+
if (killed) return;
|
|
294
|
+
if (existsSync(ABORT_FILE)) {
|
|
295
|
+
log(`Abort file detected: ${ABORT_FILE}`);
|
|
296
|
+
killChild('abort');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
scheduleAbortPoll();
|
|
300
|
+
}, jitteredDelay(POLL_INTERVAL));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (ABORT_FILE) {
|
|
304
|
+
log(`Abort file: ${ABORT_FILE} (poll: ${POLL_INTERVAL}ms ±30% jitter)`);
|
|
305
|
+
scheduleAbortPoll();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --- Timeout ---
|
|
309
|
+
|
|
310
|
+
const timeoutId = setTimeout(() => {
|
|
311
|
+
killChild('timeout');
|
|
312
|
+
}, TIMEOUT_S * 1000);
|
|
313
|
+
|
|
314
|
+
// --- Clean exit ---
|
|
315
|
+
|
|
316
|
+
child.on('exit', (code, signal) => {
|
|
317
|
+
clearTimeout(timeoutId);
|
|
318
|
+
if (abortPollId) clearTimeout(abortPollId);
|
|
319
|
+
state.exitCode = code;
|
|
320
|
+
state.exitSignal = signal;
|
|
321
|
+
state.duration = Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000);
|
|
322
|
+
|
|
323
|
+
log(`Exit — code: ${code} signal: ${signal} | tokens: ${state.tokensTotal} | tools: ${state.toolCalls} | sends: ${state.sendCalls} | duration: ${state.duration}s${state.killedBy ? ` | killed: ${state.killedBy}` : ''}`);
|
|
324
|
+
writeState();
|
|
325
|
+
|
|
326
|
+
if (logStream) logStream.end();
|
|
327
|
+
process.exit(code ?? 1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// --- Signal forwarding ---
|
|
331
|
+
|
|
332
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
333
|
+
process.on(sig, () => {
|
|
334
|
+
log(`Received ${sig}, forwarding to child`);
|
|
335
|
+
if (child) child.kill(sig);
|
|
336
|
+
});
|
|
337
|
+
}
|
package/niki.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tjamescouch/niki",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Deterministic process supervisor for AI agents — token budgets, rate limits, and abort control",
|
|
5
|
+
"bin": {
|
|
6
|
+
"niki": "./bin/niki"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/tjamescouch/niki"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"agent",
|
|
19
|
+
"supervisor",
|
|
20
|
+
"rate-limit",
|
|
21
|
+
"token-budget",
|
|
22
|
+
"claude",
|
|
23
|
+
"llm"
|
|
24
|
+
]
|
|
25
|
+
}
|