@tjamescouch/niki 0.5.2 → 0.5.4
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 +66 -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,11 +103,38 @@ 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']);
|
|
107
110
|
const LOG_JSON = opts['log-json'];
|
|
108
111
|
|
|
112
|
+
// --- Invocation rate limiting (prevent tight crash loops) ---
|
|
113
|
+
|
|
114
|
+
const INVOCATION_LOCKFILE = `/tmp/niki-invocation-${process.pid}.lock`;
|
|
115
|
+
const GLOBAL_LOCKFILE = '/tmp/niki-last-invocation';
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(GLOBAL_LOCKFILE)) {
|
|
119
|
+
const lastInvocation = parseFloat(readFileSync(GLOBAL_LOCKFILE, 'utf8'));
|
|
120
|
+
const now = Date.now() / 1000;
|
|
121
|
+
const delta = now - lastInvocation;
|
|
122
|
+
|
|
123
|
+
if (delta < 1.0) {
|
|
124
|
+
console.error(`[niki] ERROR: Invoked too quickly (${delta.toFixed(3)}s since last invocation)`);
|
|
125
|
+
console.error(`[niki] Refusing to start — something is calling niki in a tight loop`);
|
|
126
|
+
console.error(`[niki] This prevents expensive crash loops. Wait at least 1 second between invocations.`);
|
|
127
|
+
process.exit(42); // Exit code 42 = rate-limited
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Update timestamp
|
|
132
|
+
writeFileSync(GLOBAL_LOCKFILE, String(Date.now() / 1000));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Non-fatal if we can't write lockfile (e.g., permission issues)
|
|
135
|
+
console.error(`[niki] Warning: Could not check invocation rate (${err.message})`);
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
// --- Log levels ---
|
|
110
139
|
|
|
111
140
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
@@ -179,6 +208,24 @@ function writeState() {
|
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
|
|
211
|
+
function writeMetrics() {
|
|
212
|
+
if (!METRICS_FILE) return;
|
|
213
|
+
try {
|
|
214
|
+
const p = resolve(METRICS_FILE);
|
|
215
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
216
|
+
// Append one JSON line per session — never overwrite, never truncate
|
|
217
|
+
const entry = JSON.stringify({
|
|
218
|
+
...state,
|
|
219
|
+
endedAt: new Date().toISOString(),
|
|
220
|
+
budget: BUDGET,
|
|
221
|
+
timeoutS: TIMEOUT_S,
|
|
222
|
+
});
|
|
223
|
+
appendFileSync(p, entry + '\n');
|
|
224
|
+
} catch {
|
|
225
|
+
// Best effort — metrics are not worth crashing over
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
182
229
|
// --- Budget threshold warnings ---
|
|
183
230
|
|
|
184
231
|
function checkBudgetThresholds() {
|
|
@@ -301,6 +348,21 @@ function killChild(reason) {
|
|
|
301
348
|
if (killed || !child) return;
|
|
302
349
|
killed = true;
|
|
303
350
|
state.killedBy = reason;
|
|
351
|
+
|
|
352
|
+
// Make kill events unmissable in interleaved log streams
|
|
353
|
+
const banner = [
|
|
354
|
+
'',
|
|
355
|
+
'🦜 ══════════════════════════════════════════════',
|
|
356
|
+
`🦜 NIKI KILL — ${reason.toUpperCase()}`,
|
|
357
|
+
`🦜 tokens: ${state.tokensTotal.toLocaleString()}/${BUDGET.toLocaleString()} | duration: ${Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000)}s`,
|
|
358
|
+
`🦜 sends: ${state.sendCallsThisMinute}/${MAX_SENDS}/min | tools: ${state.toolCallsThisMinute}/${MAX_TOOL_CALLS}/min`,
|
|
359
|
+
'🦜 ══════════════════════════════════════════════',
|
|
360
|
+
'',
|
|
361
|
+
];
|
|
362
|
+
for (const line of banner) {
|
|
363
|
+
process.stderr.write(`[niki] ${line}\n`);
|
|
364
|
+
if (logStream) logStream.write(line + '\n');
|
|
365
|
+
}
|
|
304
366
|
log(`KILL — reason: ${reason} | tokens: ${state.tokensTotal}/${BUDGET} | sends: ${state.sendCallsThisMinute}/min | tools: ${state.toolCallsThisMinute}/min`, 'error');
|
|
305
367
|
|
|
306
368
|
child.kill('SIGTERM');
|
|
@@ -687,6 +749,7 @@ function startChild() {
|
|
|
687
749
|
const level = state.killedBy ? 'error' : (code === 0 ? 'info' : 'warn');
|
|
688
750
|
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
751
|
writeState();
|
|
752
|
+
writeMetrics();
|
|
690
753
|
|
|
691
754
|
if (shouldRestart(code, signal)) {
|
|
692
755
|
state.restarts++;
|