Feature: AgentSentinel Integration¶
Overview¶
Feature Name: AgentSentinel Integration Status: Draft Last Updated: 2026-03-20 Related Projects: - PlexusOne Desktop - macOS terminal multiplexer for AI agents - AgentSentinel - Auto-approval system for AI CLI tools
Problem Statement¶
When running multiple AI CLI agents (Claude Code, Codex, Kiro, Gemini CLI), users face two challenges:
- Manual approval fatigue: AI tools frequently ask for permission to run commands, requiring constant "y" responses
- Lack of visibility: When AgentSentinel runs in the background, users don't know:
- How many AgentSentinel processes are running
- Which panes/sessions are being monitored
- How many approvals have occurred
- Whether dangerous commands were blocked
Goals¶
- Visibility: Show AgentSentinel status within PlexusOne Desktop UI
- Control: Enable/disable auto-approval per pane from PlexusOne Desktop
- Safety: Surface blocked dangerous commands to the user
- Simplicity: Minimal changes to both projects; loose coupling
Non-Goals¶
- Embedding AgentSentinel logic directly in PlexusOne Desktop (keep as separate process)
- Real-time streaming of approval events (polling is sufficient)
- Replacing AgentSentinel's CLI interface
Architecture¶
System Overview¶
┌─────────────────────────────────────────────────────────────────┐
│ PlexusOne Desktop App │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Grid Layout │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Pane 1 │ │ Pane 2 │ │ │
│ │ │ 🤖 Auto-approve │ │ ⏸ Manual │ │ │
│ │ │ ✓ 12 | ✗ 0 │ │ │ │ │
│ │ │ coder-1 │ │ reviewer │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SentinelManager │ │
│ │ - Reads ~/.agentsentinel/status.json (every 2s) │ │
│ │ - Provides status per session/pane │ │
│ │ - Can start/stop sentinel via CLI │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
reads status │ (optional) sends commands
▼
~/.agentsentinel/status.json
~/.agentsentinel/control.sock (future)
│
│
┌─────────────────────────────────────────────────────────────────┐
│ AgentSentinel Daemon (Go) │
│ │
│ - Single process watching all tmux panes │
│ - Writes status.json every 2 seconds │
│ - Logs approvals/blocks to stats file │
│ - Runs independently of PlexusOne Desktop │
└─────────────────────────────────────────────────────────────────┘
Communication Protocol¶
Phase 1: Status File (Read-only)
AgentSentinel writes status to ~/.agentsentinel/status.json:
{
"version": 1,
"pid": 12345,
"started_at": "2026-03-20T10:00:00Z",
"uptime_seconds": 3600,
"config": {
"interval_ms": 500,
"block_danger": true,
"notifications": true
},
"watching": [
{
"session": "coder-1",
"pane_id": "%5",
"pane_title": "claude",
"approvals": 12,
"blocked": 0,
"last_approval_at": "2026-03-20T10:05:00Z",
"last_blocked_at": null,
"last_blocked_command": null
},
{
"session": "reviewer",
"pane_id": "%8",
"pane_title": "codex",
"approvals": 5,
"blocked": 1,
"last_approval_at": "2026-03-20T10:04:30Z",
"last_blocked_at": "2026-03-20T10:03:00Z",
"last_blocked_command": "rm -rf /"
}
],
"totals": {
"approvals": 17,
"blocked": 1,
"panes_watched": 2
},
"updated_at": "2026-03-20T10:05:02Z"
}
Phase 2: Control Socket (Future)
Unix socket at ~/.agentsentinel/control.sock for commands:
// Request
{"command": "pause", "session": "coder-1"}
{"command": "resume", "session": "coder-1"}
{"command": "status"}
// Response
{"ok": true}
{"ok": false, "error": "session not found"}
Changes Required¶
AgentSentinel (Go)¶
New Package: internal/status¶
package status
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
type Status struct {
Version int `json:"version"`
PID int `json:"pid"`
StartedAt time.Time `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"`
Config ConfigStatus `json:"config"`
Watching []PaneStatus `json:"watching"`
Totals TotalStatus `json:"totals"`
UpdatedAt time.Time `json:"updated_at"`
}
type ConfigStatus struct {
IntervalMs int `json:"interval_ms"`
BlockDanger bool `json:"block_danger"`
Notifications bool `json:"notifications"`
}
type PaneStatus struct {
Session string `json:"session"`
PaneID string `json:"pane_id"`
PaneTitle string `json:"pane_title,omitempty"`
Approvals int `json:"approvals"`
Blocked int `json:"blocked"`
LastApprovalAt *time.Time `json:"last_approval_at,omitempty"`
LastBlockedAt *time.Time `json:"last_blocked_at,omitempty"`
LastBlockedCmd string `json:"last_blocked_command,omitempty"`
}
type TotalStatus struct {
Approvals int `json:"approvals"`
Blocked int `json:"blocked"`
PanesWatched int `json:"panes_watched"`
}
type Writer struct {
path string
startedAt time.Time
}
func NewWriter() *Writer {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".agentsentinel")
os.MkdirAll(dir, 0755)
return &Writer{
path: filepath.Join(dir, "status.json"),
startedAt: time.Now(),
}
}
func (w *Writer) Write(status *Status) error {
status.Version = 1
status.PID = os.Getpid()
status.StartedAt = w.startedAt
status.UptimeSeconds = int64(time.Since(w.startedAt).Seconds())
status.UpdatedAt = time.Now()
data, err := json.MarshalIndent(status, "", " ")
if err != nil {
return err
}
// Write atomically
tmpPath := w.path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
return os.Rename(tmpPath, w.path)
}
func (w *Writer) Remove() error {
return os.Remove(w.path)
}
Update Watcher¶
// In watcher.go, add status writing to the main loop:
func (w *Watcher) Run(ctx context.Context) error {
statusWriter := status.NewWriter()
defer statusWriter.Remove() // Clean up on exit
ticker := time.NewTicker(w.interval)
statusTicker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
defer statusTicker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
w.scan()
case <-statusTicker.C:
w.writeStatus(statusWriter)
}
}
}
func (w *Watcher) writeStatus(sw *status.Writer) {
panes := w.getPaneStatuses()
sw.Write(&status.Status{
Config: status.ConfigStatus{
IntervalMs: int(w.interval.Milliseconds()),
BlockDanger: w.blockDanger,
Notifications: w.notifications,
},
Watching: panes,
Totals: status.TotalStatus{
Approvals: w.totalApprovals,
Blocked: w.totalBlocked,
PanesWatched: len(panes),
},
})
}
New CLI Command: agentsentinel status --json¶
// cmd/status.go - add JSON output option
var statusJSONFlag bool
func init() {
statusCmd.Flags().BoolVar(&statusJSONFlag, "json", false, "Output status as JSON")
}
func runStatus(cmd *cobra.Command, args []string) error {
status, err := readStatusFile()
if err != nil {
if os.IsNotExist(err) {
if statusJSONFlag {
fmt.Println(`{"running": false}`)
} else {
fmt.Println("AgentSentinel is not running")
}
return nil
}
return err
}
if statusJSONFlag {
data, _ := json.MarshalIndent(status, "", " ")
fmt.Println(string(data))
} else {
printHumanStatus(status)
}
return nil
}
PlexusOne Desktop (Swift)¶
New Service: SentinelManager¶
// Services/SentinelManager.swift
import Foundation
import Observation
struct SentinelStatus: Codable {
let version: Int
let pid: Int
let startedAt: Date
let uptimeSeconds: Int64
let config: SentinelConfig
let watching: [PaneWatchStatus]
let totals: SentinelTotals
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case version, pid, config, watching, totals
case startedAt = "started_at"
case uptimeSeconds = "uptime_seconds"
case updatedAt = "updated_at"
}
}
struct SentinelConfig: Codable {
let intervalMs: Int
let blockDanger: Bool
let notifications: Bool
enum CodingKeys: String, CodingKey {
case intervalMs = "interval_ms"
case blockDanger = "block_danger"
case notifications
}
}
struct PaneWatchStatus: Codable {
let session: String
let paneId: String
let paneTitle: String?
let approvals: Int
let blocked: Int
let lastApprovalAt: Date?
let lastBlockedAt: Date?
let lastBlockedCommand: String?
enum CodingKeys: String, CodingKey {
case session, approvals, blocked
case paneId = "pane_id"
case paneTitle = "pane_title"
case lastApprovalAt = "last_approval_at"
case lastBlockedAt = "last_blocked_at"
case lastBlockedCommand = "last_blocked_command"
}
}
struct SentinelTotals: Codable {
let approvals: Int
let blocked: Int
let panesWatched: Int
enum CodingKeys: String, CodingKey {
case approvals, blocked
case panesWatched = "panes_watched"
}
}
@Observable
class SentinelManager {
private(set) var status: SentinelStatus?
private(set) var isRunning: Bool = false
private(set) var lastError: Error?
private var refreshTask: Task<Void, Never>?
private let statusPath: URL
init() {
let home = FileManager.default.homeDirectoryForCurrentUser
statusPath = home.appendingPathComponent(".agentsentinel/status.json")
}
func startMonitoring() {
refreshTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refresh()
try? await Task.sleep(for: .seconds(2))
}
}
}
func stopMonitoring() {
refreshTask?.cancel()
refreshTask = nil
}
func refresh() async {
do {
let data = try Data(contentsOf: statusPath)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
status = try decoder.decode(SentinelStatus.self, from: data)
isRunning = true
lastError = nil
} catch {
if (error as NSError).code == NSFileReadNoSuchFileError {
isRunning = false
status = nil
} else {
lastError = error
}
}
}
func statusForSession(_ session: String) -> PaneWatchStatus? {
status?.watching.first { $0.session == session }
}
func isWatching(_ session: String) -> Bool {
statusForSession(session) != nil
}
}
Update PaneHeaderView¶
// In PaneView.swift, update header to show sentinel status:
struct PaneHeaderView: View {
// ... existing properties ...
let sentinelStatus: PaneWatchStatus?
var body: some View {
HStack(spacing: 4) {
// Session dropdown (existing)
// ...
Spacer()
// Sentinel status indicator
if let sentinel = sentinelStatus {
HStack(spacing: 2) {
Image(systemName: "bolt.fill")
.font(.system(size: 9))
.foregroundColor(.green)
Text("✓\(sentinel.approvals)")
.font(.system(size: 9))
.foregroundColor(.green)
if sentinel.blocked > 0 {
Text("✗\(sentinel.blocked)")
.font(.system(size: 9))
.foregroundColor(.red)
}
}
.help("Auto-approve active: \(sentinel.approvals) approved, \(sentinel.blocked) blocked")
}
// ... rest of header ...
}
}
}
UI Mockups¶
Pane Header with Sentinel Status¶
┌──────────────────────────────────────────────────────────────┐
│ [▼ coder-1 🟢] ⚡✓12 ✗0 #1 [×] │
├──────────────────────────────────────────────────────────────┤
│ │
│ Terminal content here... │
│ │
└──────────────────────────────────────────────────────────────┘
Legend:
⚡ = Sentinel active (bolt icon)
✓12 = 12 approvals (green)
✗0 = 0 blocked (red if > 0)
Status Bar with Global Sentinel Status¶
┌──────────────────────────────────────────────────────────────┐
│ #1 🟢 coder-1 │ #2 🟡 reviewer │ ⚡ Sentinel: ✓45 ✗2 │ + │
└──────────────────────────────────────────────────────────────┘
Blocked Command Alert (Toast)¶
┌────────────────────────────────────────┐
│ ⚠️ Dangerous command blocked │
│ rm -rf / in coder-1 │
│ [View] [Dismiss] │
└────────────────────────────────────────┘
Implementation Phases¶
Phase 1: Status File (MVP)¶
AgentSentinel:
- [ ] Add internal/status package
- [ ] Update watcher to write status.json every 2s
- [ ] Add --json flag to status command
- [ ] Clean up status file on graceful shutdown
PlexusOne Desktop:
- [ ] Add SentinelManager service
- [ ] Display sentinel status in pane header
- [ ] Show global totals in status bar
Phase 2: Enhanced Visibility¶
PlexusOne Desktop: - [ ] Toast notifications for blocked commands - [ ] Sentinel status in Settings - [ ] Historical stats view (read from log file)
Phase 3: Control (Future)¶
AgentSentinel: - [ ] Unix socket for control commands - [ ] Pause/resume per session - [ ] Dynamic pattern updates
PlexusOne Desktop: - [ ] Start/stop sentinel from UI - [ ] Per-pane enable/disable toggle - [ ] Pattern configuration UI
Testing¶
AgentSentinel¶
# Start sentinel
agentsentinel watch --notify &
# Check status file
cat ~/.agentsentinel/status.json
# Check JSON output
agentsentinel status --json
PlexusOne Desktop¶
// Unit test for SentinelManager
func testStatusParsing() {
let json = """
{"version":1,"pid":123,"watching":[...]}
"""
let status = try JSONDecoder().decode(SentinelStatus.self, from: json.data(using: .utf8)!)
XCTAssertEqual(status.pid, 123)
}
Open Questions¶
- Status file location:
~/.agentsentinel/status.jsonor XDG-compliant path? - Polling interval: 2 seconds sufficient? Should it be configurable?
- Multiple instances: Should we support multiple sentinel processes? (Current design assumes single daemon)
- Startup: Should PlexusOne Desktop auto-start AgentSentinel if not running?