@cello-protocol/cli 0.0.142 → 0.0.144

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.
@@ -1 +1 @@
1
- {"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../src/hermes/assets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,sEAAsE;AACtE,eAAO,MAAM,kBAAkB,w4CA6B9B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,QAkjCjC,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,eAAe,60GAiE3B,CAAC"}
1
+ {"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../src/hermes/assets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,sEAAsE;AACtE,eAAO,MAAM,kBAAkB,w4CA6B9B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,QAurCjC,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,eAAe,60GAiE3B,CAAC"}
@@ -150,6 +150,28 @@ CALL_TIMEOUT_SECONDS = 30.0
150
150
  # Server-side wait for the adapter's own cello_receive. Short on purpose: the notification that
151
151
  # triggered it means the content is already durable, so this is a fetch, not a poll.
152
152
  RECEIVE_TIMEOUT_MS = 5000
153
+
154
+ # State notices that channel mode does NOT hand to the agent, because a message follows within
155
+ # about a second and the notice's only effect is to occupy the agent at exactly the moment the
156
+ # message needs it free. Observed live 2026-08-07: 'created' started a turn, the message then
157
+ # found the chat busy, and the whole feature fell back to the manual path - the one time it
158
+ # worked, the agent had answered the notice with a bare [SILENT] and freed itself in time. The
159
+ # difference between "it works" and "it does nothing" was that race.
160
+ #
161
+ # A DENYLIST, deliberately: an unrecognised state is DELIVERED. Terminal ones (sealed, closed,
162
+ # interrupted) carry the only information about themselves - nothing follows them - so dropping
163
+ # an unknown state would be the silent kind of wrong.
164
+ STATE_WAKES_SUPPRESSED_IN_CHANNEL = {"created"}
165
+
166
+ # When the chat is mid-turn, wait for it rather than immediately downgrading to a notice. Turns
167
+ # end in seconds; fetching DURING one is the one thing that can lose a message outright. Total
168
+ # patience is LIMIT x DELAY before the notice fallback.
169
+ BUSY_RETRY_LIMIT = 5
170
+ BUSY_RETRY_DELAY_SECONDS = 2.0
171
+ # Upper bound on one drain. A conversation that has been away a long time can have a lot
172
+ # queued; handing an agent an unbounded turn is its own failure. Hitting this logs loudly -
173
+ # a silent truncation would read as "the agent saw everything" when it did not.
174
+ MAX_DRAIN_MESSAGES = 25
153
175
  # Matches the daemon IPC server's MAX_BUFFER_SIZE (4 MB).
154
176
  MAX_LINE_BYTES = 4 * 1024 * 1024
155
177
 
@@ -281,6 +303,8 @@ class CelloAdapter(BasePlatformAdapter):
281
303
  # may execute before there is one.
282
304
  self._wake_queue: Optional[asyncio.Queue] = None
283
305
  self._wake_task: Optional[asyncio.Task] = None
306
+ # Live references to pending busy-retry timers (see _requeue_wake_later).
307
+ self._retry_tasks: set = set()
284
308
  self._pending: Dict[str, asyncio.Future] = {}
285
309
  self._next_id = 1
286
310
  self._closing = False
@@ -369,6 +393,12 @@ class CelloAdapter(BasePlatformAdapter):
369
393
  for task in (self._read_task, self._reconnect_task, self._wake_task):
370
394
  if task is not None and not task.done():
371
395
  task.cancel()
396
+ # Pending retries name a chat that is going away; leaving them running would re-queue
397
+ # wakes against a dead socket after disconnect.
398
+ for task in list(self._retry_tasks):
399
+ if not task.done():
400
+ task.cancel()
401
+ self._retry_tasks.clear()
372
402
  self._read_task = None
373
403
  self._reconnect_task = None
374
404
  self._wake_task = None
@@ -418,6 +448,29 @@ class CelloAdapter(BasePlatformAdapter):
418
448
  if self._wake_task is None or self._wake_task.done():
419
449
  self._wake_task = asyncio.create_task(self._wake_worker())
420
450
 
451
+ def _requeue_wake_later(self, frame: Dict[str, Any], delay: float) -> None:
452
+ """Put a wake back on the queue after the given delay, off the worker.
453
+
454
+ A plain sleep inside the worker would stall EVERY other agent's wake behind this one
455
+ chat's turn, which is the opposite of what the retry is for.
456
+ """
457
+ async def _later() -> None:
458
+ try:
459
+ await asyncio.sleep(delay)
460
+ if self._closing or self._wake_queue is None:
461
+ return
462
+ self._wake_queue.put_nowait(frame)
463
+ except asyncio.CancelledError:
464
+ raise
465
+ except Exception:
466
+ logger.exception("[cello] Failed to re-queue a wake after a busy turn")
467
+
468
+ task = asyncio.create_task(_later())
469
+ # Hold a reference: asyncio only keeps a WEAK one, so an un-held task can be garbage
470
+ # collected mid-sleep and the wake would vanish with it.
471
+ self._retry_tasks.add(task)
472
+ task.add_done_callback(self._retry_tasks.discard)
473
+
421
474
  async def _wake_worker(self) -> None:
422
475
  while True:
423
476
  frame = await self._wake_queue.get()
@@ -591,45 +644,90 @@ class CelloAdapter(BasePlatformAdapter):
591
644
  notifications per connection (so this is the connection that was woken), and the
592
645
  read-before-send gate (M8C-CURSOR-1) tracks a per-connection cursor - so reading here is
593
646
  exactly what later lets send() through on the same socket.
647
+
648
+ DRAIN, not a single read. cello_receive serves THIS CONNECTION's oldest unread message,
649
+ not the one the notification just announced - the two are the same only when the
650
+ connection is already caught up. On a conversation with any history behind it (which is
651
+ every existing conversation the first time this adapter attaches) the first read returns
652
+ a message from minutes ago, and the agent answers the wrong thing.
653
+
654
+ Worse, it then cannot answer at all: the read-before-send gate refuses a send while
655
+ anything is still unread, so the reply the agent just wrote is REFUSED and lost - the
656
+ agent believes it answered and the peer hears nothing. Observed live 2026-08-07 on
657
+ session 9bc456f6: adapter read seq 0 (five minutes stale), agent replied, two
658
+ session.send.blocked with unreadReceived=1, reply gone.
659
+
660
+ Draining fixes both: the agent sees everything waiting, in order, and the gate is clear
661
+ by the time it answers.
594
662
  """
595
- try:
596
- # An explicit SHORT server-side wait. The daemon's default is 30 s, which is exactly
597
- # CALL_TIMEOUT_SECONDS - so the two would race and the client timeout could fire on a
598
- # call the daemon was about to answer. The content this notification announces is
599
- # already durable, so a long wait buys nothing.
600
- result = await self._call(
601
- "cello_receive",
602
- {"session_id": session_id, "timeout_ms": RECEIVE_TIMEOUT_MS},
603
- timeout=RECEIVE_TIMEOUT_MS / 1000.0 + 5.0,
604
- )
605
- except Exception as exc:
606
- # Name the EXCEPTION TYPE: asyncio.TimeoutError stringifies to "", so "%s" alone
607
- # produced a log line that named a session and no cause at all - pointing the operator
608
- # at the daemon when the fault could be entirely local.
609
- logger.error(
610
- "[cello] Could not fetch content for session %s (%s: %r) - falling back to a wake "
611
- "notice so the agent can still read it through the cello_* MCP tools",
612
- session_id, exc.__class__.__name__, exc,
613
- )
614
- return None
615
- if not isinstance(result, dict) or result.get("ok") is False:
616
- reason = result.get("reason") if isinstance(result, dict) else "malformed_response"
617
- logger.error(
618
- "[cello] cello_receive refused session %s (%s) - falling back to a wake notice",
619
- session_id, reason,
620
- )
621
- return None
622
- content = result.get("content")
623
- if not isinstance(content, str) or not content:
624
- # An 'ok' answer with nothing in it (timed out, or a sibling connection took the
625
- # message). An empty user turn tells the agent nothing; the wake notice at least
626
- # names the session.
663
+ parts: list = []
664
+ for attempt in range(MAX_DRAIN_MESSAGES):
665
+ try:
666
+ # First read waits briefly for the announced message; every later read is
667
+ # non-blocking (timeout_ms 0) because it is only draining what is ALREADY there.
668
+ # An explicit SHORT server-side wait on the first: the daemon's default is 30 s,
669
+ # exactly CALL_TIMEOUT_SECONDS, so the two would race and the client could give up
670
+ # on a call the daemon was about to answer.
671
+ wait_ms = RECEIVE_TIMEOUT_MS if attempt == 0 else 0
672
+ result = await self._call(
673
+ "cello_receive",
674
+ {"session_id": session_id, "timeout_ms": wait_ms},
675
+ timeout=wait_ms / 1000.0 + 5.0,
676
+ )
677
+ except Exception as exc:
678
+ # Name the EXCEPTION TYPE: asyncio.TimeoutError stringifies to "", so "%s" alone
679
+ # produced a log line that named a session and no cause at all - pointing the
680
+ # operator at the daemon when the fault could be entirely local.
681
+ logger.error(
682
+ "[cello] Could not fetch content for session %s (%s: %r) - %s",
683
+ session_id, exc.__class__.__name__, exc,
684
+ "delivering the %d message(s) already read" % len(parts) if parts
685
+ else "falling back to a wake notice so the agent can still read it through "
686
+ "the cello_* MCP tools",
687
+ )
688
+ break
689
+ if not isinstance(result, dict) or result.get("ok") is False:
690
+ reason = result.get("reason") if isinstance(result, dict) else "malformed_response"
691
+ # Only the FIRST read's refusal is a failure to report; a later one just means the
692
+ # drain reached the end (e.g. the session sealed between reads).
693
+ if not parts:
694
+ logger.error(
695
+ "[cello] cello_receive refused session %s (%s) - falling back to a wake "
696
+ "notice", session_id, reason,
697
+ )
698
+ break
699
+ content = result.get("content")
700
+ if not isinstance(content, str) or not content:
701
+ # 'ok' with nothing in it: the queue is empty. On the FIRST read that means the
702
+ # message went to a sibling connection or timed out, and an empty user turn tells
703
+ # the agent nothing - the wake notice at least names the session.
704
+ if not parts:
705
+ logger.warning(
706
+ "[cello] cello_receive returned no content for session %s - falling back "
707
+ "to a wake notice", session_id,
708
+ )
709
+ break
710
+ parts.append(content)
711
+ else:
712
+ # Hit the cap with more possibly waiting. Say so: a silent truncation here reads as
713
+ # "the agent saw everything" when it did not, and the unread tail will keep the
714
+ # read-before-send gate closed.
627
715
  logger.warning(
628
- "[cello] cello_receive returned no content for session %s - falling back to a "
629
- "wake notice", session_id,
716
+ "[cello] Stopped draining session %s at %d messages; any remaining are still "
717
+ "unread and may block this agent's next reply until it catches up",
718
+ session_id, MAX_DRAIN_MESSAGES,
630
719
  )
720
+
721
+ if not parts:
631
722
  return None
632
- return content
723
+ if len(parts) > 1:
724
+ logger.info(
725
+ "[cello] Delivered %d queued messages for session %s as one turn",
726
+ len(parts), session_id,
727
+ )
728
+ # Joined into ONE turn rather than emitted as several events: they share a session, so
729
+ # they share an anchor, and one turn means one reply - which is what the peer expects.
730
+ return "\n\n".join(parts)
633
731
 
634
732
  async def _on_notification(self, frame: Dict[str, Any]) -> None:
635
733
  kind = str(frame.get("notification", ""))
@@ -666,6 +764,21 @@ class CelloAdapter(BasePlatformAdapter):
666
764
  )
667
765
  return
668
766
 
767
+ # The phantom doorbell. In channel mode a 'created' notice announces a conversation that
768
+ # the message arriving a second later announces better - and handing it to the agent
769
+ # starts a turn that makes the agent BUSY exactly when the message needs it free.
770
+ if (
771
+ self._delivery_mode == "channel"
772
+ and kind == "session_state_changed"
773
+ and _safe_scalar(data.get("state")) in STATE_WAKES_SUPPRESSED_IN_CHANNEL
774
+ ):
775
+ logger.debug(
776
+ "[cello] Not waking the agent for a '%s' state notice on session %s - the message "
777
+ "that follows carries everything it says", _safe_scalar(data.get("state")),
778
+ session_id,
779
+ )
780
+ return
781
+
669
782
  counterparty = self._counterparty_of(kind, data)
670
783
  chat_id = self._chat_id_for(counterparty)
671
784
  if chat_id is None:
@@ -714,12 +827,32 @@ class CelloAdapter(BasePlatformAdapter):
714
827
  # not provide is worse than no check. Computed once here and reused below.
715
828
  session_key = self._session_key_for(source)
716
829
  text = None
717
- if (
718
- self._delivery_mode == "channel"
719
- and kind == "cello_message"
720
- and session_key not in self._active_sessions
721
- ):
722
- text = await self._fetch_content(session_id)
830
+ if self._delivery_mode == "channel" and kind == "cello_message":
831
+ if session_key in self._active_sessions:
832
+ # BUSY: wait for the turn rather than downgrading on the spot. Immediately falling
833
+ # back to the notice sent the agent down the manual path for every message that
834
+ # happened to land mid-turn - which is most of them, since the agent is busy more
835
+ # often than not. Retrying costs the peer a couple of seconds; the alternative
836
+ # costs them the feature. Fetching anyway is NOT an option: it consumes the
837
+ # message, and a busy chat's queued event can be merged or replaced.
838
+ attempts = frame.get("_cello_busy_retries", 0)
839
+ if isinstance(attempts, int) and attempts < BUSY_RETRY_LIMIT:
840
+ frame["_cello_busy_retries"] = attempts + 1
841
+ logger.debug(
842
+ "[cello] %s is mid-turn; re-trying the fetch for session %s in %.0fs "
843
+ "(attempt %d of %d)",
844
+ session_key, session_id, BUSY_RETRY_DELAY_SECONDS,
845
+ attempts + 1, BUSY_RETRY_LIMIT,
846
+ )
847
+ self._requeue_wake_later(frame, BUSY_RETRY_DELAY_SECONDS)
848
+ return
849
+ logger.info(
850
+ "[cello] %s stayed mid-turn for %.0fs; handing the agent a notice for session "
851
+ "%s instead of the message, so it can still read it with the cello_* tools",
852
+ session_key, BUSY_RETRY_LIMIT * BUSY_RETRY_DELAY_SECONDS, session_id,
853
+ )
854
+ else:
855
+ text = await self._fetch_content(session_id)
723
856
  if text is None:
724
857
  text = self._wake_prompt(kind, data)
725
858
  event = MessageEvent(
@@ -1 +1 @@
1
- {"version":3,"file":"assets.js","sourceRoot":"","sources":["../../src/hermes/assets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,sEAAsE;AACtE,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BjC,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkjC9C,CAAC;AAEF,wEAAwE;AACxE,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiE9B,CAAC"}
1
+ {"version":3,"file":"assets.js","sourceRoot":"","sources":["../../src/hermes/assets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,sEAAsE;AACtE,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BjC,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAurC9C,CAAC;AAEF,wEAAwE;AACxE,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiE9B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cello-protocol/cli",
3
- "version": "0.0.142",
3
+ "version": "0.0.144",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,8 +17,8 @@
17
17
  "package.json"
18
18
  ],
19
19
  "dependencies": {
20
- "@cello-protocol/protocol-types": "0.0.47",
21
- "@cello-protocol/daemon": "0.0.138"
20
+ "@cello-protocol/daemon": "0.0.138",
21
+ "@cello-protocol/protocol-types": "0.0.47"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^25.6.2"