@tjamescouch/niki 0.5.2 → 0.5.3
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 +19 -0
- package/bin/niki +40 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ niki --budget 500000 --log /tmp/niki.log --state /tmp/niki-state.json -- claude
|
|
|
53
53
|
| `--max-tool-calls <n>` | `30` | Max total tool calls per minute |
|
|
54
54
|
| `--log <file>` | none | Append diagnostics to file |
|
|
55
55
|
| `--state <file>` | none | Write exit-state JSON on completion |
|
|
56
|
+
| `--metrics <file>` | none | Append session metrics as JSONL on exit (cumulative across runs) |
|
|
56
57
|
| `--cooldown <seconds>` | `5` | Grace period after SIGTERM before SIGKILL |
|
|
57
58
|
| `--abort-file <path>` | none | Poll this file for external abort signal |
|
|
58
59
|
| `--poll-interval <ms>` | `1000` | Base poll interval for abort file (±30% jitter) |
|
|
@@ -85,6 +86,24 @@ When `--state` is provided, niki writes a JSON snapshot on exit:
|
|
|
85
86
|
}
|
|
86
87
|
```
|
|
87
88
|
|
|
89
|
+
|
|
90
|
+
## Metrics file
|
|
91
|
+
|
|
92
|
+
When `--metrics` is provided, niki **appends** one JSON line per session exit. The file grows across restarts, giving you a full history:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# View last 5 sessions
|
|
96
|
+
tail -5 /tmp/niki-metrics.jsonl | jq .
|
|
97
|
+
|
|
98
|
+
# Total tokens across all sessions
|
|
99
|
+
cat /tmp/niki-metrics.jsonl | jq -s '[.[].tokensTotal] | add'
|
|
100
|
+
|
|
101
|
+
# Sessions killed by reason
|
|
102
|
+
cat /tmp/niki-metrics.jsonl | jq -s 'group_by(.killedBy) | map({reason: .[0].killedBy, count: length})'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each line contains the full session state plus `endedAt`, `budget`, and `timeoutS` for context.
|
|
106
|
+
|
|
88
107
|
`killedBy` is one of: `"budget"`, `"timeout"`, `"rate-sends"`, `"rate-tools"`, `"abort"`, or `null` (clean exit).
|
|
89
108
|
|
|
90
109
|
## Security
|
package/bin/niki
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { spawn, execSync } from 'node:child_process';
|
|
24
|
-
import { createWriteStream, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { createWriteStream, writeFileSync, appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
25
25
|
import { dirname, resolve } from 'node:path';
|
|
26
26
|
import { parseArgs } from 'node:util';
|
|
27
27
|
|
|
@@ -40,12 +40,13 @@ Options:
|
|
|
40
40
|
--max-tool-calls <n> Max total tool calls per minute (default: 30)
|
|
41
41
|
--stall-timeout <secs> Kill after N seconds of no output (default: 60, 0=disabled)
|
|
42
42
|
--startup-timeout <s> Longer stall timeout until first output (default: 180, 0=use stall-timeout)
|
|
43
|
-
--dead-air-timeout <m> Minutes of zero CPU + zero output before kill (default:
|
|
43
|
+
--dead-air-timeout <m> Minutes of zero CPU + zero output before kill (default: 1440, 0=disabled)
|
|
44
44
|
--max-nudges <n> Max stdin nudge attempts before kill on stall (default: 3)
|
|
45
45
|
--log <file> Write diagnostics log to file
|
|
46
46
|
--log-level <level> Minimum log level: debug, info, warn, error (default: info)
|
|
47
47
|
--log-json Emit logs as JSON lines (for machine parsing)
|
|
48
48
|
--state <file> Write state JSON on exit (budget used, reason, etc.)
|
|
49
|
+
--metrics <file> Append session metrics as JSONL on exit (cumulative across runs)
|
|
49
50
|
--cooldown <seconds> Grace period after SIGTERM before SIGKILL (default: 5)
|
|
50
51
|
--abort-file <path> Poll this file for external abort signal
|
|
51
52
|
--poll-interval <ms> Base poll interval in ms for abort file (default: 1000)
|
|
@@ -73,12 +74,13 @@ const { values: opts } = parseArgs({
|
|
|
73
74
|
'max-tool-calls': { type: 'string', default: '30' },
|
|
74
75
|
'stall-timeout': { type: 'string', default: '60' },
|
|
75
76
|
'startup-timeout': { type: 'string', default: '180' },
|
|
76
|
-
'dead-air-timeout': { type: 'string', default: '
|
|
77
|
+
'dead-air-timeout': { type: 'string', default: '1440' },
|
|
77
78
|
'max-nudges': { type: 'string', default: '3' },
|
|
78
79
|
log: { type: 'string' },
|
|
79
80
|
'log-level': { type: 'string', default: 'info' },
|
|
80
81
|
'log-json': { type: 'boolean', default: false },
|
|
81
82
|
state: { type: 'string' },
|
|
83
|
+
metrics: { type: 'string' },
|
|
82
84
|
cooldown: { type: 'string', default: '5' },
|
|
83
85
|
'abort-file': { type: 'string' },
|
|
84
86
|
'poll-interval': { type: 'string', default: '1000' },
|
|
@@ -101,6 +103,7 @@ const ABORT_FILE = opts['abort-file'] ? resolve(opts['abort-file']) : null;
|
|
|
101
103
|
const POLL_INTERVAL = parseInt(opts['poll-interval'], 10);
|
|
102
104
|
const LOG_FILE = opts.log;
|
|
103
105
|
const STATE_FILE = opts.state;
|
|
106
|
+
const METRICS_FILE = opts.metrics;
|
|
104
107
|
const RESTART = opts.restart;
|
|
105
108
|
const MAX_RESTARTS = parseInt(opts['max-restarts'], 10);
|
|
106
109
|
const RESTART_DELAY_S = parseFloat(opts['restart-delay']);
|
|
@@ -179,6 +182,24 @@ function writeState() {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
function writeMetrics() {
|
|
186
|
+
if (!METRICS_FILE) return;
|
|
187
|
+
try {
|
|
188
|
+
const p = resolve(METRICS_FILE);
|
|
189
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
190
|
+
// Append one JSON line per session — never overwrite, never truncate
|
|
191
|
+
const entry = JSON.stringify({
|
|
192
|
+
...state,
|
|
193
|
+
endedAt: new Date().toISOString(),
|
|
194
|
+
budget: BUDGET,
|
|
195
|
+
timeoutS: TIMEOUT_S,
|
|
196
|
+
});
|
|
197
|
+
appendFileSync(p, entry + '\n');
|
|
198
|
+
} catch {
|
|
199
|
+
// Best effort — metrics are not worth crashing over
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
182
203
|
// --- Budget threshold warnings ---
|
|
183
204
|
|
|
184
205
|
function checkBudgetThresholds() {
|
|
@@ -301,6 +322,21 @@ function killChild(reason) {
|
|
|
301
322
|
if (killed || !child) return;
|
|
302
323
|
killed = true;
|
|
303
324
|
state.killedBy = reason;
|
|
325
|
+
|
|
326
|
+
// Make kill events unmissable in interleaved log streams
|
|
327
|
+
const banner = [
|
|
328
|
+
'',
|
|
329
|
+
'🦜 ══════════════════════════════════════════════',
|
|
330
|
+
`🦜 NIKI KILL — ${reason.toUpperCase()}`,
|
|
331
|
+
`🦜 tokens: ${state.tokensTotal.toLocaleString()}/${BUDGET.toLocaleString()} | duration: ${Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000)}s`,
|
|
332
|
+
`🦜 sends: ${state.sendCallsThisMinute}/${MAX_SENDS}/min | tools: ${state.toolCallsThisMinute}/${MAX_TOOL_CALLS}/min`,
|
|
333
|
+
'🦜 ══════════════════════════════════════════════',
|
|
334
|
+
'',
|
|
335
|
+
];
|
|
336
|
+
for (const line of banner) {
|
|
337
|
+
process.stderr.write(`[niki] ${line}\n`);
|
|
338
|
+
if (logStream) logStream.write(line + '\n');
|
|
339
|
+
}
|
|
304
340
|
log(`KILL — reason: ${reason} | tokens: ${state.tokensTotal}/${BUDGET} | sends: ${state.sendCallsThisMinute}/min | tools: ${state.toolCallsThisMinute}/min`, 'error');
|
|
305
341
|
|
|
306
342
|
child.kill('SIGTERM');
|
|
@@ -687,6 +723,7 @@ function startChild() {
|
|
|
687
723
|
const level = state.killedBy ? 'error' : (code === 0 ? 'info' : 'warn');
|
|
688
724
|
log(`Exit — code: ${code} signal: ${signal} | tokens: ${state.tokensTotal.toLocaleString()} | tools: ${state.toolCalls} | sends: ${state.sendCalls} | duration: ${state.duration}s | output: ${gotFirstOutput}${state.killedBy ? ` | killed: ${state.killedBy}` : ''} | restarts: ${state.restarts}`, level);
|
|
689
725
|
writeState();
|
|
726
|
+
writeMetrics();
|
|
690
727
|
|
|
691
728
|
if (shouldRestart(code, signal)) {
|
|
692
729
|
state.restarts++;
|