@saadnvd1/agent-os 0.3.588 → 0.3.592

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/app/globals.css CHANGED
@@ -131,6 +131,17 @@
131
131
  animation: status-pulse 1.6s ease-in-out infinite;
132
132
  }
133
133
 
134
+ /* Shown only once something has taken a moment: a quick send never flashes it. */
135
+ @keyframes status-late {
136
+ from {
137
+ opacity: 0;
138
+ }
139
+ }
140
+
141
+ @utility status-late {
142
+ animation: status-late 0.2s ease-out 0.6s both;
143
+ }
144
+
134
145
  @media (prefers-reduced-motion: reduce) {
135
146
  .status-live {
136
147
  animation: none;
@@ -51,6 +51,7 @@ import { FIND_EVENT, isFindKey, useFind } from "./useFind";
51
51
  import { McpServers } from "./McpServers";
52
52
  import { ArtifactCard } from "./Artifact";
53
53
  import { PlanCard } from "./PlanCard";
54
+ import { ChatIdle } from "./idle";
54
55
  import { ContextMeter } from "./ContextMeter";
55
56
  import { SetupCard } from "./SetupCard";
56
57
  import { UserTurn } from "./EventRow";
@@ -77,6 +78,7 @@ import {
77
78
  CommandOutput,
78
79
  Compacted,
79
80
  SkillChip,
81
+ UserMessage,
80
82
  ErrorMessage,
81
83
  NoteLine,
82
84
  Reasoning,
@@ -86,10 +88,10 @@ import {
86
88
 
87
89
  interface ItemActions {
88
90
  respond: (id: string, answer: ApprovalDecision) => void;
89
- // Absent while the agent is working, and inside undone messages.
91
+ // Absent inside undone messages; hidden while the agent works (ChatIdle).
90
92
  onCarryPlan?: (id: string) => void;
91
93
  onKeepPlanning?: () => void;
92
- // Absent while the agent is working, and inside undone messages.
94
+ // Absent inside undone messages; hidden while the agent works (ChatIdle).
93
95
  onUndo?: (id: string) => void;
94
96
  onQuote?: (text: string) => void;
95
97
  }
@@ -294,6 +296,9 @@ export function ChatPanel({
294
296
  loadTaskOutput,
295
297
  taskOutputs,
296
298
  queue,
299
+ pending,
300
+ retrySend,
301
+ dropSend,
297
302
  queueEdit,
298
303
  queueMove,
299
304
  queueDelete,
@@ -606,12 +611,12 @@ export function ChatPanel({
606
611
  const actions: ItemActions = useMemo(
607
612
  () => ({
608
613
  respond,
609
- onUndo: idle ? (id) => undo(id, true) : undefined,
610
- onCarryPlan: idle ? carryPlan : undefined,
614
+ onUndo: (id) => undo(id, true),
615
+ onCarryPlan: carryPlan,
611
616
  onKeepPlanning: focusComposer,
612
617
  onQuote: (text) => setInsert(quoteInsert(text)),
613
618
  }),
614
- [respond, idle, undo, carryPlan, focusComposer]
619
+ [respond, undo, carryPlan, focusComposer]
615
620
  );
616
621
 
617
622
  return (
@@ -653,13 +658,42 @@ export function ChatPanel({
653
658
  </div>
654
659
  )}
655
660
  <SetupCard sessionId={sessionId} />
656
- {blocks.length === 0 && !queue.length && (
657
- <p className="text-muted-foreground py-16 text-center text-sm">
658
- Ask anything to start.
659
- </p>
660
- )}
661
+ {blocks.length === 0 &&
662
+ !queue.length &&
663
+ !pending.length && (
664
+ <p className="text-muted-foreground py-16 text-center text-sm">
665
+ Ask anything to start.
666
+ </p>
667
+ )}
661
668
  </div>
662
- <TimelineRows blocks={blocks} actions={actions} />
669
+ <ChatIdle.Provider value={idle}>
670
+ <TimelineRows blocks={blocks} actions={actions} />
671
+ </ChatIdle.Provider>
672
+ {pending.map((p) => (
673
+ <div
674
+ key={p.id}
675
+ className="mx-auto w-full max-w-3xl px-4 pb-4"
676
+ >
677
+ <UserMessage
678
+ item={{
679
+ id: p.id,
680
+ kind: "user",
681
+ text: p.text,
682
+ images: p.images,
683
+ createdAt: p.at,
684
+ }}
685
+ pending={{
686
+ status: p.status,
687
+ error: p.error,
688
+ onRetry: () => retrySend(p.id),
689
+ onEdit: () => {
690
+ dropSend(p.id);
691
+ setPrefill({ text: p.text, at: Date.now() });
692
+ },
693
+ }}
694
+ />
695
+ </div>
696
+ ))}
663
697
  </div>
664
698
  </BookmarkChatContext.Provider>
665
699
  </ToolBodies.Provider>
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "react";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { describe, expect, it, vi } from "vitest";
4
- import { AssistantMessage } from "./Messages";
4
+ import { AssistantMessage, UserMessage } from "./Messages";
5
5
 
6
6
  const item = {
7
7
  id: "a1",
@@ -47,3 +47,42 @@ describe("AssistantMessage quote action", () => {
47
47
  expect(html).not.toContain("Quote message");
48
48
  });
49
49
  });
50
+
51
+ describe("a sent message not in the chat yet", () => {
52
+ const user = { id: "user-c-1", kind: "user", text: "An article" } as never;
53
+ const render = (status: "sending" | "accepted" | "failed") =>
54
+ renderToStaticMarkup(
55
+ createElement(UserMessage, {
56
+ item: user,
57
+ pending: {
58
+ status,
59
+ error: "connection lost",
60
+ onRetry: vi.fn(),
61
+ onEdit: vi.fn(),
62
+ },
63
+ })
64
+ );
65
+
66
+ it("shows the text at once, dimmed, with its progress", () => {
67
+ expect(render("sending")).toContain("An article");
68
+ expect(render("sending")).toContain("opacity-60");
69
+ expect(render("sending")).toContain("Sending");
70
+ expect(render("accepted")).toContain("Starting the agent");
71
+ });
72
+
73
+ it("says a failed one wasn't sent, with Retry and Edit", () => {
74
+ const html = render("failed");
75
+ expect(html).toContain("Not sent · connection lost");
76
+ expect(html).toContain(">Retry</button>");
77
+ expect(html).toContain(">Edit</button>");
78
+ expect(html).not.toContain("opacity-60");
79
+ });
80
+
81
+ it("a message in the chat shows no status", () => {
82
+ const html = renderToStaticMarkup(
83
+ createElement(UserMessage, { item: user })
84
+ );
85
+ expect(html).not.toContain("Sending");
86
+ expect(html).not.toContain("Not sent");
87
+ });
88
+ });
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { memo } from "react";
3
4
  import {
4
5
  Check,
5
6
  ChevronRight,
@@ -9,6 +10,7 @@ import {
9
10
  Sparkles,
10
11
  } from "lucide-react";
11
12
  import type { ChatItem } from "@/lib/chat/events";
13
+ import type { PendingSend } from "@/lib/chat/pending";
12
14
  import { cn } from "@/lib/utils";
13
15
  import { leadingCommand } from "@/lib/chat/commands";
14
16
  import { compactedLine } from "@/lib/chat/stale";
@@ -68,7 +70,9 @@ function Prose({ text }: { text: string }) {
68
70
  );
69
71
  }
70
72
 
71
- function UserText({ text }: { text: string }) {
73
+ // Memoized: a chat full of pasted articles re-renders its rows when a turn
74
+ // starts and ends, and splitting and linking each one again is the cost.
75
+ const UserText = memo(function UserText({ text }: { text: string }) {
72
76
  return (
73
77
  <>
74
78
  {splitUserText(text).map((part, i) => {
@@ -89,45 +93,111 @@ function UserText({ text }: { text: string }) {
89
93
  })}
90
94
  </>
91
95
  );
96
+ });
97
+
98
+ // A sent message not in the chat yet (lib/chat/pending).
99
+ export interface PendingStatus {
100
+ status: PendingSend["status"];
101
+ error?: string;
102
+ onRetry: () => void;
103
+ onEdit: () => void;
104
+ }
105
+
106
+ function StatusButton({
107
+ onClick,
108
+ children,
109
+ }: {
110
+ onClick: () => void;
111
+ children: string;
112
+ }) {
113
+ return (
114
+ <button
115
+ type="button"
116
+ onClick={onClick}
117
+ className="text-foreground hover:bg-foreground/[0.06] min-h-11 rounded-md px-2 underline underline-offset-2 md:min-h-7"
118
+ >
119
+ {children}
120
+ </button>
121
+ );
122
+ }
123
+
124
+ function SendStatus({ pending }: { pending: PendingStatus }) {
125
+ if (pending.status === "failed")
126
+ return (
127
+ <div
128
+ data-copy-skip
129
+ role="alert"
130
+ className="text-destructive flex items-center gap-1 font-mono text-[11px]"
131
+ >
132
+ <span>Not sent · {pending.error}</span>
133
+ <StatusButton onClick={pending.onRetry}>Retry</StatusButton>
134
+ <StatusButton onClick={pending.onEdit}>Edit</StatusButton>
135
+ </div>
136
+ );
137
+ return (
138
+ <div
139
+ data-copy-skip
140
+ aria-live="polite"
141
+ className="text-muted-foreground status-late flex items-center gap-1.5 font-mono text-[11px]"
142
+ >
143
+ <span className="status-live bg-muted-foreground h-1.5 w-1.5 rounded-full" />
144
+ {pending.status === "accepted" ? "Starting the agent" : "Sending"}
145
+ </div>
146
+ );
92
147
  }
93
148
 
94
149
  export function UserMessage({
95
150
  item,
96
151
  onUndo,
152
+ pending,
97
153
  }: {
98
154
  item: Of<"user">;
99
155
  onUndo?: () => void;
156
+ pending?: PendingStatus;
100
157
  }) {
101
- return (
102
- <BookmarkPress itemId={item.id}>
103
- <div className="group flex flex-col items-end gap-1">
104
- {item.from && (
105
- <span data-copy-skip className="label-mono text-muted-foreground">
106
- from {item.from}
107
- </span>
108
- )}
109
- {item.images?.length ? (
110
- <div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
111
- {item.images.map((img, i) => (
112
- <ImageThumb
113
- key={i}
114
- src={`data:${img.mediaType};base64,${img.data}`}
115
- />
116
- ))}
117
- </div>
118
- ) : null}
119
- {item.text && (
120
- <div className="bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap">
121
- <UserText text={item.text} />
122
- </div>
123
- )}
158
+ const message = (
159
+ <div className="group flex flex-col items-end gap-1">
160
+ {item.from && (
161
+ <span data-copy-skip className="label-mono text-muted-foreground">
162
+ from {item.from}
163
+ </span>
164
+ )}
165
+ {item.images?.length ? (
166
+ <div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
167
+ {item.images.map((img, i) => (
168
+ <ImageThumb
169
+ key={i}
170
+ src={`data:${img.mediaType};base64,${img.data}`}
171
+ />
172
+ ))}
173
+ </div>
174
+ ) : null}
175
+ {item.text && (
176
+ <div
177
+ className={cn(
178
+ "bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap",
179
+ pending &&
180
+ (pending.status === "failed"
181
+ ? "ring-destructive ring-1 ring-inset"
182
+ : "opacity-60")
183
+ )}
184
+ >
185
+ <UserText text={item.text} />
186
+ </div>
187
+ )}
188
+ {pending ? (
189
+ <SendStatus pending={pending} />
190
+ ) : (
124
191
  <div className="-mr-1.5 flex items-center">
125
192
  {onUndo && <UndoButton onClick={onUndo} />}
126
193
  <BookmarkButton itemId={item.id} />
127
194
  </div>
128
- </div>
129
- </BookmarkPress>
195
+ )}
196
+ </div>
130
197
  );
198
+ // Not in the chat yet: nothing to star.
199
+ if (pending) return message;
200
+ return <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
131
201
  }
132
202
 
133
203
  export function AssistantMessage({
@@ -3,6 +3,7 @@
3
3
  import { Check, ListChecks } from "lucide-react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import type { ChatItem } from "@/lib/chat/events";
6
+ import { useChatIdle } from "./idle";
6
7
  import { Markdown } from "./Markdown";
7
8
 
8
9
  type PlanItem = Extract<ChatItem, { kind: "plan" }>;
@@ -14,10 +15,11 @@ export function PlanCard({
14
15
  onKeepPlanning,
15
16
  }: {
16
17
  item: PlanItem;
17
- // Absent while a turn runs.
18
+ // Absent inside undone messages; a running turn disables it too.
18
19
  onCarryOut?: () => void;
19
20
  onKeepPlanning: () => void;
20
21
  }) {
22
+ const idle = useChatIdle();
21
23
  return (
22
24
  <div className="bg-primary/[0.06] space-y-3 rounded-xl p-3">
23
25
  <div className="text-primary flex items-center gap-2 text-xs font-medium">
@@ -41,7 +43,7 @@ export function PlanCard({
41
43
  </Button>
42
44
  <Button
43
45
  className="h-11 md:h-9"
44
- disabled={!onCarryOut}
46
+ disabled={!onCarryOut || !idle}
45
47
  onClick={onCarryOut}
46
48
  >
47
49
  Carry it out
@@ -14,6 +14,7 @@ import type { UndoPreview } from "@/lib/chat/events";
14
14
  import type { UndoItem } from "@/lib/chat/group";
15
15
  import { shortPath } from "@/lib/chat/diff";
16
16
  import { cn } from "@/lib/utils";
17
+ import { useChatIdle } from "./idle";
17
18
  import { useRowState } from "./rowState";
18
19
 
19
20
  const counts = (p: { insertions?: number; deletions?: number }) =>
@@ -86,7 +87,9 @@ export function UndoDialog({
86
87
  );
87
88
  }
88
89
 
90
+ // Shown only while the chat is idle.
89
91
  export function UndoButton({ onClick }: { onClick: () => void }) {
92
+ if (!useChatIdle()) return null;
90
93
  return (
91
94
  <button
92
95
  type="button"
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ // Whether the chat can take an undo or carry out a plan (no turn running,
6
+ // connected). A context rather than a prop, so a turn starting or ending
7
+ // re-renders those buttons and not every row of a long conversation.
8
+ export const ChatIdle = createContext(true);
9
+ export const useChatIdle = () => useContext(ChatIdle);
@@ -23,6 +23,26 @@ import type {
23
23
  import type { SearchResult } from "@/lib/chat/find";
24
24
  import { addBody, touchBody } from "@/lib/chat/bodyCache";
25
25
  import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
26
+ import {
27
+ NOT_SENT,
28
+ clientId,
29
+ overSends,
30
+ toResend,
31
+ type PendingSend,
32
+ } from "@/lib/chat/pending";
33
+ import { usePending } from "./usePending";
34
+
35
+ // A pending send as the socket's `send`, under its own id.
36
+ const sendFrame = (
37
+ p: Pick<PendingSend, "id" | "text" | "images" | "compact">
38
+ ) =>
39
+ JSON.stringify({
40
+ type: "send",
41
+ text: p.text,
42
+ images: p.images,
43
+ clientId: p.id,
44
+ ...(p.compact && { compact: true }),
45
+ });
26
46
 
27
47
  // The index of the item with `id`, searched from the end: what changes is
28
48
  // almost always the latest.
@@ -180,6 +200,12 @@ export function useChat(
180
200
  onUndoneRef.current = onUndone;
181
201
  });
182
202
  const wsRef = useRef<WebSocket | null>(null);
203
+ const {
204
+ pending,
205
+ pendingRef,
206
+ dispatch: pend,
207
+ restore: restorePending,
208
+ } = usePending(sessionId, connected);
183
209
 
184
210
  useEffect(() => {
185
211
  let closed = false;
@@ -214,7 +240,18 @@ export function useChat(
214
240
  `${protocol}//${window.location.host}/ws/chat?session=${encodeURIComponent(sessionId)}&paged=1`
215
241
  );
216
242
  wsRef.current = ws;
217
- ws.onopen = () => setConnected(true);
243
+ ws.onopen = () => {
244
+ setConnected(true);
245
+ restorePending();
246
+ // Sends the last socket took and may have lost: the server drops
247
+ // any it already has.
248
+ for (const p of overSends(pendingRef.current))
249
+ pend({ type: "failed", id: p.id, error: NOT_SENT });
250
+ for (const p of toResend(pendingRef.current)) {
251
+ ws.send(sendFrame(p));
252
+ pend({ type: "resent", id: p.id, now: Date.now() });
253
+ }
254
+ };
218
255
  ws.onmessage = (event) => {
219
256
  const m = JSON.parse(event.data) as ChatServerMessage;
220
257
  if (m.type === "snapshot") {
@@ -235,8 +272,17 @@ export function useChat(
235
272
  setQueue(m.queue ?? []);
236
273
  setSuggestion(m.suggestion ?? null);
237
274
  setLoadedFor(sessionId);
275
+ pend({
276
+ type: "resolved",
277
+ ids: [...m.items, ...(m.queue ?? [])].map((i) => i.id),
278
+ });
238
279
  } else if (m.type === "queue") {
239
280
  setQueue(m.queue);
281
+ pend({ type: "resolved", ids: m.queue.map((q) => q.id) });
282
+ } else if (m.type === "accepted") {
283
+ pend({ type: "accepted", id: m.id, now: Date.now() });
284
+ } else if (m.type === "send_failed") {
285
+ pend({ type: "failed", id: m.id, error: m.message });
240
286
  } else if (m.type === "suggestion") {
241
287
  setSuggestion(m.text);
242
288
  } else if (m.type === "files") {
@@ -303,6 +349,8 @@ export function useChat(
303
349
  addBody(prev, m.id, m.body, visibleBodies.current)
304
350
  );
305
351
  } else if (m.type === "item") {
352
+ if (m.item.kind === "user")
353
+ pend({ type: "resolved", ids: [m.item.id] });
306
354
  withDeltas(() => {
307
355
  const routed = routeItem(
308
356
  itemsRef.current,
@@ -346,25 +394,44 @@ export function useChat(
346
394
  document.removeEventListener("visibilitychange", onVisible);
347
395
  wsRef.current?.close();
348
396
  };
349
- }, [sessionId, setItems, setEarlierTasks]);
397
+ }, [sessionId, setItems, setEarlierTasks, pend, pendingRef, restorePending]);
350
398
 
351
399
  const queryClient = useQueryClient();
352
400
  const send = useCallback(
353
401
  // `compact`: the reader let a stale chat compact first (lib/chat/stale).
402
+ // It shows as a pending bubble at once, until it's in the chat.
354
403
  (text: string, images?: ChatImage[], compact?: boolean) => {
404
+ const p = { id: clientId(), text, images, compact };
405
+ pend({ type: "add", send: p, now: Date.now() });
355
406
  const ws = wsRef.current;
356
- if (!ws) return;
357
- ws.send(
358
- JSON.stringify({
359
- type: "send",
360
- text,
361
- images,
362
- ...(compact && { compact: true }),
363
- })
364
- );
365
- if (ws.readyState === WebSocket.OPEN) markRunning(queryClient, sessionId);
407
+ if (ws?.readyState !== WebSocket.OPEN) {
408
+ pend({ type: "failed", id: p.id, error: NOT_SENT });
409
+ return;
410
+ }
411
+ ws.send(sendFrame(p));
412
+ markRunning(queryClient, sessionId);
366
413
  },
367
- [queryClient, sessionId]
414
+ [queryClient, sessionId, pend]
415
+ );
416
+
417
+ const retrySend = useCallback(
418
+ (id: string) => {
419
+ const p = pendingRef.current.find((s) => s.id === id);
420
+ const ws = wsRef.current;
421
+ if (!p) return;
422
+ if (ws?.readyState !== WebSocket.OPEN) {
423
+ pend({ type: "failed", id, error: NOT_SENT });
424
+ return;
425
+ }
426
+ ws.send(sendFrame(p));
427
+ pend({ type: "resent", id, now: Date.now() });
428
+ },
429
+ [pend, pendingRef]
430
+ );
431
+
432
+ const dropSend = useCallback(
433
+ (id: string) => pend({ type: "drop", id }),
434
+ [pend]
368
435
  );
369
436
 
370
437
  const interrupt = useCallback(() => {
@@ -549,6 +616,9 @@ export function useChat(
549
616
  loadTaskOutput,
550
617
  taskOutputs,
551
618
  queue,
619
+ pending,
620
+ retrySend,
621
+ dropSend,
552
622
  queueEdit,
553
623
  queueMove,
554
624
  queueDelete,
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ loadPending,
6
+ pendingReducer,
7
+ savePending,
8
+ type PendingAction,
9
+ type PendingSend,
10
+ } from "@/lib/chat/pending";
11
+
12
+ const NONE: PendingSend[] = [];
13
+
14
+ // The composer's sends not yet in the chat (lib/chat/pending), kept in this
15
+ // browser per session. `dispatch` and `restore` are stable and read no
16
+ // render's state, so the socket's handlers can call them.
17
+ export function usePending(sessionId: string, connected: boolean) {
18
+ const [state, setState] = useState({ session: sessionId, list: NONE });
19
+ // Another session's sends never show here, even for the render before
20
+ // its own are restored.
21
+ const pending = state.session === sessionId ? state.list : NONE;
22
+ const ref = useRef<PendingSend[]>(NONE);
23
+ const session = useRef(sessionId);
24
+ const restored = useRef<string | null>(null);
25
+ useEffect(() => {
26
+ session.current = sessionId;
27
+ ref.current = NONE;
28
+ }, [sessionId]);
29
+
30
+ const replace = useCallback((list: PendingSend[]) => {
31
+ ref.current = list;
32
+ setState({ session: session.current, list });
33
+ }, []);
34
+ const dispatch = useCallback(
35
+ (action: PendingAction) => {
36
+ const next = pendingReducer(ref.current, action);
37
+ if (next === ref.current) return;
38
+ replace(next);
39
+ savePending(session.current, next);
40
+ },
41
+ [replace]
42
+ );
43
+ // Once per session, as its socket first opens: what an earlier page left
44
+ // mid-send, to be sent again.
45
+ const restore = useCallback(() => {
46
+ if (restored.current === session.current) return;
47
+ restored.current = session.current;
48
+ const now = Date.now();
49
+ replace(
50
+ loadPending(session.current).map((p) =>
51
+ p.status === "failed"
52
+ ? p
53
+ : { ...p, status: "sending" as const, at: now }
54
+ )
55
+ );
56
+ }, [replace]);
57
+
58
+ // Timeouts only count while connected: a reconnect sends them again.
59
+ const live = connected && pending.some((p) => p.status !== "failed");
60
+ useEffect(() => {
61
+ if (!live) return;
62
+ const timer = setInterval(
63
+ () => dispatch({ type: "tick", now: Date.now() }),
64
+ 1000
65
+ );
66
+ return () => clearInterval(timer);
67
+ }, [live, dispatch]);
68
+
69
+ return { pending, pendingRef: ref, dispatch, restore };
70
+ }
package/lib/bus/format.ts CHANGED
@@ -15,16 +15,19 @@ export interface BusMessageView {
15
15
  }
16
16
 
17
17
  const INLINE_LIMIT = 600;
18
+ // A relayed line stays under the linked machine's own cap (tasks/[id]/message).
19
+ export const RELAY_LIMIT = 30_000;
18
20
 
19
21
  // Enough of a session id to address it (aos send takes a unique prefix).
20
22
  export const shortId = (id: string) => id.slice(0, 8);
21
23
 
22
- // Typed into the recipient's terminal. Long bodies are left for `aos inbox`.
23
- export function wakeLine(m: {
24
- fromName: string;
25
- fromId: string | null;
26
- body: string;
27
- }): string {
24
+ // Typed into the recipient's terminal. Long bodies are left for `aos inbox`,
25
+ // unless `inline`: the recipient's AgentOS is another machine's, whose inbox
26
+ // never held the message, so the whole body goes in the line.
27
+ export function wakeLine(
28
+ m: { fromName: string; fromId: string | null; body: string },
29
+ opts: { inline?: boolean } = {}
30
+ ): string {
28
31
  const short = m.fromId ? shortId(m.fromId) : null;
29
32
  const who = short
30
33
  ? `"${m.fromName}" (${short})`
@@ -32,8 +35,13 @@ export function wakeLine(m: {
32
35
  ? "the user (via AgentOS)"
33
36
  : `${m.fromName} (via AgentOS)`;
34
37
  const oneLine = m.body.replace(/\s*\n\s*/g, " ").trim();
35
- const text =
36
- oneLine.length <= INLINE_LIMIT
38
+ const text = opts.inline
39
+ ? `: ${
40
+ oneLine.length <= RELAY_LIMIT
41
+ ? oneLine
42
+ : `${oneLine.slice(0, RELAY_LIMIT)} [cut: ${oneLine.length - RELAY_LIMIT} more chars; ask the sender to resend the rest]`
43
+ }`
44
+ : oneLine.length <= INLINE_LIMIT
37
45
  ? `: ${oneLine}`
38
46
  : ` (${m.body.length} chars). Read it with: aos inbox`;
39
47
  // By id: a reply can't break when either side is renamed.
package/lib/bus/index.ts CHANGED
@@ -333,7 +333,12 @@ export async function sendMessage(opts: {
333
333
  await statusDetector.refreshCache();
334
334
  const delivery = await deliver(
335
335
  to,
336
- opts.line ?? wakeLine({ fromName, fromId: from?.id ?? null, body }),
336
+ opts.line ??
337
+ wakeLine(
338
+ { fromName, fromId: from?.id ?? null, body },
339
+ // The linked machine's inbox never held a long body.
340
+ { inline: isHeldRemote(to) }
341
+ ),
337
342
  {
338
343
  name: fromName,
339
344
  messageId: id,