bloby-bot 0.54.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.54.10",
3
+ "version": "0.54.11",
4
4
  "releaseNotes": [
5
5
  "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
6
  "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
@@ -44,11 +44,13 @@ const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
44
44
  const REQUEST_TIMEOUT_MS = 60_000;
45
45
  const VALID_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
46
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.
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.
52
54
  */
53
55
  const TURN_WATCHDOG_MS = 5 * 60_000;
54
56
 
@@ -172,8 +174,8 @@ class CodexRpc {
172
174
  private closed = false;
173
175
  private stderrBuf = '';
174
176
 
175
- start(): void {
176
- 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'] });
177
179
  const rl = readline.createInterface({ input: this.proc.stdout });
178
180
  rl.on('line', (line) => this.onLine(line));
179
181
 
@@ -246,14 +248,30 @@ class CodexRpc {
246
248
  }
247
249
 
248
250
  private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
249
- const isApproval = msg.method.endsWith('/requestApproval');
250
- if (isApproval) {
251
- log.info(`[codex-rpc] auto-accepting server request: ${msg.method}`);
252
- this.respond(msg.id, 'acceptForSession');
253
- 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`);
254
274
  }
255
- log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying with error`);
256
- this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
257
275
  }
258
276
 
259
277
  private respond(id: number, result: any): void {
@@ -304,8 +322,16 @@ class CodexRpc {
304
322
  p.reject(new Error('RPC connection closed'));
305
323
  }
306
324
  this.pending.clear();
307
- try { this.proc?.stdin.end(); } catch {}
308
- 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
+ }
309
335
  this.proc = null;
310
336
  }
311
337
  }
@@ -320,6 +346,9 @@ interface CodexConversation {
320
346
  onMessage: OnAgentMessage;
321
347
  /** Currently in-flight turn id (set on `turn/started`, cleared on `turn/completed`). */
322
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;
323
352
  /** Streaming text accumulator for the current turn's agentMessage items. */
324
353
  fullText: string;
325
354
  /** Tools/items used during the current turn, for the bot:turn-complete payload. */
@@ -462,10 +491,14 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
462
491
 
463
492
  function handleNotification(conv: CodexConversation, n: { method: string; params?: any }): void {
464
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);
465
497
  switch (n.method) {
466
498
  case 'turn/started': {
467
499
  conv.currentTurnId = p.turn?.id || null;
468
500
  conv.fullText = '';
501
+ conv.currentMsgItemId = null;
469
502
  conv.usedFileTools = false;
470
503
  break;
471
504
  }
@@ -473,6 +506,13 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
473
506
  case 'item/agentMessage/delta': {
474
507
  const delta: string = p.delta || '';
475
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;
476
516
  conv.fullText += delta;
477
517
  conv.onMessage('bot:token', { conversationId: conv.id, token: delta });
478
518
  break;
@@ -512,6 +552,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
512
552
  input: { query: item.query || '' },
513
553
  });
514
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;
515
566
  // userMessage / agentMessage / reasoning — no tool-style event.
516
567
  }
517
568
  break;
@@ -528,6 +579,14 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
528
579
  conv.onMessage('bot:token', { conversationId: conv.id, token: text });
529
580
  }
530
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
+ }
531
590
  break;
532
591
  }
533
592
 
@@ -598,8 +657,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
598
657
  break;
599
658
  }
600
659
 
601
- // thread/started, thread/status/changed, mcpServer/startupStatus/updated,
602
- // 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.
603
671
  }
604
672
  }
605
673
 
@@ -631,7 +699,7 @@ async function spawnAndInitialize(
631
699
 
632
700
  const { id: modelId, effort } = parseModelString(model);
633
701
  const rpc = new CodexRpc();
634
- rpc.start();
702
+ rpc.start(buildMcpConfigArgs());
635
703
 
636
704
  const conv: CodexConversation = {
637
705
  id: conversationId,
@@ -640,6 +708,7 @@ async function spawnAndInitialize(
640
708
  effort,
641
709
  onMessage,
642
710
  currentTurnId: null,
711
+ currentMsgItemId: null,
643
712
  fullText: '',
644
713
  usedFileTools: false,
645
714
  pendingInputs: [],
@@ -703,27 +772,109 @@ async function spawnAndInitialize(
703
772
  }
704
773
 
705
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
+ }
706
807
 
707
808
  function primeWorkspaceSkills(rpc: CodexRpc): void {
809
+ syncCodexSkillRoot();
708
810
  rpc.request('skills/list', {
709
811
  cwds: [WORKSPACE_DIR],
710
812
  forceReload: true,
711
- perCwdExtraUserRoots: [{
712
- cwd: WORKSPACE_DIR,
713
- extraUserRoots: [SKILLS_DIR],
714
- }],
715
813
  }).then((result: any) => {
716
814
  const entry = result?.data?.[0];
717
815
  const all = entry?.skills ?? [];
718
- const workspace = all.filter((s: any) => s.scope !== 'system');
816
+ const repo = all.filter((s: any) => s.scope === 'repo');
719
817
  const errors = entry?.errors ?? [];
720
- 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` : ''}`);
721
819
  for (const err of errors) log.warn(`[codex] skill load error: ${err.path} — ${err.message}`);
722
820
  }).catch((err: any) => {
723
821
  log.warn(`[codex] skills/list failed: ${err.message}`);
724
822
  });
725
823
  }
726
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
+
727
878
  /* ── Harness implementation ────────────────────────────────────────────── */
728
879
 
729
880
  export function hasConversation(conversationId: string): boolean {
@@ -856,7 +1007,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
856
1007
  const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
857
1008
 
858
1009
  const rpc = new CodexRpc();
859
- rpc.start();
1010
+ rpc.start(buildMcpConfigArgs());
860
1011
 
861
1012
  let fullText = '';
862
1013
  const usedTools = new Set<string>();
@@ -1856,6 +1856,28 @@ mint();
1856
1856
  };
1857
1857
  }
1858
1858
 
1859
+ // Same for Codex OAuth: the app-server reads ~/.codex/auth.json at spawn, so a
1860
+ // running subprocess only adopts a new identity on re-spawn. End live conversations
1861
+ // after a successful exchange so the next message cold-starts with the fresh token.
1862
+ // (Wraps only the one-shot /exchange; the device-code /status route latches
1863
+ // success on every poll and must not re-fire teardown — handled at re-spawn instead.)
1864
+ if (req.method === 'POST' && req.url === '/api/auth/codex/exchange') {
1865
+ const origEnd = res.end.bind(res);
1866
+ (res as any).end = function (this: typeof res, ...args: any[]) {
1867
+ try {
1868
+ const body = typeof args[0] === 'string' ? args[0] : args[0]?.toString();
1869
+ if (body) {
1870
+ const json = JSON.parse(body);
1871
+ if (json.success) {
1872
+ log.info('[orchestrator] Codex re-auth succeeded — restarting conversations with fresh token');
1873
+ endAllConversations();
1874
+ }
1875
+ }
1876
+ } catch {}
1877
+ return origEnd(...args);
1878
+ };
1879
+ }
1880
+
1859
1881
  workerApp(req, res);
1860
1882
  return;
1861
1883
  }
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: alexa
3
+ description: "Alexa voice channel for your agent via the public 'Morphy' skill. Code-based pairing, voice-first response style, three-pattern decision tree (fast / chat-deferred / HA-announce-deferred)."
4
+ ---
5
+
1
6
  # Alexa
2
7
 
3
8
  ## What This Is
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: mac
3
+ description: "Morphy native macOS companion. Activates on the [Mac] tag. Output is a concise spoken reply (TTS); optionally accompany it with a <notch_html>…</notch_html> visual card rendered inside the MacBook notch (383×147pt, transparent over black). Frequent snippets are cached in workspace/skills/mac/frequentSnippets/ for instant re-use."
4
+ ---
5
+
1
6
  # Mac (Morphy notch)
2
7
 
3
8
  ## What This Is
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: plaud
3
+ description: "Plaud Note integration. Pairs the user's Plaud account (email OTP or paste-token for Google/Apple identities), pulls recordings into workspace/files/audio/plaud/, and routes transcription through either the Bloby Marketplace audio-to-text service (pay-per-minute) or the human's own provider (Groq / OpenAI Whisper / Mistral Voxtral / local)."
4
+ ---
5
+
1
6
  # Plaud
2
7
 
3
8
  ## What This Is
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: whatsapp
3
+ description: "WhatsApp channel for your agent via Baileys. QR auth, messaging, voice transcription, channel and business modes."
4
+ ---
5
+
1
6
  # WhatsApp
2
7
 
3
8
  ## What This Is