multiagents 0.1.0

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.
@@ -0,0 +1,285 @@
1
+ // ============================================================================
2
+ // multiagents — Process Monitor
3
+ // ============================================================================
4
+ // Monitors agent process lifecycle: stdout parsing, status updates, crash
5
+ // detection.
6
+ // ============================================================================
7
+
8
+ import type { Subprocess } from "bun";
9
+ import type { BrokerClient } from "../shared/broker-client.ts";
10
+ import { log } from "../shared/utils.ts";
11
+
12
+ const LOG_PREFIX = "monitor";
13
+
14
+ // Track last-seen cumulative tokens per slot (Codex sends cumulative totals)
15
+ const lastTokenTotals = new Map<number, { input: number; output: number; cacheRead: number }>();
16
+
17
+ /** Update token usage for a slot — handles both delta and cumulative formats. */
18
+ async function updateTokenUsage(
19
+ slotId: number,
20
+ tokens: { input: number; output: number; cacheRead: number },
21
+ brokerClient: BrokerClient,
22
+ ): Promise<void> {
23
+ // Codex sends cumulative totals; Claude sends per-result totals.
24
+ // For Codex, we compute the delta from the last seen value.
25
+ // For Claude, each "result" contains the full session usage, so we also delta.
26
+ const last = lastTokenTotals.get(slotId) ?? { input: 0, output: 0, cacheRead: 0 };
27
+ const deltaInput = Math.max(0, tokens.input - last.input);
28
+ const deltaOutput = Math.max(0, tokens.output - last.output);
29
+ const deltaCacheRead = Math.max(0, tokens.cacheRead - last.cacheRead);
30
+
31
+ lastTokenTotals.set(slotId, tokens);
32
+
33
+ if (deltaInput === 0 && deltaOutput === 0 && deltaCacheRead === 0) return;
34
+
35
+ try {
36
+ await brokerClient.updateSlot({
37
+ id: slotId,
38
+ input_tokens: deltaInput,
39
+ output_tokens: deltaOutput,
40
+ cache_read_tokens: deltaCacheRead,
41
+ });
42
+ } catch {
43
+ // Best-effort token tracking
44
+ }
45
+ }
46
+
47
+ /** Event emitted by the process monitor. */
48
+ export interface AgentEvent {
49
+ type: string;
50
+ severity: "info" | "warning" | "critical";
51
+ slotId: number;
52
+ sessionId: string;
53
+ message: string;
54
+ data?: Record<string, unknown>;
55
+ }
56
+
57
+ /**
58
+ * Monitor an agent subprocess: read stdout for progress signals,
59
+ * update slot status in the broker, and fire events on exit.
60
+ *
61
+ * This function is non-blocking — it starts async readers and returns
62
+ * immediately.
63
+ */
64
+ export function monitorProcess(
65
+ proc: Subprocess,
66
+ slotId: number,
67
+ sessionId: string,
68
+ brokerClient: BrokerClient,
69
+ onEvent: (event: AgentEvent) => void,
70
+ ): void {
71
+ // Read stdout for JSON progress lines
72
+ if (proc.stdout) {
73
+ readStream(proc.stdout, slotId, sessionId, brokerClient, onEvent);
74
+ }
75
+
76
+ // Read stderr for error output
77
+ if (proc.stderr) {
78
+ readStderr(proc.stderr, slotId, sessionId, onEvent);
79
+ }
80
+
81
+ // Monitor process exit
82
+ proc.exited.then((exitCode) => {
83
+ handleExit(exitCode, slotId, sessionId, brokerClient, onEvent);
84
+ });
85
+ }
86
+
87
+ /** Read stdout stream, parse JSON lines for progress signals. */
88
+ async function readStream(
89
+ stdout: ReadableStream<Uint8Array>,
90
+ slotId: number,
91
+ sessionId: string,
92
+ brokerClient: BrokerClient,
93
+ onEvent: (event: AgentEvent) => void,
94
+ ): Promise<void> {
95
+ const decoder = new TextDecoder();
96
+ let buffer = "";
97
+
98
+ try {
99
+ const reader = stdout.getReader();
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done) break;
103
+
104
+ buffer += decoder.decode(value, { stream: true });
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop() ?? "";
107
+
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) continue;
111
+ await processLine(trimmed, slotId, sessionId, brokerClient, onEvent);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ log(LOG_PREFIX, `stdout reader error for slot ${slotId}: ${err}`);
116
+ }
117
+ }
118
+
119
+ /** Process a single stdout line, attempting JSON parse for structured signals. */
120
+ async function processLine(
121
+ line: string,
122
+ slotId: number,
123
+ sessionId: string,
124
+ brokerClient: BrokerClient,
125
+ onEvent: (event: AgentEvent) => void,
126
+ ): Promise<void> {
127
+ // Try parsing as JSON (Claude stream-json format)
128
+ try {
129
+ const parsed = JSON.parse(line);
130
+
131
+ // Claude stream-json result message (includes final token usage)
132
+ if (parsed.type === "result" && parsed.result) {
133
+ onEvent({
134
+ type: "agent_output",
135
+ severity: "info",
136
+ slotId,
137
+ sessionId,
138
+ message: `Agent produced result`,
139
+ data: { result: parsed.result },
140
+ });
141
+
142
+ // Extract token usage from Claude result
143
+ const usage = parsed.result?.usage;
144
+ if (usage) {
145
+ await updateTokenUsage(slotId, {
146
+ input: usage.input_tokens ?? 0,
147
+ output: usage.output_tokens ?? 0,
148
+ cacheRead: usage.cache_read_input_tokens ?? usage.cache_creation_input_tokens ?? 0,
149
+ }, brokerClient);
150
+ }
151
+ }
152
+
153
+ // Codex token_count message
154
+ if (parsed.msg?.type === "token_count" && parsed.msg?.info?.total_token_usage) {
155
+ const tu = parsed.msg.info.total_token_usage;
156
+ await updateTokenUsage(slotId, {
157
+ input: tu.input_tokens ?? 0,
158
+ output: tu.output_tokens ?? 0,
159
+ cacheRead: tu.cached_input_tokens ?? 0,
160
+ }, brokerClient);
161
+ }
162
+
163
+ // Claude stream-json assistant message with content
164
+ if (parsed.type === "assistant" && parsed.message?.content) {
165
+ // Update the slot's context snapshot with latest output
166
+ try {
167
+ const contentText = Array.isArray(parsed.message.content)
168
+ ? parsed.message.content
169
+ .filter((c: any) => c.type === "text")
170
+ .map((c: any) => c.text)
171
+ .join("")
172
+ : String(parsed.message.content);
173
+
174
+ const summary = contentText.slice(0, 200);
175
+ await brokerClient.updateSlot({
176
+ id: slotId,
177
+ context_snapshot: JSON.stringify({
178
+ last_summary: summary,
179
+ last_status: "working",
180
+ updated_at: Date.now(),
181
+ }),
182
+ });
183
+ } catch {
184
+ // Best-effort snapshot update
185
+ }
186
+ }
187
+
188
+ // Tool use signals progress
189
+ if (parsed.type === "assistant" && parsed.message?.stop_reason === "tool_use") {
190
+ onEvent({
191
+ type: "agent_progress",
192
+ severity: "info",
193
+ slotId,
194
+ sessionId,
195
+ message: "Agent is using tools",
196
+ });
197
+ }
198
+
199
+ return;
200
+ } catch {
201
+ // Not JSON — treat as plain text output, ignore
202
+ }
203
+ }
204
+
205
+ /** Read stderr for error messages. */
206
+ async function readStderr(
207
+ stderr: ReadableStream<Uint8Array>,
208
+ slotId: number,
209
+ sessionId: string,
210
+ onEvent: (event: AgentEvent) => void,
211
+ ): Promise<void> {
212
+ const decoder = new TextDecoder();
213
+ let buffer = "";
214
+
215
+ try {
216
+ const reader = stderr.getReader();
217
+ while (true) {
218
+ const { done, value } = await reader.read();
219
+ if (done) break;
220
+
221
+ buffer += decoder.decode(value, { stream: true });
222
+ const lines = buffer.split("\n");
223
+ buffer = lines.pop() ?? "";
224
+
225
+ for (const line of lines) {
226
+ const trimmed = line.trim();
227
+ if (!trimmed) continue;
228
+
229
+ // Check for error patterns
230
+ if (/error|fatal|panic|exception/i.test(trimmed)) {
231
+ onEvent({
232
+ type: "agent_error",
233
+ severity: "warning",
234
+ slotId,
235
+ sessionId,
236
+ message: `Agent stderr: ${trimmed.slice(0, 200)}`,
237
+ });
238
+ }
239
+ }
240
+ }
241
+ } catch (err) {
242
+ log(LOG_PREFIX, `stderr reader error for slot ${slotId}: ${err}`);
243
+ }
244
+ }
245
+
246
+ /** Handle process exit — update slot and emit event. */
247
+ async function handleExit(
248
+ exitCode: number,
249
+ slotId: number,
250
+ sessionId: string,
251
+ brokerClient: BrokerClient,
252
+ onEvent: (event: AgentEvent) => void,
253
+ ): Promise<void> {
254
+ // Update slot status to disconnected
255
+ try {
256
+ await brokerClient.updateSlot({
257
+ id: slotId,
258
+ status: "disconnected",
259
+ });
260
+ } catch (err) {
261
+ log(LOG_PREFIX, `Failed to update slot ${slotId} on exit: ${err}`);
262
+ }
263
+
264
+ if (exitCode === 0) {
265
+ onEvent({
266
+ type: "agent_completed",
267
+ severity: "info",
268
+ slotId,
269
+ sessionId,
270
+ message: `Agent in slot ${slotId} completed successfully`,
271
+ data: { exit_code: exitCode },
272
+ });
273
+ } else {
274
+ onEvent({
275
+ type: "agent_crashed",
276
+ severity: "critical",
277
+ slotId,
278
+ sessionId,
279
+ message: `Agent in slot ${slotId} exited with code ${exitCode}`,
280
+ data: { exit_code: exitCode },
281
+ });
282
+ }
283
+
284
+ log(LOG_PREFIX, `Slot ${slotId} process exited with code ${exitCode}`);
285
+ }