bloby-bot 0.53.9 → 0.54.10

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.
@@ -42,7 +42,15 @@ 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 watchdog. `turn/completed` is a NON-guaranteed notification — if the
48
+ * app-server stalls mid-turn without exiting, the RPC `exit` handler never fires
49
+ * and `busy` stays true forever (live: wedges the dashboard + defers backend
50
+ * restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done never
51
+ * arrives). Claude's one-shot path has the same 5-min guard. Mirrors it here.
52
+ */
53
+ const TURN_WATCHDOG_MS = 5 * 60_000;
46
54
 
47
55
  /**
48
56
  * Resolve the `codex` binary. We don't trust $PATH because Bloby may be
@@ -115,7 +123,7 @@ async function assembleBaseInstructions(
115
123
  recentMessages?: RecentMessage[],
116
124
  ): Promise<string> {
117
125
  const memoryFiles = readMemoryFiles();
118
- const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
126
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'codex');
119
127
  let prompt = basePrompt;
120
128
  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
129
 
@@ -326,6 +334,15 @@ interface CodexConversation {
326
334
  busy: boolean;
327
335
  /** True for one-shot queries — the conversation ends after the first turn completes. */
328
336
  oneShot: boolean;
337
+ /**
338
+ * Latest context occupancy from `thread/tokenUsage/updated` (codex does NOT
339
+ * report usage on `turn/completed` — Turn has no usage field). Emitted on
340
+ * `bot:turn-complete` so the orchestrator's proactive recycling can fire.
341
+ */
342
+ lastContextTokens: number;
343
+ lastContextWindow: number;
344
+ /** Active per-turn watchdog timer (see TURN_WATCHDOG_MS). */
345
+ turnWatchdog: NodeJS.Timeout | null;
329
346
  }
330
347
 
331
348
  const conversations = new Map<string, CodexConversation>();
@@ -353,17 +370,59 @@ function buildUserInput(text: string, savedFiles?: SavedFile[]): Array<Record<st
353
370
  return input;
354
371
  }
355
372
 
373
+ function clearTurnWatchdog(conv: CodexConversation): void {
374
+ if (conv.turnWatchdog) {
375
+ clearTimeout(conv.turnWatchdog);
376
+ conv.turnWatchdog = null;
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Arm the per-turn watchdog. On fire, unstick the conversation the same way a
382
+ * real `turn/completed` would (so the dashboard, `anyConversationBusy`, and the
383
+ * channel slot all release), then tear the conversation down — the next message
384
+ * cold-starts a fresh thread.
385
+ */
386
+ function armTurnWatchdog(conv: CodexConversation): void {
387
+ clearTurnWatchdog(conv);
388
+ conv.turnWatchdog = setTimeout(() => {
389
+ conv.turnWatchdog = null;
390
+ log.warn(`[codex] turn watchdog fired (${TURN_WATCHDOG_MS}ms) — conv=${conv.id}; unsticking + tearing down`);
391
+ conv.busy = false;
392
+ conv.currentTurnId = null;
393
+ conv.onMessage('bot:error', { conversationId: conv.id, error: 'Codex turn timed out — no response from app-server.' });
394
+ if (conv.oneShot) {
395
+ conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
396
+ } else {
397
+ conv.onMessage('bot:turn-complete', {
398
+ conversationId: conv.id,
399
+ usedFileTools: conv.usedFileTools,
400
+ contextTokens: conv.lastContextTokens || 0,
401
+ contextWindow: conv.lastContextWindow || 0,
402
+ idle: true,
403
+ });
404
+ }
405
+ teardownConversation(conv.id);
406
+ }, TURN_WATCHDOG_MS);
407
+ }
408
+
356
409
  async function startTurn(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
357
410
  const input = buildUserInput(content, savedFiles);
358
411
  conv.busy = true;
359
412
  conv.fullText = '';
360
413
  conv.usedFileTools = false;
361
414
  conv.onMessage('bot:typing', { conversationId: conv.id });
415
+ armTurnWatchdog(conv);
362
416
  try {
363
417
  const params: Record<string, any> = { threadId: conv.threadId, input };
364
418
  if (conv.effort) params.effort = conv.effort;
365
- await conv.rpc.request('turn/start', params);
419
+ // turn/start resolves immediately with { turn }; seize the id now so a
420
+ // pushMessage arriving before the turn/started notification can steer
421
+ // instead of starting a second turn.
422
+ const res = await conv.rpc.request<{ turn?: { id?: string } }>('turn/start', params);
423
+ if (res?.turn?.id) conv.currentTurnId = res.turn.id;
366
424
  } catch (err: any) {
425
+ clearTurnWatchdog(conv);
367
426
  conv.busy = false;
368
427
  conv.currentTurnId = null;
369
428
  conv.onMessage('bot:error', { conversationId: conv.id, error: `turn/start failed: ${err.message}` });
@@ -385,11 +444,12 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
385
444
  // Active turn — inject mid-flight.
386
445
  const input = buildUserInput(content, savedFiles);
387
446
  try {
388
- await conv.rpc.request('turn/steer', {
447
+ const res = await conv.rpc.request<{ turnId?: string }>('turn/steer', {
389
448
  threadId: conv.threadId,
390
449
  expectedTurnId: conv.currentTurnId,
391
450
  input,
392
451
  });
452
+ if (res?.turnId) conv.currentTurnId = res.turnId;
393
453
  conv.onMessage('bot:typing', { conversationId: conv.id });
394
454
  } catch (err: any) {
395
455
  // expectedTurnId mismatch most likely means the turn just finished —
@@ -430,10 +490,11 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
430
490
  });
431
491
  break;
432
492
  case 'mcpToolCall':
493
+ // ThreadItem.mcpToolCall fields are `server` + `tool` (no toolName/name/input).
433
494
  conv.onMessage('bot:tool', {
434
495
  conversationId: conv.id,
435
- name: item.toolName || item.name || 'mcp_tool',
436
- input: item.arguments || item.input || {},
496
+ name: item.tool ? (item.server ? `${item.server}/${item.tool}` : item.tool) : 'mcp_tool',
497
+ input: item.arguments || {},
437
498
  });
438
499
  break;
439
500
  case 'fileChange':
@@ -470,18 +531,34 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
470
531
  break;
471
532
  }
472
533
 
534
+ case 'thread/tokenUsage/updated': {
535
+ // Codex's only token-usage signal. ThreadTokenUsage = { total, last, modelContextWindow };
536
+ // `last` is the current prompt occupancy (mirrors Claude's input+cacheRead+cacheCreation),
537
+ // the right basis for the recycle compare in supervisor/index.ts (fraction*window, not lifetime).
538
+ const tu = p.tokenUsage || {};
539
+ const last = tu.last || {};
540
+ conv.lastContextTokens = (last.inputTokens || 0) + (last.cachedInputTokens || 0);
541
+ if (typeof tu.modelContextWindow === 'number' && tu.modelContextWindow > 0) {
542
+ conv.lastContextWindow = tu.modelContextWindow;
543
+ }
544
+ break;
545
+ }
546
+
473
547
  case 'turn/completed': {
474
548
  const status: string = p.turn?.status || 'completed';
475
549
  const turnError = p.turn?.error;
476
550
 
551
+ clearTurnWatchdog(conv);
477
552
  conv.currentTurnId = null;
478
553
  conv.busy = false;
479
554
 
480
- if (status === 'failed' || status === 'systemError') {
555
+ if (status === 'failed') {
481
556
  conv.onMessage('bot:error', {
482
557
  conversationId: conv.id,
483
558
  error: turnError?.message || 'Codex turn failed.',
484
559
  });
560
+ } else if (status === 'interrupted') {
561
+ // Interrupted turns carry no final answer — stay silent.
485
562
  } else if (conv.fullText) {
486
563
  conv.onMessage('bot:response', { conversationId: conv.id, content: conv.fullText });
487
564
  }
@@ -490,16 +567,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
490
567
  conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
491
568
  teardownConversation(conv.id);
492
569
  } else {
493
- // Context-size signal for the orchestrator's proactive session recycling.
494
- // The codex app-server reports token usage on turn/completed; field names vary
495
- // across versions, so probe defensively (0 if absent → falls back to codex's
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).
570
+ // Context-size signal for the orchestrator's proactive session recycling,
571
+ // sourced from the cached `thread/tokenUsage/updated` values above. 0 if codex
572
+ // never sent one this turn → falls back to codex's own in-thread auto-compaction.
501
573
  const idle = conv.pendingInputs.length === 0;
502
- conv.onMessage('bot:turn-complete', { conversationId: conv.id, usedFileTools: conv.usedFileTools, contextTokens, contextWindow, idle });
574
+ conv.onMessage('bot:turn-complete', {
575
+ conversationId: conv.id,
576
+ usedFileTools: conv.usedFileTools,
577
+ contextTokens: conv.lastContextTokens || 0,
578
+ contextWindow: conv.lastContextWindow || 0,
579
+ idle,
580
+ });
503
581
 
504
582
  // Drain any messages that were submitted while we were busy.
505
583
  const next = conv.pendingInputs.shift();
@@ -509,6 +587,12 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
509
587
  }
510
588
 
511
589
  case 'error': {
590
+ // ErrorNotification carries willRetry — codex will retry transient errors
591
+ // itself; don't surface those as a hard bot:error before the retry lands.
592
+ if (p.willRetry) {
593
+ log.info(`[codex] transient error (will retry): ${p.error?.message || 'unknown'}`);
594
+ break;
595
+ }
512
596
  const errMsg = p.error?.message || 'Codex error notification';
513
597
  conv.onMessage('bot:error', { conversationId: conv.id, error: errMsg });
514
598
  break;
@@ -522,6 +606,7 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
522
606
  function teardownConversation(conversationId: string): void {
523
607
  const conv = conversations.get(conversationId);
524
608
  if (!conv) return;
609
+ clearTurnWatchdog(conv);
525
610
  conversations.delete(conversationId);
526
611
  try { conv.rpc.close(); } catch {}
527
612
  conv.onMessage('bot:conversation-ended', { conversationId });
@@ -531,7 +616,7 @@ async function spawnAndInitialize(
531
616
  conversationId: string,
532
617
  model: string,
533
618
  onMessage: OnAgentMessage,
534
- baseInstructions: string,
619
+ instructions: string,
535
620
  oneShot: boolean,
536
621
  ): Promise<CodexConversation | null> {
537
622
  // Pre-flight: confirm we have valid OAuth tokens before spending time spawning.
@@ -560,6 +645,9 @@ async function spawnAndInitialize(
560
645
  pendingInputs: [],
561
646
  busy: false,
562
647
  oneShot,
648
+ lastContextTokens: 0,
649
+ lastContextWindow: 0,
650
+ turnWatchdog: null,
563
651
  };
564
652
 
565
653
  rpc.onNotification((n) => handleNotification(conv, n));
@@ -582,7 +670,13 @@ async function spawnAndInitialize(
582
670
  const startResult = await rpc.request<{ thread: { id: string } }>('thread/start', {
583
671
  cwd: WORKSPACE_DIR,
584
672
  model: modelId,
585
- baseInstructions,
673
+ // Bloby's persona/workflow prompt rides developerInstructions (ADDITIVE),
674
+ // NOT baseInstructions. baseInstructions fully OVERRIDES codex's native base
675
+ // prompt — which carries the apply_patch FREEFORM spec + shell protocol the
676
+ // model needs to edit files. Leaving baseInstructions unset keeps that native
677
+ // scaffolding; developerInstructions layers Bloby's persona on top of it.
678
+ developerInstructions: instructions,
679
+ personality: 'pragmatic',
586
680
  // Bloby's posture matches Claude's bypassPermissions — the bot is
587
681
  // running on the user's own machine with their full consent. Skip the
588
682
  // approval prompts and give it write access to the workspace + beyond.
@@ -782,7 +876,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
782
876
  case 'item/started': {
783
877
  const item = p.item || {};
784
878
  if (item.type === 'commandExecution') usedTools.add('shell');
785
- else if (item.type === 'mcpToolCall') usedTools.add(item.toolName || item.name || 'mcp_tool');
879
+ else if (item.type === 'mcpToolCall') usedTools.add(item.tool || 'mcp_tool');
786
880
  else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
787
881
  else if (item.type === 'webSearch') usedTools.add('web_search');
788
882
  break;
@@ -798,13 +892,14 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
798
892
  }
799
893
  case 'turn/completed': {
800
894
  const status = p.turn?.status || 'completed';
801
- if (status === 'failed' || status === 'systemError') {
895
+ if (status === 'failed') {
802
896
  turnError = p.turn?.error?.message || 'Codex turn failed.';
803
897
  }
804
898
  resolveTurn?.();
805
899
  break;
806
900
  }
807
901
  case 'error': {
902
+ if (p.willRetry) break; // transient — codex retries itself
808
903
  turnError = p.error?.message || 'Codex error';
809
904
  resolveTurn?.();
810
905
  break;
@@ -833,7 +928,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
833
928
  const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
834
929
  cwd: WORKSPACE_DIR,
835
930
  model,
836
- ...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
931
+ ...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
837
932
  approvalPolicy: 'never',
838
933
  sandbox: 'danger-full-access',
839
934
  });
@@ -843,7 +938,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
843
938
  const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
844
939
  cwd: WORKSPACE_DIR,
845
940
  model,
846
- ...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
941
+ ...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
847
942
  approvalPolicy: 'never',
848
943
  sandbox: 'danger-full-access',
849
944
  });
@@ -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));