@saadnvd1/agent-os 0.3.770 → 0.3.777

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 (89) 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/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  5. package/app/api/shares/preview/route.ts +6 -1
  6. package/app/api/shares/route.test.ts +51 -0
  7. package/app/page.tsx +6 -0
  8. package/components/Artifacts/ArtifactActions.tsx +27 -3
  9. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  10. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  11. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  12. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  13. package/components/Artifacts/Compare/index.tsx +191 -0
  14. package/components/Chat/ChatPanel.tsx +6 -0
  15. package/components/Chat/EventRow.tsx +6 -0
  16. package/components/Chat/Messages.tsx +6 -0
  17. package/components/Chat/ShareFromHere.tsx +39 -0
  18. package/components/CommandPalette/CommandPalette.tsx +5 -4
  19. package/components/CommandPalette/CommandsBody.tsx +97 -131
  20. package/components/CommandPalette/PaletteRows.tsx +79 -0
  21. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  22. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  23. package/components/CommandPalette/useAppCommands.ts +14 -0
  24. package/components/CommandPalette/useConversationRows.ts +55 -0
  25. package/components/Live/GuestPromptCard.tsx +64 -0
  26. package/components/Live/InviteDialog.tsx +225 -0
  27. package/components/Live/LiveChip.tsx +109 -0
  28. package/components/Live/LivePanel.tsx +81 -0
  29. package/components/Live/LobbyCard.tsx +64 -0
  30. package/components/Pane/MobileTabBar.tsx +8 -0
  31. package/components/Pane/PaneMenu.tsx +10 -0
  32. package/components/SessionList/SessionRowMenu.tsx +6 -0
  33. package/components/SessionList/useSessionSwitching.ts +2 -2
  34. package/components/Share/MessageList.tsx +87 -0
  35. package/components/Share/RangePicker.tsx +59 -0
  36. package/components/Share/ShareDialog.tsx +113 -52
  37. package/components/Shares/SharedLinksPanel.tsx +51 -0
  38. package/data/live/index.ts +2 -0
  39. package/data/live/keys.ts +4 -0
  40. package/data/live/queries.ts +81 -0
  41. package/data/session-artifacts/keys.ts +2 -0
  42. package/data/session-artifacts/queries.ts +25 -0
  43. package/data/shares/queries.ts +11 -0
  44. package/hooks/usePaletteCommands.ts +4 -1
  45. package/lib/artifacts/actions.ts +11 -1
  46. package/lib/artifacts/compare/compareVersions.ts +66 -0
  47. package/lib/artifacts/compare/diff.ts +178 -0
  48. package/lib/artifacts/compare/extractText.ts +64 -0
  49. package/lib/artifacts/compare/index.ts +11 -0
  50. package/lib/artifacts/compare/types.ts +44 -0
  51. package/lib/artifacts/match.ts +64 -0
  52. package/lib/artifacts/store.ts +39 -10
  53. package/lib/artifacts/tools.ts +9 -4
  54. package/lib/artifacts/versions.ts +2 -1
  55. package/lib/chat/events.ts +4 -1
  56. package/lib/chat/registry.ts +10 -0
  57. package/lib/connect/control-api.ts +44 -0
  58. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  59. package/lib/live/clean.ts +54 -0
  60. package/lib/live/frames.ts +132 -0
  61. package/lib/live/inbox.ts +225 -0
  62. package/lib/live/publisher.ts +84 -0
  63. package/lib/live/ring.ts +61 -0
  64. package/lib/live/runtime.ts +51 -0
  65. package/lib/live/service.ts +318 -0
  66. package/lib/live/socket.ts +293 -0
  67. package/lib/live/store.ts +149 -0
  68. package/lib/live/types.ts +39 -0
  69. package/lib/palette/registry.ts +71 -17
  70. package/lib/palette/sections.ts +143 -0
  71. package/lib/palette/tabs.ts +21 -0
  72. package/lib/palette/usage.ts +81 -0
  73. package/lib/security/demo.ts +3 -0
  74. package/lib/share/connect.ts +24 -0
  75. package/lib/share/markdown.ts +3 -0
  76. package/lib/share/range.ts +189 -0
  77. package/lib/share/redact.ts +23 -1
  78. package/lib/share/render.tsx +3 -0
  79. package/lib/share/secret-patterns.ts +77 -0
  80. package/lib/share/service.ts +64 -19
  81. package/lib/share/snapshot.ts +39 -2
  82. package/lib/share/styles.ts +2 -0
  83. package/lib/share/view.ts +11 -0
  84. package/lib/sidebar/row-menu.ts +2 -0
  85. package/package.json +1 -1
  86. package/server.ts +2 -0
  87. package/stores/liveUi.ts +17 -0
  88. package/stores/palette.ts +13 -11
  89. package/stores/shareUi.ts +6 -1
@@ -0,0 +1,189 @@
1
+ // Which part of a chat a share covers. Pure, imports nothing outside itself,
2
+ // so the dialog and the server resolve a range the same way.
3
+
4
+ /** A range of messages by id, both ends included. */
5
+ export interface ShareRange {
6
+ fromId: string;
7
+ toId: string;
8
+ }
9
+
10
+ export interface RangeRow {
11
+ id: string;
12
+ type: string;
13
+ createdAt?: number;
14
+ }
15
+
16
+ export type RangePreset = "last" | "last5" | "today" | "all";
17
+
18
+ export const PRESETS: { id: RangePreset; label: string }[] = [
19
+ { id: "last", label: "Last exchange" },
20
+ { id: "last5", label: "Last 5 exchanges" },
21
+ { id: "today", label: "Today" },
22
+ { id: "all", label: "Whole chat" },
23
+ ];
24
+
25
+ export const EARLIER_NOT_SHARED = "Earlier messages not shared";
26
+ export const LATER_NOT_SHARED = "Later messages not shared";
27
+
28
+ export const DEFAULT_PRESET: RangePreset = "last";
29
+
30
+ /** Positions in `ids` that a range covers, or null when an end isn't there or they're backwards. A missing end means the chat's own start or end. */
31
+ export function windowOf(
32
+ ids: string[],
33
+ fromId?: string,
34
+ toId?: string
35
+ ): { start: number; end: number } | null {
36
+ const start = fromId === undefined ? 0 : ids.indexOf(fromId);
37
+ const end = toId === undefined ? ids.length - 1 : ids.indexOf(toId);
38
+ if (start < 0 || end < 0 || start > end) return null;
39
+ return { start, end };
40
+ }
41
+
42
+ /** Where each exchange begins: a user prompt. Rows before the first prompt are an exchange of their own. */
43
+ export function exchangeStarts(rows: RangeRow[]): number[] {
44
+ const starts = rows.flatMap((r, i) => (r.type === "user" ? [i] : []));
45
+ if (rows.length && starts[0] !== 0) starts.unshift(0);
46
+ return starts;
47
+ }
48
+
49
+ const startOfDay = (now: Date) =>
50
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
51
+
52
+ /** The range a preset means, or null when it has nothing to pick (no messages today). */
53
+ export function presetRange(
54
+ rows: RangeRow[],
55
+ preset: RangePreset,
56
+ now: Date
57
+ ): ShareRange | null {
58
+ if (!rows.length) return null;
59
+ const last = rows[rows.length - 1].id;
60
+ const starts = exchangeStarts(rows);
61
+ let from: number;
62
+ switch (preset) {
63
+ case "last":
64
+ from = starts[starts.length - 1];
65
+ break;
66
+ case "last5":
67
+ from = starts[Math.max(0, starts.length - 5)];
68
+ break;
69
+ case "all":
70
+ from = 0;
71
+ break;
72
+ case "today": {
73
+ const day = startOfDay(now);
74
+ const todays = rows
75
+ .map((r, i) => ({ r, i }))
76
+ .filter(({ r }) => (r.createdAt ?? 0) >= day);
77
+ if (!todays.length) return null;
78
+ from = (todays.find(({ r }) => r.type === "user") ?? todays[0]).i;
79
+ break;
80
+ }
81
+ }
82
+ return { fromId: rows[from].id, toId: last };
83
+ }
84
+
85
+ /** The preset a range equals, or null for a custom one. */
86
+ export function activePreset(
87
+ rows: RangeRow[],
88
+ range: ShareRange | null,
89
+ now: Date
90
+ ): RangePreset | null {
91
+ if (!range) return null;
92
+ return (
93
+ PRESETS.find((p) => {
94
+ const r = presetRange(rows, p.id, now);
95
+ return r && r.fromId === range.fromId && r.toId === range.toId;
96
+ })?.id ?? null
97
+ );
98
+ }
99
+
100
+ /** From a message to the latest one. */
101
+ export function rangeFromHere(rows: RangeRow[], id: string): ShareRange | null {
102
+ return rows.some((r) => r.id === id)
103
+ ? { fromId: id, toId: rows[rows.length - 1].id }
104
+ : null;
105
+ }
106
+
107
+ /** A click sets the range to one message; shift-click stretches it from the anchor to the clicked one, either direction. */
108
+ export function clickRange(
109
+ rows: RangeRow[],
110
+ anchorId: string | null,
111
+ clickedId: string,
112
+ extend: boolean
113
+ ): ShareRange {
114
+ const a = extend && anchorId ? rows.findIndex((r) => r.id === anchorId) : -1;
115
+ const b = rows.findIndex((r) => r.id === clickedId);
116
+ if (a < 0 || b < 0) return { fromId: clickedId, toId: clickedId };
117
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
118
+ return { fromId: rows[lo].id, toId: rows[hi].id };
119
+ }
120
+
121
+ /** Rows the range covers (all of them when there is none yet). */
122
+ export function inRange<T extends RangeRow>(
123
+ rows: T[],
124
+ range: ShareRange | null
125
+ ): T[] {
126
+ if (!range) return rows;
127
+ const w = windowOf(
128
+ rows.map((r) => r.id),
129
+ range.fromId,
130
+ range.toId
131
+ );
132
+ return w ? rows.slice(w.start, w.end + 1) : [];
133
+ }
134
+
135
+ const clock = (d: Date) => {
136
+ const h = d.getHours();
137
+ const m = String(d.getMinutes()).padStart(2, "0");
138
+ return { text: `${h % 12 || 12}:${m}`, ampm: h < 12 ? "AM" : "PM" };
139
+ };
140
+
141
+ const sameDay = (a: Date, b: Date) =>
142
+ a.getFullYear() === b.getFullYear() &&
143
+ a.getMonth() === b.getMonth() &&
144
+ a.getDate() === b.getDate();
145
+
146
+ const MONTHS = [
147
+ "Jan",
148
+ "Feb",
149
+ "Mar",
150
+ "Apr",
151
+ "May",
152
+ "Jun",
153
+ "Jul",
154
+ "Aug",
155
+ "Sep",
156
+ "Oct",
157
+ "Nov",
158
+ "Dec",
159
+ ];
160
+
161
+ const dayLabel = (d: Date) => `${MONTHS[d.getMonth()]} ${d.getDate()}`;
162
+
163
+ /** "2:14–2:40 PM", "11:50 AM–12:10 PM", or with dates when it crosses a day. */
164
+ export function timeSpan(from: number, to: number): string {
165
+ const a = new Date(from);
166
+ const b = new Date(to);
167
+ const ca = clock(a);
168
+ const cb = clock(b);
169
+ if (!sameDay(a, b))
170
+ return `${dayLabel(a)}, ${ca.text} ${ca.ampm} – ${dayLabel(b)}, ${cb.text} ${cb.ampm}`;
171
+ if (ca.text === cb.text && ca.ampm === cb.ampm)
172
+ return `${ca.text} ${ca.ampm}`;
173
+ return ca.ampm === cb.ampm
174
+ ? `${ca.text}–${cb.text} ${cb.ampm}`
175
+ : `${ca.text} ${ca.ampm}–${cb.text} ${cb.ampm}`;
176
+ }
177
+
178
+ /** "Sharing 6 of 120 messages · 2:14–2:40 PM". */
179
+ export function rangeSummary(total: number, shared: RangeRow[]): string {
180
+ if (!shared.length) return "Nothing selected to share";
181
+ const noun = total === 1 ? "message" : "messages";
182
+ const times = shared
183
+ .map((r) => r.createdAt)
184
+ .filter((t): t is number => typeof t === "number" && t > 0);
185
+ const base = `Sharing ${shared.length} of ${total} ${noun}`;
186
+ return times.length
187
+ ? `${base} · ${timeSpan(Math.min(...times), Math.max(...times))}`
188
+ : base;
189
+ }
@@ -18,6 +18,15 @@ import {
18
18
  BASE64_RUN,
19
19
  looksRandom,
20
20
  } from "@/lib/orchestrator/untrusted";
21
+ import {
22
+ BASIC_AUTH,
23
+ CLI_FLAG,
24
+ DATA_URI,
25
+ KEY_VALUE,
26
+ MYSQL_P,
27
+ TOKEN_RUN,
28
+ isHighEntropyToken,
29
+ } from "./secret-patterns";
21
30
  import { HOME } from "@/lib/session-audit/normalize";
22
31
 
23
32
  export type Kind = "secret" | "path" | "ip" | "host" | "email";
@@ -59,7 +68,8 @@ const SECRET = "[secret]";
59
68
  const BEARER = /(\bbearer\s+)(?!\[secret\])[A-Za-z0-9._~+/=-]{8,}/gi;
60
69
  const INLINE_ASSIGNMENT =
61
70
  /(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[secret\])[^\s'"]+/g;
62
- const LONG_HEX = /\b[a-fA-F0-9]{40,}\b/g;
71
+ // Up to 64 hex characters is a git sha or a hash, not a key.
72
+ const LONG_HEX = /\b[a-fA-F0-9]{65,}\b/g;
63
73
 
64
74
  const IPV4 =
65
75
  /(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.]*\w)/g;
@@ -104,6 +114,10 @@ export function redactForShare(
104
114
  let t = input;
105
115
  const on = (k: Kind) => !opts.only || opts.only.includes(k);
106
116
 
117
+ // Inline base64 (images) is content, not a credential: set aside, put back.
118
+ const uris: string[] = [];
119
+ t = t.replace(DATA_URI, (m) => `\u0000${uris.push(m) - 1}\u0000`);
120
+
107
121
  // 1. Credentials by shape.
108
122
  t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
109
123
  t = sub(
@@ -132,9 +146,16 @@ export function redactForShare(
132
146
  "secret",
133
147
  (m) => m.endsWith(SECRET)
134
148
  );
149
+ const keepValue = (_m: string, k: string, v: string) =>
150
+ `${k}${/^["']/.test(v) ? v[0] + SECRET + v[0] : SECRET}`;
151
+ t = sub(t, KEY_VALUE, keepValue, hits, "secret");
152
+ t = sub(t, CLI_FLAG, keepValue, hits, "secret");
153
+ t = sub(t, MYSQL_P, (_m, k) => `${k}${SECRET}`, hits, "secret");
154
+ t = sub(t, BASIC_AUTH, (_m, k) => `${k}${SECRET}`, hits, "secret");
135
155
  for (const re of SECRET_TOKENS) t = sub(t, re, SECRET, hits, "secret");
136
156
  t = sub(t, LONG_HEX, SECRET, hits, "secret");
137
157
  t = sub(t, BASE64_RUN, SECRET, hits, "secret", (m) => !looksRandom(m));
158
+ t = sub(t, TOKEN_RUN, SECRET, hits, "secret", (m) => !isHighEntropyToken(m));
138
159
 
139
160
  // 2. This machine's own secrets, wherever they appear.
140
161
  for (const value of ctx.secrets) {
@@ -192,5 +213,6 @@ export function redactForShare(
192
213
  // 6. Email addresses.
193
214
  if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
194
215
 
216
+ t = t.replace(/\u0000(\d+)\u0000/g, (_m, i) => uris[Number(i)]);
195
217
  return { text: t, hits };
196
218
  }
@@ -14,6 +14,7 @@ import remarkGfm from "remark-gfm";
14
14
  import { Prism as Highlighter } from "react-syntax-highlighter";
15
15
  import type { Snapshot, SnapshotItem } from "./snapshot";
16
16
  import { STYLES } from "./styles";
17
+ import { EARLIER_NOT_SHARED, LATER_NOT_SHARED } from "./range";
17
18
 
18
19
  export const MAX_SHARE_BYTES = 5 * 1024 * 1024;
19
20
 
@@ -123,9 +124,11 @@ export function renderChatSnapshot(
123
124
  ): string {
124
125
  const body = renderToStaticMarkup(
125
126
  <>
127
+ {snapshot.cutBefore && <div className="cut">{EARLIER_NOT_SHARED}</div>}
126
128
  {snapshot.items.map((item) => (
127
129
  <Item key={item.id} item={item} />
128
130
  ))}
131
+ {snapshot.cutAfter && <div className="cut">{LATER_NOT_SHARED}</div>}
129
132
  </>
130
133
  );
131
134
  const date = opts.sharedAt.slice(0, 10);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Generic credential shapes for redactForShare: key/value secrets in any
3
+ * syntax, Basic auth headers, mysql -p, and high-entropy tokens. Pure data
4
+ * and predicates; redact.ts applies them and counts the hits.
5
+ */
6
+
7
+ const WORDS =
8
+ "password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth|private[_-]?key";
9
+
10
+ // A value that is a type, a placeholder or a call, not a credential.
11
+ const NOT_A_VALUE =
12
+ "(?![\"']?\\[secret\\])" +
13
+ "(?!(?:string|number|boolean|any|unknown|null|undefined|true|false|none|void|required|optional)\\b)" +
14
+ "(?![$<{(\\[%]|\\.\\.\\.)(?![\\w.]+\\()";
15
+
16
+ /**
17
+ * `password=x`, `"api_key": "x"`, `token: x`, `export DB_PASSWORD=x`,
18
+ * `--password=x`. The key may carry any prefix (GITHUB_TOKEN) but must end
19
+ * in the word, so `author` and `max_tokens` stay. Group 1 is kept.
20
+ */
21
+ export const KEY_VALUE = new RegExp(
22
+ `((?<![\\w.-])[\\w.-]*(?:${WORDS})["']?[ \\t]*[:=][ \\t]*)` +
23
+ `${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|[^\\s,;'"&}\\])]+)`,
24
+ "gi"
25
+ );
26
+
27
+ /** `--password value`, `--api-key value` (space separated). */
28
+ export const CLI_FLAG = new RegExp(
29
+ `(--[\\w-]*?(?:${WORDS})[ \\t]+)(?!-)${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|\\S+)`,
30
+ "gi"
31
+ );
32
+
33
+ /**
34
+ * mysql-family `-pSECRET` (attached: `-p secret` would be a database name).
35
+ * psql's -p is a port, so it is left alone.
36
+ */
37
+ export const MYSQL_P =
38
+ /(\b(?:mysql|mysqldump|mysqladmin|mariadb)\b[^\n|;&]*?\s-p)(?=\S)(\S+)/gi;
39
+
40
+ /** `Authorization: Basic <x>` (Bearer is handled in redact.ts). */
41
+ export const BASIC_AUTH =
42
+ /(\bauthorization["']?\s*[:=]\s*["']?basic\s+)[A-Za-z0-9+/=._~-]{4,}/gi;
43
+
44
+ /** A base64 data URI, such as an inline image. Kept as is: not a credential. */
45
+ export const DATA_URI =
46
+ /data:[\w.+-]+\/[\w.+-]+(?:;[\w=.-]+)*;base64,[A-Za-z0-9+/=_-]+/g;
47
+
48
+ /** Candidate runs for the entropy test. */
49
+ export const TOKEN_RUN = /(?<![A-Za-z0-9_\-+/=])[A-Za-z0-9_\-+/=]{32,}/g;
50
+
51
+ export const ENTROPY_MIN = 4.0;
52
+
53
+ export function shannon(s: string): number {
54
+ const counts = new Map<string, number>();
55
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
56
+ let h = 0;
57
+ for (const n of counts.values())
58
+ h -= (n / s.length) * Math.log2(n / s.length);
59
+ return h;
60
+ }
61
+
62
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
63
+
64
+ function looksLikePath(s: string): boolean {
65
+ if (/^(?:\.{0,2}\/|~\/)/.test(s)) return true;
66
+ const parts = s.split("/");
67
+ return parts.length > 2 && parts.every((p) => p.length >= 2);
68
+ }
69
+
70
+ /** True when a run is a random-looking token rather than prose, a hash or a path. */
71
+ export function isHighEntropyToken(s: string): boolean {
72
+ if (/^[0-9a-f]+$/i.test(s) && s.length <= 64) return false; // git sha, md5/sha256
73
+ if (UUID.test(s)) return false;
74
+ if (looksLikePath(s)) return false;
75
+ if (!/[a-z]/.test(s) || !/[A-Z]/.test(s) || !/[0-9]/.test(s)) return false;
76
+ return shannon(s) > ENTROPY_MIN;
77
+ }
@@ -16,7 +16,11 @@ import {
16
16
  renderChatSnapshot,
17
17
  } from "./render";
18
18
  import { artifactToMarkdown, chatToMarkdown } from "./markdown";
19
- import { buildArtifactSnapshot, buildChatSnapshot } from "./snapshot";
19
+ import {
20
+ RangeGone,
21
+ buildArtifactSnapshot,
22
+ buildChatSnapshot,
23
+ } from "./snapshot";
20
24
 
21
25
  export interface ShareRequest {
22
26
  kind: "chat" | "artifact";
@@ -25,6 +29,9 @@ export interface ShareRequest {
25
29
  includeTools: boolean;
26
30
  /** Messages the reader left out in the preview. */
27
31
  dropIds?: string[];
32
+ /** The part of the chat shared: from this message to that one, ends included. */
33
+ fromId?: string;
34
+ toId?: string;
28
35
  follow?: "latest";
29
36
  sharedAt: string;
30
37
  }
@@ -34,6 +41,7 @@ export interface ItemRow {
34
41
  type: string;
35
42
  tools: boolean;
36
43
  excerpt: string;
44
+ createdAt?: number;
37
45
  }
38
46
 
39
47
  export interface Page {
@@ -87,6 +95,15 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
87
95
  throw new BadShare("dropIds must be a list of ids", 400);
88
96
  dropIds = b.dropIds as string[];
89
97
  }
98
+ const end = (key: "fromId" | "toId") => {
99
+ const v = b[key];
100
+ if (v === undefined) return undefined;
101
+ if (typeof v !== "string" || !v || v.length > 200)
102
+ throw new BadShare(`${key} must be a message id`, 400);
103
+ return v;
104
+ };
105
+ const fromId = end("fromId");
106
+ const toId = end("toId");
90
107
  if (b.follow !== undefined && b.follow !== "latest")
91
108
  throw new BadShare("follow must be latest", 400);
92
109
  if (b.follow && kind !== "artifact")
@@ -100,11 +117,41 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
100
117
  artifactId,
101
118
  includeTools: b.includeTools === true,
102
119
  dropIds,
120
+ fromId,
121
+ toId,
103
122
  follow: b.follow === "latest" ? "latest" : undefined,
104
123
  sharedAt: at,
105
124
  };
106
125
  }
107
126
 
127
+ const rowOf = ({ id, type, tools, excerpt, createdAt }: ItemRow): ItemRow => ({
128
+ id,
129
+ type,
130
+ tools,
131
+ excerpt,
132
+ createdAt,
133
+ });
134
+
135
+ function chatSession(sessionId: string) {
136
+ let session;
137
+ try {
138
+ session = getSession(sessionId);
139
+ } catch {
140
+ throw new BadShare("No such chat", 404);
141
+ }
142
+ if (session.view !== "chat")
143
+ throw new BadShare("Only chats can be shared", 400);
144
+ }
145
+
146
+ /** Every shareable message of a chat, tool rows included, in order. */
147
+ export async function listChatItems(req: ShareRequest): Promise<ItemRow[]> {
148
+ chatSession(req.sessionId!);
149
+ const all = await buildChatSnapshot(req.sessionId!, shareContext(), {
150
+ includeTools: true,
151
+ });
152
+ return all.items.map(rowOf);
153
+ }
154
+
108
155
  const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
109
156
 
110
157
  /** Binds both parts: neither can change without the hash changing. */
@@ -120,29 +167,27 @@ export async function buildPage(req: ShareRequest): Promise<Page> {
120
167
  let items: ItemRow[] = [];
121
168
 
122
169
  if (req.kind === "chat") {
123
- let session;
124
- try {
125
- session = getSession(req.sessionId!);
126
- } catch {
127
- throw new BadShare("No such chat", 404);
128
- }
129
- if (session.view !== "chat")
130
- throw new BadShare("Only chats can be shared", 400);
170
+ chatSession(req.sessionId!);
131
171
  const all = await buildChatSnapshot(req.sessionId!, ctx, {
132
172
  includeTools: req.includeTools,
133
173
  });
134
- items = all.items.map(({ id, type, tools, excerpt }) => ({
135
- id,
136
- type,
137
- tools,
138
- excerpt,
139
- }));
140
- const snap = req.dropIds?.length
141
- ? await buildChatSnapshot(req.sessionId!, ctx, {
174
+ items = all.items.map(rowOf);
175
+ const narrowed =
176
+ req.dropIds?.length || req.fromId !== undefined || req.toId !== undefined;
177
+ let snap = all;
178
+ if (narrowed) {
179
+ try {
180
+ snap = await buildChatSnapshot(req.sessionId!, ctx, {
142
181
  includeTools: req.includeTools,
143
182
  dropIds: req.dropIds,
144
- })
145
- : all;
183
+ fromId: req.fromId,
184
+ toId: req.toId,
185
+ });
186
+ } catch (err) {
187
+ if (err instanceof RangeGone) throw new BadShare(err.message, 409);
188
+ throw err;
189
+ }
190
+ }
146
191
  if (!snap.items.length)
147
192
  throw new BadShare("Nothing to share: every message is left out", 400);
148
193
  html = renderChatSnapshot(snap, { sharedAt: req.sharedAt });
@@ -15,6 +15,7 @@ import {
15
15
  type Hits,
16
16
  type ShareContext,
17
17
  } from "./redact";
18
+ import { windowOf } from "./range";
18
19
 
19
20
  export type SnapshotType =
20
21
  "user" | "assistant" | "plan" | "error" | "divider" | "artifact" | "detail";
@@ -32,6 +33,7 @@ export interface SnapshotItem {
32
33
  excerpt: string;
33
34
  /** Hidden unless "include tool calls and thinking" is on. */
34
35
  tools: boolean;
36
+ createdAt?: number;
35
37
  }
36
38
 
37
39
  export interface Snapshot {
@@ -39,9 +41,19 @@ export interface Snapshot {
39
41
  items: SnapshotItem[];
40
42
  /** Whether the tools group was left out, to say so on the page. */
41
43
  toolsHidden: boolean;
44
+ /** Messages before / after the shared range were left out, to say so. */
45
+ cutBefore?: boolean;
46
+ cutAfter?: boolean;
42
47
  hits: Hits;
43
48
  }
44
49
 
50
+ /** The range names a message that is no longer in the chat. */
51
+ export class RangeGone extends Error {
52
+ constructor() {
53
+ super("That part of the chat changed; pick the range again.");
54
+ }
55
+ }
56
+
45
57
  export const MAX_TOOL_INPUT = 4_000;
46
58
  export const MAX_TOOL_OUTPUT = 20_000;
47
59
  export const HIDDEN_FILE = "[contents of a secret file hidden]";
@@ -189,7 +201,12 @@ export function toRaw(item: ChatItem): Raw | null {
189
201
  export async function buildChatSnapshot(
190
202
  sessionId: string,
191
203
  ctx: ShareContext,
192
- opts: { includeTools: boolean; dropIds?: string[] }
204
+ opts: {
205
+ includeTools: boolean;
206
+ dropIds?: string[];
207
+ fromId?: string;
208
+ toId?: string;
209
+ }
193
210
  ): Promise<Snapshot> {
194
211
  const session = getSession(sessionId);
195
212
  const dropped = new Set(opts.dropIds ?? []);
@@ -201,13 +218,31 @@ export async function buildChatSnapshot(
201
218
  };
202
219
 
203
220
  const items: SnapshotItem[] = [];
204
- for (const item of visible(listItems(sessionId))) {
221
+ const all = visible(listItems(sessionId));
222
+ let window: { start: number; end: number } | null = null;
223
+ if (opts.fromId !== undefined || opts.toId !== undefined) {
224
+ window = windowOf(
225
+ all.map((i) => i.id),
226
+ opts.fromId,
227
+ opts.toId
228
+ );
229
+ if (!window) throw new RangeGone();
230
+ }
231
+ let cutBefore = false;
232
+ let cutAfter = false;
233
+ for (const [at, item] of all.entries()) {
205
234
  const raw = toRaw(item);
206
235
  if (!raw) continue;
207
236
  if (raw.tools && !opts.includeTools) continue;
237
+ if (window && (at < window.start || at > window.end)) {
238
+ if (at < window.start) cutBefore = true;
239
+ else cutAfter = true;
240
+ continue;
241
+ }
208
242
  if (dropped.has(raw.id)) continue;
209
243
  const row: SnapshotItem = {
210
244
  ...raw,
245
+ createdAt: item.createdAt,
211
246
  label: raw.label === undefined ? undefined : take(raw.label),
212
247
  text: take(raw.text),
213
248
  excerpt: "",
@@ -225,6 +260,8 @@ export async function buildChatSnapshot(
225
260
  title: take(session.name).slice(0, 120) || "Chat",
226
261
  items,
227
262
  toolsHidden: !opts.includeTools,
263
+ cutBefore,
264
+ cutAfter,
228
265
  hits,
229
266
  };
230
267
  }
@@ -56,6 +56,8 @@ h1.title{margin:0 0 4px;font-size:20px;line-height:1.3}
56
56
  .plan>.kicker{margin-top:12px}
57
57
  .kicker{font-size:12px;font-weight:600;color:hsl(var(--muted-foreground));text-transform:uppercase;letter-spacing:.04em}
58
58
  .error{color:hsl(var(--destructive));border:1px solid hsl(var(--destructive)/.4);border-radius:10px;padding:8px 14px;white-space:pre-wrap;overflow-wrap:anywhere}
59
+ .cut{display:flex;align-items:center;gap:12px;color:hsl(var(--muted-foreground));font-size:12px;font-style:italic}
60
+ .cut::before,.cut::after{content:"";flex:1;border-top:1px dashed hsl(var(--border))}
59
61
  .divider{display:flex;align-items:center;gap:12px;color:hsl(var(--muted-foreground));font-size:12px}
60
62
  .divider::before,.divider::after{content:"";flex:1;border-top:1px solid hsl(var(--border))}
61
63
  details.tool{border:1px solid hsl(var(--border));border-radius:10px;background:hsl(var(--card))}
package/lib/share/view.ts CHANGED
@@ -15,6 +15,11 @@ export interface PreviewRequest extends ShareTarget {
15
15
  includeTools: boolean;
16
16
  /** Messages left out of the share. */
17
17
  dropIds?: string[];
18
+ /** Only the messages from this one to `toId`, both included; either end open when absent. */
19
+ fromId?: string;
20
+ toId?: string;
21
+ /** Just the list of messages, no page: what the range picker needs first. */
22
+ listOnly?: boolean;
18
23
  }
19
24
 
20
25
  export interface PreviewItem {
@@ -22,11 +27,17 @@ export interface PreviewItem {
22
27
  type: string;
23
28
  tools: boolean;
24
29
  excerpt: string;
30
+ createdAt?: number;
25
31
  }
26
32
 
27
33
  /** Download formats a server can make; the client lists what it is told. */
28
34
  export type ShareFormat = "pdf" | "html" | "md";
29
35
 
36
+ export interface MessageList {
37
+ /** Every shareable message in order, tool rows included. */
38
+ items: PreviewItem[];
39
+ }
40
+
30
41
  export interface SharePreview {
31
42
  html: string;
32
43
  markdown: string;
@@ -17,6 +17,7 @@ export type RowMenuItem =
17
17
  | "copy-link"
18
18
  | "share"
19
19
  | "export"
20
+ | "invite-live"
20
21
  | "fork"
21
22
  | "fresh-start"
22
23
  | "new-conversation"
@@ -127,6 +128,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
127
128
  ...group("share", [
128
129
  "copy-link",
129
130
  s.view === "chat" && "share",
131
+ s.view === "chat" && "invite-live",
130
132
  s.view === "chat" && "export",
131
133
  ]),
132
134
  ...group("restart", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.770",
3
+ "version": "0.3.777",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  import { lanEnabled } from "./lib/security/network-settings";
55
55
  import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
56
56
  import { startConnect } from "./lib/connect/serve";
57
+ import { startLive } from "./lib/live/service";
57
58
  import { serverPort } from "./lib/connect/config";
58
59
  import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
59
60
  import {
@@ -399,6 +400,7 @@ app.prepare().then(async () => {
399
400
  ? { hostname: () => null }
400
401
  : startConnect({ onRequest, onUpgrade }, port);
401
402
  policy.machineHost = () => connect.hostname();
403
+ if (!demo) startLive();
402
404
  // HTTPS on the tailnet (its own port) for passkeys and other secure-origin APIs.
403
405
  if (
404
406
  !demo &&
@@ -0,0 +1,17 @@
1
+ import { proxy } from "valtio";
2
+
3
+ // The invite dialog: which chat it is open on, started from a menu.
4
+ export const liveUi = proxy<{
5
+ target: { sessionId: string; title: string } | null;
6
+ }>({
7
+ target: null,
8
+ });
9
+
10
+ export const liveUiActions = {
11
+ open: (sessionId: string, title: string) => {
12
+ liveUi.target = { sessionId, title };
13
+ },
14
+ close: () => {
15
+ liveUi.target = null;
16
+ },
17
+ };