@tjamescouch/niki 0.5.4 → 0.5.6

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 (2) hide show
  1. package/bin/niki +119 -20
  2. package/package.json +1 -1
package/bin/niki CHANGED
@@ -34,6 +34,7 @@ if (SEPARATOR === -1 || SEPARATOR === process.argv.length - 1) {
34
34
  Usage: niki [options] -- <command> [args...]
35
35
 
36
36
  Options:
37
+ --profile <name> Apply a preset profile (default: none; profiles: longrun)
37
38
  --budget <tokens> Max total tokens (input+output) before SIGTERM (default: 1000000)
38
39
  --timeout <seconds> Max wall-clock runtime before SIGTERM (default: 3600)
39
40
  --max-sends <n> Max agentchat_send calls per minute (default: 10)
@@ -53,8 +54,10 @@ Options:
53
54
  --restart Restart the child process when it exits (default: off)
54
55
  --max-restarts <n> Max restart attempts, 0=unlimited (default: 0)
55
56
  --restart-delay <secs> Delay between restarts with ±30% jitter (default: 5)
57
+ --kill-orphaned-mcp Kill stale agentchat-mcp processes on startup (default: off)
56
58
 
57
59
  Examples:
60
+ niki --profile longrun -- gro --persistent --model sonnet
58
61
  niki --budget 500000 -- claude -p "your prompt" --verbose
59
62
  niki --timeout 1800 --max-sends 5 -- claude -p "..." --model sonnet --verbose
60
63
  niki --restart --max-restarts 10 -- gro --model gpt-5.2 "your prompt"`);
@@ -65,28 +68,66 @@ const nikiArgs = process.argv.slice(2, SEPARATOR);
65
68
  const childCmd = process.argv[SEPARATOR + 1];
66
69
  const childArgs = process.argv.slice(SEPARATOR + 2);
67
70
 
71
+ function getProfileArg(args) {
72
+ const i = args.indexOf("--profile");
73
+ if (i === -1) return null;
74
+ const v = args[i + 1];
75
+ return v && !v.startsWith("-") ? v : null;
76
+ }
77
+
78
+ const DEFAULTS = {
79
+ budget: "1000000",
80
+ timeout: "3600",
81
+ "max-sends": "10",
82
+ "max-tool-calls": "30",
83
+ "stall-timeout": "60",
84
+ "startup-timeout": "180",
85
+ "dead-air-timeout": "5",
86
+ "max-nudges": "3",
87
+ cooldown: "5",
88
+ "poll-interval": "1000",
89
+ "max-restarts": "0",
90
+ "restart-delay": "5"
91
+ };
92
+
93
+ const PROFILES = {
94
+ longrun: {
95
+ timeout: "86400",
96
+ "stall-timeout": "0",
97
+ "startup-timeout": "0",
98
+ "dead-air-timeout": "0",
99
+ cooldown: "15"
100
+ }
101
+ };
102
+
103
+ const profileArg = getProfileArg(nikiArgs);
104
+ const D = profileArg && PROFILES[profileArg] ? { ...DEFAULTS, ...PROFILES[profileArg] } : DEFAULTS;
105
+
106
+
68
107
  const { values: opts } = parseArgs({
69
108
  args: nikiArgs,
70
109
  options: {
71
- budget: { type: 'string', default: '1000000' },
72
- timeout: { type: 'string', default: '3600' },
73
- 'max-sends': { type: 'string', default: '10' },
74
- 'max-tool-calls': { type: 'string', default: '30' },
75
- 'stall-timeout': { type: 'string', default: '60' },
76
- 'startup-timeout': { type: 'string', default: '180' },
77
- 'dead-air-timeout': { type: 'string', default: '1440' },
78
- 'max-nudges': { type: 'string', default: '3' },
110
+ profile: { type: 'string' },
111
+ budget: { type: 'string', default: D.budget },
112
+ timeout: { type: 'string', default: D.timeout },
113
+ 'max-sends': { type: 'string', default: D["max-sends"] },
114
+ 'max-tool-calls': { type: 'string', default: D["max-tool-calls"] },
115
+ 'stall-timeout': { type: 'string', default: D["stall-timeout"] },
116
+ 'startup-timeout': { type: 'string', default: D["startup-timeout"] },
117
+ 'dead-air-timeout': { type: 'string', default: D["dead-air-timeout"] },
118
+ 'max-nudges': { type: 'string', default: D["max-nudges"] },
79
119
  log: { type: 'string' },
80
120
  'log-level': { type: 'string', default: 'info' },
81
121
  'log-json': { type: 'boolean', default: false },
82
122
  state: { type: 'string' },
83
123
  metrics: { type: 'string' },
84
- cooldown: { type: 'string', default: '5' },
124
+ cooldown: { type: 'string', default: D.cooldown },
85
125
  'abort-file': { type: 'string' },
86
- 'poll-interval': { type: 'string', default: '1000' },
126
+ 'poll-interval': { type: 'string', default: D["poll-interval"] },
87
127
  restart: { type: 'boolean', default: false },
88
- 'max-restarts': { type: 'string', default: '0' },
89
- 'restart-delay': { type: 'string', default: '5' },
128
+ 'max-restarts': { type: 'string', default: D["max-restarts"] },
129
+ 'restart-delay': { type: 'string', default: D["restart-delay"] },
130
+ 'kill-orphaned-mcp': { type: 'boolean', default: false },
90
131
  },
91
132
  });
92
133
 
@@ -107,6 +148,7 @@ const METRICS_FILE = opts.metrics;
107
148
  const RESTART = opts.restart;
108
149
  const MAX_RESTARTS = parseInt(opts['max-restarts'], 10);
109
150
  const RESTART_DELAY_S = parseFloat(opts['restart-delay']);
151
+ const KILL_ORPHANED_MCP = opts['kill-orphaned-mcp'];
110
152
  const LOG_JSON = opts['log-json'];
111
153
 
112
154
  // --- Invocation rate limiting (prevent tight crash loops) ---
@@ -177,6 +219,13 @@ if (LOG_FILE) {
177
219
  logStream = createWriteStream(resolve(LOG_FILE), { flags: 'a' });
178
220
  }
179
221
 
222
+ // Format bytes/tokens in human-readable form
223
+ function fmtSize(n) {
224
+ if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
225
+ if (n >= 1000) return `${(n/1000).toFixed(1)}K`;
226
+ return String(n);
227
+ }
228
+
180
229
  function log(msg, level = 'info', fields = null) {
181
230
  const numLevel = LOG_LEVELS[level] ?? LOG_LEVELS.info;
182
231
  if (numLevel < LOG_LEVEL) return;
@@ -193,10 +242,27 @@ function log(msg, level = 'info', fields = null) {
193
242
  const prefix = level === 'info' ? '' : `[${level.toUpperCase()}] `;
194
243
  const line = `[${ts}] ${prefix}${msg}`;
195
244
  if (logStream) logStream.write(line + '\n');
196
- process.stderr.write(`[niki] ${line}\n`);
245
+
246
+ // For stderr: use concise format at info level, verbose at debug
247
+ if (level === 'debug' || LOG_LEVEL === LOG_LEVELS.debug) {
248
+ process.stderr.write(`[niki] ${line}\n`);
249
+ } else if (level === 'info') {
250
+ // Concise format: strip timestamp, just show the message
251
+ process.stderr.write(`[niki] ${msg}\n`);
252
+ } else {
253
+ // Warn/error: show full line
254
+ process.stderr.write(`[niki] ${line}\n`);
255
+ }
197
256
  }
198
257
  }
199
258
 
259
+ // Log API traffic in human-readable format
260
+ function logAPI(tokensIn, tokensOut) {
261
+ const budgetPct = ((state.tokensTotal / BUDGET) * 100).toFixed(1);
262
+ const msg = `[API: ${fmtSize(tokensIn)} → / ${fmtSize(tokensOut)} ←] Budget: ${fmtSize(state.tokensTotal)}/${fmtSize(BUDGET)} (${budgetPct}%)`;
263
+ log(msg, 'info');
264
+ }
265
+
200
266
  function writeState() {
201
267
  if (!STATE_FILE) return;
202
268
  try {
@@ -263,6 +329,9 @@ const TOKEN_PATTERNS = [
263
329
 
264
330
  function parseTokens(line) {
265
331
  let changed = false;
332
+ const prevIn = state.tokensIn;
333
+ const prevOut = state.tokensOut;
334
+
266
335
  for (const { regex, field } of TOKEN_PATTERNS) {
267
336
  regex.lastIndex = 0;
268
337
  let match;
@@ -278,6 +347,15 @@ function parseTokens(line) {
278
347
  }
279
348
  }
280
349
  if (changed) {
350
+ const deltaIn = state.tokensIn - prevIn;
351
+ const deltaOut = state.tokensOut - prevOut;
352
+
353
+ // Human-readable API traffic log
354
+ if (deltaIn > 0 || deltaOut > 0) {
355
+ logAPI(deltaIn, deltaOut);
356
+ }
357
+
358
+ // Verbose log for debug mode
281
359
  log(`Tokens — in: ${state.tokensIn.toLocaleString()} out: ${state.tokensOut.toLocaleString()} total: ${state.tokensTotal.toLocaleString()}/${BUDGET.toLocaleString()} (${Math.round(state.tokensTotal / BUDGET * 100)}%)`, 'debug');
282
360
  checkBudgetThresholds();
283
361
  }
@@ -534,7 +612,7 @@ function onChildOutput() {
534
612
  }
535
613
  if (!gotFirstOutput) {
536
614
  gotFirstOutput = true;
537
- log(`First output received after ${Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000)}s — switching to stall-timeout=${STALL_TIMEOUT_S}s`, 'info');
615
+ log(`First output received after ${Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000)}s — switching to stall-timeout=${STALL_TIMEOUT_S}s`, 'debug');
538
616
  }
539
617
  resetStallTimer();
540
618
  }
@@ -655,10 +733,23 @@ function scheduleAbortPoll() {
655
733
  let timeoutId = null;
656
734
 
657
735
  function startChild() {
658
- log(`Starting: ${childCmd} ${childArgs.join(' ').substring(0, 100)}...`, 'info');
659
- log(`Budget: ${BUDGET.toLocaleString()} tokens | Timeout: ${TIMEOUT_S}s | Startup: ${STARTUP_TIMEOUT_S}s | Stall: ${STALL_TIMEOUT_S}s | Dead air: ${DEAD_AIR_TIMEOUT_M}min | Max sends: ${MAX_SENDS}/min | Max tools: ${MAX_TOOL_CALLS}/min`, 'info');
736
+ // Concise startup message
737
+ const cmd = `${childCmd} ${childArgs.join(' ').substring(0, 60)}...`;
738
+ log(`[START] ${cmd} | Budget: ${fmtSize(BUDGET)} tokens | Timeout: ${TIMEOUT_S}s`, 'info');
739
+
740
+ // Verbose details at debug level
741
+ log(`Budget: ${BUDGET.toLocaleString()} tokens | Timeout: ${TIMEOUT_S}s | Startup: ${STARTUP_TIMEOUT_S}s | Stall: ${STALL_TIMEOUT_S}s | Dead air: ${DEAD_AIR_TIMEOUT_M}min | Max sends: ${MAX_SENDS}/min | Max tools: ${MAX_TOOL_CALLS}/min`, 'debug');
660
742
  if (RESTART) {
661
- log(`Restart: enabled | max: ${MAX_RESTARTS || 'unlimited'} | delay: ${RESTART_DELAY_S}s ±30% | restarts so far: ${state.restarts}`, 'info');
743
+ log(`Restart: enabled | max: ${MAX_RESTARTS || 'unlimited'} | delay: ${RESTART_DELAY_S}s ±30% | restarts so far: ${state.restarts}`, 'debug');
744
+ }
745
+
746
+ // If --kill-orphaned-mcp: kill stale agentchat-mcp procs before spawning.
747
+ // Prevents session_displaced churn from orphaned prior-session MCP processes.
748
+ if (KILL_ORPHANED_MCP) {
749
+ try {
750
+ execSync('pkill -f agentchat-mcp', { stdio: 'ignore' });
751
+ log('Killed orphaned agentchat-mcp processes', 'debug');
752
+ } catch { /* pkill exits non-zero when nothing matched */ }
662
753
  }
663
754
 
664
755
  child = spawn(childCmd, childArgs, {
@@ -712,13 +803,13 @@ function startChild() {
712
803
 
713
804
  // Start stall detection
714
805
  if (STALL_TIMEOUT_S > 0 || STARTUP_TIMEOUT_S > 0) {
715
- log(`Stall detection: startup-timeout=${STARTUP_TIMEOUT_S}s, stall-timeout=${STALL_TIMEOUT_S}s, max-nudges=${MAX_NUDGES}`, 'info');
806
+ log(`Stall detection: startup-timeout=${STARTUP_TIMEOUT_S}s, stall-timeout=${STALL_TIMEOUT_S}s, max-nudges=${MAX_NUDGES}`, 'debug');
716
807
  resetStallTimer();
717
808
  }
718
809
 
719
810
  // Start dead air detection
720
811
  if (DEAD_AIR_TIMEOUT_M > 0) {
721
- log(`Dead air detection: ${DEAD_AIR_TIMEOUT_M}min threshold, ${Math.round(DEAD_AIR_POLL_MS / 1000)}s poll interval`, 'info');
812
+ log(`Dead air detection: ${DEAD_AIR_TIMEOUT_M}min threshold, ${Math.round(DEAD_AIR_POLL_MS / 1000)}s poll interval`, 'debug');
722
813
  scheduleDeadAirPoll();
723
814
  }
724
815
 
@@ -747,7 +838,15 @@ function startChild() {
747
838
  state.gotFirstOutput = gotFirstOutput;
748
839
 
749
840
  const level = state.killedBy ? 'error' : (code === 0 ? 'info' : 'warn');
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);
841
+
842
+ // Concise exit message
843
+ const exitMsg = state.killedBy
844
+ ? `[EXIT] Killed: ${state.killedBy} | Tokens: ${fmtSize(state.tokensTotal)} | ${state.duration}s`
845
+ : `[EXIT] Code: ${code} | Tokens: ${fmtSize(state.tokensTotal)} | ${state.duration}s`;
846
+ log(exitMsg, level);
847
+
848
+ // Verbose details at debug level
849
+ 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}`, 'debug');
751
850
  writeState();
752
851
  writeMetrics();
753
852
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/niki",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Deterministic process supervisor for AI agents — token budgets, rate limits, and abort control",
5
5
  "bin": {
6
6
  "niki": "./bin/niki"