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.
- package/package.json +1 -1
- package/src/api/src/create-server.ts +2 -0
- package/src/api/src/engine/agent-loop.ts +52 -9
- package/src/api/src/engine/context-assembler.ts +59 -0
- package/src/api/src/engine/org-tree.ts +17 -0
- package/src/api/src/engine/runners/claude-cli.ts +157 -0
- package/src/api/src/engine/runners/direct-api.ts +2 -0
- package/src/api/src/engine/runners/types.ts +4 -0
- package/src/api/src/engine/tools/definitions.ts +55 -1
- package/src/api/src/engine/tools/executor.ts +163 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +6 -0
- package/src/shared/types.ts +2 -0
- package/src/web/dist/assets/index-CoIZS-E0.css +1 -0
- package/src/web/dist/assets/index-mJNkf7g0.js +115 -0
- package/src/web/dist/assets/{preview-app-CywDONM1.js → preview-app-BHNMBMSl.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-BoJAXuTo.js +0 -115
- package/src/web/dist/assets/index-rya2vj54.css +0 -1
|
@@ -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) => {
|
package/src/shared/types.ts
CHANGED
|
@@ -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
|
|