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.
@@ -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
- const isApproval = msg.method.endsWith('/requestApproval');
242
- if (isApproval) {
243
- log.info(`[codex-rpc] auto-accepting server request: ${msg.method}`);
244
- this.respond(msg.id, 'acceptForSession');
245
- return;
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
- try { this.proc?.stdin.end(); } catch {}
300
- try { this.proc?.kill('SIGTERM'); } catch {}
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
- await conv.rpc.request('turn/start', params);
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.toolName || item.name || 'mcp_tool',
436
- input: item.arguments || item.input || {},
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' || status === 'systemError') {
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
- // 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).
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', { conversationId: conv.id, usedFileTools: conv.usedFileTools, contextTokens, contextWindow, idle });
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
- // thread/started, thread/status/changed, mcpServer/startupStatus/updated,
518
- // remoteControl/status/changed informational, no-op for the dashboard.
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
- baseInstructions: string,
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
- baseInstructions,
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 workspace = all.filter((s: any) => s.scope !== 'system');
816
+ const repo = all.filter((s: any) => s.scope === 'repo');
625
817
  const errors = entry?.errors ?? [];
626
- log.ok(`[codex] skills primed: ${workspace.length} workspace, ${all.length - workspace.length} system${errors.length ? `, ${errors.length} rejected` : ''}`);
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.toolName || item.name || 'mcp_tool');
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' || status === 'systemError') {
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 ? { baseInstructions: 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 ? { baseInstructions: 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));