bloby-bot 0.53.10 → 0.54.11
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 +2 -2
- package/shared/config.ts +5 -0
- package/supervisor/backend.ts +29 -4
- package/supervisor/channels/manager.ts +81 -19
- package/supervisor/channels/types.ts +5 -0
- package/supervisor/chat/src/components/Chat/EnvForm.tsx +2 -1
- package/supervisor/harnesses/claude.ts +12 -2
- package/supervisor/harnesses/codex.ts +289 -43
- package/supervisor/harnesses/pi/index.ts +8 -1
- package/supervisor/index.ts +126 -7
- package/worker/prompts/bloby-system-prompt-codex.txt +778 -0
- package/worker/prompts/bloby-system-prompt-pi.txt +778 -0
- package/worker/prompts/prompt-assembler.ts +49 -14
- package/workspace/skills/alexa/SKILL.md +5 -0
- package/workspace/skills/mac/SKILL.md +5 -0
- package/workspace/skills/plaud/SKILL.md +5 -0
- package/workspace/skills/whatsapp/SKILL.md +30 -2
|
@@ -42,7 +42,17 @@ export type { RecentMessage, AgentAttachment };
|
|
|
42
42
|
|
|
43
43
|
const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
|
|
44
44
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
45
|
-
const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
|
|
45
|
+
const VALID_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
46
|
+
/**
|
|
47
|
+
* Per-turn IDLE watchdog. `turn/completed` is a NON-guaranteed notification — if
|
|
48
|
+
* the app-server stalls mid-turn without exiting, the RPC `exit` handler never
|
|
49
|
+
* fires and `busy` stays true forever (live: wedges the dashboard + defers
|
|
50
|
+
* backend restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done
|
|
51
|
+
* never arrives). This is an IDLE timeout, reset on every notification for the
|
|
52
|
+
* conversation — a legitimately long turn (deep reasoning, many tool calls)
|
|
53
|
+
* keeps emitting events and is never killed; only true silence trips recovery.
|
|
54
|
+
*/
|
|
55
|
+
const TURN_WATCHDOG_MS = 5 * 60_000;
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Resolve the `codex` binary. We don't trust $PATH because Bloby may be
|
|
@@ -115,7 +125,7 @@ async function assembleBaseInstructions(
|
|
|
115
125
|
recentMessages?: RecentMessage[],
|
|
116
126
|
): Promise<string> {
|
|
117
127
|
const memoryFiles = readMemoryFiles();
|
|
118
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
128
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'codex');
|
|
119
129
|
let prompt = basePrompt;
|
|
120
130
|
prompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
|
|
121
131
|
|
|
@@ -164,8 +174,8 @@ class CodexRpc {
|
|
|
164
174
|
private closed = false;
|
|
165
175
|
private stderrBuf = '';
|
|
166
176
|
|
|
167
|
-
start(): void {
|
|
168
|
-
this.proc = spawn(resolveCodexBin(), ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
177
|
+
start(extraArgs: string[] = []): void {
|
|
178
|
+
this.proc = spawn(resolveCodexBin(), ['app-server', ...extraArgs], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
169
179
|
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
170
180
|
rl.on('line', (line) => this.onLine(line));
|
|
171
181
|
|
|
@@ -238,14 +248,30 @@ class CodexRpc {
|
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
251
|
+
// Responses are OBJECTS, not bare strings: CommandExecution/FileChange approval
|
|
252
|
+
// responses are `{ decision }` (CommandExecutionApprovalDecision / FileChangeApprovalDecision),
|
|
253
|
+
// and the legacy v1 aliases take `{ decision }` with the ReviewDecision enum.
|
|
254
|
+
// (None of these fire under approvalPolicy:'never' + danger-full-access, but reply
|
|
255
|
+
// correctly so an edge-case request can't stall the turn with a malformed reply.)
|
|
256
|
+
switch (msg.method) {
|
|
257
|
+
case 'item/commandExecution/requestApproval':
|
|
258
|
+
case 'item/fileChange/requestApproval':
|
|
259
|
+
log.info(`[codex-rpc] auto-accepting ${msg.method}`);
|
|
260
|
+
this.respond(msg.id, { decision: 'acceptForSession' });
|
|
261
|
+
return;
|
|
262
|
+
case 'execCommandApproval':
|
|
263
|
+
case 'applyPatchApproval':
|
|
264
|
+
log.info(`[codex-rpc] auto-accepting (legacy) ${msg.method}`);
|
|
265
|
+
this.respond(msg.id, { decision: 'approved_for_session' });
|
|
266
|
+
return;
|
|
267
|
+
// account/chatgptAuthTokens/refresh is only used by client-managed-token
|
|
268
|
+
// clients; Bloby authenticates via chatgpt OAuth and the app-server refreshes
|
|
269
|
+
// ~/.codex/auth.json itself, so this server-request never fires for us. Decline
|
|
270
|
+
// cleanly (a stale-credential edge would surface as a normal turn error instead).
|
|
271
|
+
default:
|
|
272
|
+
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying -32601`);
|
|
273
|
+
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
246
274
|
}
|
|
247
|
-
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying with error`);
|
|
248
|
-
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
private respond(id: number, result: any): void {
|
|
@@ -296,8 +322,16 @@ class CodexRpc {
|
|
|
296
322
|
p.reject(new Error('RPC connection closed'));
|
|
297
323
|
}
|
|
298
324
|
this.pending.clear();
|
|
299
|
-
|
|
300
|
-
try {
|
|
325
|
+
const proc = this.proc;
|
|
326
|
+
try { proc?.stdin.end(); } catch {}
|
|
327
|
+
try { proc?.kill('SIGTERM'); } catch {}
|
|
328
|
+
// Escalate to SIGKILL if the app-server ignores SIGTERM (no true leak today
|
|
329
|
+
// since SIGTERM reaps it, but a SIGTERM-ignoring build would otherwise survive).
|
|
330
|
+
if (proc) {
|
|
331
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
|
|
332
|
+
if (typeof t.unref === 'function') t.unref();
|
|
333
|
+
proc.once('exit', () => clearTimeout(t));
|
|
334
|
+
}
|
|
301
335
|
this.proc = null;
|
|
302
336
|
}
|
|
303
337
|
}
|
|
@@ -312,6 +346,9 @@ interface CodexConversation {
|
|
|
312
346
|
onMessage: OnAgentMessage;
|
|
313
347
|
/** Currently in-flight turn id (set on `turn/started`, cleared on `turn/completed`). */
|
|
314
348
|
currentTurnId: string | null;
|
|
349
|
+
/** itemId of the agentMessage currently streaming — used to insert a paragraph
|
|
350
|
+
* break when a turn emits multiple separate agentMessage items. */
|
|
351
|
+
currentMsgItemId: string | null;
|
|
315
352
|
/** Streaming text accumulator for the current turn's agentMessage items. */
|
|
316
353
|
fullText: string;
|
|
317
354
|
/** Tools/items used during the current turn, for the bot:turn-complete payload. */
|
|
@@ -326,6 +363,15 @@ interface CodexConversation {
|
|
|
326
363
|
busy: boolean;
|
|
327
364
|
/** True for one-shot queries — the conversation ends after the first turn completes. */
|
|
328
365
|
oneShot: boolean;
|
|
366
|
+
/**
|
|
367
|
+
* Latest context occupancy from `thread/tokenUsage/updated` (codex does NOT
|
|
368
|
+
* report usage on `turn/completed` — Turn has no usage field). Emitted on
|
|
369
|
+
* `bot:turn-complete` so the orchestrator's proactive recycling can fire.
|
|
370
|
+
*/
|
|
371
|
+
lastContextTokens: number;
|
|
372
|
+
lastContextWindow: number;
|
|
373
|
+
/** Active per-turn watchdog timer (see TURN_WATCHDOG_MS). */
|
|
374
|
+
turnWatchdog: NodeJS.Timeout | null;
|
|
329
375
|
}
|
|
330
376
|
|
|
331
377
|
const conversations = new Map<string, CodexConversation>();
|
|
@@ -353,17 +399,59 @@ function buildUserInput(text: string, savedFiles?: SavedFile[]): Array<Record<st
|
|
|
353
399
|
return input;
|
|
354
400
|
}
|
|
355
401
|
|
|
402
|
+
function clearTurnWatchdog(conv: CodexConversation): void {
|
|
403
|
+
if (conv.turnWatchdog) {
|
|
404
|
+
clearTimeout(conv.turnWatchdog);
|
|
405
|
+
conv.turnWatchdog = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Arm the per-turn watchdog. On fire, unstick the conversation the same way a
|
|
411
|
+
* real `turn/completed` would (so the dashboard, `anyConversationBusy`, and the
|
|
412
|
+
* channel slot all release), then tear the conversation down — the next message
|
|
413
|
+
* cold-starts a fresh thread.
|
|
414
|
+
*/
|
|
415
|
+
function armTurnWatchdog(conv: CodexConversation): void {
|
|
416
|
+
clearTurnWatchdog(conv);
|
|
417
|
+
conv.turnWatchdog = setTimeout(() => {
|
|
418
|
+
conv.turnWatchdog = null;
|
|
419
|
+
log.warn(`[codex] turn watchdog fired (${TURN_WATCHDOG_MS}ms) — conv=${conv.id}; unsticking + tearing down`);
|
|
420
|
+
conv.busy = false;
|
|
421
|
+
conv.currentTurnId = null;
|
|
422
|
+
conv.onMessage('bot:error', { conversationId: conv.id, error: 'Codex turn timed out — no response from app-server.' });
|
|
423
|
+
if (conv.oneShot) {
|
|
424
|
+
conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
|
|
425
|
+
} else {
|
|
426
|
+
conv.onMessage('bot:turn-complete', {
|
|
427
|
+
conversationId: conv.id,
|
|
428
|
+
usedFileTools: conv.usedFileTools,
|
|
429
|
+
contextTokens: conv.lastContextTokens || 0,
|
|
430
|
+
contextWindow: conv.lastContextWindow || 0,
|
|
431
|
+
idle: true,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
teardownConversation(conv.id);
|
|
435
|
+
}, TURN_WATCHDOG_MS);
|
|
436
|
+
}
|
|
437
|
+
|
|
356
438
|
async function startTurn(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
|
|
357
439
|
const input = buildUserInput(content, savedFiles);
|
|
358
440
|
conv.busy = true;
|
|
359
441
|
conv.fullText = '';
|
|
360
442
|
conv.usedFileTools = false;
|
|
361
443
|
conv.onMessage('bot:typing', { conversationId: conv.id });
|
|
444
|
+
armTurnWatchdog(conv);
|
|
362
445
|
try {
|
|
363
446
|
const params: Record<string, any> = { threadId: conv.threadId, input };
|
|
364
447
|
if (conv.effort) params.effort = conv.effort;
|
|
365
|
-
|
|
448
|
+
// turn/start resolves immediately with { turn }; seize the id now so a
|
|
449
|
+
// pushMessage arriving before the turn/started notification can steer
|
|
450
|
+
// instead of starting a second turn.
|
|
451
|
+
const res = await conv.rpc.request<{ turn?: { id?: string } }>('turn/start', params);
|
|
452
|
+
if (res?.turn?.id) conv.currentTurnId = res.turn.id;
|
|
366
453
|
} catch (err: any) {
|
|
454
|
+
clearTurnWatchdog(conv);
|
|
367
455
|
conv.busy = false;
|
|
368
456
|
conv.currentTurnId = null;
|
|
369
457
|
conv.onMessage('bot:error', { conversationId: conv.id, error: `turn/start failed: ${err.message}` });
|
|
@@ -385,11 +473,12 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
|
|
|
385
473
|
// Active turn — inject mid-flight.
|
|
386
474
|
const input = buildUserInput(content, savedFiles);
|
|
387
475
|
try {
|
|
388
|
-
await conv.rpc.request('turn/steer', {
|
|
476
|
+
const res = await conv.rpc.request<{ turnId?: string }>('turn/steer', {
|
|
389
477
|
threadId: conv.threadId,
|
|
390
478
|
expectedTurnId: conv.currentTurnId,
|
|
391
479
|
input,
|
|
392
480
|
});
|
|
481
|
+
if (res?.turnId) conv.currentTurnId = res.turnId;
|
|
393
482
|
conv.onMessage('bot:typing', { conversationId: conv.id });
|
|
394
483
|
} catch (err: any) {
|
|
395
484
|
// expectedTurnId mismatch most likely means the turn just finished —
|
|
@@ -402,10 +491,14 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
|
|
|
402
491
|
|
|
403
492
|
function handleNotification(conv: CodexConversation, n: { method: string; params?: any }): void {
|
|
404
493
|
const p = n.params || {};
|
|
494
|
+
// Any notification for this conv proves the app-server is alive and working —
|
|
495
|
+
// reset the idle watchdog so a long-but-active turn isn't torn down.
|
|
496
|
+
if (conv.turnWatchdog) armTurnWatchdog(conv);
|
|
405
497
|
switch (n.method) {
|
|
406
498
|
case 'turn/started': {
|
|
407
499
|
conv.currentTurnId = p.turn?.id || null;
|
|
408
500
|
conv.fullText = '';
|
|
501
|
+
conv.currentMsgItemId = null;
|
|
409
502
|
conv.usedFileTools = false;
|
|
410
503
|
break;
|
|
411
504
|
}
|
|
@@ -413,6 +506,13 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
413
506
|
case 'item/agentMessage/delta': {
|
|
414
507
|
const delta: string = p.delta || '';
|
|
415
508
|
if (!delta) break;
|
|
509
|
+
// A turn can emit multiple agentMessage items (commentary then final_answer).
|
|
510
|
+
// On a new itemId, insert a paragraph break so they don't run together (mirrors claude.ts).
|
|
511
|
+
if (p.itemId && conv.currentMsgItemId && p.itemId !== conv.currentMsgItemId && conv.fullText && !conv.fullText.endsWith('\n')) {
|
|
512
|
+
conv.fullText += '\n\n';
|
|
513
|
+
conv.onMessage('bot:token', { conversationId: conv.id, token: '\n\n' });
|
|
514
|
+
}
|
|
515
|
+
if (p.itemId) conv.currentMsgItemId = p.itemId;
|
|
416
516
|
conv.fullText += delta;
|
|
417
517
|
conv.onMessage('bot:token', { conversationId: conv.id, token: delta });
|
|
418
518
|
break;
|
|
@@ -430,10 +530,11 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
430
530
|
});
|
|
431
531
|
break;
|
|
432
532
|
case 'mcpToolCall':
|
|
533
|
+
// ThreadItem.mcpToolCall fields are `server` + `tool` (no toolName/name/input).
|
|
433
534
|
conv.onMessage('bot:tool', {
|
|
434
535
|
conversationId: conv.id,
|
|
435
|
-
name: item.
|
|
436
|
-
input: item.arguments ||
|
|
536
|
+
name: item.tool ? (item.server ? `${item.server}/${item.tool}` : item.tool) : 'mcp_tool',
|
|
537
|
+
input: item.arguments || {},
|
|
437
538
|
});
|
|
438
539
|
break;
|
|
439
540
|
case 'fileChange':
|
|
@@ -451,6 +552,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
451
552
|
input: { query: item.query || '' },
|
|
452
553
|
});
|
|
453
554
|
break;
|
|
555
|
+
case 'collabAgentToolCall':
|
|
556
|
+
// Codex's collaborating sub-agents (rarely enabled) → Bloby's sub-agent UX.
|
|
557
|
+
if (item.tool === 'spawnAgent') {
|
|
558
|
+
conv.onMessage('bot:task-created', {
|
|
559
|
+
conversationId: conv.id,
|
|
560
|
+
taskId: item.id,
|
|
561
|
+
description: item.prompt || 'sub-agent',
|
|
562
|
+
type: 'collab',
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
454
566
|
// userMessage / agentMessage / reasoning — no tool-style event.
|
|
455
567
|
}
|
|
456
568
|
break;
|
|
@@ -467,6 +579,27 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
467
579
|
conv.onMessage('bot:token', { conversationId: conv.id, token: text });
|
|
468
580
|
}
|
|
469
581
|
}
|
|
582
|
+
if (item.type === 'collabAgentToolCall' && item.tool === 'spawnAgent') {
|
|
583
|
+
conv.onMessage('bot:task-done', {
|
|
584
|
+
conversationId: conv.id,
|
|
585
|
+
taskId: item.id,
|
|
586
|
+
status: item.status,
|
|
587
|
+
summary: item.prompt || '',
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'thread/tokenUsage/updated': {
|
|
594
|
+
// Codex's only token-usage signal. ThreadTokenUsage = { total, last, modelContextWindow };
|
|
595
|
+
// `last` is the current prompt occupancy (mirrors Claude's input+cacheRead+cacheCreation),
|
|
596
|
+
// the right basis for the recycle compare in supervisor/index.ts (fraction*window, not lifetime).
|
|
597
|
+
const tu = p.tokenUsage || {};
|
|
598
|
+
const last = tu.last || {};
|
|
599
|
+
conv.lastContextTokens = (last.inputTokens || 0) + (last.cachedInputTokens || 0);
|
|
600
|
+
if (typeof tu.modelContextWindow === 'number' && tu.modelContextWindow > 0) {
|
|
601
|
+
conv.lastContextWindow = tu.modelContextWindow;
|
|
602
|
+
}
|
|
470
603
|
break;
|
|
471
604
|
}
|
|
472
605
|
|
|
@@ -474,14 +607,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
474
607
|
const status: string = p.turn?.status || 'completed';
|
|
475
608
|
const turnError = p.turn?.error;
|
|
476
609
|
|
|
610
|
+
clearTurnWatchdog(conv);
|
|
477
611
|
conv.currentTurnId = null;
|
|
478
612
|
conv.busy = false;
|
|
479
613
|
|
|
480
|
-
if (status === 'failed'
|
|
614
|
+
if (status === 'failed') {
|
|
481
615
|
conv.onMessage('bot:error', {
|
|
482
616
|
conversationId: conv.id,
|
|
483
617
|
error: turnError?.message || 'Codex turn failed.',
|
|
484
618
|
});
|
|
619
|
+
} else if (status === 'interrupted') {
|
|
620
|
+
// Interrupted turns carry no final answer — stay silent.
|
|
485
621
|
} else if (conv.fullText) {
|
|
486
622
|
conv.onMessage('bot:response', { conversationId: conv.id, content: conv.fullText });
|
|
487
623
|
}
|
|
@@ -490,16 +626,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
490
626
|
conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
|
|
491
627
|
teardownConversation(conv.id);
|
|
492
628
|
} else {
|
|
493
|
-
// Context-size signal for the orchestrator's proactive session recycling
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
// own built-in auto-compaction).
|
|
497
|
-
const tu: any = p.turn?.usage || p.usage || {};
|
|
498
|
-
const contextTokens = tu.input_tokens ?? tu.inputTokens ?? tu.total_tokens ?? tu.totalTokens ?? tu.tokens ?? 0;
|
|
499
|
-
const contextWindow = tu.context_window ?? tu.contextWindow ?? 0;
|
|
500
|
-
// idle = no message queued behind this turn (the drain happens just below).
|
|
629
|
+
// Context-size signal for the orchestrator's proactive session recycling,
|
|
630
|
+
// sourced from the cached `thread/tokenUsage/updated` values above. 0 if codex
|
|
631
|
+
// never sent one this turn → falls back to codex's own in-thread auto-compaction.
|
|
501
632
|
const idle = conv.pendingInputs.length === 0;
|
|
502
|
-
conv.onMessage('bot:turn-complete', {
|
|
633
|
+
conv.onMessage('bot:turn-complete', {
|
|
634
|
+
conversationId: conv.id,
|
|
635
|
+
usedFileTools: conv.usedFileTools,
|
|
636
|
+
contextTokens: conv.lastContextTokens || 0,
|
|
637
|
+
contextWindow: conv.lastContextWindow || 0,
|
|
638
|
+
idle,
|
|
639
|
+
});
|
|
503
640
|
|
|
504
641
|
// Drain any messages that were submitted while we were busy.
|
|
505
642
|
const next = conv.pendingInputs.shift();
|
|
@@ -509,19 +646,35 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
509
646
|
}
|
|
510
647
|
|
|
511
648
|
case 'error': {
|
|
649
|
+
// ErrorNotification carries willRetry — codex will retry transient errors
|
|
650
|
+
// itself; don't surface those as a hard bot:error before the retry lands.
|
|
651
|
+
if (p.willRetry) {
|
|
652
|
+
log.info(`[codex] transient error (will retry): ${p.error?.message || 'unknown'}`);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
512
655
|
const errMsg = p.error?.message || 'Codex error notification';
|
|
513
656
|
conv.onMessage('bot:error', { conversationId: conv.id, error: errMsg });
|
|
514
657
|
break;
|
|
515
658
|
}
|
|
516
659
|
|
|
517
|
-
|
|
518
|
-
|
|
660
|
+
case 'mcpServer/startupStatus/updated': {
|
|
661
|
+
// Surface MCP servers (from MCP.json → -c overrides) that fail to start,
|
|
662
|
+
// so a misconfigured server is visible instead of silently absent.
|
|
663
|
+
if (p.status === 'failed' || p.status === 'cancelled') {
|
|
664
|
+
log.warn(`[codex] MCP server "${p.name}" ${p.status}${p.error ? `: ${p.error}` : ''}`);
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// thread/started, thread/status/changed, remoteControl/status/changed —
|
|
670
|
+
// informational, no-op for the dashboard.
|
|
519
671
|
}
|
|
520
672
|
}
|
|
521
673
|
|
|
522
674
|
function teardownConversation(conversationId: string): void {
|
|
523
675
|
const conv = conversations.get(conversationId);
|
|
524
676
|
if (!conv) return;
|
|
677
|
+
clearTurnWatchdog(conv);
|
|
525
678
|
conversations.delete(conversationId);
|
|
526
679
|
try { conv.rpc.close(); } catch {}
|
|
527
680
|
conv.onMessage('bot:conversation-ended', { conversationId });
|
|
@@ -531,7 +684,7 @@ async function spawnAndInitialize(
|
|
|
531
684
|
conversationId: string,
|
|
532
685
|
model: string,
|
|
533
686
|
onMessage: OnAgentMessage,
|
|
534
|
-
|
|
687
|
+
instructions: string,
|
|
535
688
|
oneShot: boolean,
|
|
536
689
|
): Promise<CodexConversation | null> {
|
|
537
690
|
// Pre-flight: confirm we have valid OAuth tokens before spending time spawning.
|
|
@@ -546,7 +699,7 @@ async function spawnAndInitialize(
|
|
|
546
699
|
|
|
547
700
|
const { id: modelId, effort } = parseModelString(model);
|
|
548
701
|
const rpc = new CodexRpc();
|
|
549
|
-
rpc.start();
|
|
702
|
+
rpc.start(buildMcpConfigArgs());
|
|
550
703
|
|
|
551
704
|
const conv: CodexConversation = {
|
|
552
705
|
id: conversationId,
|
|
@@ -555,11 +708,15 @@ async function spawnAndInitialize(
|
|
|
555
708
|
effort,
|
|
556
709
|
onMessage,
|
|
557
710
|
currentTurnId: null,
|
|
711
|
+
currentMsgItemId: null,
|
|
558
712
|
fullText: '',
|
|
559
713
|
usedFileTools: false,
|
|
560
714
|
pendingInputs: [],
|
|
561
715
|
busy: false,
|
|
562
716
|
oneShot,
|
|
717
|
+
lastContextTokens: 0,
|
|
718
|
+
lastContextWindow: 0,
|
|
719
|
+
turnWatchdog: null,
|
|
563
720
|
};
|
|
564
721
|
|
|
565
722
|
rpc.onNotification((n) => handleNotification(conv, n));
|
|
@@ -582,7 +739,13 @@ async function spawnAndInitialize(
|
|
|
582
739
|
const startResult = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
583
740
|
cwd: WORKSPACE_DIR,
|
|
584
741
|
model: modelId,
|
|
585
|
-
|
|
742
|
+
// Bloby's persona/workflow prompt rides developerInstructions (ADDITIVE),
|
|
743
|
+
// NOT baseInstructions. baseInstructions fully OVERRIDES codex's native base
|
|
744
|
+
// prompt — which carries the apply_patch FREEFORM spec + shell protocol the
|
|
745
|
+
// model needs to edit files. Leaving baseInstructions unset keeps that native
|
|
746
|
+
// scaffolding; developerInstructions layers Bloby's persona on top of it.
|
|
747
|
+
developerInstructions: instructions,
|
|
748
|
+
personality: 'pragmatic',
|
|
586
749
|
// Bloby's posture matches Claude's bypassPermissions — the bot is
|
|
587
750
|
// running on the user's own machine with their full consent. Skip the
|
|
588
751
|
// approval prompts and give it write access to the workspace + beyond.
|
|
@@ -609,27 +772,109 @@ async function spawnAndInitialize(
|
|
|
609
772
|
}
|
|
610
773
|
|
|
611
774
|
const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
|
|
775
|
+
// Codex discovers "repo"-scope skills under `<cwd>/.codex/skills` (verified
|
|
776
|
+
// against 0.135.0 — a bare `<cwd>/skills` is NOT scanned, and `skills/list`
|
|
777
|
+
// has no extra-root param). Bloby keeps the canonical skills in
|
|
778
|
+
// `workspace/skills/<name>`, so we mirror each one into `.codex/skills/<name>`
|
|
779
|
+
// as a symlink — single source of truth, discoverable by codex's native router.
|
|
780
|
+
// (Each SKILL.md needs YAML frontmatter or codex rejects it — see SKILL_FORMAT_MIGRATION.md.)
|
|
781
|
+
const CODEX_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.codex', 'skills');
|
|
782
|
+
|
|
783
|
+
/** Mirror workspace/skills/<name> → workspace/.codex/skills/<name> as symlinks (idempotent). */
|
|
784
|
+
function syncCodexSkillRoot(): void {
|
|
785
|
+
let names: string[] = [];
|
|
786
|
+
try {
|
|
787
|
+
names = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
788
|
+
.filter((e) => e.isDirectory() || e.isSymbolicLink())
|
|
789
|
+
.map((e) => e.name);
|
|
790
|
+
} catch {
|
|
791
|
+
return; // no skills dir — nothing to mirror
|
|
792
|
+
}
|
|
793
|
+
try { fs.mkdirSync(CODEX_SKILLS_ROOT, { recursive: true }); } catch {}
|
|
794
|
+
for (const name of names) {
|
|
795
|
+
const target = path.join(SKILLS_DIR, name);
|
|
796
|
+
const link = path.join(CODEX_SKILLS_ROOT, name);
|
|
797
|
+
try {
|
|
798
|
+
const cur = fs.existsSync(link) ? fs.realpathSync(link) : null;
|
|
799
|
+
if (cur === fs.realpathSync(target)) continue; // already correct
|
|
800
|
+
try { fs.rmSync(link, { recursive: true, force: true }); } catch {}
|
|
801
|
+
fs.symlinkSync(target, link, 'dir');
|
|
802
|
+
} catch (err: any) {
|
|
803
|
+
log.warn(`[codex] could not mirror skill "${name}" into .codex/skills: ${err.message}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
612
807
|
|
|
613
808
|
function primeWorkspaceSkills(rpc: CodexRpc): void {
|
|
809
|
+
syncCodexSkillRoot();
|
|
614
810
|
rpc.request('skills/list', {
|
|
615
811
|
cwds: [WORKSPACE_DIR],
|
|
616
812
|
forceReload: true,
|
|
617
|
-
perCwdExtraUserRoots: [{
|
|
618
|
-
cwd: WORKSPACE_DIR,
|
|
619
|
-
extraUserRoots: [SKILLS_DIR],
|
|
620
|
-
}],
|
|
621
813
|
}).then((result: any) => {
|
|
622
814
|
const entry = result?.data?.[0];
|
|
623
815
|
const all = entry?.skills ?? [];
|
|
624
|
-
const
|
|
816
|
+
const repo = all.filter((s: any) => s.scope === 'repo');
|
|
625
817
|
const errors = entry?.errors ?? [];
|
|
626
|
-
log.ok(`[codex] skills primed: ${
|
|
818
|
+
log.ok(`[codex] skills primed: ${repo.length} workspace (repo), ${all.length - repo.length} user/system${errors.length ? `, ${errors.length} rejected` : ''}`);
|
|
627
819
|
for (const err of errors) log.warn(`[codex] skill load error: ${err.path} — ${err.message}`);
|
|
628
820
|
}).catch((err: any) => {
|
|
629
821
|
log.warn(`[codex] skills/list failed: ${err.message}`);
|
|
630
822
|
});
|
|
631
823
|
}
|
|
632
824
|
|
|
825
|
+
/* ── MCP wiring ────────────────────────────────────────────────────────── */
|
|
826
|
+
|
|
827
|
+
const MCP_CONFIG_FILE = path.join(WORKSPACE_DIR, 'MCP.json');
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Load MCP servers from workspace/MCP.json (the same file the Claude harness
|
|
831
|
+
* reads). Accepts the canonical unwrapped map `{ name: { command, args, env } }`,
|
|
832
|
+
* a `{ mcpServers: {...} }` wrapper, or a legacy array of single-key maps.
|
|
833
|
+
* Returns {} when absent/invalid — so this is a no-op until the user populates MCP.json.
|
|
834
|
+
*/
|
|
835
|
+
function loadMcpServersForCodex(): Record<string, any> {
|
|
836
|
+
try {
|
|
837
|
+
const raw = JSON.parse(fs.readFileSync(MCP_CONFIG_FILE, 'utf-8'));
|
|
838
|
+
let map: any = raw;
|
|
839
|
+
if (raw && typeof raw === 'object' && raw.mcpServers && typeof raw.mcpServers === 'object') map = raw.mcpServers;
|
|
840
|
+
else if (Array.isArray(raw)) map = Object.assign({}, ...raw);
|
|
841
|
+
if (map && typeof map === 'object' && !Array.isArray(map)) return map;
|
|
842
|
+
} catch {}
|
|
843
|
+
return {};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Serialize a JS value as a TOML literal for a `-c key=value` override. */
|
|
847
|
+
function toToml(v: any): string {
|
|
848
|
+
if (Array.isArray(v)) return `[${v.map(toToml).join(',')}]`;
|
|
849
|
+
if (v && typeof v === 'object') return `{${Object.entries(v).map(([k, val]) => `${JSON.stringify(k)}=${toToml(val)}`).join(',')}}`;
|
|
850
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
851
|
+
return JSON.stringify(String(v)); // TOML basic string — JSON escaping is compatible
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Translate MCP.json into `codex app-server -c mcp_servers.<name>.<field>=<toml>`
|
|
856
|
+
* spawn flags. Codex sources MCP from its own config layer rather than a per-query
|
|
857
|
+
* param (verified against 0.135.0: a `-c mcp_servers.X.command=...` override shows
|
|
858
|
+
* up in both mcpServerStatus/list and config/read). Only the stdio fields Bloby
|
|
859
|
+
* uses (command/args/env) are translated; names must be TOML-bare-key safe.
|
|
860
|
+
*/
|
|
861
|
+
function buildMcpConfigArgs(): string[] {
|
|
862
|
+
const servers = loadMcpServersForCodex();
|
|
863
|
+
const args: string[] = [];
|
|
864
|
+
let wired = 0;
|
|
865
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
866
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) { log.warn(`[codex] skipping MCP server "${name}" — name not TOML-bare-key safe`); continue; }
|
|
867
|
+
const c: any = cfg || {};
|
|
868
|
+
if (!c.command) { log.warn(`[codex] skipping MCP server "${name}" — no command`); continue; }
|
|
869
|
+
args.push('-c', `mcp_servers.${name}.command=${toToml(c.command)}`);
|
|
870
|
+
if (Array.isArray(c.args) && c.args.length) args.push('-c', `mcp_servers.${name}.args=${toToml(c.args)}`);
|
|
871
|
+
if (c.env && typeof c.env === 'object' && Object.keys(c.env).length) args.push('-c', `mcp_servers.${name}.env=${toToml(c.env)}`);
|
|
872
|
+
wired++;
|
|
873
|
+
}
|
|
874
|
+
if (wired) log.info(`[codex] wiring ${wired} MCP server(s) from MCP.json via -c overrides`);
|
|
875
|
+
return args;
|
|
876
|
+
}
|
|
877
|
+
|
|
633
878
|
/* ── Harness implementation ────────────────────────────────────────────── */
|
|
634
879
|
|
|
635
880
|
export function hasConversation(conversationId: string): boolean {
|
|
@@ -762,7 +1007,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
762
1007
|
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
763
1008
|
|
|
764
1009
|
const rpc = new CodexRpc();
|
|
765
|
-
rpc.start();
|
|
1010
|
+
rpc.start(buildMcpConfigArgs());
|
|
766
1011
|
|
|
767
1012
|
let fullText = '';
|
|
768
1013
|
const usedTools = new Set<string>();
|
|
@@ -782,7 +1027,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
782
1027
|
case 'item/started': {
|
|
783
1028
|
const item = p.item || {};
|
|
784
1029
|
if (item.type === 'commandExecution') usedTools.add('shell');
|
|
785
|
-
else if (item.type === 'mcpToolCall') usedTools.add(item.
|
|
1030
|
+
else if (item.type === 'mcpToolCall') usedTools.add(item.tool || 'mcp_tool');
|
|
786
1031
|
else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
|
|
787
1032
|
else if (item.type === 'webSearch') usedTools.add('web_search');
|
|
788
1033
|
break;
|
|
@@ -798,13 +1043,14 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
798
1043
|
}
|
|
799
1044
|
case 'turn/completed': {
|
|
800
1045
|
const status = p.turn?.status || 'completed';
|
|
801
|
-
if (status === 'failed'
|
|
1046
|
+
if (status === 'failed') {
|
|
802
1047
|
turnError = p.turn?.error?.message || 'Codex turn failed.';
|
|
803
1048
|
}
|
|
804
1049
|
resolveTurn?.();
|
|
805
1050
|
break;
|
|
806
1051
|
}
|
|
807
1052
|
case 'error': {
|
|
1053
|
+
if (p.willRetry) break; // transient — codex retries itself
|
|
808
1054
|
turnError = p.error?.message || 'Codex error';
|
|
809
1055
|
resolveTurn?.();
|
|
810
1056
|
break;
|
|
@@ -833,7 +1079,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
833
1079
|
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
834
1080
|
cwd: WORKSPACE_DIR,
|
|
835
1081
|
model,
|
|
836
|
-
...(req.systemPrompt ? {
|
|
1082
|
+
...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
|
|
837
1083
|
approvalPolicy: 'never',
|
|
838
1084
|
sandbox: 'danger-full-access',
|
|
839
1085
|
});
|
|
@@ -843,7 +1089,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
843
1089
|
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
844
1090
|
cwd: WORKSPACE_DIR,
|
|
845
1091
|
model,
|
|
846
|
-
...(req.systemPrompt ? {
|
|
1092
|
+
...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
|
|
847
1093
|
approvalPolicy: 'never',
|
|
848
1094
|
sandbox: 'danger-full-access',
|
|
849
1095
|
});
|
|
@@ -107,7 +107,7 @@ async function buildSystemPrompt(
|
|
|
107
107
|
recentMessages?: RecentMessage[],
|
|
108
108
|
): Promise<string> {
|
|
109
109
|
const memoryFiles = readMemoryFiles();
|
|
110
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
110
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'pi');
|
|
111
111
|
let systemPrompt = basePrompt;
|
|
112
112
|
systemPrompt += LIVE_CONVERSATION_HINT;
|
|
113
113
|
systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
|
|
@@ -369,6 +369,12 @@ export async function startBlobyAgentQuery(
|
|
|
369
369
|
|
|
370
370
|
const abortController = new AbortController();
|
|
371
371
|
activeQueries.set(conversationId, abortController);
|
|
372
|
+
// Hard watchdog — a hung provider stream would otherwise pin this query forever (finally never
|
|
373
|
+
// runs, bot:done never fires). Abort after 5 min; cleared in the finally on normal completion.
|
|
374
|
+
const watchdog = setTimeout(() => {
|
|
375
|
+
log.warn(`[pi/bloby-agent] one-shot timed out (5m) — aborting conv=${conversationId}`);
|
|
376
|
+
abortController.abort();
|
|
377
|
+
}, 300_000);
|
|
372
378
|
|
|
373
379
|
let systemPrompt: string;
|
|
374
380
|
if (supportPrompt) {
|
|
@@ -425,6 +431,7 @@ export async function startBlobyAgentQuery(
|
|
|
425
431
|
onMessage('bot:error', { conversationId, error: err?.message || String(err) });
|
|
426
432
|
}
|
|
427
433
|
} finally {
|
|
434
|
+
clearTimeout(watchdog);
|
|
428
435
|
activeQueries.delete(conversationId);
|
|
429
436
|
const FILE_TOOL_NAMES = ['Write', 'Edit', 'write', 'edit'];
|
|
430
437
|
const usedFileTools = FILE_TOOL_NAMES.some((t) => usedTools.has(t));
|