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