4runr-os 2.10.64 → 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.
@@ -11,6 +11,7 @@ import { Sentinel } from '@4runr/sentinel';
11
11
  import {
12
12
  getSentinelLimitsFilePath,
13
13
  applyPersistedSentinelConfig,
14
+ hydrateSentinelConfigFromDisk,
14
15
  } from '../security/sentinel-config-store.js';
15
16
 
16
17
  jest.mock('../middleware/rateLimit.js', () => ({
@@ -81,5 +82,12 @@ describe('POST /api/sentinel/policies/custom persistence', () => {
81
82
  const sentinel = Sentinel.getInstance();
82
83
  expect(applyPersistedSentinelConfig(sentinel)).toBe(true);
83
84
  expect(sentinel.getConfig().runIdleMs).toBe(distinctiveIdle);
85
+
86
+ Sentinel.resetInstance();
87
+ const stale = Sentinel.getInstance();
88
+ expect(stale.getConfig().runIdleMs).not.toBe(distinctiveIdle);
89
+
90
+ hydrateSentinelConfigFromDisk(stale);
91
+ expect(stale.getConfig().runIdleMs).toBe(distinctiveIdle);
84
92
  });
85
93
  });
@@ -12,7 +12,11 @@ import {
12
12
  } from '@4runr/sentinel';
13
13
  import { requireAuth } from '../middleware/auth.js';
14
14
  import { readRateLimit, writeRateLimit } from '../middleware/rateLimit.js';
15
- import { persistSentinelConfigAfterApply } from '../security/sentinel-config-store.js';
15
+ import {
16
+ persistSentinelConfigAfterApply,
17
+ hydrateSentinelConfigFromDisk,
18
+ getSentinelLimitsFilePath,
19
+ } from '../security/sentinel-config-store.js';
16
20
 
17
21
  /**
18
22
  * Register Sentinel policy management routes
@@ -171,7 +175,7 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
171
175
 
172
176
  try {
173
177
  sentinel.updateConfig(config);
174
- const savedTo = persistSentinelConfigAfterApply(sentinel, config);
178
+ const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
175
179
  return {
176
180
  success: true,
177
181
  template: templateName,
@@ -231,11 +235,11 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
231
235
 
232
236
  try {
233
237
  sentinel.updateConfig(config);
234
- const savedTo = persistSentinelConfigAfterApply(sentinel, config);
238
+ const savedTo = persistSentinelConfigAfterApply(sentinel, config, fastify.log);
235
239
  return {
236
240
  success: true,
237
241
  config,
238
- message: `Custom Sentinel limits applied and saved to ${savedTo}. Survives Gateway restart; env vars override individual fields when set.`,
242
+ message: `Custom limits saved (${savedTo}). Reopen Sentinel or restart Gateway to reload; env vars override fields when set.`,
239
243
  };
240
244
  } catch (error: unknown) {
241
245
  const msg = error instanceof Error ? error.message : String(error);
@@ -270,10 +274,12 @@ export async function sentinelPolicyRoutes(fastify: FastifyInstance) {
270
274
  try {
271
275
  const { Sentinel } = await import('@4runr/sentinel');
272
276
  const sentinel = Sentinel.getInstance();
273
-
277
+ hydrateSentinelConfigFromDisk(sentinel);
278
+
274
279
  return {
275
280
  success: true,
276
- config: sentinel.getConfig()
281
+ config: sentinel.getConfig(),
282
+ persistedPath: getSentinelLimitsFilePath(),
277
283
  };
278
284
  } catch (error: any) {
279
285
  return reply.status(500).send({
@@ -109,13 +109,8 @@ export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
109
109
  fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
110
110
  }
111
111
 
112
- /**
113
- * Apply saved limits on Gateway boot. Env-defined fields keep env values.
114
- */
115
- export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
116
- info: (msg: string, meta?: Record<string, unknown>) => void;
117
- warn: (msg: string, meta?: Record<string, unknown>) => void;
118
- }): boolean {
112
+ /** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
113
+ export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
119
114
  const saved = loadSentinelLimitsFromDisk();
120
115
  if (!saved) return false;
121
116
 
@@ -124,27 +119,90 @@ export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
124
119
 
125
120
  for (const { field } of ENV_FIELD_KEYS) {
126
121
  if (envDefinesField(field)) continue;
127
- (merged as Record<string, unknown>)[field] = saved[field];
122
+ (merged as any)[field] = saved[field];
128
123
  }
129
124
 
130
125
  try {
131
126
  sentinel.updateConfig(merged);
132
- logger?.info('Sentinel limits loaded from disk', {
133
- path: getSentinelLimitsFilePath(),
134
- });
135
127
  return true;
136
- } catch (err) {
137
- logger?.warn('Failed to apply saved Sentinel limits', {
138
- error: err instanceof Error ? err.message : String(err),
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Apply saved limits on Gateway boot. Env-defined fields keep env values.
135
+ */
136
+ export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
137
+ info: (msg: string, meta?: Record<string, unknown>) => void;
138
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
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,
139
146
  });
140
147
  return false;
141
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
+
165
+ const ok = mergePersistedSentinelConfig(sentinel);
166
+
167
+ if (ok) {
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,
180
+ });
181
+ }
182
+
183
+ return ok;
184
+ }
185
+
186
+ /** Call before returning policy config to clients so long-lived Gateway picks up disk saves. */
187
+ export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
188
+ mergePersistedSentinelConfig(sentinel);
142
189
  }
143
190
 
144
191
  export function persistSentinelConfigAfterApply(
145
192
  sentinel: Sentinel,
146
- config: SentinelConfig
193
+ config: SentinelConfig,
194
+ logger?: {
195
+ info: (msg: string, meta?: Record<string, unknown>) => void;
196
+ }
147
197
  ): string {
198
+ const filePath = getSentinelLimitsFilePath();
148
199
  saveSentinelLimitsToDisk(config);
149
- 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;
150
208
  }
@@ -828,13 +828,30 @@ impl App {
828
828
  }
829
829
  }
830
830
 
831
+ pub fn open_sentinel_config(&mut self, ws: Option<&WebSocketClient>) {
832
+ self.state.pending_sentinel_load_id = None;
833
+ self.state.pending_sentinel_apply_id = None;
834
+ self.push_overlay(Screen::SentinelConfig);
835
+ self.state
836
+ .logs
837
+ .push_back("[NAV] Opening Sentinel Configuration...".into());
838
+ if self.state.operation_mode == OperationMode::Connected {
839
+ if let Some(ws) = ws {
840
+ self.begin_sentinel_load_request(ws);
841
+ }
842
+ } else {
843
+ self.state.sentinel_config.error = Some(
844
+ "Connect to Gateway first (connect portal).".to_string(),
845
+ );
846
+ }
847
+ self.request_immediate_render("open_sentinel_config");
848
+ }
849
+
831
850
  pub fn begin_sentinel_load_request(&mut self, ws: &WebSocketClient) {
832
851
  if self.state.operation_mode != OperationMode::Connected {
833
852
  return;
834
853
  }
835
- if self.state.pending_sentinel_load_id.is_some()
836
- || self.state.pending_sentinel_apply_id.is_some()
837
- {
854
+ if self.state.pending_sentinel_load_id.is_some() {
838
855
  return;
839
856
  }
840
857
  self.state.sentinel_config.loading = true;
@@ -1551,19 +1568,7 @@ impl App {
1551
1568
  }
1552
1569
  }
1553
1570
  "sentinel" | "sentinel config" | "sentinel policies" => {
1554
- self.push_overlay(Screen::SentinelConfig);
1555
- self.state
1556
- .logs
1557
- .push_back("[NAV] Opening Sentinel Configuration...".into());
1558
- if self.state.operation_mode == OperationMode::Connected {
1559
- if let Some(ws) = ws_client {
1560
- self.begin_sentinel_load_request(ws);
1561
- }
1562
- } else {
1563
- self.state.sentinel_config.error = Some(
1564
- "Connect to Gateway first (connect portal).".to_string(),
1565
- );
1566
- }
1571
+ self.open_sentinel_config(ws_client);
1567
1572
  }
1568
1573
  "config" | "settings" => {
1569
1574
  self.push_overlay(Screen::Settings);
@@ -2514,7 +2519,7 @@ impl App {
2514
2519
 
2515
2520
  match key.code {
2516
2521
  KeyCode::Tab => {
2517
- self.state.sentinel_config.toggle_view_mode();
2522
+ self.state.sentinel_config.cycle_view_mode();
2518
2523
  self.request_immediate_render("sentinel_tab");
2519
2524
  }
2520
2525
  KeyCode::Up => {
@@ -2566,21 +2571,10 @@ impl App {
2566
2571
  self.state.sentinel_config.error =
2567
2572
  Some("Connect to Gateway first.".to_string());
2568
2573
  } else if let Some(ws) = ws_client {
2569
- match self.state.sentinel_config.view_mode {
2570
- SentinelViewMode::Templates => {
2571
- if let Some(t) = self.state.sentinel_config.selected_template() {
2572
- let key = t.key.clone();
2573
- let name = t.name.clone();
2574
- self.begin_sentinel_apply_request(ws, &key);
2575
- self.add_log(format!("[SENTINEL] Applying template: {}", name));
2576
- }
2577
- }
2578
- SentinelViewMode::Custom => {
2579
- let draft = self.state.sentinel_config.custom_draft.clone();
2580
- self.begin_sentinel_apply_custom_request(ws, &draft);
2581
- self.add_log("[SENTINEL] Applying custom limits".to_string());
2582
- }
2583
- }
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());
2584
2578
  }
2585
2579
  self.request_render("sentinel_apply");
2586
2580
  }
@@ -14,10 +14,27 @@ 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
- Templates,
18
17
  Custom,
19
18
  }
20
19
 
20
+ const ABOUT_SENTINEL_LINES: &[&str] = &[
21
+ "Sentinel is 4Runr's runtime safety layer for agent executions on this Gateway.",
22
+ "",
23
+ "What it does:",
24
+ " • Watches each run while it is running (queue processor path)",
25
+ " • Enforces limits: idle time, max duration, token cap, cost, loop detection",
26
+ " • On violation: kills the run (status killed, policy_violation:* in logs)",
27
+ "",
28
+ "Scope:",
29
+ " • One policy for ALL runs on this Gateway (not per-agent yet)",
30
+ " • A run = one agent execution (POST /api/runs → start → agent runs)",
31
+ "",
32
+ "Persistence:",
33
+ " • Enter: saves to disk (%APPDATA%\\4runr\\config\\sentinel-limits.json)",
34
+ " • Survives Gateway restart",
35
+ " • Env vars in docker-compose override individual fields when set",
36
+ ];
37
+
21
38
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22
39
  pub enum CustomField {
23
40
  Enabled,
@@ -138,7 +155,7 @@ pub struct SentinelConfigState {
138
155
  impl Default for SentinelConfigState {
139
156
  fn default() -> Self {
140
157
  Self {
141
- view_mode: SentinelViewMode::Templates,
158
+ view_mode: SentinelViewMode::Custom,
142
159
  templates: Vec::new(),
143
160
  selected_index: 0,
144
161
  custom_draft: SentinelCurrentConfig::default(),
@@ -165,14 +182,10 @@ impl SentinelConfigState {
165
182
  self.templates.get(self.selected_index)
166
183
  }
167
184
 
168
- pub fn toggle_view_mode(&mut self) {
169
- self.view_mode = match self.view_mode {
170
- SentinelViewMode::Templates => {
171
- self.sync_custom_draft_from_current();
172
- SentinelViewMode::Custom
173
- }
174
- SentinelViewMode::Custom => SentinelViewMode::Templates,
175
- };
185
+ pub fn cycle_view_mode(&mut self) {
186
+ // Only one view now, so cycling does nothing
187
+ self.view_mode = SentinelViewMode::Custom;
188
+ self.sync_custom_draft_from_current();
176
189
  self.error = None;
177
190
  self.status_message = None;
178
191
  }
@@ -190,40 +203,22 @@ impl SentinelConfigState {
190
203
  }
191
204
 
192
205
  pub fn select_previous(&mut self) {
193
- match self.view_mode {
194
- SentinelViewMode::Templates => {
195
- if self.selected_index > 0 {
196
- self.selected_index -= 1;
197
- }
198
- }
199
- SentinelViewMode::Custom => {
200
- let idx = CustomField::ALL
201
- .iter()
202
- .position(|f| *f == self.custom_field)
203
- .unwrap_or(0);
204
- if idx > 0 {
205
- self.custom_field = CustomField::ALL[idx - 1];
206
- }
207
- }
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];
208
212
  }
209
213
  }
210
214
 
211
215
  pub fn select_next(&mut self) {
212
- match self.view_mode {
213
- SentinelViewMode::Templates => {
214
- if self.selected_index + 1 < self.templates.len() {
215
- self.selected_index += 1;
216
- }
217
- }
218
- SentinelViewMode::Custom => {
219
- let idx = CustomField::ALL
220
- .iter()
221
- .position(|f| *f == self.custom_field)
222
- .unwrap_or(0);
223
- if idx + 1 < CustomField::ALL.len() {
224
- self.custom_field = CustomField::ALL[idx + 1];
225
- }
226
- }
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];
227
222
  }
228
223
  }
229
224
 
@@ -488,10 +483,6 @@ pub fn render(f: &mut Frame, state: &AppState) {
488
483
  ])
489
484
  .split(area);
490
485
 
491
- let mode_label = match sc.view_mode {
492
- SentinelViewMode::Templates => "Templates",
493
- SentinelViewMode::Custom => "Custom",
494
- };
495
486
  let health_txt = if sc.health_status.is_empty() {
496
487
  "—".to_string()
497
488
  } else {
@@ -499,10 +490,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
499
490
  };
500
491
 
501
492
  let header = Block::default()
502
- .title(format!(
503
- " 🛡️ Sentinel — {} · {} ",
504
- mode_label, health_txt
505
- ))
493
+ .title(format!(" 🛡️ Sentinel — {} ", health_txt))
506
494
  .borders(Borders::ALL)
507
495
  .border_style(Style::default().fg(BRAND_PURPLE))
508
496
  .style(Style::default().bg(BG_PANEL));
@@ -513,25 +501,12 @@ pub fn render(f: &mut Frame, state: &AppState) {
513
501
  .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
514
502
  .split(chunks[1]);
515
503
 
516
- match sc.view_mode {
517
- SentinelViewMode::Templates => {
518
- render_active_panel(f, body[0], sc);
519
- render_templates_panel(f, body[1], sc);
520
- }
521
- SentinelViewMode::Custom => {
522
- render_custom_editor(f, body[0], sc);
523
- render_custom_help(f, body[1], sc);
524
- }
525
- }
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);
526
507
 
527
- let default_footer = match sc.view_mode {
528
- SentinelViewMode::Templates => {
529
- "Tab Custom · ↑/↓ Template · Enter Apply · R Refresh · ESC Close"
530
- }
531
- SentinelViewMode::Custom => {
532
- "Tab Templates · ↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · ESC Close"
533
- }
534
- };
508
+ const DEFAULT_FOOTER: &str =
509
+ "↑/↓ Field · ←/→ Value · Enter Apply (saves to disk) · R Refresh · ESC Close";
535
510
  const CUSTOM_SHORTCUTS: &str =
536
511
  "←/→ or +/- adjust value · wheel · Space ON/OFF · Enter apply · ESC close";
537
512
 
@@ -542,13 +517,9 @@ pub fn render(f: &mut Frame, state: &AppState) {
542
517
  } else if let Some(e) = &sc.error {
543
518
  e.clone()
544
519
  } else if let Some(m) = &sc.status_message {
545
- if sc.view_mode == SentinelViewMode::Custom {
546
- format!("{}\n{}", m, CUSTOM_SHORTCUTS)
547
- } else {
548
- m.clone()
549
- }
520
+ format!("{}\n{}", m, CUSTOM_SHORTCUTS)
550
521
  } else {
551
- default_footer.to_string()
522
+ DEFAULT_FOOTER.to_string()
552
523
  };
553
524
 
554
525
  let footer = Block::default()
@@ -573,6 +544,48 @@ pub fn render(f: &mut Frame, state: &AppState) {
573
544
  );
574
545
  }
575
546
 
547
+ fn render_about_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
548
+ let block = Block::default()
549
+ .title(" 📖 What is Sentinel? ")
550
+ .borders(Borders::ALL)
551
+ .border_style(Style::default().fg(CYBER_CYAN))
552
+ .style(Style::default().bg(BG_PANEL));
553
+ let inner = block.inner(area);
554
+ f.render_widget(block, area);
555
+
556
+ let mut lines: Vec<Line> = Vec::new();
557
+ for line in ABOUT_SENTINEL_LINES {
558
+ if line.is_empty() {
559
+ lines.push(Line::from(""));
560
+ continue;
561
+ }
562
+ let style = if line.starts_with(" •") {
563
+ Style::default().fg(TEXT_PRIMARY)
564
+ } else if line.ends_with(':') && !line.starts_with(' ') {
565
+ Style::default().fg(NEON_GREEN).bold()
566
+ } else {
567
+ Style::default().fg(TEXT_DIM)
568
+ };
569
+ lines.push(Line::from(Span::styled(*line, style)));
570
+ }
571
+ lines.push(Line::from(""));
572
+ lines.push(Line::from(vec![
573
+ Span::styled("Active limits now: ", Style::default().fg(TEXT_DIM)),
574
+ Span::styled(
575
+ if sc.current_enabled { "ON" } else { "OFF" },
576
+ Style::default().fg(NEON_GREEN),
577
+ ),
578
+ Span::styled(" · idle ", Style::default().fg(TEXT_DIM)),
579
+ Span::styled(fmt_ms(sc.current_idle_ms), Style::default().fg(TEXT_PRIMARY)),
580
+ Span::styled(" · max ", Style::default().fg(TEXT_DIM)),
581
+ Span::styled(fmt_ms(sc.current_duration_ms), Style::default().fg(TEXT_PRIMARY)),
582
+ Span::styled(" · cost ", Style::default().fg(TEXT_DIM)),
583
+ Span::styled(fmt_cost(sc.current_max_cost), Style::default().fg(AMBER_WARN)),
584
+ ]));
585
+
586
+ f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
587
+ }
588
+
576
589
  fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
577
590
  let block = Block::default()
578
591
  .title(" ⚙️ Active limits (live) ")
@@ -627,7 +640,7 @@ fn render_active_panel(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
627
640
  ),
628
641
  ]),
629
642
  Line::from(""),
630
- Line::from("Press Tab → Custom to tune your own limits.").style(Style::default().fg(TEXT_MUTED)),
643
+ Line::from("Tab → About / Custom / Templates to switch views.").style(Style::default().fg(TEXT_MUTED)),
631
644
  ];
632
645
  f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
633
646
  }
@@ -715,50 +728,81 @@ fn render_custom_editor(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
715
728
  f.render_widget(List::new(items), inner);
716
729
  }
717
730
 
718
- fn render_custom_help(f: &mut Frame, area: Rect, sc: &SentinelConfigState) {
719
- let block = Block::default()
720
- .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? ")
721
743
  .borders(Borders::ALL)
722
- .border_style(Style::default().fg(BRAND_PURPLE))
744
+ .border_style(Style::default().fg(CYBER_CYAN))
723
745
  .style(Style::default().bg(BG_PANEL));
724
- let inner = block.inner(area);
725
- 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
+ );
726
768
 
769
+ // Bottom: Field-specific help
727
770
  let field = sc.custom_field;
728
- 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![
729
780
  Line::from(""),
730
781
  Line::from(field.help()).style(Style::default().fg(TEXT_PRIMARY)),
731
782
  Line::from(""),
732
783
  ];
733
784
  if let Some(reason) = field.kill_reason() {
734
- lines.push(Line::from(vec![
785
+ help_lines.push(Line::from(vec![
735
786
  Span::styled("Kill log reason: ", Style::default().fg(TEXT_DIM)),
736
787
  Span::styled(reason, Style::default().fg(AMBER_WARN)),
737
788
  ]));
738
- lines.push(Line::from(""));
789
+ help_lines.push(Line::from(""));
739
790
  }
740
- lines.push(Line::from("Persistent via env (Gateway boot):").style(Style::default().fg(NEON_GREEN).bold()));
741
- lines.push(Line::from(field.env_var()).style(Style::default().fg(CYBER_CYAN)));
742
- lines.push(Line::from(""));
743
- lines.push(
744
- Line::from("Enter Apply → Gateway + disk (%APPDATA%\\4runr\\config\\sentinel-limits.json).")
745
- .style(Style::default().fg(NEON_GREEN)),
746
- );
747
- lines.push(
748
- Line::from("Survives Gateway restart. Env vars in docker-compose override fields when set.")
749
- .style(Style::default().fg(TEXT_MUTED)),
750
- );
751
- lines.push(
752
- Line::from("Scope: all agent runs on this Gateway (one policy per run execution).")
753
- .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()),
754
794
  );
755
- lines.push(Line::from(""));
756
- lines.push(
757
- 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.")
758
799
  .style(Style::default().fg(NEON_GREEN)),
759
800
  );
760
801
 
761
- 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
+ );
762
806
  }
763
807
 
764
808
  #[cfg(test)]
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.10.64",
3
+ "version": "2.10.66",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.64: Sentinel limits persist to disk; Custom tab edit with arrow keys and M:SS display.",
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",