Wire play/stop buttons and DM send to agent lifecycle

- Play button in GroupAgentsPanel now starts agent session via emitAgentStart
- Stop button now stops running agent session via emitAgentStop
- Sending a DM to a stopped agent auto-wakes it (sets active + emitAgentStart)
- Fix autoPrompt in AgentPane to work for fresh sessions (not just done/error)
- Fix btmsg: admin (tier 0) bypasses stale heartbeat check so messages deliver

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DexterFromLab 2026-03-12 12:33:08 +01:00
parent 42907f22a4
commit 2c710fa0db
6 changed files with 114 additions and 34 deletions

View file

@ -215,6 +215,8 @@ pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<S
} }
// Check if recipient is stale (no heartbeat in 5 min) — route to dead letter queue // Check if recipient is stale (no heartbeat in 5 min) — route to dead letter queue
// Skip heartbeat check for admin (tier 0) — admin messages always go through
if sender_tier > 0 {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@ -248,6 +250,7 @@ pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<S
return Err(error_msg); return Err(error_msg);
} }
}
let msg_id = uuid::Uuid::new_v4().to_string(); let msg_id = uuid::Uuid::new_v4().to_string();
db.execute( db.execute(

View file

@ -151,14 +151,15 @@
// NOTE: Do NOT stop agents in onDestroy — it fires on layout changes/remounts, // NOTE: Do NOT stop agents in onDestroy — it fires on layout changes/remounts,
// not just explicit close. Stop-on-close is handled by workspace teardown. // not just explicit close. Stop-on-close is handled by workspace teardown.
// Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh) // Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh, play button)
$effect(() => { $effect(() => {
if (!autoPrompt || isRunning) return; if (!autoPrompt || isRunning) return;
// Only trigger if session exists and is idle (done/error) // Allow: no session yet (first start) or session completed (done/error)
if (!session || (session.status !== 'done' && session.status !== 'error')) return; if (session && session.status !== 'done' && session.status !== 'error') return;
const prompt = autoPrompt; const prompt = autoPrompt;
const resume = !!session; // new query if first start, resume if existing session
onautopromptconsumed?.(); onautopromptconsumed?.();
startQuery(prompt, true); // resume session with context refresh startQuery(prompt, resume);
}); });
let promptRef = $state<HTMLTextAreaElement | undefined>(); let promptRef = $state<HTMLTextAreaElement | undefined>();

View file

@ -12,6 +12,8 @@
type AgentMessageRecord, type AgentMessageRecord,
} from '../../adapters/groups-bridge'; } from '../../adapters/groups-bridge';
import { registerSessionProject } from '../../agent-dispatcher'; import { registerSessionProject } from '../../agent-dispatcher';
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
import { stopAgent } from '../../adapters/agent-bridge';
import { trackProject, updateProjectSession } from '../../stores/health.svelte'; import { trackProject, updateProjectSession } from '../../stores/health.svelte';
import { import {
createAgentSession, createAgentSession,
@ -97,11 +99,30 @@
lastPromptTime = Date.now(); lastPromptTime = Date.now();
} }
// Listen for play-button start events from GroupAgentsPanel
const unsubAgentStart = onAgentStart((projectId) => {
if (projectId !== project.id) return;
// Only auto-start if not already running and no pending prompt
if (contextRefreshPrompt) return;
const startPrompt = project.isAgent
? 'Start your work. Check your inbox with `btmsg inbox` and review the task board with `bttask board`. Take action on any pending items.'
: 'Review the instructions above and begin your work.';
contextRefreshPrompt = startPrompt;
});
// Listen for stop-button events from GroupAgentsPanel
const unsubAgentStop = onAgentStop((projectId) => {
if (projectId !== project.id) return;
stopAgent(sessionId).catch(() => {});
});
// Start timer and clean up // Start timer and clean up
startReinjectionTimer(); startReinjectionTimer();
onDestroy(() => { onDestroy(() => {
if (reinjectionTimer) clearInterval(reinjectionTimer); if (reinjectionTimer) clearInterval(reinjectionTimer);
if (wakeCheckTimer) clearInterval(wakeCheckTimer); if (wakeCheckTimer) clearInterval(wakeCheckTimer);
unsubAgentStart();
unsubAgentStop();
}); });
// Wake scheduler integration — poll for wake events (Manager agents only) // Wake scheduler integration — poll for wake events (Manager agents only)

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getActiveGroup } from '../../stores/workspace.svelte'; import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
import { import {
type BtmsgAgent, type BtmsgAgent,
type BtmsgMessage, type BtmsgMessage,
@ -17,6 +17,7 @@
getChannelMessages, getChannelMessages,
sendChannelMessage, sendChannelMessage,
createChannel, createChannel,
setAgentStatus,
} from '../../adapters/btmsg-bridge'; } from '../../adapters/btmsg-bridge';
const ADMIN_ID = 'admin'; const ADMIN_ID = 'admin';
@ -127,6 +128,13 @@
try { try {
if (currentView.type === 'dm') { if (currentView.type === 'dm') {
await sendMessage(ADMIN_ID, currentView.agentId, text); await sendMessage(ADMIN_ID, currentView.agentId, text);
// Auto-wake agent if stopped
const recipient = agents.find(a => a.id === currentView.agentId);
if (recipient && recipient.status !== 'active') {
await setAgentStatus(currentView.agentId, 'active');
emitAgentStart(currentView.agentId);
await pollBtmsg();
}
} else if (currentView.type === 'channel') { } else if (currentView.type === 'channel') {
await sendChannelMessage(currentView.channelId, ADMIN_ID, text); await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
} else { } else {
@ -140,6 +148,11 @@
} }
} }
/** Refresh agent list (reused by poll and wake logic) */
async function pollBtmsg() {
await loadData();
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte'; import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups'; import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge'; import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
import type { AgentId } from '../../types/ids'; import type { AgentId } from '../../types/ids';
@ -63,6 +63,12 @@
try { try {
await setAgentStatus(agent.id, newStatus); await setAgentStatus(agent.id, newStatus);
await pollBtmsg(); // Refresh immediately await pollBtmsg(); // Refresh immediately
if (newStatus === 'active') {
setActiveProject(agent.id);
emitAgentStart(agent.id);
} else {
emitAgentStop(agent.id);
}
} catch (e) { } catch (e) {
console.warn('Failed to set agent status:', e); console.warn('Failed to set agent status:', e);
} }

View file

@ -82,6 +82,42 @@ export function emitTerminalToggle(projectId: string): void {
} }
} }
// --- Agent start event (play button in GroupAgentsPanel) ---
type AgentStartCallback = (projectId: string) => void;
let agentStartCallbacks: AgentStartCallback[] = [];
export function onAgentStart(cb: AgentStartCallback): () => void {
agentStartCallbacks.push(cb);
return () => {
agentStartCallbacks = agentStartCallbacks.filter(c => c !== cb);
};
}
export function emitAgentStart(projectId: string): void {
for (const cb of agentStartCallbacks) {
cb(projectId);
}
}
// --- Agent stop event (stop button in GroupAgentsPanel) ---
type AgentStopCallback = (projectId: string) => void;
let agentStopCallbacks: AgentStopCallback[] = [];
export function onAgentStop(cb: AgentStopCallback): () => void {
agentStopCallbacks.push(cb);
return () => {
agentStopCallbacks = agentStopCallbacks.filter(c => c !== cb);
};
}
export function emitAgentStop(projectId: string): void {
for (const cb of agentStopCallbacks) {
cb(projectId);
}
}
// --- Getters --- // --- Getters ---
export function getGroupsConfig(): GroupsFile | null { export function getGroupsConfig(): GroupsFile | null {