openmausbot 0.1.74 → 0.1.76

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.
Files changed (51) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-CwoQdM0z.js → index-C6blGSq5.js} +1 -1
  3. package/dist/assets/index-fKXFpB_J.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +133 -11
  7. package/dist-server/index.js +3379 -1431
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +557 -325
  10. package/dist-server/pair-cli.js +557 -325
  11. package/dist-server/permission-proxy.js +119 -14
  12. package/dist-server/server/agent-tool-policy.js +1 -0
  13. package/dist-server/server/atomic.js +37 -1
  14. package/dist-server/server/auto-approve.js +15 -0
  15. package/dist-server/server/bot-overview.js +5 -1
  16. package/dist-server/server/chief-of-staff.js +17 -12
  17. package/dist-server/server/delegations.js +177 -63
  18. package/dist-server/server/drivers/acp/core.js +2 -1
  19. package/dist-server/server/drivers/acp/grok.js +12 -1
  20. package/dist-server/server/drivers/acp/hermes.js +21 -3
  21. package/dist-server/server/drivers/agents-proxy.js +113 -10
  22. package/dist-server/server/drivers/claude.js +91 -17
  23. package/dist-server/server/drivers/codex.js +15 -4
  24. package/dist-server/server/drivers/openai-chat.js +90 -67
  25. package/dist-server/server/drivers/openai-compat.js +9 -1
  26. package/dist-server/server/drivers/pi.js +18 -3
  27. package/dist-server/server/drivers/retry.js +4 -0
  28. package/dist-server/server/env-path.js +18 -17
  29. package/dist-server/server/index.js +1172 -302
  30. package/dist-server/server/message-db.js +21 -0
  31. package/dist-server/server/peer-roster.js +48 -9
  32. package/dist-server/server/permission-proxy.js +90 -15
  33. package/dist-server/server/procs.js +19 -3
  34. package/dist-server/server/room-handoffs.js +237 -0
  35. package/dist-server/server/section-context.js +64 -14
  36. package/dist-server/server/setup-mode.js +6 -7
  37. package/dist-server/server/store.js +266 -8
  38. package/dist-server/server/surface.js +16 -5
  39. package/dist-server/server/team-backup.js +17 -1
  40. package/dist-server/server/team-setup-requests.js +252 -0
  41. package/dist-server/server/thread-events.js +10 -1
  42. package/dist-server/server/turn-dispatch-guard.js +31 -3
  43. package/dist-server/server/workspace.js +13 -0
  44. package/dist-server/shared/approval-mode.js +7 -0
  45. package/dist-server/shared/ask-question.js +172 -0
  46. package/dist-server/shared/provider-safety.js +6 -0
  47. package/dist-server/shared/team-backup.js +1 -0
  48. package/dist-server/shared/team-setup.js +1 -0
  49. package/package.json +1 -1
  50. package/dist/assets/index-BRLHsQ5g.js +0 -298
  51. package/dist/assets/index-ByLQ7a3F.css +0 -1
@@ -17,8 +17,7 @@ import { getOrCreateChannel, mirrorExchange } from "./comms-visibility.js";
17
17
  import { DATA_DIR } from "./config.js";
18
18
  import { newId } from "./contracts.js";
19
19
  import { requestPeerApproval } from "./peer-approval.js";
20
- import { peerAllowed } from "./peer-roster.js";
21
- import { sectionKey } from "./store.js";
20
+ import { canAccessTeam, peerAllowed } from "./peer-roster.js";
22
21
  /** Per source-thread queue. Persisted to delegations.json on every change
23
22
  * and reloaded at boot: a handoff queued right before a restart runs after
24
23
  * it. (Provider PERMISSIONS still die with the process — nobody can answer
@@ -35,7 +34,12 @@ const RECEIPTS_FILE = join(DATA_DIR, "delegation-receipts.json");
35
34
  const MAX_RECEIPTS = 100;
36
35
  const RECEIPT_MAX_AGE_MS = 48 * 60 * 60 * 1000;
37
36
  const RESULT_MAX_CHARS = 4_000;
38
- export const MAX_BUSY_ATTEMPTS = 3;
37
+ /** A busy handoff's delivery window. An available target may still pick up
38
+ * an overdue item. At restart, elapsed windows are renewed before any boot
39
+ * dispatch, so the first recovered job cannot cause the remaining backlog
40
+ * to expire. This is not an uptime clock: sleep within a running process
41
+ * still counts, and a non-expired restored window keeps its deadline. */
42
+ export const DELEGATION_TTL_MS = 24 * 60 * 60 * 1000;
39
43
  let receipts = [];
40
44
  function saveReceipts() {
41
45
  try {
@@ -72,7 +76,7 @@ export function pendingDelegationInfo(id) {
72
76
  for (const [sourceThreadId, items] of pendingDelegations) {
73
77
  const item = items.find((candidate) => candidate.id === id);
74
78
  if (item)
75
- return { sourceThreadId, toBotId: item.toBotId, attempts: item.attempts };
79
+ return { sourceThreadId, toBotId: item.toBotId, queuedAt: item.queuedAt, waiting: item.waitingOnBusy === true };
76
80
  }
77
81
  return null;
78
82
  }
@@ -85,8 +89,10 @@ export function threadsWaitingOn(toBotId) {
85
89
  .map(([threadId]) => threadId);
86
90
  }
87
91
  /** Mark a target's observed busy period as finished and return the source
88
- * threads that should be retried. This makes retries count distinct busy
89
- * periods, not unrelated drain requests on the same source thread.
92
+ * threads that should be retried. A handoff waits until the target is free,
93
+ * bounded only by the 24-hour expiry — this just clears the "parked on a
94
+ * busy period" marker so the next drain re-evaluates it, rather than
95
+ * counting or limiting retries.
90
96
  * `only` narrows the release: a bot that is still busy in one thread has
91
97
  * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
92
98
  * while its active-thread handoffs go on waiting for it to go idle. */
@@ -118,8 +124,10 @@ function savePending() {
118
124
  /** Load what a previous process left queued. Missing or corrupt → empty. */
119
125
  export function _loadPending() {
120
126
  pendingDelegations.clear();
127
+ let backfilled = false;
121
128
  try {
122
129
  const raw = JSON.parse(readFileSync(DELEGATIONS_FILE, "utf8"));
130
+ const now = Date.now();
123
131
  for (const [threadId, list] of Object.entries(raw)) {
124
132
  if (!Array.isArray(list))
125
133
  continue;
@@ -131,6 +139,20 @@ export function _loadPending() {
131
139
  typeof item.message !== "string" ||
132
140
  !Number.isFinite(item.depth))
133
141
  return [];
142
+ // Restore every elapsed window before boot dispatch. Merely checking
143
+ // whether the target is idle loses the second old job as soon as the
144
+ // first recovered job occupies it. Invalid/legacy timestamps get the
145
+ // same fresh window; valid, unexpired windows keep their deadline.
146
+ const hasUsableQueuedAt = Number.isFinite(item.queuedAt) && item.queuedAt <= now &&
147
+ now - item.queuedAt < DELEGATION_TTL_MS;
148
+ if (!hasUsableQueuedAt)
149
+ backfilled = true;
150
+ // A legacy item saved with attempts >= 1 already posted its old
151
+ // "retry n/3" chip under the since-removed bounded-retry scheme; load
152
+ // it as already announced so it doesn't post a second waiting chip
153
+ // the first time this queue drains.
154
+ const legacyAttempts = value.attempts;
155
+ const legacyAlreadyAnnounced = typeof legacyAttempts === "number" && Number.isFinite(legacyAttempts) && legacyAttempts > 0;
134
156
  const loaded = {
135
157
  id: typeof item.id === "string" && item.id ? item.id : newId(),
136
158
  sourceBotId: typeof item.sourceBotId === "string" && item.sourceBotId ? item.sourceBotId : "",
@@ -138,12 +160,14 @@ export function _loadPending() {
138
160
  message: item.message,
139
161
  ...(typeof item.reason === "string" ? { reason: item.reason } : {}),
140
162
  depth: Math.max(0, Math.trunc(item.depth)),
141
- attempts: Number.isFinite(item.attempts) ? Math.max(0, Math.trunc(item.attempts)) : 0,
163
+ queuedAt: hasUsableQueuedAt ? Math.min(item.queuedAt, now) : now,
142
164
  };
143
165
  if (item.approvalAlreadyGranted === true)
144
166
  loaded.approvalAlreadyGranted = true;
145
167
  if (item.waitingOnBusy === true)
146
168
  loaded.waitingOnBusy = true;
169
+ if (item.waitAnnounced === true || legacyAlreadyAnnounced)
170
+ loaded.waitAnnounced = true;
147
171
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
148
172
  loaded.originatingGroupId = item.originatingGroupId;
149
173
  }
@@ -159,6 +183,10 @@ export function _loadPending() {
159
183
  catch {
160
184
  /* fresh install, or unreadable — start empty */
161
185
  }
186
+ // Persist repaired/renewed windows before dispatch. A quick restart loop
187
+ // must retain that still-valid deadline, not renew it on every load.
188
+ if (backfilled)
189
+ savePending();
162
190
  receipts = [];
163
191
  try {
164
192
  const rawReceipts = JSON.parse(readFileSync(RECEIPTS_FILE, "utf8"));
@@ -239,7 +267,7 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
239
267
  ? originatingGroup.id
240
268
  : undefined;
241
269
  const id = newId();
242
- list.push({ ...item, id, sourceBotId: from.id, attempts: 0, ...(groupId ? { originatingGroupId: groupId } : {}) });
270
+ list.push({ ...item, id, sourceBotId: from.id, queuedAt: Date.now(), ...(groupId ? { originatingGroupId: groupId } : {}) });
243
271
  pendingDelegations.set(sourceThreadId, list);
244
272
  savePending();
245
273
  const sourceGroup = sourceThreadId ? bus.store.groupByThread(sourceThreadId) : undefined;
@@ -332,8 +360,9 @@ onSettled) {
332
360
  }
333
361
  }
334
362
  finally {
335
- // A requeued item (busy target, retries left) stays for the drain
336
- // that the target's own settling turn will trigger.
363
+ // A requeued item (target still busy, waiting until it's free or the
364
+ // 24-hour expiry) stays for the drain that the target's own settling
365
+ // turn will trigger.
337
366
  const stillQueued = pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id);
338
367
  if (outcome !== "requeued")
339
368
  acknowledgeDelegation(threadId, item.id);
@@ -353,8 +382,8 @@ onSettled) {
353
382
  drainingThreads.delete(threadId);
354
383
  // A later turn may have queued and settled while this thread was
355
384
  // waiting for approval. Only items OUTSIDE our snapshot warrant a fresh
356
- // drain — re-draining a just-requeued item would burn its bounded busy
357
- // retries in milliseconds instead of once per target settle.
385
+ // drain — re-draining a just-requeued item would spin it in a tight
386
+ // loop instead of once per target settle.
358
387
  const redrainRequested = queuedRedrains.delete(threadId);
359
388
  const snapshotIds = new Set(snapshot.map((item) => item.id));
360
389
  const hasNewItems = pendingDelegations.get(threadId)?.some((item) => !snapshotIds.has(item.id)) ?? false;
@@ -375,6 +404,85 @@ function acknowledgeDelegation(threadId, itemId) {
375
404
  pendingDelegations.delete(threadId);
376
405
  savePending();
377
406
  }
407
+ const isExpired = (item, now) => now - item.queuedAt >= DELEGATION_TTL_MS;
408
+ /** Record an expired handoff. The chip goes into the source thread only
409
+ * while it still belongs to the bot that owns the handoff — a deleted
410
+ * conversation gets the receipt and nothing else. */
411
+ function expireDelegation(bus, sourceThreadId, item, ownerId) {
412
+ const name = bus.store.bot(item.toBotId)?.name ?? item.toBotId;
413
+ recordDelegationReceipt({
414
+ id: item.id,
415
+ sourceThreadId,
416
+ toBotId: item.toBotId,
417
+ toBotName: name,
418
+ status: "expired",
419
+ result: `@${name} was not free to take this for 24 hours`,
420
+ });
421
+ if (!sourceThreadBelongsToBot(bus.store, ownerId, sourceThreadId))
422
+ return;
423
+ bus.store.appendMessage(sourceThreadId, {
424
+ role: "bot",
425
+ kind: "activity",
426
+ tool: { name: `Delegation to @${name} expired — not picked up within 24 hours`, ok: false },
427
+ });
428
+ }
429
+ /** Past its 24 hours AND unable to be delivered right now — the same rule
430
+ * `processOne` applies, so the hourly sweep and a live drain never disagree
431
+ * about which items are actually stuck. A target that was deleted counts as
432
+ * "cannot take the turn": there is nothing to wait on, so such items still
433
+ * expire even though there is no bot left to test busy/free against. */
434
+ function isDueForExpiry(bus, item, now) {
435
+ if (!isExpired(item, now))
436
+ return false;
437
+ const target = bus.store.bot(item.toBotId);
438
+ if (!target)
439
+ return true;
440
+ return !targetCanTakeTurn(bus, target, item);
441
+ }
442
+ /** Expire every queued handoff past DELEGATION_TTL_MS that still cannot be
443
+ * delivered, wherever it waits. A drain already expires what it touches;
444
+ * this covers the handoff nothing drains — a target that never settles
445
+ * while its source sits idle. A thread mid-drain is skipped: that drain
446
+ * owns its items and expires them itself. An item whose target could take
447
+ * the turn right now is left queued instead — the next drain on its source
448
+ * thread (or the next `retryDelegationsWaitingOn`) delivers it; the sweep
449
+ * only cleans up what is genuinely stuck. Each expiry is reported through
450
+ * `onSettled`, the same hook a drain uses to wake the delegating bot.
451
+ * Returns how many expired. */
452
+ export function expireStaleDelegations(bus, now, onSettled) {
453
+ const expired = [];
454
+ for (const [threadId, items] of pendingDelegations) {
455
+ if (drainingThreads.has(threadId))
456
+ continue;
457
+ const due = items.filter((item) => isDueForExpiry(bus, item, now));
458
+ if (!due.length)
459
+ continue;
460
+ const remaining = items.filter((item) => !isDueForExpiry(bus, item, now));
461
+ if (remaining.length)
462
+ pendingDelegations.set(threadId, remaining);
463
+ else
464
+ pendingDelegations.delete(threadId);
465
+ const ownerId = bus.store.botByThread(threadId)?.id;
466
+ for (const item of due) {
467
+ expireDelegation(bus, threadId, item, ownerId ?? item.sourceBotId);
468
+ const receipt = findDelegationReceipt(item.id);
469
+ if (receipt)
470
+ expired.push(receipt);
471
+ }
472
+ }
473
+ if (!expired.length)
474
+ return 0;
475
+ savePending();
476
+ for (const receipt of expired) {
477
+ try {
478
+ onSettled?.(receipt);
479
+ }
480
+ catch (error) {
481
+ console.error("delegation expired but its source could not be resumed", error);
482
+ }
483
+ }
484
+ return expired.length;
485
+ }
378
486
  /** Drop a thread's queued handoffs without running them, telling the user
379
487
  * they were dropped. Used when the queueing turn failed or was interrupted. */
380
488
  export function discardDelegations(bus, threadId) {
@@ -441,7 +549,17 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
441
549
  if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
442
550
  return "settled";
443
551
  }
444
- const held = holdWhileTargetBusy(bus, target, sourceThreadId, item);
552
+ // Past its 24 hours AND the target still cannot take the turn: this is the
553
+ // bound on a busy wait. Use the same free/busy test as holdWhileTargetBusy;
554
+ // an available target gets even an overdue item. Restart recovery renews
555
+ // elapsed windows in _loadPending before any target becomes busy. Decide
556
+ // before announcing a wait so an item cannot post both chips in one pass.
557
+ const canTakeTurn = targetCanTakeTurn(bus, target, item);
558
+ if (!canTakeTurn && isExpired(item, Date.now())) {
559
+ expireDelegation(bus, sourceThreadId, item, sender.id);
560
+ return "settled";
561
+ }
562
+ const held = holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn);
445
563
  if (held)
446
564
  return held;
447
565
  if (item.waitingOnBusy) {
@@ -494,7 +612,15 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
494
612
  if (dropIfThreadGone(bus, current, sourceThreadId, item)) {
495
613
  return "settled";
496
614
  }
497
- const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item);
615
+ // Approval may have waited for minutes (or, with approvalAlreadyGranted,
616
+ // up to 24h since the original ask_bot approval) — recheck the same
617
+ // free/busy-gated expiry as the pre-approval path before dispatching.
618
+ const canTakeTurnAfterApproval = targetCanTakeTurn(bus, current, item);
619
+ if (!canTakeTurnAfterApproval && isExpired(item, Date.now())) {
620
+ expireDelegation(bus, sourceThreadId, item, currentSender.id);
621
+ return "settled";
622
+ }
623
+ const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item, canTakeTurnAfterApproval);
498
624
  if (heldAfterApproval)
499
625
  return heldAfterApproval;
500
626
  sender = currentSender;
@@ -516,62 +642,50 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
516
642
  await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
517
643
  return "dispatched";
518
644
  }
519
- /** A busy target holds the handoff. What "busy" means depends on where the
520
- * turn will run: a classic delegation lands in the target's active thread,
521
- * so it waits for the bot to go idle, with bounded retries so a stuck peer
522
- * cannot pin the handoff forever. A fresh-thread handoff needs only a free
523
- * slot, and waits in line for one without a bound — it IS the line, the
524
- * same one a person's message joins at that limit. Returns null when the
525
- * target can take the turn now. */
526
- function holdWhileTargetBusy(bus, target, sourceThreadId, item) {
527
- if (item.targetThreadId) {
528
- if (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
529
- return null;
530
- if (item.waitingOnBusy)
531
- return "requeued";
532
- item.waitingOnBusy = true;
533
- savePending();
534
- // one chip per wait, not one per retry: the line moves on every settle
535
- if (item.attempts === 0) {
536
- item.attempts = 1;
537
- const title = bus.store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
538
- bus.store.appendMessage(sourceThreadId, {
539
- role: "bot",
540
- kind: "activity",
541
- tool: { name: `Thread #${title} on @${target.name} waiting for a free slot` },
542
- });
543
- }
544
- return "requeued";
545
- }
546
- if (!target.busy)
645
+ /** "Is the target free to take this handoff right now?" What "busy" means
646
+ * depends on where the turn will run: a classic delegation lands in the
647
+ * target's active thread, so it needs the bot to be idle; a fresh-thread
648
+ * handoff needs only a free slot. This is the single free/busy test shared
649
+ * by the expiry decision in `processOne` and the hold decision below, so
650
+ * the two can never disagree about whether a handoff could have been
651
+ * delivered right now. */
652
+ function targetCanTakeTurn(bus, target, item) {
653
+ return item.targetThreadId
654
+ ? (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
655
+ : !target.busy;
656
+ }
657
+ /** A busy target holds the handoff. Neither counts busy periods — the only
658
+ * bound is DELEGATION_TTL_MS, checked in processOne before this runs (using
659
+ * the same `targetCanTakeTurn` test, passed in as `canTakeTurn` when the
660
+ * caller already computed it so the two checks can't disagree). One waiting
661
+ * chip per handoff, worded for what the target is actually doing. Returns
662
+ * null when the target can take the turn now. */
663
+ function holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn = targetCanTakeTurn(bus, target, item)) {
664
+ if (canTakeTurn)
547
665
  return null;
548
666
  if (item.waitingOnBusy)
549
667
  return "requeued";
550
- item.attempts += 1;
551
668
  item.waitingOnBusy = true;
552
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
553
- savePending();
669
+ if (!item.waitAnnounced) {
670
+ item.waitAnnounced = true;
554
671
  bus.store.appendMessage(sourceThreadId, {
555
672
  role: "bot",
556
673
  kind: "activity",
557
- tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
674
+ tool: { name: waitingChipText(bus.store, target, item) },
558
675
  });
559
- return "requeued";
560
676
  }
561
- recordDelegationReceipt({
562
- id: item.id,
563
- sourceThreadId,
564
- toBotId: target.id,
565
- toBotName: target.name,
566
- status: "busy_gave_up",
567
- result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
568
- });
569
- bus.store.appendMessage(sourceThreadId, {
570
- role: "bot",
571
- kind: "activity",
572
- tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
573
- });
574
- return "settled";
677
+ savePending();
678
+ return "requeued";
679
+ }
680
+ function waitingChipText(store, target, item) {
681
+ if (item.targetThreadId) {
682
+ const title = store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
683
+ return `Thread #${title} on @${target.name} waiting for a free slot`;
684
+ }
685
+ if (target.activity === "waiting-on-you") {
686
+ return `Waiting for @${target.name}, who's waiting on you — it'll go through after you answer`;
687
+ }
688
+ return `Delegation to @${target.name} waiting — they're busy; it'll go through when they're free`;
575
689
  }
576
690
  /** The thread a fresh-thread handoff was opened in may be deleted while the
577
691
  * handoff waits. There is nowhere for the turn to run then, and running it
@@ -609,8 +723,8 @@ function sourceThreadBelongsToBot(store, botId, threadId) {
609
723
  * was queued must be checked again at the final dispatch edge — the user may
610
724
  * have moved either bot, or narrowed the sender's peers, in between. */
611
725
  function dropIfUnreachable(bus, sender, target, sourceThreadId, item) {
612
- const sectionsDiffer = sectionKey(sender.section) !== sectionKey(target.section);
613
- if (!sectionsDiffer && peerAllowed(sender, target.id))
726
+ const sectionsDiffer = !canAccessTeam(sender, target.section);
727
+ if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target.id))
614
728
  return false;
615
729
  const reason = sectionsDiffer
616
730
  ? "bots now belong to different sections"
@@ -745,7 +745,8 @@ export function createAcpDriver(support) {
745
745
  }
746
746
  }
747
747
  const images = turn.images ?? [];
748
- const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true;
748
+ const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
749
+ support.acceptsUnadvertisedImages?.(init) === true;
749
750
  if (images.length && support.images === true && !runtimeAcceptsImages) {
750
751
  throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
751
752
  }
@@ -175,10 +175,21 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
175
175
  writeFileSync(path, next);
176
176
  return slug;
177
177
  }
178
+ /** Grok 1.0.25 consumes native image blocks but advertises image:false.
179
+ * Verified with the real CLI and a loopback model: scripts/verify-grok-images.ts.
180
+ * Keep unknown/older runtimes on the normal capability negotiation path. */
181
+ export function grokAcceptsUnadvertisedImages(init) {
182
+ const meta = init?._meta;
183
+ if (meta?.grokShell !== true || typeof meta.agentVersion !== "string")
184
+ return false;
185
+ const version = /^1\.0\.(\d+)$/.exec(meta.agentVersion);
186
+ return Boolean(version && Number(version[1]) >= 25);
187
+ }
178
188
  const support = {
179
189
  driverKind: "grokAgent",
180
190
  displayName: "Grok",
181
- images: false,
191
+ images: true,
192
+ acceptsUnadvertisedImages: grokAcceptsUnadvertisedImages,
182
193
  models: STATIC_GROK_MODELS,
183
194
  resolveModels: (env) => mergeLocalInject(readGrokModelCatalog(env), env),
184
195
  // Grok's accepted levels vary by model and the CLI validates lazily — a
@@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { parse as parseYaml } from "yaml";
12
+ import { resolveCli } from "../../procs.js";
12
13
  import { decodeInjectId, hostApiKey, INJECT_SEP, localHost, mergeLocalInject } from "../local-inject.js";
13
14
  import { createAcpDriver } from "./core.js";
14
15
  const EMPTY = { default: "", options: [] };
@@ -249,11 +250,28 @@ export function hermesConfiguredModel(env = process.env) {
249
250
  * Failure is non-fatal and returns [] — a catalog probe must never be the
250
251
  * reason an agent becomes unselectable.
251
252
  */
252
- async function fetchHermesAcpModels(cli, env) {
253
+ export const HERMES_ACP_MODELS_TIMEOUT_ENV = "HERMES_ACP_MODELS_TIMEOUT_MS";
254
+ /** Overall deadline for the initialize + session/new catalog probe. A Hermes
255
+ * install with several authenticated providers answers `initialize` in about
256
+ * a second but needs ~6-7s for `session/new`, whose result carries the model
257
+ * list — the old 5s cap killed the probe every time on exactly the installs
258
+ * that have a catalog worth showing. */
259
+ export const HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS = 15_000;
260
+ function hermesAcpModelsTimeoutMs(env) {
261
+ const raw = env[HERMES_ACP_MODELS_TIMEOUT_ENV];
262
+ if (raw === undefined)
263
+ return HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS;
264
+ const parsed = Number(raw);
265
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 2_147_483_647
266
+ ? parsed
267
+ : HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS;
268
+ }
269
+ export async function fetchHermesAcpModels(cli, env) {
253
270
  return await new Promise((resolve) => {
254
271
  let child;
255
272
  try {
256
- child = spawn(cli, ["acp"], { stdio: ["pipe", "pipe", "ignore"], env: env });
273
+ const resolved = resolveCli(cli, ["acp"], env);
274
+ child = spawn(resolved.command, resolved.args, { stdio: ["pipe", "pipe", "ignore"], env: env, windowsHide: true });
257
275
  }
258
276
  catch {
259
277
  return resolve([]);
@@ -285,7 +303,7 @@ async function fetchHermesAcpModels(cli, env) {
285
303
  }
286
304
  resolve(out);
287
305
  };
288
- timer = setTimeout(() => done([]), 5_000);
306
+ timer = setTimeout(() => done([]), hermesAcpModelsTimeoutMs(env));
289
307
  child.once("error", () => done([]));
290
308
  child.once("close", () => {
291
309
  if (hardKillTimer)