@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.
Files changed (3) hide show
  1. package/README.md +19 -0
  2. package/bin/niki +40 -3
  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: 5, 0=disabled)
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: '5' },
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++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/niki",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Deterministic process supervisor for AI agents — token budgets, rate limits, and abort control",
5
5
  "bin": {
6
6
  "niki": "./bin/niki"