@saadnvd1/agent-os 0.3.775 → 0.3.778

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 (46) hide show
  1. package/app/api/live-shares/[id]/route.ts +72 -0
  2. package/app/api/live-shares/route.test.ts +45 -0
  3. package/app/api/live-shares/route.ts +40 -0
  4. package/app/page.tsx +5 -0
  5. package/components/Chat/ChatPanel.tsx +6 -0
  6. package/components/Chat/EventRow.tsx +6 -0
  7. package/components/Live/GuestPromptCard.tsx +64 -0
  8. package/components/Live/InviteDialog.tsx +225 -0
  9. package/components/Live/LiveChip.tsx +109 -0
  10. package/components/Live/LivePanel.tsx +81 -0
  11. package/components/Live/LobbyCard.tsx +64 -0
  12. package/components/Pane/MobileTabBar.tsx +8 -0
  13. package/components/Pane/PaneMenu.tsx +10 -0
  14. package/components/SessionList/SessionRowMenu.tsx +6 -0
  15. package/components/Shares/SharedLinksPanel.tsx +51 -0
  16. package/data/live/index.ts +2 -0
  17. package/data/live/keys.ts +4 -0
  18. package/data/live/queries.ts +81 -0
  19. package/lib/chat/events.ts +4 -1
  20. package/lib/chat/registry.ts +10 -0
  21. package/lib/connect/control-api.ts +44 -0
  22. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  23. package/lib/live/clean.ts +54 -0
  24. package/lib/live/frames.ts +132 -0
  25. package/lib/live/inbox.ts +225 -0
  26. package/lib/live/publisher.ts +84 -0
  27. package/lib/live/ring.ts +61 -0
  28. package/lib/live/runtime.ts +51 -0
  29. package/lib/live/service.ts +318 -0
  30. package/lib/live/socket.ts +293 -0
  31. package/lib/live/store.ts +149 -0
  32. package/lib/live/types.ts +39 -0
  33. package/lib/orchestrator/describe.ts +26 -2
  34. package/lib/orchestrator/facts.ts +4 -0
  35. package/lib/orchestrator/tools.ts +1 -1
  36. package/lib/program-status/store.ts +2 -1
  37. package/lib/session-activity.ts +5 -3
  38. package/lib/share/connect.ts +24 -0
  39. package/lib/share/redact.ts +23 -1
  40. package/lib/share/secret-patterns.ts +77 -0
  41. package/lib/sidebar/row-menu.ts +2 -0
  42. package/lib/sidebar/shelves.ts +4 -1
  43. package/lib/status/collect.ts +2 -1
  44. package/package.json +1 -1
  45. package/server.ts +2 -0
  46. package/stores/liveUi.ts +17 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What goes up to Connect for one chat item, or nothing. Which items a
3
+ * guest may see is decided by the shared snapshot rules (toRaw); this only
4
+ * shapes them into frames. Text is redacted whole, never as a delta.
5
+ */
6
+
7
+ import type { ChatItem, ChatState } from "../chat/events";
8
+ import { isSecretPath } from "../secret-paths";
9
+ import { toRaw, strings } from "../share/snapshot";
10
+ import { redactForShare, type ShareContext } from "./../share/redact";
11
+
12
+ export type ItemFrame =
13
+ | {
14
+ id: string;
15
+ kind: "user" | "assistant" | "note";
16
+ text: string;
17
+ from?: string;
18
+ at: number;
19
+ }
20
+ | {
21
+ id: string;
22
+ kind: "tool";
23
+ name: string;
24
+ summary: string;
25
+ status: "running" | "done" | "error";
26
+ at: number;
27
+ };
28
+
29
+ export type LiveState = "working" | "idle" | "waiting_on_host";
30
+
31
+ export const MAX_TEXT_BYTES = 24 * 1024;
32
+ export const MAX_SUMMARY_BYTES = 200;
33
+ const MAX_NAME_BYTES = 64;
34
+ const MAX_ID = 64;
35
+
36
+ /** The longest prefix of `s` that fits in `max` bytes, cut on a character. */
37
+ export function clipBytes(s: string, max: number): string {
38
+ if (Buffer.byteLength(s) <= max) return s;
39
+ let out = "";
40
+ let size = 0;
41
+ for (const ch of s) {
42
+ const n = Buffer.byteLength(ch);
43
+ if (size + n > max) break;
44
+ out += ch;
45
+ size += n;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
51
+
52
+ export function stateOf(state: ChatState): LiveState {
53
+ if (state === "running") return "working";
54
+ if (state === "waiting") return "waiting_on_host";
55
+ return "idle";
56
+ }
57
+
58
+ // Messages that were not typed by the reader (the agent bus, schedules,
59
+ // AgentOS itself) stay on this machine; a sent suggestion is the reader's.
60
+ function userFrom(item: Extract<ChatItem, { kind: "user" }>): string | null {
61
+ const o = item.origin;
62
+ if (!o) return item.peer || item.from ? null : "host";
63
+ if (o.kind === "guest") return `guest:${o.label}`;
64
+ if (o.kind === "decision") return "host";
65
+ return null;
66
+ }
67
+
68
+ export interface ItemMapper {
69
+ /** `text` replaces the item's own text (an assistant turn still streaming). */
70
+ (item: ChatItem, text?: string): ItemFrame | null;
71
+ }
72
+
73
+ export function itemMapper(ctx: ShareContext): ItemMapper {
74
+ const take = (s: string) => redactForShare(s, ctx).text;
75
+ return (item, text) => {
76
+ const at = item.createdAt ?? Date.now();
77
+ const id = item.id.slice(0, MAX_ID);
78
+ if (item.kind === "tool") {
79
+ const secret = strings(item.input).some(isSecretPath);
80
+ const label = secret ? item.name : item.title || item.name;
81
+ const status =
82
+ item.status === "running"
83
+ ? "running"
84
+ : item.status === "done"
85
+ ? "done"
86
+ : "error";
87
+ return {
88
+ id,
89
+ kind: "tool",
90
+ name: clipBytes(oneLine(take(item.name)), MAX_NAME_BYTES),
91
+ summary: clipBytes(oneLine(take(label)), MAX_SUMMARY_BYTES),
92
+ status,
93
+ at,
94
+ };
95
+ }
96
+ if (item.kind === "todos") {
97
+ const done = item.todos.filter((t) => t.status === "completed").length;
98
+ return {
99
+ id,
100
+ kind: "note",
101
+ text: `To do: ${done} of ${item.todos.length} done`,
102
+ at,
103
+ };
104
+ }
105
+ let from: string | undefined;
106
+ if (item.kind === "user") {
107
+ const f = userFrom(item);
108
+ if (!f) return null;
109
+ from = f;
110
+ }
111
+ const raw = toRaw(item);
112
+ if (!raw || raw.tools || raw.type === "artifact") return null;
113
+ const body = clipBytes(take(text ?? raw.text), MAX_TEXT_BYTES);
114
+ if (item.kind === "user")
115
+ return {
116
+ id,
117
+ kind: "user",
118
+ text: body,
119
+ from,
120
+ at,
121
+ };
122
+ if (item.kind === "assistant")
123
+ return body.trim() ? { id, kind: "assistant", text: body, at } : null;
124
+ // Plans, errors and the compaction divider are one-line notes.
125
+ return {
126
+ id,
127
+ kind: "note",
128
+ text: clipBytes(oneLine(body), 400) || "…",
129
+ at,
130
+ };
131
+ };
132
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * What Connect sends down. Only the types below are accepted, and only for a
3
+ * share this machine minted and still holds. Everything else is dropped and
4
+ * counted: nothing downward is executed, interpreted or sent on to an agent.
5
+ */
6
+
7
+ import { z } from "zod";
8
+ import { cleanBody, cleanName } from "./clean";
9
+ import { chunk } from "./ring";
10
+ import {
11
+ admittedGuestIds,
12
+ hasJoinRequest,
13
+ hasSuggestion,
14
+ logLive,
15
+ markLiveEnded,
16
+ openSuggestions,
17
+ } from "./store";
18
+ import { MAX_LOBBY, MAX_PENDING_CARDS, live, type Runtime } from "./runtime";
19
+
20
+ export const DOWNWARD = [
21
+ "roster",
22
+ "join_request",
23
+ "presence",
24
+ "suggestion",
25
+ "guest_left",
26
+ "ended",
27
+ "slow_down",
28
+ "invalid",
29
+ "admit_failed",
30
+ ] as const;
31
+
32
+ const id = z.string().min(1).max(64);
33
+ const guest = z.object({ id, name: z.string().max(400) });
34
+
35
+ const SCHEMAS = {
36
+ roster: z.object({
37
+ guests: z.array(guest.extend({ status: z.string().max(32) })).max(100),
38
+ }),
39
+ join_request: z.object({ guest }),
40
+ presence: z.object({ guest, online: z.boolean() }),
41
+ suggestion: z.object({ id, guest, text: z.string().max(8000) }),
42
+ guest_left: z.object({ guest }),
43
+ ended: z.object({}),
44
+ slow_down: z.object({}),
45
+ invalid: z.object({}),
46
+ admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
47
+ } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
48
+
49
+ export interface InboxEffects {
50
+ send: (
51
+ shareId: string,
52
+ action: string,
53
+ data?: Record<string, unknown>
54
+ ) => void;
55
+ /** The share is over: stop publishing and drop the subscription. */
56
+ ended: (shareId: string) => void;
57
+ }
58
+
59
+ const dropped = { count: 0 };
60
+ export const droppedCount = () => dropped.count;
61
+
62
+ function drop(why: string) {
63
+ dropped.count++;
64
+ console.warn(`live: dropped a frame from Connect (${why})`);
65
+ }
66
+
67
+ /** Handles one frame for the share it arrived on. Returns whether it was used. */
68
+ export function handleFrame(
69
+ shareId: string,
70
+ frame: Record<string, unknown>,
71
+ fx: InboxEffects,
72
+ now = Date.now()
73
+ ): boolean {
74
+ const rt = live.runtimes.get(shareId);
75
+ if (!rt || rt.share.ended_at) return (drop("unknown share"), false);
76
+ const type = frame.type;
77
+ if (
78
+ typeof type !== "string" ||
79
+ !(DOWNWARD as readonly string[]).includes(type)
80
+ )
81
+ return (drop("type not allowed"), false);
82
+ if (frame.share_id !== undefined && frame.share_id !== shareId)
83
+ return (drop("another share"), false);
84
+ const parsed = SCHEMAS[type as keyof typeof SCHEMAS].safeParse(frame);
85
+ if (!parsed.success) return (drop(`bad ${type}`), false);
86
+
87
+ switch (type) {
88
+ case "roster": {
89
+ const admitted = admittedGuestIds(shareId);
90
+ for (const g of (parsed.data as z.infer<typeof SCHEMAS.roster>).guests) {
91
+ const name = cleanName(g.name);
92
+ if (!name) continue;
93
+ if (admitted.has(g.id)) setGuest(rt, g.id, name, "admitted", now);
94
+ else if (g.status === "pending") {
95
+ if (!rt.guests.has(g.id) && pendingCount(rt) >= MAX_LOBBY) continue;
96
+ setGuest(rt, g.id, name, "pending", now);
97
+ }
98
+ }
99
+ return true;
100
+ }
101
+ case "join_request": {
102
+ const g = (parsed.data as z.infer<typeof SCHEMAS.join_request>).guest;
103
+ const name = cleanName(g.name);
104
+ if (!name) return (drop("bad name"), false);
105
+ if (rt.guests.has(g.id)) return true;
106
+ if (pendingCount(rt) >= MAX_LOBBY) {
107
+ fx.send(shareId, "deny", { guest_id: g.id });
108
+ return true;
109
+ }
110
+ setGuest(rt, g.id, name, "pending", now);
111
+ if (!hasJoinRequest(shareId, g.id))
112
+ logLive({
113
+ share_id: shareId,
114
+ kind: "join_request",
115
+ guest_id: g.id,
116
+ guest_name: name,
117
+ ref: null,
118
+ text: null,
119
+ sent_text: null,
120
+ });
121
+ return true;
122
+ }
123
+ case "presence": {
124
+ const p = parsed.data as z.infer<typeof SCHEMAS.presence>;
125
+ const known = rt.guests.get(p.guest.id);
126
+ if (!known || known.status !== "admitted") return true;
127
+ known.online = p.online;
128
+ if (p.online) {
129
+ const frames = chunk(rt.ring.list());
130
+ for (const items of frames)
131
+ fx.send(shareId, "catchup", { guest_id: known.id, items });
132
+ if (!frames.length) return true;
133
+ }
134
+ return true;
135
+ }
136
+ case "guest_left": {
137
+ const g = (parsed.data as z.infer<typeof SCHEMAS.guest_left>).guest;
138
+ const known = rt.guests.get(g.id);
139
+ if (!known) return true;
140
+ if (known.status === "pending") rt.guests.delete(g.id);
141
+ else known.online = false;
142
+ return true;
143
+ }
144
+ case "suggestion": {
145
+ const s = parsed.data as z.infer<typeof SCHEMAS.suggestion>;
146
+ const known = rt.guests.get(s.guest.id);
147
+ // Only a guest this machine let in can leave a card.
148
+ if (!known || known.status !== "admitted")
149
+ return (drop("suggestion from a guest not admitted here"), false);
150
+ const text = cleanBody(s.text);
151
+ if (!text) return (drop("empty suggestion"), false);
152
+ if (hasSuggestion(shareId, s.id))
153
+ return (drop("duplicate suggestion"), false);
154
+ if (openSuggestions(shareId).length >= MAX_PENDING_CARDS) {
155
+ fx.send(shareId, "suggestion_status", {
156
+ guest_id: known.id,
157
+ id: s.id,
158
+ status: "dismissed",
159
+ });
160
+ return true;
161
+ }
162
+ logLive({
163
+ share_id: shareId,
164
+ kind: "suggestion",
165
+ guest_id: known.id,
166
+ guest_name: known.name,
167
+ ref: s.id,
168
+ text,
169
+ sent_text: null,
170
+ });
171
+ return true;
172
+ }
173
+ case "ended":
174
+ markLiveEnded(shareId);
175
+ fx.ended(shareId);
176
+ return true;
177
+ case "slow_down":
178
+ rt.slowUntil = now + 5000;
179
+ return true;
180
+ case "admit_failed": {
181
+ const gid = (parsed.data as z.infer<typeof SCHEMAS.admit_failed>)
182
+ .guest_id;
183
+ const g = gid ? rt.guests.get(gid) : undefined;
184
+ if (g) g.status = "pending";
185
+ // The admit line must not outlive a refused admit.
186
+ if (gid && admittedGuestIds(shareId).has(gid))
187
+ logLive({
188
+ share_id: shareId,
189
+ kind: "deny",
190
+ guest_id: gid,
191
+ guest_name: g?.name ?? null,
192
+ ref: null,
193
+ text: null,
194
+ sent_text: null,
195
+ });
196
+ rt.notice = "Your plan's guest limit is reached.";
197
+ return true;
198
+ }
199
+ case "invalid":
200
+ rt.drops++;
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function pendingCount(rt: Runtime) {
207
+ return [...rt.guests.values()].filter((x) => x.status === "pending").length;
208
+ }
209
+
210
+ function setGuest(
211
+ rt: Runtime,
212
+ id: string,
213
+ name: string,
214
+ status: "pending" | "admitted",
215
+ at: number
216
+ ) {
217
+ const old = rt.guests.get(id);
218
+ rt.guests.set(id, {
219
+ id,
220
+ name,
221
+ status,
222
+ online: old?.online ?? false,
223
+ at: old?.at ?? at,
224
+ });
225
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Chat messages of a live session, as frames for Connect. Registered as a tap
3
+ * beside emit() in the chat registry, only for sessions with a live share.
4
+ * An assistant turn is redacted whole and sent at most once a second.
5
+ */
6
+
7
+ import type { ChatItem, ChatServerMessage } from "../chat/events";
8
+ import { listItems } from "../chat/store";
9
+ import { visible } from "../share/snapshot";
10
+ import { stateOf } from "./frames";
11
+ import { live, type Runtime } from "./runtime";
12
+
13
+ const FLUSH_MS = 1000;
14
+ const SLOW_FLUSH_MS = 4000;
15
+
16
+ export function seed(rt: Runtime, sessionId: string): void {
17
+ for (const item of visible(listItems(sessionId))) {
18
+ const frame = rt.map(item);
19
+ if (frame) rt.ring.upsert(frame);
20
+ }
21
+ }
22
+
23
+ export function flush(rt: Runtime): void {
24
+ if (rt.flushTimer) clearTimeout(rt.flushTimer);
25
+ rt.flushTimer = null;
26
+ const batch = [...rt.dirty.values()];
27
+ rt.dirty.clear();
28
+ for (const { item, text } of batch) {
29
+ // A word still being typed is held back: half a key isn't recognisable.
30
+ const open = text !== undefined && rt.streaming.has(item.id);
31
+ const frame = rt.map(item, open ? text.replace(/\S+$/, "") : text);
32
+ if (!frame) continue;
33
+ rt.ring.upsert(frame);
34
+ live.socket?.send(rt.share.id, "item", frame);
35
+ }
36
+ }
37
+
38
+ function schedule(rt: Runtime, now: boolean) {
39
+ if (now) return flush(rt);
40
+ if (rt.flushTimer) return;
41
+ rt.flushTimer = setTimeout(
42
+ () => flush(rt),
43
+ Date.now() < rt.slowUntil ? SLOW_FLUSH_MS : FLUSH_MS
44
+ );
45
+ }
46
+
47
+ function mark(rt: Runtime, item: ChatItem, text?: string) {
48
+ rt.dirty.set(item.id, { item, text });
49
+ }
50
+
51
+ export function onMessage(sessionId: string, m: ChatServerMessage): void {
52
+ for (const rt of live.runtimes.values()) {
53
+ if (rt.share.session_id !== sessionId || rt.share.ended_at) continue;
54
+ if (m.type === "state") {
55
+ live.socket?.send(rt.share.id, "state", { state: stateOf(m.state) });
56
+ } else if (m.type === "item") {
57
+ const item = m.item;
58
+ if (item.kind === "turn_end") {
59
+ flush(rt);
60
+ live.socket?.send(rt.share.id, "state", { state: "idle" });
61
+ continue;
62
+ }
63
+ const streaming =
64
+ (item.kind === "assistant" || item.kind === "reasoning") &&
65
+ item.streaming === true;
66
+ if (item.kind === "assistant") {
67
+ if (streaming) rt.streaming.set(item.id, { item, text: item.text });
68
+ else rt.streaming.delete(item.id);
69
+ }
70
+ mark(
71
+ rt,
72
+ item,
73
+ item.kind === "assistant" && streaming ? item.text : undefined
74
+ );
75
+ schedule(rt, !streaming);
76
+ } else if (m.type === "delta") {
77
+ const cur = rt.streaming.get(m.id);
78
+ if (!cur) continue;
79
+ cur.text += m.text;
80
+ mark(rt, cur.item, cur.text);
81
+ schedule(rt, false);
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,61 @@
1
+ import type { ItemFrame } from "./frames";
2
+
3
+ export const RING_ITEMS = 200;
4
+ export const RING_BYTES = 512 * 1024;
5
+
6
+ const sizeOf = (f: ItemFrame) => Buffer.byteLength(JSON.stringify(f));
7
+
8
+ /** The last items of a share, already redacted, for guests who join late. */
9
+ export class Ring {
10
+ private items = new Map<string, { frame: ItemFrame; size: number }>();
11
+ private bytes = 0;
12
+
13
+ upsert(frame: ItemFrame): void {
14
+ const old = this.items.get(frame.id);
15
+ if (old) this.bytes -= old.size;
16
+ const size = sizeOf(frame);
17
+ // Same slot when it already exists, so an upsert doesn't reorder the chat.
18
+ this.items.set(frame.id, { frame, size });
19
+ this.bytes += size;
20
+ while (this.items.size > RING_ITEMS || this.bytes > RING_BYTES) {
21
+ const first = this.items.keys().next().value;
22
+ if (first === undefined) break;
23
+ this.bytes -= this.items.get(first)!.size;
24
+ this.items.delete(first);
25
+ }
26
+ }
27
+
28
+ list(): ItemFrame[] {
29
+ return [...this.items.values()].map((v) => v.frame);
30
+ }
31
+
32
+ get size() {
33
+ return this.items.size;
34
+ }
35
+
36
+ get byteSize() {
37
+ return this.bytes;
38
+ }
39
+ }
40
+
41
+ /** Catch-up frames: at most 200 items and `maxBytes` each, in order. */
42
+ export function chunk(
43
+ frames: ItemFrame[],
44
+ maxBytes = 48 * 1024
45
+ ): ItemFrame[][] {
46
+ const out: ItemFrame[][] = [];
47
+ let cur: ItemFrame[] = [];
48
+ let size = 0;
49
+ for (const f of frames) {
50
+ const n = sizeOf(f);
51
+ if (cur.length && (size + n > maxBytes || cur.length >= RING_ITEMS)) {
52
+ out.push(cur);
53
+ cur = [];
54
+ size = 0;
55
+ }
56
+ cur.push(f);
57
+ size += n;
58
+ }
59
+ if (cur.length) out.push(cur);
60
+ return out;
61
+ }
@@ -0,0 +1,51 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import type { ShareContext } from "../share/redact";
3
+ import type { LiveShareRow } from "./store";
4
+ import type { ItemMapper } from "./frames";
5
+ import { Ring } from "./ring";
6
+ import type { LiveSocket } from "./socket";
7
+
8
+ export interface Guest {
9
+ id: string;
10
+ name: string;
11
+ /** pending: waiting in the lobby. admitted: let in by this machine. */
12
+ status: "pending" | "admitted";
13
+ online: boolean;
14
+ /** When the request arrived, for the 10-minute lobby expiry. */
15
+ at: number;
16
+ }
17
+
18
+ /** What one live share holds in memory; everything durable is in the log. */
19
+ export interface Runtime {
20
+ share: LiveShareRow;
21
+ guests: Map<string, Guest>;
22
+ ring: Ring;
23
+ ctx: ShareContext;
24
+ map: ItemMapper;
25
+ /** Accumulated text of assistant items still streaming, by item id. */
26
+ streaming: Map<string, { item: ChatItem; text: string }>;
27
+ /** Items waiting for the next flush, by id (latest wins). */
28
+ dirty: Map<string, { item: ChatItem; text?: string }>;
29
+ flushTimer: ReturnType<typeof setTimeout> | null;
30
+ slowUntil: number;
31
+ notice: string | null;
32
+ drops: number;
33
+ }
34
+
35
+ export interface LiveGlobals {
36
+ runtimes: Map<string, Runtime>;
37
+ socket: LiveSocket | null;
38
+ started: boolean;
39
+ }
40
+
41
+ // One per process, shared by the custom server and the route bundles.
42
+ const g = globalThis as unknown as { __agentosLive?: LiveGlobals };
43
+ export const live: LiveGlobals = (g.__agentosLive ??= {
44
+ runtimes: new Map(),
45
+ socket: null,
46
+ started: false,
47
+ });
48
+
49
+ export const LOBBY_MS = 10 * 60_000;
50
+ export const MAX_PENDING_CARDS = 20;
51
+ export const MAX_LOBBY = 10;