macroclaw 0.31.0 → 0.33.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.
- package/package.json +1 -1
- package/src/app.test.ts +159 -103
- package/src/app.ts +13 -0
- package/src/claude.integration-test.ts +65 -27
- package/src/claude.test.ts +369 -189
- package/src/claude.ts +171 -71
- package/src/orchestrator.test.ts +301 -249
- package/src/orchestrator.ts +198 -166
- package/src/prompts.test.ts +102 -162
- package/src/prompts.ts +62 -53
package/src/orchestrator.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import {
|
|
3
3
|
Claude,
|
|
4
|
+
type ClaudeProcess,
|
|
4
5
|
QueryParseError,
|
|
5
6
|
QueryProcessError,
|
|
7
|
+
type QueryResult,
|
|
6
8
|
QueryValidationError,
|
|
7
|
-
type RunningQuery
|
|
8
9
|
} from "./claude";
|
|
9
10
|
import { writeHistoryPrompt, writeHistoryResult } from "./history";
|
|
10
11
|
import { createLogger } from "./logger";
|
|
11
12
|
import { generateName } from "./naming";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
backgroundAgentProgressEvent,
|
|
15
|
+
backgroundAgentResultEvent,
|
|
16
|
+
backgroundAgentStartEvent,
|
|
17
|
+
buttonClickEvent,
|
|
18
|
+
healthCheckEvent,
|
|
19
|
+
peekEvent,
|
|
20
|
+
SYSTEM_PROMPT,
|
|
21
|
+
scheduleTriggerEvent,
|
|
22
|
+
userMessageEvent,
|
|
23
|
+
} from "./prompts";
|
|
13
24
|
import { Queue } from "./queue";
|
|
14
25
|
import { loadSessions, saveSessions } from "./sessions";
|
|
15
26
|
|
|
@@ -82,7 +93,8 @@ interface SessionInfo {
|
|
|
82
93
|
name: string;
|
|
83
94
|
prompt: string;
|
|
84
95
|
model?: string;
|
|
85
|
-
|
|
96
|
+
process: ClaudeProcess<AgentOutput>;
|
|
97
|
+
pendingResult: Promise<QueryResult<AgentOutput>>;
|
|
86
98
|
lastMessageAt: Date;
|
|
87
99
|
healthCheckTimer?: Timer;
|
|
88
100
|
}
|
|
@@ -109,6 +121,7 @@ export class Orchestrator {
|
|
|
109
121
|
#healthCheckTimeout: number;
|
|
110
122
|
|
|
111
123
|
#mainSessionId: string | undefined;
|
|
124
|
+
#mainProcess: ClaudeProcess<AgentOutput> | null = null;
|
|
112
125
|
#runningSessions = new Map<string, SessionInfo>();
|
|
113
126
|
#queue: Queue<OrchestratorRequest>;
|
|
114
127
|
|
|
@@ -136,13 +149,11 @@ export class Orchestrator {
|
|
|
136
149
|
|
|
137
150
|
handleCron(name: string, prompt: string, model?: string, missed?: { missedBy: string; scheduledAt: string }): void {
|
|
138
151
|
const cronName = `cron-${name}`;
|
|
139
|
-
const formatted =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
text: prompt,
|
|
145
|
-
});
|
|
152
|
+
const formatted = scheduleTriggerEvent(
|
|
153
|
+
cronName,
|
|
154
|
+
{ name, missedBy: missed?.missedBy, scheduledAt: missed?.scheduledAt },
|
|
155
|
+
prompt,
|
|
156
|
+
);
|
|
146
157
|
this.#spawnBackgroundRaw(cronName, prompt, formatted, model ?? this.#config.model);
|
|
147
158
|
}
|
|
148
159
|
|
|
@@ -159,14 +170,14 @@ export class Orchestrator {
|
|
|
159
170
|
return;
|
|
160
171
|
}
|
|
161
172
|
const lines = sessions.map(([sid, s]) => {
|
|
162
|
-
const elapsed = Math.round((Date.now() - s.
|
|
173
|
+
const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
|
|
163
174
|
const isMain = sid === this.#mainSessionId;
|
|
164
175
|
return isMain
|
|
165
176
|
? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
|
|
166
177
|
: `- ${escapeHtml(s.name)} (${elapsed}s)`;
|
|
167
178
|
});
|
|
168
179
|
const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
|
|
169
|
-
const elapsed = Math.round((Date.now() - s.
|
|
180
|
+
const elapsed = Math.round((Date.now() - s.process.startedAt.getTime()) / 1000);
|
|
170
181
|
const text = `${s.name} (${elapsed}s)`.slice(0, 27);
|
|
171
182
|
return { text, data: `detail:${sid}` };
|
|
172
183
|
});
|
|
@@ -181,7 +192,7 @@ export class Orchestrator {
|
|
|
181
192
|
return;
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
const elapsed = Math.round((Date.now() - session.
|
|
195
|
+
const elapsed = Math.round((Date.now() - session.process.startedAt.getTime()) / 1000);
|
|
185
196
|
const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
|
|
186
197
|
const isMain = sessionId === this.#mainSessionId;
|
|
187
198
|
const lines = [
|
|
@@ -209,20 +220,18 @@ export class Orchestrator {
|
|
|
209
220
|
this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
|
|
210
221
|
|
|
211
222
|
try {
|
|
212
|
-
const prompt =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
session:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
});
|
|
219
|
-
const query = this.#claude.forkSession(
|
|
223
|
+
const prompt = peekEvent(
|
|
224
|
+
`peek-${session.name}`,
|
|
225
|
+
session.name,
|
|
226
|
+
`Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
|
|
227
|
+
);
|
|
228
|
+
const peekProcess = this.#claude.forkSession(
|
|
220
229
|
sessionId,
|
|
221
|
-
prompt,
|
|
222
230
|
textResultType,
|
|
223
231
|
{ model: "haiku" },
|
|
224
232
|
);
|
|
225
|
-
const { value } = await
|
|
233
|
+
const { value } = await peekProcess.send(prompt);
|
|
234
|
+
peekProcess.kill().catch(() => {});
|
|
226
235
|
this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
|
|
227
236
|
} catch (err) {
|
|
228
237
|
this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
|
|
@@ -239,7 +248,7 @@ export class Orchestrator {
|
|
|
239
248
|
this.#clearSession(sessionId);
|
|
240
249
|
|
|
241
250
|
try {
|
|
242
|
-
await session.
|
|
251
|
+
await session.process.kill();
|
|
243
252
|
} catch (err) {
|
|
244
253
|
log.error({ err, name: session.name }, "Kill failed");
|
|
245
254
|
}
|
|
@@ -247,102 +256,127 @@ export class Orchestrator {
|
|
|
247
256
|
this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
|
|
248
257
|
}
|
|
249
258
|
|
|
259
|
+
async handleClear(): Promise<void> {
|
|
260
|
+
const sid = this.#mainProcess?.sessionId;
|
|
261
|
+
if (sid) {
|
|
262
|
+
this.#clearSession(sid);
|
|
263
|
+
}
|
|
264
|
+
if (this.#mainProcess) {
|
|
265
|
+
try {
|
|
266
|
+
await this.#mainProcess.kill();
|
|
267
|
+
} catch (err) {
|
|
268
|
+
log.error({ err }, "Failed to kill main process during clear");
|
|
269
|
+
}
|
|
270
|
+
this.#mainProcess = null;
|
|
271
|
+
}
|
|
272
|
+
this.#mainSessionId = undefined;
|
|
273
|
+
saveSessions({}, this.#config.settingsDir);
|
|
274
|
+
log.info("Session cleared");
|
|
275
|
+
this.#callOnResponse({ message: "Session cleared." });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Tear down all sessions and the main process. Used in tests. */
|
|
279
|
+
async dispose(): Promise<void> {
|
|
280
|
+
for (const sid of [...this.#runningSessions.keys()]) {
|
|
281
|
+
this.#clearSession(sid);
|
|
282
|
+
}
|
|
283
|
+
if (this.#mainProcess) {
|
|
284
|
+
await this.#mainProcess.kill().catch(() => {});
|
|
285
|
+
this.#mainProcess = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- Main process lifecycle ---
|
|
290
|
+
|
|
291
|
+
#ensureMainProcess(): ClaudeProcess<AgentOutput> {
|
|
292
|
+
if (this.#mainProcess && this.#mainProcess.state !== "dead") {
|
|
293
|
+
return this.#mainProcess;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const opts = { model: this.#config.model };
|
|
297
|
+
this.#mainProcess = this.#mainSessionId
|
|
298
|
+
? this.#claude.resumeSession(this.#mainSessionId, responseResultType, opts)
|
|
299
|
+
: this.#claude.newSession(responseResultType, opts);
|
|
300
|
+
|
|
301
|
+
if (this.#mainProcess.sessionId !== this.#mainSessionId) {
|
|
302
|
+
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
303
|
+
saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
log.info({ sessionId: this.#mainSessionId }, "Main process created");
|
|
307
|
+
return this.#mainProcess;
|
|
308
|
+
}
|
|
250
309
|
|
|
251
310
|
// --- Internal queue handler ---
|
|
252
311
|
|
|
253
312
|
async #handleRequest(request: OrchestratorRequest): Promise<void> {
|
|
254
313
|
log.debug({ type: request.type }, "Incoming request");
|
|
255
314
|
|
|
256
|
-
const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
|
|
257
315
|
let movedToBackground: string | undefined;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// Main started recently — wait for it to finish or threshold
|
|
267
|
-
const remaining = this.#waitThreshold - elapsed;
|
|
268
|
-
const finished = await Promise.race([
|
|
269
|
-
mainInfo.query.result.then(() => true as const, () => true as const),
|
|
270
|
-
new Promise<false>((r) => setTimeout(() => r(false), remaining)),
|
|
271
|
-
]);
|
|
272
|
-
|
|
273
|
-
if (!finished) {
|
|
274
|
-
log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (wait timed out)");
|
|
316
|
+
let backgroundedName: string | undefined;
|
|
317
|
+
const currentMain = this.#mainProcess;
|
|
318
|
+
|
|
319
|
+
if (currentMain?.state === "busy") {
|
|
320
|
+
const mainInfo = this.#runningSessions.get(currentMain.sessionId);
|
|
321
|
+
if (mainInfo) {
|
|
322
|
+
const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
|
|
323
|
+
if (elapsed >= this.#waitThreshold) {
|
|
275
324
|
movedToBackground = mainInfo.prompt;
|
|
325
|
+
backgroundedName = mainInfo.name;
|
|
326
|
+
} else {
|
|
327
|
+
const remaining = this.#waitThreshold - elapsed;
|
|
328
|
+
const finished = await Promise.race([
|
|
329
|
+
mainInfo.pendingResult.then(() => true as const, () => true as const),
|
|
330
|
+
new Promise<false>((r) => setTimeout(() => r(false), remaining)),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
if (!finished) {
|
|
334
|
+
movedToBackground = mainInfo.prompt;
|
|
335
|
+
backgroundedName = mainInfo.name;
|
|
336
|
+
}
|
|
276
337
|
}
|
|
277
|
-
// If finished: completion handler already delivered the result and removed from map.
|
|
278
338
|
}
|
|
279
339
|
}
|
|
280
340
|
|
|
341
|
+
if (movedToBackground && currentMain) {
|
|
342
|
+
log.info({ name: backgroundedName, sessionId: currentMain.sessionId }, "Moving main session to background");
|
|
343
|
+
this.#mainProcess = this.#claude.forkSession(
|
|
344
|
+
this.#mainSessionId ?? currentMain.sessionId,
|
|
345
|
+
responseResultType,
|
|
346
|
+
{ model: this.#config.model },
|
|
347
|
+
);
|
|
348
|
+
this.#mainSessionId = this.#mainProcess.sessionId;
|
|
349
|
+
saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
|
|
350
|
+
}
|
|
351
|
+
|
|
281
352
|
await writeHistoryPrompt(request);
|
|
282
353
|
|
|
283
354
|
const label = Orchestrator.#requestLabel(request);
|
|
284
355
|
const name = generateName(label);
|
|
285
|
-
const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
|
|
286
356
|
const formatted = this.#formatPrompt(request, name, backgroundedName);
|
|
287
357
|
|
|
288
|
-
this.#
|
|
358
|
+
this.#sendToMain(name, label, formatted);
|
|
289
359
|
}
|
|
290
360
|
|
|
291
|
-
// ---
|
|
361
|
+
// --- Main session send ---
|
|
292
362
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.#callOnResponse({
|
|
297
|
-
message: response.message || "[No output]",
|
|
298
|
-
files: response.files,
|
|
299
|
-
buttons: response.buttons,
|
|
300
|
-
});
|
|
301
|
-
} else {
|
|
302
|
-
log.debug("Silent response");
|
|
303
|
-
}
|
|
363
|
+
#sendToMain(name: string, displayPrompt: string, formatted: string): void {
|
|
364
|
+
const process = this.#ensureMainProcess();
|
|
365
|
+
const sid = process.sessionId;
|
|
304
366
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
#callOnResponse(response: OrchestratorResponse): void {
|
|
315
|
-
this.#config.onResponse(response).catch((err) => {
|
|
316
|
-
log.error({ err }, "onResponse callback failed");
|
|
367
|
+
const pendingResult = process.send(formatted);
|
|
368
|
+
this.#runningSessions.set(sid, {
|
|
369
|
+
name,
|
|
370
|
+
prompt: displayPrompt,
|
|
371
|
+
model: this.#config.model,
|
|
372
|
+
process,
|
|
373
|
+
pendingResult,
|
|
374
|
+
lastMessageAt: new Date(),
|
|
317
375
|
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// --- Main session query ---
|
|
321
|
-
|
|
322
|
-
#startMainQuery(name: string, displayPrompt: string, formatted: string, model: string | undefined): void {
|
|
323
|
-
const opts = { model };
|
|
324
|
-
let query: RunningQuery<AgentOutput>;
|
|
325
|
-
|
|
326
|
-
if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
|
|
327
|
-
query = this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, opts);
|
|
328
|
-
} else if (this.#mainSessionId) {
|
|
329
|
-
query = this.#claude.resumeSession(this.#mainSessionId, formatted, responseResultType, opts);
|
|
330
|
-
} else {
|
|
331
|
-
query = this.#claude.newSession(formatted, responseResultType, opts);
|
|
332
|
-
}
|
|
333
376
|
|
|
334
|
-
|
|
335
|
-
this.#runningSessions.set(sid, { name, prompt: displayPrompt, model, query, lastMessageAt: new Date() });
|
|
377
|
+
log.debug({ name, sessionId: sid }, "Main query sent");
|
|
336
378
|
|
|
337
|
-
|
|
338
|
-
log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
|
|
339
|
-
this.#mainSessionId = sid;
|
|
340
|
-
saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
log.debug({ name, sessionId: sid }, "Main query started");
|
|
344
|
-
|
|
345
|
-
query.result.then(
|
|
379
|
+
pendingResult.then(
|
|
346
380
|
async ({ value: response }) => {
|
|
347
381
|
if (!this.#runningSessions.has(sid)) {
|
|
348
382
|
log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
|
|
@@ -351,11 +385,12 @@ export class Orchestrator {
|
|
|
351
385
|
}
|
|
352
386
|
this.#clearSession(sid);
|
|
353
387
|
|
|
354
|
-
if (
|
|
388
|
+
if (process === this.#mainProcess) {
|
|
355
389
|
log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
|
|
356
390
|
await this.#deliverResponse(response);
|
|
357
391
|
} else {
|
|
358
392
|
log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
|
|
393
|
+
process.kill().catch(() => {});
|
|
359
394
|
this.#queue.push({ type: "background-agent-result", name, response });
|
|
360
395
|
}
|
|
361
396
|
},
|
|
@@ -371,57 +406,58 @@ export class Orchestrator {
|
|
|
371
406
|
);
|
|
372
407
|
}
|
|
373
408
|
|
|
374
|
-
|
|
375
|
-
|
|
409
|
+
// --- Response delivery ---
|
|
410
|
+
|
|
411
|
+
async #deliverResponse(response: AgentOutput): Promise<void> {
|
|
412
|
+
await writeHistoryResult(response);
|
|
413
|
+
if (response.action === "send") {
|
|
414
|
+
this.#callOnResponse({
|
|
415
|
+
message: response.message || "[No output]",
|
|
416
|
+
files: response.files,
|
|
417
|
+
buttons: response.buttons,
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
log.debug("Silent response");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (response.backgroundAgents?.length) {
|
|
424
|
+
for (const agent of response.backgroundAgents) {
|
|
425
|
+
const agentModel = agent.model ?? this.#config.model;
|
|
426
|
+
this.#spawnBackground(agent.name, agent.prompt, agentModel);
|
|
427
|
+
this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
376
431
|
|
|
432
|
+
#callOnResponse(response: OrchestratorResponse): void {
|
|
433
|
+
this.#config.onResponse(response).catch((err) => {
|
|
434
|
+
log.error({ err }, "onResponse callback failed");
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
|
|
377
439
|
switch (request.type) {
|
|
378
440
|
case "user":
|
|
379
|
-
|
|
380
|
-
name,
|
|
381
|
-
type: "user-message",
|
|
382
|
-
session: "main",
|
|
383
|
-
text: request.message || undefined,
|
|
384
|
-
files: request.files,
|
|
385
|
-
backgroundedEvent,
|
|
386
|
-
};
|
|
387
|
-
break;
|
|
441
|
+
return userMessageEvent(name, request.message || "", { files: request.files, backgroundedEvent });
|
|
388
442
|
case "background-agent-result":
|
|
389
|
-
|
|
443
|
+
return backgroundAgentResultEvent(
|
|
390
444
|
name,
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
files: request.response.files,
|
|
397
|
-
},
|
|
398
|
-
backgroundedEvent,
|
|
399
|
-
instructions: "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
|
|
400
|
-
};
|
|
401
|
-
break;
|
|
445
|
+
request.name,
|
|
446
|
+
{ text: request.response.message || "[No output]", files: request.response.files },
|
|
447
|
+
"Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
|
|
448
|
+
{ backgroundedEvent },
|
|
449
|
+
);
|
|
402
450
|
case "background-agent-progress":
|
|
403
|
-
|
|
451
|
+
return backgroundAgentProgressEvent(
|
|
404
452
|
name,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
backgroundedEvent,
|
|
411
|
-
};
|
|
412
|
-
break;
|
|
453
|
+
request.name,
|
|
454
|
+
request.progress,
|
|
455
|
+
"This is an interim progress update, not a final result. Do not report to the user unless it contains exceptionally important information.",
|
|
456
|
+
{ backgroundedEvent },
|
|
457
|
+
);
|
|
413
458
|
case "button":
|
|
414
|
-
|
|
415
|
-
name,
|
|
416
|
-
type: "button-click",
|
|
417
|
-
session: "main",
|
|
418
|
-
button: request.label,
|
|
419
|
-
backgroundedEvent,
|
|
420
|
-
};
|
|
421
|
-
break;
|
|
459
|
+
return buttonClickEvent(name, request.label, { backgroundedEvent });
|
|
422
460
|
}
|
|
423
|
-
|
|
424
|
-
return buildEvent(input);
|
|
425
461
|
}
|
|
426
462
|
|
|
427
463
|
static #requestLabel(request: OrchestratorRequest): string {
|
|
@@ -455,41 +491,37 @@ export class Orchestrator {
|
|
|
455
491
|
// --- Background management ---
|
|
456
492
|
|
|
457
493
|
#spawnBackground(name: string, prompt: string, model: string | undefined) {
|
|
458
|
-
const formatted =
|
|
459
|
-
name,
|
|
460
|
-
type: "background-agent-start",
|
|
461
|
-
session: "background",
|
|
462
|
-
text: prompt,
|
|
463
|
-
});
|
|
494
|
+
const formatted = backgroundAgentStartEvent(name, prompt);
|
|
464
495
|
this.#spawnBackgroundRaw(name, prompt, formatted, model);
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
#spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
|
|
468
|
-
const
|
|
469
|
-
? this.#claude.forkSession(this.#mainSessionId,
|
|
470
|
-
: this.#claude.newSession(
|
|
471
|
-
|
|
472
|
-
|
|
499
|
+
const process = this.#mainSessionId
|
|
500
|
+
? this.#claude.forkSession(this.#mainSessionId, responseResultType, { model })
|
|
501
|
+
: this.#claude.newSession(responseResultType, { model });
|
|
502
|
+
|
|
503
|
+
const sid = process.sessionId;
|
|
504
|
+
const pendingResult = process.send(formatted);
|
|
473
505
|
|
|
474
|
-
|
|
475
|
-
const sid = query.sessionId;
|
|
476
|
-
const info: SessionInfo = { name, prompt, model, query, lastMessageAt: new Date() };
|
|
506
|
+
const info: SessionInfo = { name, prompt, model, process, pendingResult, lastMessageAt: new Date() };
|
|
477
507
|
this.#runningSessions.set(sid, info);
|
|
478
508
|
|
|
479
509
|
log.debug({ name, sessionId: sid }, "Background session registered");
|
|
480
510
|
|
|
481
511
|
this.#scheduleHealthCheck(sid);
|
|
482
512
|
|
|
483
|
-
|
|
513
|
+
pendingResult.then(
|
|
484
514
|
({ value: response }) => {
|
|
485
515
|
if (!this.#runningSessions.has(sid)) return;
|
|
486
516
|
this.#clearSession(sid);
|
|
517
|
+
process.kill().catch(() => {});
|
|
487
518
|
log.debug({ name, message: response.message }, "Background session finished");
|
|
488
519
|
this.#queue.push({ type: "background-agent-result", name, response });
|
|
489
520
|
},
|
|
490
521
|
(err) => {
|
|
491
522
|
if (!this.#runningSessions.has(sid)) return;
|
|
492
523
|
this.#clearSession(sid);
|
|
524
|
+
process.kill().catch(() => {});
|
|
493
525
|
log.error({ name, err }, "Background session failed");
|
|
494
526
|
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
|
|
495
527
|
},
|
|
@@ -523,17 +555,15 @@ export class Orchestrator {
|
|
|
523
555
|
|
|
524
556
|
log.debug({ name: info.name, sessionId }, "Running health check");
|
|
525
557
|
|
|
526
|
-
const prompt =
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
instructions: "Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
|
|
532
|
-
});
|
|
558
|
+
const prompt = healthCheckEvent(
|
|
559
|
+
`health-check-${info.name}`,
|
|
560
|
+
info.name,
|
|
561
|
+
"Report your current status. If your task is complete, set finished=true and provide the full output. If still working, set finished=false and describe current progress in one sentence.",
|
|
562
|
+
);
|
|
533
563
|
|
|
534
|
-
let
|
|
564
|
+
let hcProcess: ClaudeProcess<z.infer<typeof healthCheckSchema>>;
|
|
535
565
|
try {
|
|
536
|
-
|
|
566
|
+
hcProcess = this.#claude.forkSession(sessionId, healthCheckResultType, { model: "haiku" });
|
|
537
567
|
} catch (err) {
|
|
538
568
|
log.error({ name: info.name, sessionId, err }, "Health check fork failed");
|
|
539
569
|
this.#scheduleHealthCheck(sessionId);
|
|
@@ -541,18 +571,20 @@ export class Orchestrator {
|
|
|
541
571
|
}
|
|
542
572
|
|
|
543
573
|
const result = await Promise.race([
|
|
544
|
-
|
|
574
|
+
hcProcess.send(prompt).then((r) => r.value),
|
|
545
575
|
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), this.#healthCheckTimeout)),
|
|
546
576
|
]);
|
|
547
577
|
|
|
578
|
+
// Always kill health check process
|
|
579
|
+
hcProcess.kill().catch(() => {});
|
|
580
|
+
|
|
548
581
|
// Session may have completed/been killed while health check was running
|
|
549
582
|
if (!this.#runningSessions.has(sessionId)) return;
|
|
550
583
|
|
|
551
584
|
if (result === "timeout") {
|
|
552
585
|
log.warn({ name: info.name, sessionId }, "Health check timed out, killing session");
|
|
553
|
-
try { await query.kill(); } catch { /* ignore */ }
|
|
554
586
|
this.#clearSession(sessionId);
|
|
555
|
-
try { await info.
|
|
587
|
+
try { await info.process.kill(); } catch { /* ignore */ }
|
|
556
588
|
this.#callOnResponse({ message: `Agent <b>${escapeHtml(info.name)}</b> appears unresponsive, killed it.` });
|
|
557
589
|
return;
|
|
558
590
|
}
|
|
@@ -560,7 +592,7 @@ export class Orchestrator {
|
|
|
560
592
|
if (result.finished) {
|
|
561
593
|
log.info({ name: info.name, sessionId }, "Health check: agent reports finished");
|
|
562
594
|
this.#clearSession(sessionId);
|
|
563
|
-
try { await info.
|
|
595
|
+
try { await info.process.kill(); } catch { /* ignore */ }
|
|
564
596
|
const response = result.output ?? { action: "send" as const, message: "[Agent finished but returned no output]", actionReason: "health-check-finished" };
|
|
565
597
|
this.#queue.push({ type: "background-agent-result", name: info.name, response });
|
|
566
598
|
return;
|