tycono 0.1.93-beta.2 → 0.1.93

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.
@@ -6,6 +6,9 @@ import type { ToolCall, ToolResult } from '../llm-adapter.js';
6
6
  import { validateWrite, validateRead } from '../authority-validator.js';
7
7
  import type { OrgTree } from '../org-tree.js';
8
8
  import { buildKnowledgeGateWarning } from '../knowledge-gate.js';
9
+ import { ActivityStream } from '../../services/activity-stream.js';
10
+ import { digest, quietDigest, type DigestResult } from '../../services/digest-engine.js';
11
+ import type { ActivityEvent } from '../../../../shared/types.js';
9
12
 
10
13
  /* ─── Types ──────────────────────────────────── */
11
14
 
@@ -14,9 +17,14 @@ export interface ToolExecutorOptions {
14
17
  roleId: string;
15
18
  orgTree: OrgTree;
16
19
  codeRoot?: string;
20
+ sessionId?: string;
17
21
  onDispatch?: (roleId: string, task: string) => Promise<string>;
18
22
  onConsult?: (roleId: string, question: string) => Promise<string>;
19
23
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
24
+ /** For supervision: abort a running session */
25
+ onAbortSession?: (sessionId: string) => boolean;
26
+ /** For supervision: amend a running session with new instructions */
27
+ onAmendSession?: (sessionId: string, instruction: string) => boolean;
20
28
  }
21
29
 
22
30
  /* ─── Tool Executor ──────────────────────────── */
@@ -48,6 +56,12 @@ export async function executeTool(
48
56
  return await dispatchTask(id, input, onDispatch);
49
57
  case 'consult':
50
58
  return await consultTask(id, input, onConsult);
59
+ case 'heartbeat_watch':
60
+ return await heartbeatWatch(id, input, companyRoot);
61
+ case 'amend_session':
62
+ return amendSession(id, input, options.onAmendSession);
63
+ case 'abort_session':
64
+ return abortSession(id, input, options.onAbortSession);
51
65
  default:
52
66
  return { tool_use_id: id, content: `Unknown tool: ${name}`, is_error: true };
53
67
  }
@@ -412,3 +426,152 @@ async function consultTask(
412
426
  const result = await onConsult(roleId, question);
413
427
  return { tool_use_id: id, content: result };
414
428
  }
429
+
430
+ /* ─── Supervision Tools (SV-3, SV-6, SV-7) ──── */
431
+
432
+ const MAX_WATCH_DURATION = 300;
433
+ const DEFAULT_WATCH_DURATION = 120;
434
+
435
+ /**
436
+ * heartbeat_watch: Block for N seconds collecting events from activity streams.
437
+ * Returns a DigestEngine summary. Early-returns on alert events.
438
+ * $0 LLM cost during wait — all blocking is server-side.
439
+ */
440
+ async function heartbeatWatch(
441
+ id: string,
442
+ input: Record<string, unknown>,
443
+ companyRoot: string,
444
+ ): Promise<ToolResult> {
445
+ const sessionIds = input.sessionIds as string[] | undefined;
446
+ if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length === 0) {
447
+ return { tool_use_id: id, content: 'Error: sessionIds array is required', is_error: true };
448
+ }
449
+
450
+ const durationSec = Math.min(
451
+ Math.max(Number(input.durationSec) || DEFAULT_WATCH_DURATION, 5),
452
+ MAX_WATCH_DURATION,
453
+ );
454
+ const alertOn = (input.alertOn as string[] | undefined) ?? ['msg:done', 'msg:error'];
455
+ const alertSet = new Set(alertOn);
456
+
457
+ // Collect current checkpoints (last known seq for each session)
458
+ const startCheckpoints = new Map<string, number>();
459
+ for (const sid of sessionIds) {
460
+ const events = ActivityStream.readAll(sid);
461
+ startCheckpoints.set(sid, events.length > 0 ? events[events.length - 1].seq + 1 : 0);
462
+ }
463
+
464
+ // Set up event collection with live subscriptions
465
+ const collectedEvents = new Map<string, ActivityEvent[]>();
466
+ for (const sid of sessionIds) {
467
+ collectedEvents.set(sid, []);
468
+ }
469
+
470
+ let earlyReturn = false;
471
+ const unsubscribers: Array<() => void> = [];
472
+
473
+ // Subscribe to live events for early alert detection
474
+ for (const sid of sessionIds) {
475
+ const stream = ActivityStream.getOrCreate(sid, 'unknown');
476
+ const handler = (event: ActivityEvent) => {
477
+ const events = collectedEvents.get(sid);
478
+ if (events) events.push(event);
479
+ if (alertSet.has(event.type)) {
480
+ earlyReturn = true;
481
+ }
482
+ };
483
+ stream.subscribe(handler);
484
+ unsubscribers.push(() => stream.unsubscribe(handler));
485
+ }
486
+
487
+ // Wait for duration or early return
488
+ await new Promise<void>((resolve) => {
489
+ const timeout = setTimeout(resolve, durationSec * 1000);
490
+ const checkInterval = setInterval(() => {
491
+ if (earlyReturn) {
492
+ clearTimeout(timeout);
493
+ clearInterval(checkInterval);
494
+ resolve();
495
+ }
496
+ }, 500); // Check every 500ms
497
+ // Ensure cleanup even if early
498
+ setTimeout(() => { clearInterval(checkInterval); }, durationSec * 1000 + 100);
499
+ });
500
+
501
+ // Unsubscribe all
502
+ for (const unsub of unsubscribers) unsub();
503
+
504
+ // If live subscription missed events (e.g., stream was not active), read from file
505
+ for (const sid of sessionIds) {
506
+ const fromSeq = startCheckpoints.get(sid) ?? 0;
507
+ const liveEvents = collectedEvents.get(sid) ?? [];
508
+ if (liveEvents.length === 0) {
509
+ // Fallback: read from JSONL
510
+ const fileEvents = ActivityStream.readFrom(sid, fromSeq);
511
+ collectedEvents.set(sid, fileEvents);
512
+ }
513
+ }
514
+
515
+ // Run DigestEngine
516
+ const result: DigestResult = digest(collectedEvents);
517
+
518
+ // SV-10: Quiet tick gate
519
+ if (result.significanceScore < 2 && result.anomalies.length === 0) {
520
+ const quietText = quietDigest(sessionIds.length, result.eventCount, result.errorCount);
521
+ return { tool_use_id: id, content: quietText };
522
+ }
523
+
524
+ return { tool_use_id: id, content: result.text };
525
+ }
526
+
527
+ /**
528
+ * amend_session: Send additional instructions to a running session (SV-6)
529
+ */
530
+ function amendSession(
531
+ id: string,
532
+ input: Record<string, unknown>,
533
+ onAmend?: (sessionId: string, instruction: string) => boolean,
534
+ ): ToolResult {
535
+ const sessionId = String(input.sessionId ?? '');
536
+ const instruction = String(input.instruction ?? '');
537
+
538
+ if (!sessionId || !instruction) {
539
+ return { tool_use_id: id, content: 'Error: sessionId and instruction are required', is_error: true };
540
+ }
541
+
542
+ if (!onAmend) {
543
+ return { tool_use_id: id, content: 'Error: amend_session not available in this context', is_error: true };
544
+ }
545
+
546
+ const success = onAmend(sessionId, instruction);
547
+ if (success) {
548
+ return { tool_use_id: id, content: `Session ${sessionId} amended. Instruction will be injected at next turn boundary.` };
549
+ }
550
+ return { tool_use_id: id, content: `Failed to amend session ${sessionId}. Session may not be running.`, is_error: true };
551
+ }
552
+
553
+ /**
554
+ * abort_session: Abort a running session immediately (SV-7)
555
+ */
556
+ function abortSession(
557
+ id: string,
558
+ input: Record<string, unknown>,
559
+ onAbort?: (sessionId: string) => boolean,
560
+ ): ToolResult {
561
+ const sessionId = String(input.sessionId ?? '');
562
+ const reason = String(input.reason ?? 'Aborted by supervisor');
563
+
564
+ if (!sessionId) {
565
+ return { tool_use_id: id, content: 'Error: sessionId is required', is_error: true };
566
+ }
567
+
568
+ if (!onAbort) {
569
+ return { tool_use_id: id, content: 'Error: abort_session not available in this context', is_error: true };
570
+ }
571
+
572
+ const success = onAbort(sessionId);
573
+ if (success) {
574
+ return { tool_use_id: id, content: `Session ${sessionId} aborted. Reason: ${reason}` };
575
+ }
576
+ return { tool_use_id: id, content: `Failed to abort session ${sessionId}. Session may not be running.`, is_error: true };
577
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Supervision API — Long-poll watch + peer session discovery (SV-13)
3
+ *
4
+ * GET /api/supervision/watch?sessions=ses-001,ses-002&duration=120&alertOn=msg:done
5
+ * → Long-poll: blocks for duration seconds → returns JSON digest
6
+ *
7
+ * GET /api/supervision/peers?waveId=xxx&roleId=cto
8
+ * → Returns peer C-Level sessions in the same wave
9
+ */
10
+ import { Router, type Request, type Response } from 'express';
11
+ import { ActivityStream } from '../services/activity-stream.js';
12
+ import { digest, quietDigest } from '../services/digest-engine.js';
13
+ import { executionManager } from '../services/execution-manager.js';
14
+ import type { ActivityEvent } from '../../../shared/types.js';
15
+
16
+ export const supervisionRouter = Router();
17
+
18
+ /* ─── GET /watch — Long-poll supervision digest ─── */
19
+
20
+ supervisionRouter.get('/watch', async (req: Request, res: Response) => {
21
+ const sessionsParam = req.query.sessions as string | undefined;
22
+ if (!sessionsParam) {
23
+ res.status(400).json({ error: 'sessions query parameter is required (comma-separated session IDs)' });
24
+ return;
25
+ }
26
+
27
+ const sessionIds = sessionsParam.split(',').filter(Boolean);
28
+ if (sessionIds.length === 0) {
29
+ res.status(400).json({ error: 'At least one session ID is required' });
30
+ return;
31
+ }
32
+
33
+ const durationSec = Math.min(Math.max(Number(req.query.duration) || 120, 5), 300);
34
+ const alertOnParam = req.query.alertOn as string | undefined;
35
+ const alertOn = alertOnParam ? alertOnParam.split(',') : ['msg:done', 'msg:error'];
36
+ const alertSet = new Set(alertOn);
37
+
38
+ // Record start checkpoints
39
+ const startCheckpoints = new Map<string, number>();
40
+ for (const sid of sessionIds) {
41
+ const events = ActivityStream.readAll(sid);
42
+ startCheckpoints.set(sid, events.length > 0 ? events[events.length - 1].seq + 1 : 0);
43
+ }
44
+
45
+ // Set up event collection
46
+ const collectedEvents = new Map<string, ActivityEvent[]>();
47
+ for (const sid of sessionIds) {
48
+ collectedEvents.set(sid, []);
49
+ }
50
+
51
+ let earlyReturn = false;
52
+ const unsubscribers: Array<() => void> = [];
53
+
54
+ for (const sid of sessionIds) {
55
+ const stream = ActivityStream.getOrCreate(sid, 'unknown');
56
+ const handler = (event: ActivityEvent) => {
57
+ const events = collectedEvents.get(sid);
58
+ if (events) events.push(event);
59
+ if (alertSet.has(event.type)) {
60
+ earlyReturn = true;
61
+ }
62
+ };
63
+ stream.subscribe(handler);
64
+ unsubscribers.push(() => stream.unsubscribe(handler));
65
+ }
66
+
67
+ // Wait for duration or early return
68
+ await new Promise<void>((resolve) => {
69
+ const timeout = setTimeout(resolve, durationSec * 1000);
70
+ const checkInterval = setInterval(() => {
71
+ if (earlyReturn) {
72
+ clearTimeout(timeout);
73
+ clearInterval(checkInterval);
74
+ resolve();
75
+ }
76
+ }, 500);
77
+ setTimeout(() => { clearInterval(checkInterval); }, durationSec * 1000 + 100);
78
+ });
79
+
80
+ // Unsubscribe all
81
+ for (const unsub of unsubscribers) unsub();
82
+
83
+ // Fallback: read from JSONL for sessions with no live events
84
+ for (const sid of sessionIds) {
85
+ const fromSeq = startCheckpoints.get(sid) ?? 0;
86
+ const liveEvents = collectedEvents.get(sid) ?? [];
87
+ if (liveEvents.length === 0) {
88
+ const fileEvents = ActivityStream.readFrom(sid, fromSeq);
89
+ collectedEvents.set(sid, fileEvents);
90
+ }
91
+ }
92
+
93
+ const result = digest(collectedEvents);
94
+
95
+ res.json({
96
+ text: result.text,
97
+ significanceScore: result.significanceScore,
98
+ anomalies: result.anomalies,
99
+ checkpoints: Object.fromEntries(result.checkpoints),
100
+ eventCount: result.eventCount,
101
+ errorCount: result.errorCount,
102
+ earlyReturn,
103
+ });
104
+ });
105
+
106
+ /* ─── GET /peers — Peer C-Level session discovery ─── */
107
+
108
+ supervisionRouter.get('/peers', (req: Request, res: Response) => {
109
+ const waveId = req.query.waveId as string | undefined;
110
+ const roleId = req.query.roleId as string | undefined;
111
+
112
+ if (!waveId || !roleId) {
113
+ res.status(400).json({ error: 'waveId and roleId are required' });
114
+ return;
115
+ }
116
+
117
+ // Find all active executions in the same wave that are C-Level
118
+ const allExecs = executionManager.listExecutions({ active: true });
119
+ const peers = allExecs.filter(exec => {
120
+ if (exec.roleId === roleId) return false; // Exclude self
121
+ // Check if this execution belongs to the same wave
122
+ // Wave membership is tracked via session store
123
+ return true; // For now, return all active C-Level sessions
124
+ });
125
+
126
+ res.json({
127
+ waveId,
128
+ roleId,
129
+ peers: peers.map(p => ({
130
+ sessionId: p.id,
131
+ roleId: p.roleId,
132
+ task: p.task.slice(0, 200),
133
+ status: p.status,
134
+ })),
135
+ });
136
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * DigestEngine — Server-side JSONL event summarizer for C-Level supervision.
3
+ *
4
+ * Pure TypeScript, zero LLM calls ($0 cost).
5
+ * Classifies activity events by significance tier, detects anomalies,
6
+ * and produces a concise digest for C-Level consumption.
7
+ *
8
+ * SV-2: Core supervision service
9
+ */
10
+ import type { ActivityEvent, ActivityEventType } from '../../../shared/types.js';
11
+
12
+ /* ─── Types ──────────────────────────────────── */
13
+
14
+ export interface Anomaly {
15
+ type: 'error' | 'stall' | 'scope_creep' | 'awaiting_input' | 'budget_warning';
16
+ sessionId: string;
17
+ message: string;
18
+ severity: number; // 0-10
19
+ }
20
+
21
+ export interface DigestResult {
22
+ text: string; // C-Level readable summary (<500 tokens quiet, <2000 active)
23
+ significanceScore: number; // 0-10
24
+ anomalies: Anomaly[];
25
+ checkpoints: Map<string, number>; // sessionId → lastSeq
26
+ peerActivity?: string; // peer C-Level activity summary
27
+ eventCount: number;
28
+ errorCount: number;
29
+ }
30
+
31
+ /* ─── Event Classification ───────────────────── */
32
+
33
+ type EventTier = 'critical' | 'high' | 'medium' | 'low';
34
+
35
+ const EVENT_TIER_MAP: Partial<Record<ActivityEventType, EventTier>> = {
36
+ 'msg:error': 'critical',
37
+ 'msg:awaiting_input': 'critical',
38
+ 'dispatch:start': 'high',
39
+ 'dispatch:done': 'high',
40
+ 'msg:done': 'high',
41
+ 'msg:start': 'high',
42
+ 'thinking': 'medium',
43
+ 'text': 'low',
44
+ 'stderr': 'medium',
45
+ 'turn:complete': 'low',
46
+ 'turn:warning': 'high',
47
+ 'turn:limit': 'critical',
48
+ };
49
+
50
+ const TIER_WEIGHT: Record<EventTier, number> = {
51
+ critical: 10,
52
+ high: 5,
53
+ medium: 2,
54
+ low: 0,
55
+ };
56
+
57
+ function classifyEvent(event: ActivityEvent): EventTier {
58
+ // tool:start classification depends on tool name
59
+ if (event.type === 'tool:start') {
60
+ const toolName = (event.data?.name as string) ?? '';
61
+ const highTools = ['write_file', 'edit_file', 'bash_execute', 'dispatch', 'consult'];
62
+ if (highTools.includes(toolName)) return 'high';
63
+ return 'medium';
64
+ }
65
+
66
+ if (event.type === 'tool:result') {
67
+ const isError = event.data?.is_error === true;
68
+ return isError ? 'high' : 'low';
69
+ }
70
+
71
+ return EVENT_TIER_MAP[event.type] ?? 'low';
72
+ }
73
+
74
+ /* ─── Anomaly Detection ──────────────────────── */
75
+
76
+ interface SessionState {
77
+ sessionId: string;
78
+ roleId: string;
79
+ lastEventTs: number;
80
+ eventCount: number;
81
+ errorCount: number;
82
+ isDone: boolean;
83
+ isError: boolean;
84
+ isAwaitingInput: boolean;
85
+ toolCalls: string[]; // tool names used
86
+ filesModified: string[]; // file paths modified
87
+ }
88
+
89
+ function detectAnomalies(
90
+ sessionStates: Map<string, SessionState>,
91
+ now: number,
92
+ ): Anomaly[] {
93
+ const anomalies: Anomaly[] = [];
94
+
95
+ for (const [sessionId, state] of sessionStates) {
96
+ // Stall detection: 3+ minutes without events
97
+ if (!state.isDone && !state.isError && !state.isAwaitingInput) {
98
+ const silenceMs = now - state.lastEventTs;
99
+ if (silenceMs > 3 * 60 * 1000) {
100
+ anomalies.push({
101
+ type: 'stall',
102
+ sessionId,
103
+ message: `Session ${sessionId} (${state.roleId}): No events for ${Math.round(silenceMs / 60000)}min`,
104
+ severity: 7,
105
+ });
106
+ }
107
+ }
108
+
109
+ // Error detection
110
+ if (state.isError) {
111
+ anomalies.push({
112
+ type: 'error',
113
+ sessionId,
114
+ message: `Session ${sessionId} (${state.roleId}): Ended with error`,
115
+ severity: 10,
116
+ });
117
+ }
118
+
119
+ // Awaiting input
120
+ if (state.isAwaitingInput) {
121
+ anomalies.push({
122
+ type: 'awaiting_input',
123
+ sessionId,
124
+ message: `Session ${sessionId} (${state.roleId}): Awaiting input`,
125
+ severity: 8,
126
+ });
127
+ }
128
+ }
129
+
130
+ return anomalies;
131
+ }
132
+
133
+ /* ─── Digest Builder ─────────────────────────── */
134
+
135
+ function buildDigestText(
136
+ sessionStates: Map<string, SessionState>,
137
+ eventsBySession: Map<string, ActivityEvent[]>,
138
+ anomalies: Anomaly[],
139
+ significanceScore: number,
140
+ ): string {
141
+ const parts: string[] = [];
142
+
143
+ // Header
144
+ const totalEvents = Array.from(eventsBySession.values()).reduce((sum, evts) => sum + evts.length, 0);
145
+ const totalErrors = Array.from(sessionStates.values()).reduce((sum, s) => sum + s.errorCount, 0);
146
+ const activeSessions = Array.from(sessionStates.values()).filter(s => !s.isDone && !s.isError).length;
147
+ const doneSessions = Array.from(sessionStates.values()).filter(s => s.isDone).length;
148
+
149
+ parts.push(`## Supervision Digest [score: ${significanceScore}/10]`);
150
+ parts.push(`Sessions: ${activeSessions} active, ${doneSessions} done | Events: ${totalEvents} | Errors: ${totalErrors}`);
151
+ parts.push('');
152
+
153
+ // Anomalies first (most important)
154
+ if (anomalies.length > 0) {
155
+ parts.push('### ⚠️ Anomalies');
156
+ for (const a of anomalies) {
157
+ const icon = a.type === 'error' ? '🔴' : a.type === 'stall' ? '🟡' : a.type === 'awaiting_input' ? '🟠' : '⚪';
158
+ parts.push(`- ${icon} **${a.type}**: ${a.message}`);
159
+ }
160
+ parts.push('');
161
+ }
162
+
163
+ // Per-session summary
164
+ parts.push('### Session Activity');
165
+ for (const [sessionId, state] of sessionStates) {
166
+ const events = eventsBySession.get(sessionId) ?? [];
167
+ const status = state.isDone ? '✅ Done' : state.isError ? '❌ Error' : state.isAwaitingInput ? '🟠 Awaiting' : '🔵 Active';
168
+
169
+ parts.push(`**[${state.roleId}]** ${sessionId} — ${status} (${events.length} events)`);
170
+
171
+ // Highlight significant events
172
+ const significant = events.filter(e => {
173
+ const tier = classifyEvent(e);
174
+ return tier === 'critical' || tier === 'high';
175
+ });
176
+
177
+ for (const evt of significant.slice(-5)) { // Last 5 significant events
178
+ const summary = summarizeEvent(evt);
179
+ if (summary) parts.push(` - ${summary}`);
180
+ }
181
+ }
182
+
183
+ return parts.join('\n');
184
+ }
185
+
186
+ function summarizeEvent(event: ActivityEvent): string | null {
187
+ switch (event.type) {
188
+ case 'msg:start':
189
+ return `Started: ${(event.data?.task as string ?? '').slice(0, 80)}`;
190
+ case 'msg:done':
191
+ return `Completed (${event.data?.turns ?? '?'} turns)`;
192
+ case 'msg:error':
193
+ return `Error: ${(event.data?.message as string ?? 'unknown').slice(0, 100)}`;
194
+ case 'msg:awaiting_input':
195
+ return `Awaiting input: ${(event.data?.question as string ?? '').slice(0, 80)}`;
196
+ case 'dispatch:start':
197
+ return `Dispatched → ${event.data?.targetRoleId}: ${(event.data?.task as string ?? '').slice(0, 60)}`;
198
+ case 'dispatch:done':
199
+ return `Dispatch completed: ${event.data?.targetRoleId}`;
200
+ case 'tool:start': {
201
+ const toolName = event.data?.name as string ?? 'unknown';
202
+ const input = event.data?.input as Record<string, unknown> | undefined;
203
+ if (toolName === 'write_file' || toolName === 'edit_file') {
204
+ return `${toolName}: ${(input?.path as string ?? '').slice(0, 60)}`;
205
+ }
206
+ if (toolName === 'bash_execute') {
207
+ return `bash: ${(input?.command as string ?? '').slice(0, 60)}`;
208
+ }
209
+ return null; // Skip read-only tools in summary
210
+ }
211
+ case 'turn:warning':
212
+ return `⚠️ Turn limit warning (${event.data?.turn}/${event.data?.hardLimit})`;
213
+ case 'turn:limit':
214
+ return `🔴 Turn limit reached (${event.data?.turn})`;
215
+ default:
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /* ─── Public API ─────────────────────────────── */
221
+
222
+ /**
223
+ * Digest a set of events from multiple sessions.
224
+ *
225
+ * @param eventsBySession - Map of sessionId → events collected during the watch period
226
+ * @param peerEvents - Optional events from peer C-Level sessions
227
+ */
228
+ export function digest(
229
+ eventsBySession: Map<string, ActivityEvent[]>,
230
+ peerEvents?: Map<string, ActivityEvent[]>,
231
+ ): DigestResult {
232
+ const now = Date.now();
233
+ const sessionStates = new Map<string, SessionState>();
234
+ const checkpoints = new Map<string, number>();
235
+
236
+ // Build session states
237
+ for (const [sessionId, events] of eventsBySession) {
238
+ if (events.length === 0) continue;
239
+
240
+ const state: SessionState = {
241
+ sessionId,
242
+ roleId: events[0].roleId,
243
+ lastEventTs: new Date(events[events.length - 1].ts).getTime(),
244
+ eventCount: events.length,
245
+ errorCount: events.filter(e => e.type === 'msg:error' || (e.type === 'tool:result' && e.data?.is_error)).length,
246
+ isDone: events.some(e => e.type === 'msg:done'),
247
+ isError: events.some(e => e.type === 'msg:error'),
248
+ isAwaitingInput: events.some(e => e.type === 'msg:awaiting_input') && !events.some(e => e.type === 'msg:done'),
249
+ toolCalls: events.filter(e => e.type === 'tool:start').map(e => e.data?.name as string).filter(Boolean),
250
+ filesModified: events
251
+ .filter(e => e.type === 'tool:start' && ['write_file', 'edit_file'].includes(e.data?.name as string))
252
+ .map(e => (e.data?.input as Record<string, unknown>)?.path as string)
253
+ .filter(Boolean),
254
+ };
255
+ sessionStates.set(sessionId, state);
256
+ checkpoints.set(sessionId, events[events.length - 1].seq);
257
+ }
258
+
259
+ // Calculate significance score
260
+ let maxWeight = 0;
261
+ for (const events of eventsBySession.values()) {
262
+ for (const event of events) {
263
+ const tier = classifyEvent(event);
264
+ const weight = TIER_WEIGHT[tier];
265
+ if (weight > maxWeight) maxWeight = weight;
266
+ }
267
+ }
268
+
269
+ const anomalies = detectAnomalies(sessionStates, now);
270
+ const anomalyBoost = anomalies.length > 0 ? Math.min(anomalies.reduce((sum, a) => sum + a.severity, 0), 10) : 0;
271
+ const significanceScore = Math.min(10, Math.max(maxWeight, anomalyBoost));
272
+
273
+ // Build digest text
274
+ const text = buildDigestText(sessionStates, eventsBySession, anomalies, significanceScore);
275
+
276
+ // Peer activity digest
277
+ let peerActivity: string | undefined;
278
+ if (peerEvents && peerEvents.size > 0) {
279
+ const peerLines: string[] = ['## Peer Activity'];
280
+ for (const [sessionId, events] of peerEvents) {
281
+ if (events.length === 0) continue;
282
+ const roleId = events[0].roleId;
283
+ const significant = events.filter(e => {
284
+ const tier = classifyEvent(e);
285
+ return tier === 'critical' || tier === 'high';
286
+ });
287
+ for (const evt of significant.slice(-3)) {
288
+ const summary = summarizeEvent(evt);
289
+ if (summary) peerLines.push(`[${roleId}] ${summary}`);
290
+ }
291
+ }
292
+ if (peerLines.length > 1) {
293
+ peerActivity = peerLines.join('\n');
294
+ }
295
+ }
296
+
297
+ return {
298
+ text: peerActivity ? `${text}\n\n${peerActivity}` : text,
299
+ significanceScore,
300
+ anomalies,
301
+ checkpoints,
302
+ peerActivity,
303
+ eventCount: Array.from(eventsBySession.values()).reduce((sum, evts) => sum + evts.length, 0),
304
+ errorCount: Array.from(sessionStates.values()).reduce((sum, s) => sum + s.errorCount, 0),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Generate a quiet tick summary (for significanceScore < 2 && no anomalies)
310
+ */
311
+ export function quietDigest(sessionCount: number, eventCount: number, errorCount: number): string {
312
+ return `All ${sessionCount} sessions progressing normally. No anomalies. [${eventCount} events, ${errorCount} errors]`;
313
+ }
@@ -249,6 +249,12 @@ class ExecutionManager {
249
249
  ...process.env,
250
250
  ...portEnv,
251
251
  },
252
+ // SV-6, SV-7: Supervision callbacks (direct-api runner only)
253
+ onAbortSession: (sessionId: string) => this.abortSession(sessionId),
254
+ onAmendSession: (sessionId: string, instruction: string) => {
255
+ const result = this.continueSession(sessionId, `[SUPERVISION AMENDMENT] ${instruction}`, params.roleId);
256
+ return result !== null;
257
+ },
252
258
  },
253
259
  {
254
260
  onText: (text) => {
@@ -82,6 +82,8 @@ export type ActivityEventType =
82
82
  | 'import:scan' | 'import:process' | 'import:created'
83
83
  // Trace (full prompt/response capture for AI debugging)
84
84
  | 'trace:prompt' | 'trace:response'
85
+ // Supervision (C-Level heartbeat)
86
+ | 'heartbeat:tick' | 'heartbeat:skip'
85
87
  // Other
86
88
  | 'stderr';
87
89