4runr-os 2.10.65 → 2.10.66

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.
@@ -175,7 +175,7 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
175
175
 
176
176
  try {
177
177
  sentinel.updateConfig(config);
178
- const savedTo = persistSentinelConfigAfterApply(sentinel, config);
178
+ const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
179
179
  return {
180
180
  success: true,
181
181
  template: templateName,
@@ -235,7 +235,7 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
235
235
 
236
236
  try {
237
237
  sentinel.updateConfig(config);
238
- const savedTo = persistSentinelConfigAfterApply(sentinel, config);
238
+ const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
239
239
  return {
240
240
  success: true,
241
241
  config,
@@ -119,7 +119,7 @@ export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
119
119
 
120
120
  for (const { field } of ENV_FIELD_KEYS) {
121
121
  if (envDefinesField(field)) continue;
122
- (merged as Record<string, unknown>)[field] = saved[field];
122
+ (merged as any)[field] = saved[field];
123
123
  }
124
124
 
125
125
  try {
@@ -137,12 +137,49 @@ export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
137
137
  info: (msg: string, meta?: Record<string, unknown>) => void;
138
138
  warn: (msg: string, meta?: Record<string, unknown>) => void;
139
139
  }): boolean {
140
+ const filePath = getSentinelLimitsFilePath();
141
+ const saved = loadSentinelLimitsFromDisk();
142
+
143
+ if (!saved) {
144
+ logger?.info('No Sentinel limits file found on disk (will use env defaults)', {
145
+ expectedPath: filePath,
146
+ });
147
+ return false;
148
+ }
149
+
150
+ // Check which env vars are set and will override the disk values
151
+ const envOverrides: string[] = [];
152
+ const diskApplied: string[] = [];
153
+
154
+ for (const { field, envKeys } of ENV_FIELD_KEYS) {
155
+ if (envDefinesField(field)) {
156
+ const activeEnv = envKeys.find(k => process.env[k] !== undefined && process.env[k]?.trim() !== '');
157
+ if (activeEnv) {
158
+ envOverrides.push(`${field} (via ${activeEnv}=${process.env[activeEnv]})`);
159
+ }
160
+ } else {
161
+ diskApplied.push(`${field}=${saved[field]}`);
162
+ }
163
+ }
164
+
140
165
  const ok = mergePersistedSentinelConfig(sentinel);
166
+
141
167
  if (ok) {
142
- logger?.info('Sentinel limits loaded from disk', {
143
- path: getSentinelLimitsFilePath(),
168
+ const finalConfig = sentinel.getConfig();
169
+ logger?.info('✅ Sentinel limits loaded from disk and merged', {
170
+ diskPath: filePath,
171
+ diskValues: saved,
172
+ envOverrides: envOverrides.length > 0 ? envOverrides : 'none',
173
+ diskAppliedFields: diskApplied.length > 0 ? diskApplied : 'none (all env-overridden)',
174
+ finalActiveConfig: finalConfig,
175
+ });
176
+ } else {
177
+ logger?.warn('Failed to apply Sentinel limits from disk', {
178
+ diskPath: filePath,
179
+ savedConfig: saved,
144
180
  });
145
181
  }
182
+
146
183
  return ok;
147
184
  }
148
185
 
@@ -153,8 +190,19 @@ export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
153
190
 
154
191
  export function persistSentinelConfigAfterApply(
155
192
  sentinel: Sentinel,
156
- config: SentinelConfig
193
+ config: SentinelConfig,
194
+ logger?: {
195
+ info: (msg: string, meta?: Record<string, unknown>) => void;
196
+ }
157
197
  ): string {
198
+ const filePath = getSentinelLimitsFilePath();
158
199
  saveSentinelLimitsToDisk(config);
159
- return getSentinelLimitsFilePath();
200
+
201
+ logger?.info('✅ Sentinel limits saved to disk', {
202
+ path: filePath,
203
+ config: config,
204
+ note: 'Survives Gateway restart. Env vars in docker-compose override individual fields when set.',
205
+ });
206
+
207
+ return filePath;
160
208
  }
@@ -2571,25 +2571,10 @@ impl App {
2571
2571
  self.state.sentinel_config.error =
2572
2572
  Some("Connect to Gateway first.".to_string());
2573
2573
  } else if let Some(ws) = ws_client {
2574
- match self.state.sentinel_config.view_mode {
2575
- SentinelViewMode::About => {
2576
- self.begin_sentinel_load_request(ws);
2577
- self.request_render("sentinel_about_refresh");
2578
- }
2579
- SentinelViewMode::Templates => {
2580
- if let Some(t) = self.state.sentinel_config.selected_template() {
2581
- let key = t.key.clone();
2582
- let name = t.name.clone();
2583
- self.begin_sentinel_apply_request(ws, &key);
2584
- self.add_log(format!("[SENTINEL] Applying template: {}", name));
2585
- }
2586
- }
2587
- SentinelViewMode::Custom => {
2588
- let draft = self.state.sentinel_config.custom_draft.clone();
2589
- self.begin_sentinel_apply_custom_request(ws, &draft);
2590
- self.add_log("[SENTINEL] Applying custom limits".to_string());
2591
- }
2592
- }
2574
+ // Apply custom configuration
2575
+ let draft = self.state.sentinel_config.custom_draft.clone();
2576
+ self.begin_sentinel_apply_custom_request(ws, &draft);
2577
+ self.add_log("[SENTINEL] Applying custom limits...".to_string());
2593
2578
  }
2594
2579
  self.request_render("sentinel_apply");
2595
2580
  }
@@ -14,8 +14,6 @@ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
14
14
 
15
15
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
16
  pub enum SentinelViewMode {
17
- About,
18
- Templates,
19
17
  Custom,
20
18
  }
21
19
 
@@ -27,13 +25,13 @@ const ABOUT_SENTINEL_LINES: &[&str] = &[
27
25
  " • Enforces limits: idle time, max duration, token cap, cost, loop detection",
28
26
  " • On violation: kills the run (status killed, policy_violation:* in logs)",
29
27
  "",
30
- "Scope today:",
28
+ "Scope:",
31
29
  " • One policy for ALL runs on this Gateway (not per-agent yet)",
32
30
  " • A run = one agent execution (POST /api/runs → start → agent runs)",
33
31
  "",
34
- "How to use this screen:",
35
- " • Templates: apply a preset · Custom: tune fields · Enter: save to disk",
36
- " • Saved file survives Gateway restart (see path in Custom help)",
32
+ "Persistence:",
33
+ " • Enter: saves to disk (%APPDATA%\\4runr\\config\\sentinel-limits.json)",
34
+ " • Survives Gateway restart",
37
35
  " • Env vars in docker-compose override individual fields when set",
38
36
  ];
39
37
 
@@ -157,7 +155,7 @@ pub struct SentinelConfigState {
157
155
  impl Default for SentinelConfigState {
158
156
  fn default() -> Self {
159
157
  Self {
160
- view_mode: SentinelViewMode::Templates,
158
+ view_mode: SentinelViewMode::Custom,
161
159
  templates: Vec::new(),
162
160
  selected_index: 0,
163
161
  custom_draft: SentinelCurrentConfig::default(),
@@ -185,14 +183,9 @@ impl SentinelConfigState {
185
183
  }
186
184
 
187
185
  pub fn cycle_view_mode(&mut self) {
188
- self.view_mode = match self.view_mode {
189
- SentinelViewMode::About => SentinelViewMode::Templates,
190
- SentinelViewMode::Templates => {
191
- self.sync_custom_draft_from_current();
192
- SentinelViewMode::Custom
193
- }
194
- SentinelViewMode::Custom => SentinelViewMode::About,
195
- };
186
+ // Only one view now, so cycling does nothing
187
+ self.view_mode = SentinelViewMode::Custom;
188
+ self.sync_custom_draft_from_current();
196
189
  self.error = None;
197
190
  self.status_message = None;
198
191
  }
@@ -210,42 +203,22 @@ impl SentinelConfigState {
210
203
  }
211
204
 
212
205
  pub fn select_previous(&mut self) {
213
- match self.view_mode {
214
- SentinelViewMode::About => {}
215
- SentinelViewMode::Templates => {
216
- if self.selected_index > 0 {
217
- self.selected_index -= 1;
218
- }
219
- }
220
- SentinelViewMode::Custom => {
221
- let idx = CustomField::ALL
222
- .iter()
223
- .position(|f| *f == self.custom_field)
224
- .unwrap_or(0);
225
- if idx > 0 {
226
- self.custom_field = CustomField::ALL[idx - 1];
227
- }
228
- }
206
+ let idx = CustomField::ALL
207
+ .iter()
208
+ .position(|f| *f == self.custom_field)
209
+ .unwrap_or(0);
210
+ if idx > 0 {
211
+ self.custom_field = CustomField::ALL[idx - 1];
229
212
  }
230
213
  }
231
214
 
232
215
  pub fn select_next(&mut self) {
233
- match self.view_mode {
234
- SentinelViewMode::About => {}
235
- SentinelViewMode::Templates => {
236
- if self.selected_index + 1 < self.templates.len() {
237
- self.selected_index += 1;
238
- }
239
- }
240
- SentinelViewMode::Custom => {
241
- let idx = CustomField::ALL
242
- .iter()
243
- .position(|f| *f == self.custom_field)
244
- .unwrap_or(0);
245
- if idx + 1 < CustomField::ALL.len() {
246
- self.custom_field = CustomField::ALL[idx + 1];
247
- }
248
- }
216
+ let idx = CustomField::ALL
217
+ .iter()
218
+ .position(|f| *f == self.custom_field)
219
+ .unwrap_or(0);
220
+ if idx + 1 < CustomField::ALL.len() {
221
+ self.custom_field = CustomField::ALL[idx + 1];
249
222
  }
250
223
  }
251
224
 
@@ -510,11 +483,6 @@ pub fn render(f: &mut Frame, state: &AppState) {
510
483
  ])
511
484
  .split(area);
512
485
 
513
- let mode_label = match sc.view_mode {
514
- SentinelViewMode::About => "About",
515
- SentinelViewMode::Templates => "Templates",
516
- SentinelViewMode::Custom => "Custom",
517
- };
518
486
  let health_txt = if sc.health_status.is_empty() {
519
487
  "—".to_string()
520
488
  } else {
@@ -522,10 +490,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
522
490
  };
523
491
 
524
492
  let header = Block::default()
525
- .title(format!(
526
- " 🛡️ Sentinel — {} · {} ",
527
- mode_label, health_txt
528
- ))
493
+ .title(format!(" 🛡️ Sentinel — {} ", health_txt))
529
494
  .borders(Borders::ALL)
530
495
  .border_style(Style::default().fg(BRAND_PURPLE))
531
496
  .style(Style::default().bg(BG_PANEL));
@@ -536,31 +501,12 @@ pub fn render(f: &mut Frame, state: &AppState) {
536
501
  .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
537
502
  .split(chunks[1]);
538
503
 
539
- match sc.view_mode {
540
- SentinelViewMode::About => {
541
- render_about_panel(f, chunks[1], sc);
542
- }
543
- SentinelViewMode::Templates => {
544
- render_active_panel(f, body[0], sc);
545
- render_templates_panel(f, body[1], sc);
546
- }
547
- SentinelViewMode::Custom => {
548
- render_custom_editor(f, body[0], sc);
549
- render_custom_help(f, body[1], sc);
550
- }
551
- }
504
+ // Unified view: Custom editor on left, About + Help on right
505
+ render_custom_editor(f, body[0], sc);
506
+ render_custom_help_with_about(f, body[1], sc);
552
507
 
553
- let default_footer = match sc.view_mode {
554
- SentinelViewMode::About => {
555
- "Tab: Templates · R: reload from Gateway · ESC: Close"
556
- }
557
- SentinelViewMode::Templates => {
558
- "Tab: next view · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
559
- }
560
- SentinelViewMode::Custom => {
561
- "Tab: next view · ↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · ESC Close"
562
- }
563
- };
508
+ const DEFAULT_FOOTER: &str =
509
+ "↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · R Refresh · ESC Close";
564
510
  const CUSTOM_SHORTCUTS: &str =
565
511
  "←/→ or +/- adjust value · wheel · Space ON/OFF · Enter apply · ESC close";
566
512
 
@@ -571,13 +517,9 @@ pub fn render(f: &mut Frame, state: &AppState) {
571
517
  } else if let Some(e) = &sc.error {
572
518
  e.clone()
573
519
  } else if let Some(m) = &sc.status_message {
574
- if sc.view_mode == SentinelViewMode::Custom {
575
- format!("{}\n{}", m, CUSTOM_SHORTCUTS)
576
- } else {
577
- m.clone()
578
- }
520
+ format!("{}\n{}", m, CUSTOM_SHORTCUTS)
579
521
  } else {
580
- default_footer.to_string()
522
+ DEFAULT_FOOTER.to_string()
581
523
  };
582
524
 
583
525
  let footer = Block::default()
@@ -786,50 +728,81 @@ fn render_custom_editor(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
786
728
  f.render_widget(List::new(items), inner);
787
729
  }
788
730
 
789
- fn render_custom_help(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
790
- let block = Block::default()
791
- .title(format!(" ℹ️ {} ", sc.custom_field.label()))
731
+ fn render_custom_help_with_about(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
732
+ use ratatui::layout::{Constraint, Direction, Layout};
733
+
734
+ // Split into About section (top) and field help (bottom)
735
+ let chunks = Layout::default()
736
+ .direction(Direction::Vertical)
737
+ .constraints([Constraint::Length(15), Constraint::Min(10)])
738
+ .split(area);
739
+
740
+ // Top: About Sentinel
741
+ let about_block = Block::default()
742
+ .title(" 📖 What is Sentinel? ")
792
743
  .borders(Borders::ALL)
793
- .border_style(Style::default().fg(BRAND_PURPLE))
744
+ .border_style(Style::default().fg(CYBER_CYAN))
794
745
  .style(Style::default().bg(BG_PANEL));
795
- let inner = block.inner(area);
796
- f.render_widget(block, area);
746
+ let about_inner = about_block.inner(chunks[0]);
747
+ f.render_widget(about_block, chunks[0]);
748
+
749
+ let mut about_lines: Vec<Line> = Vec::new();
750
+ for line in ABOUT_SENTINEL_LINES {
751
+ if line.is_empty() {
752
+ about_lines.push(Line::from(""));
753
+ continue;
754
+ }
755
+ let style = if line.starts_with(" •") {
756
+ Style::default().fg(TEXT_PRIMARY)
757
+ } else if line.ends_with(':') && !line.starts_with(' ') {
758
+ Style::default().fg(NEON_GREEN).bold()
759
+ } else {
760
+ Style::default().fg(TEXT_DIM)
761
+ };
762
+ about_lines.push(Line::from(Span::styled(*line, style)));
763
+ }
764
+ f.render_widget(
765
+ Paragraph::new(about_lines).wrap(Wrap { trim: false }),
766
+ about_inner,
767
+ );
797
768
 
769
+ // Bottom: Field-specific help
798
770
  let field = sc.custom_field;
799
- let mut lines = vec![
771
+ let help_block = Block::default()
772
+ .title(format!(" ℹ️ {} ", field.label()))
773
+ .borders(Borders::ALL)
774
+ .border_style(Style::default().fg(BRAND_PURPLE))
775
+ .style(Style::default().bg(BG_PANEL));
776
+ let help_inner = help_block.inner(chunks[1]);
777
+ f.render_widget(help_block, chunks[1]);
778
+
779
+ let mut help_lines = vec![
800
780
  Line::from(""),
801
781
  Line::from(field.help()).style(Style::default().fg(TEXT_PRIMARY)),
802
782
  Line::from(""),
803
783
  ];
804
784
  if let Some(reason) = field.kill_reason() {
805
- lines.push(Line::from(vec![
785
+ help_lines.push(Line::from(vec![
806
786
  Span::styled("Kill log reason: ", Style::default().fg(TEXT_DIM)),
807
787
  Span::styled(reason, Style::default().fg(AMBER_WARN)),
808
788
  ]));
809
- lines.push(Line::from(""));
789
+ help_lines.push(Line::from(""));
810
790
  }
811
- lines.push(Line::from("Persistent via env (Gateway boot):").style(Style::default().fg(NEON_GREEN).bold()));
812
- lines.push(Line::from(field.env_var()).style(Style::default().fg(CYBER_CYAN)));
813
- lines.push(Line::from(""));
814
- lines.push(
815
- Line::from("Enter Apply → Gateway + disk (%APPDATA%\\4runr\\config\\sentinel-limits.json).")
816
- .style(Style::default().fg(NEON_GREEN)),
817
- );
818
- lines.push(
819
- Line::from("Survives Gateway restart. Env vars in docker-compose override fields when set.")
820
- .style(Style::default().fg(TEXT_MUTED)),
791
+ help_lines.push(
792
+ Line::from("Persistent via env (Gateway boot):")
793
+ .style(Style::default().fg(NEON_GREEN).bold()),
821
794
  );
822
- lines.push(
823
- Line::from("Scope: all agent runs on this Gateway (one policy per run execution).")
824
- .style(Style::default().fg(TEXT_MUTED)),
825
- );
826
- lines.push(Line::from(""));
827
- lines.push(
828
- Line::from("Adjust draft: ←/→ or +/- · mouse wheel · Space toggles Enforcement.")
795
+ help_lines.push(Line::from(field.env_var()).style(Style::default().fg(CYBER_CYAN)));
796
+ help_lines.push(Line::from(""));
797
+ help_lines.push(
798
+ Line::from("Adjust: ←/→ or +/- · mouse wheel · Space toggles Enforcement.")
829
799
  .style(Style::default().fg(NEON_GREEN)),
830
800
  );
831
801
 
832
- f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
802
+ f.render_widget(
803
+ Paragraph::new(help_lines).wrap(Wrap { trim: false }),
804
+ help_inner,
805
+ );
833
806
  }
834
807
 
835
808
  #[cfg(test)]
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.10.65",
3
+ "version": "2.10.66",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.65: Sentinel limits reload from disk on open; About tab explains purpose.",
5
+ "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.66: Sentinel unified Custom+About view (Templates removed); enhanced persistence logging.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "4runr": "dist/index.js",