@saadnvd1/agent-os 0.3.560 → 0.3.561

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 (40) hide show
  1. package/README.md +1 -0
  2. package/app/api/shares/[id]/route.ts +23 -0
  3. package/app/api/shares/preview/route.ts +30 -0
  4. package/app/api/shares/route.test.ts +455 -0
  5. package/app/api/shares/route.ts +101 -0
  6. package/app/page.tsx +5 -0
  7. package/components/Chat/Artifact.tsx +17 -1
  8. package/components/Pane/MobileTabBar.tsx +19 -0
  9. package/components/Pane/PaneMenu.tsx +16 -0
  10. package/components/SessionList/SessionRowMenu.tsx +16 -0
  11. package/components/Settings/SettingsDialog.test.ts +3 -0
  12. package/components/Settings/SettingsDialog.tsx +9 -0
  13. package/components/Settings/commands.ts +9 -0
  14. package/components/Settings/entry-points.test.ts +1 -0
  15. package/components/Share/ShareDialog.tsx +336 -0
  16. package/components/Share/format.test.ts +20 -0
  17. package/components/Share/format.ts +33 -0
  18. package/components/Shares/SharedLinksPanel.tsx +147 -0
  19. package/data/shares/index.ts +2 -0
  20. package/data/shares/keys.ts +4 -0
  21. package/data/shares/queries.ts +111 -0
  22. package/lib/chat/drivers/claude.ts +5 -4
  23. package/lib/connect/control-api.ts +84 -0
  24. package/lib/db/migrations.ts +23 -0
  25. package/lib/orchestrator/untrusted.ts +12 -2
  26. package/lib/security/demo.ts +1 -0
  27. package/lib/session-audit/normalize.ts +1 -1
  28. package/lib/settings-url.ts +1 -0
  29. package/lib/share/connect.ts +86 -0
  30. package/lib/share/context.ts +50 -0
  31. package/lib/share/redact.ts +190 -0
  32. package/lib/share/render.tsx +161 -0
  33. package/lib/share/route.ts +33 -0
  34. package/lib/share/service.ts +159 -0
  35. package/lib/share/snapshot.ts +261 -0
  36. package/lib/share/static-markup.ts +16 -0
  37. package/lib/share/store.ts +39 -0
  38. package/lib/share/styles.ts +83 -0
  39. package/package.json +1 -1
  40. package/stores/shareUi.ts +17 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * What a share contains: a chat's items, or one artifact, already redacted.
3
+ * Nothing here leaves the machine; the renderer turns it into a page.
4
+ */
5
+
6
+ import { listItems } from "../chat/store";
7
+ import type { ChatItem } from "../chat/events";
8
+ import { getSession } from "../chat/registry";
9
+ import { getArtifact, readArtifactHtml } from "../artifacts/store";
10
+ import { isSecretPath } from "../secret-paths";
11
+ import {
12
+ addHits,
13
+ noHits,
14
+ redactForShare,
15
+ type Hits,
16
+ type ShareContext,
17
+ } from "./redact";
18
+
19
+ export type SnapshotType =
20
+ | "user"
21
+ | "assistant"
22
+ | "plan"
23
+ | "error"
24
+ | "divider"
25
+ | "artifact"
26
+ | "detail";
27
+
28
+ export interface SnapshotItem {
29
+ id: string;
30
+ type: SnapshotType;
31
+ /** For a detail row: what it is (a tool's title, "Thinking"). */
32
+ label?: string;
33
+ text: string;
34
+ /** An artifact's page. */
35
+ html?: string;
36
+ height?: number;
37
+ /** The first words, for the dialog's message list. */
38
+ excerpt: string;
39
+ /** Hidden unless "include tool calls and thinking" is on. */
40
+ tools: boolean;
41
+ }
42
+
43
+ export interface Snapshot {
44
+ title: string;
45
+ items: SnapshotItem[];
46
+ /** Whether the tools group was left out, to say so on the page. */
47
+ toolsHidden: boolean;
48
+ hits: Hits;
49
+ }
50
+
51
+ export const MAX_TOOL_INPUT = 4_000;
52
+ export const MAX_TOOL_OUTPUT = 20_000;
53
+ const HIDDEN_FILE = "[contents of a secret file hidden]";
54
+ const NO_IMAGE = "[image not shared]";
55
+
56
+ const clip = (s: string, max: number) =>
57
+ s.length > max
58
+ ? `${s.slice(0, max)}\n… (${s.length - max} more characters)`
59
+ : s;
60
+
61
+ const excerptOf = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 120);
62
+
63
+ // Every string in a tool's input, to see whether it names a secret file.
64
+ function strings(v: unknown, out: string[] = []): string[] {
65
+ if (typeof v === "string") out.push(v);
66
+ else if (Array.isArray(v)) v.forEach((x) => strings(x, out));
67
+ else if (v && typeof v === "object")
68
+ Object.values(v).forEach((x) => strings(x, out));
69
+ return out;
70
+ }
71
+
72
+ const json = (v: unknown) => {
73
+ try {
74
+ return typeof v === "string" ? v : (JSON.stringify(v, null, 2) ?? "");
75
+ } catch {
76
+ return "";
77
+ }
78
+ };
79
+
80
+ /** Items as the reader sees them: what an undo took back is gone. */
81
+ export function visible(items: ChatItem[]): ChatItem[] {
82
+ const out: ChatItem[] = [];
83
+ for (const item of items) {
84
+ if (item.kind !== "undo") {
85
+ out.push(item);
86
+ continue;
87
+ }
88
+ const at = out.findIndex((i) => i.id === item.from);
89
+ if (at !== -1) out.length = at;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ type Raw = Omit<SnapshotItem, "excerpt">;
95
+
96
+ /** One chat item as a row, or null for what is never shared. */
97
+ export function toRaw(item: ChatItem): Raw | null {
98
+ const id = item.id;
99
+ switch (item.kind) {
100
+ case "user": {
101
+ const images = item.images?.length
102
+ ? "\n" + item.images.map(() => NO_IMAGE).join("\n")
103
+ : "";
104
+ return { id, type: "user", text: item.text + images, tools: false };
105
+ }
106
+ case "assistant":
107
+ return item.text.trim()
108
+ ? { id, type: "assistant", text: item.text, tools: false }
109
+ : null;
110
+ case "plan":
111
+ return { id, type: "plan", text: item.plan, tools: false };
112
+ case "error":
113
+ return { id, type: "error", text: item.message, tools: false };
114
+ case "compacted":
115
+ return {
116
+ id,
117
+ type: "divider",
118
+ text: "Conversation compacted",
119
+ tools: false,
120
+ };
121
+ case "artifact":
122
+ return {
123
+ id,
124
+ type: "artifact",
125
+ label: item.title,
126
+ text: "",
127
+ height: item.height,
128
+ tools: false,
129
+ };
130
+ case "reasoning":
131
+ return item.text.trim()
132
+ ? {
133
+ id,
134
+ type: "detail",
135
+ label: "Thinking",
136
+ text: item.text,
137
+ tools: true,
138
+ }
139
+ : null;
140
+ case "tool": {
141
+ const secret = strings(item.input).some(isSecretPath);
142
+ const input = clip(json(item.input), MAX_TOOL_INPUT);
143
+ const output = secret
144
+ ? HIDDEN_FILE
145
+ : clip(item.output ?? "", MAX_TOOL_OUTPUT);
146
+ return {
147
+ id,
148
+ type: "detail",
149
+ label: item.title || item.name,
150
+ text: output ? `${input}\n\n${output}` : input,
151
+ tools: true,
152
+ };
153
+ }
154
+ case "command_output":
155
+ return {
156
+ id,
157
+ type: "detail",
158
+ label: "Command output",
159
+ text: clip(item.text, MAX_TOOL_OUTPUT),
160
+ tools: true,
161
+ };
162
+ case "todos":
163
+ return {
164
+ id,
165
+ type: "detail",
166
+ label: "To do",
167
+ text: item.todos
168
+ .map((t) => `${t.status === "completed" ? "[x]" : "[ ]"} ${t.text}`)
169
+ .join("\n"),
170
+ tools: true,
171
+ };
172
+ case "mcp":
173
+ return {
174
+ id,
175
+ type: "detail",
176
+ label: "MCP servers",
177
+ text: item.servers.map((s) => `${s.name}: ${s.status}`).join("\n"),
178
+ tools: true,
179
+ };
180
+ case "task":
181
+ return {
182
+ id,
183
+ type: "detail",
184
+ label: item.description,
185
+ text: item.summary ?? item.status,
186
+ tools: true,
187
+ };
188
+ default:
189
+ // approval, note, undo, turn_end
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /** What the preview lists: every shareable item, whether or not it's on. */
195
+ export async function buildChatSnapshot(
196
+ sessionId: string,
197
+ ctx: ShareContext,
198
+ opts: { includeTools: boolean; dropIds?: string[] }
199
+ ): Promise<Snapshot> {
200
+ const session = getSession(sessionId);
201
+ const dropped = new Set(opts.dropIds ?? []);
202
+ let hits = noHits();
203
+ const take = (text: string) => {
204
+ const r = redactForShare(text, ctx);
205
+ hits = addHits(hits, r.hits);
206
+ return r.text;
207
+ };
208
+
209
+ const items: SnapshotItem[] = [];
210
+ for (const item of visible(listItems(sessionId))) {
211
+ const raw = toRaw(item);
212
+ if (!raw) continue;
213
+ if (raw.tools && !opts.includeTools) continue;
214
+ if (dropped.has(raw.id)) continue;
215
+ const row: SnapshotItem = {
216
+ ...raw,
217
+ label: raw.label === undefined ? undefined : take(raw.label),
218
+ text: take(raw.text),
219
+ excerpt: "",
220
+ };
221
+ if (raw.type === "artifact") {
222
+ const artifact = getArtifact(listArtifactId(item) ?? "");
223
+ const html = artifact ? await readArtifactHtml(artifact) : null;
224
+ if (html === null) continue;
225
+ row.html = take(html);
226
+ }
227
+ row.excerpt = excerptOf(row.text || row.label || "");
228
+ items.push(row);
229
+ }
230
+ return {
231
+ title: take(session.name).slice(0, 120) || "Chat",
232
+ items,
233
+ toolsHidden: !opts.includeTools,
234
+ hits,
235
+ };
236
+ }
237
+
238
+ const listArtifactId = (item: ChatItem) =>
239
+ item.kind === "artifact" ? item.artifactId : null;
240
+
241
+ export interface ArtifactSnapshot {
242
+ title: string;
243
+ html: string;
244
+ hits: Hits;
245
+ }
246
+
247
+ export async function buildArtifactSnapshot(
248
+ artifactId: string,
249
+ ctx: ShareContext
250
+ ): Promise<ArtifactSnapshot | null> {
251
+ const artifact = getArtifact(artifactId);
252
+ const html = artifact ? await readArtifactHtml(artifact) : null;
253
+ if (!artifact || html === null) return null;
254
+ const page = redactForShare(html, ctx);
255
+ const title = redactForShare(artifact.title, ctx);
256
+ return {
257
+ title: title.text.slice(0, 120) || "Page",
258
+ html: page.text,
259
+ hits: addHits(page.hits, title.hits),
260
+ };
261
+ }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from "module";
2
+ import path from "path";
3
+ import type { ReactElement } from "react";
4
+
5
+ // Next refuses a static import of react-dom/server in a route's module graph
6
+ // (it is meant for pages). A snapshot is a plain string, not a page, so it is
7
+ // loaded by name at run time; the bundler never sees the import.
8
+ const load = createRequire(path.join(process.cwd(), "package.json"));
9
+ let render: ((el: ReactElement) => string) | null = null;
10
+
11
+ export function renderToStaticMarkup(element: ReactElement): string {
12
+ render ??= (
13
+ load("react-dom/server") as { renderToStaticMarkup: typeof render & object }
14
+ ).renderToStaticMarkup;
15
+ return render!(element);
16
+ }
@@ -0,0 +1,39 @@
1
+ import { db } from "../db";
2
+
3
+ export interface SharedLink {
4
+ id: string;
5
+ url: string;
6
+ kind: "chat" | "artifact";
7
+ title: string;
8
+ session_id: string | null;
9
+ artifact_id: string | null;
10
+ expires_at: string | null;
11
+ created_at: string;
12
+ revoked_at: string | null;
13
+ }
14
+
15
+ export function insertSharedLink(link: SharedLink): void {
16
+ db.prepare(
17
+ `INSERT INTO shared_links
18
+ (id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at)
19
+ VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at)`
20
+ ).run(link);
21
+ }
22
+
23
+ export function listSharedLinks(): SharedLink[] {
24
+ return db
25
+ .prepare(`SELECT * FROM shared_links ORDER BY created_at DESC, rowid DESC`)
26
+ .all() as SharedLink[];
27
+ }
28
+
29
+ export function getSharedLink(id: string): SharedLink | undefined {
30
+ return db.prepare(`SELECT * FROM shared_links WHERE id = ?`).get(id) as
31
+ | SharedLink
32
+ | undefined;
33
+ }
34
+
35
+ export function markRevoked(id: string): void {
36
+ db.prepare(
37
+ `UPDATE shared_links SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`
38
+ ).run(new Date().toISOString(), id);
39
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The stylesheet inlined into a shared page. The colours are the app's
3
+ * default light and dark themes (styles/themes.css, checked by a test), under
4
+ * prefers-color-scheme, so a snapshot looks like the chat it came from.
5
+ */
6
+
7
+ export const LIGHT = {
8
+ background: "240 10% 95.5%",
9
+ foreground: "240 12% 14%",
10
+ card: "0 0% 100%",
11
+ primary: "262 83% 58%",
12
+ "primary-foreground": "0 0% 100%",
13
+ muted: "240 16% 94%",
14
+ "muted-foreground": "240 6% 46%",
15
+ border: "240 14% 88%",
16
+ destructive: "352 80% 56%",
17
+ };
18
+
19
+ export const DARK = {
20
+ background: "240 18% 3.5%",
21
+ foreground: "240 10% 93%",
22
+ card: "240 14% 6%",
23
+ primary: "262 92% 68%",
24
+ "primary-foreground": "0 0% 100%",
25
+ muted: "240 12% 10%",
26
+ "muted-foreground": "240 6% 58%",
27
+ border: "240 10% 15%",
28
+ destructive: "352 85% 62%",
29
+ };
30
+
31
+ const vars = (v: Record<string, string>) =>
32
+ Object.entries(v)
33
+ .map(([k, val]) => `--${k}:${val};`)
34
+ .join("");
35
+
36
+ // Syntax colours, One Light and One Dark as the chat uses.
37
+ const TOKENS_LIGHT = `.token.comment,.token.prolog,.token.cdata{color:#a0a1a7;font-style:italic}.token.keyword,.token.selector,.token.atrule{color:#a626a4}.token.string,.token.char,.token.attr-value,.token.inserted{color:#50a14f}.token.number,.token.boolean,.token.constant,.token.symbol{color:#986801}.token.function,.token.class-name{color:#4078f2}.token.tag,.token.property,.token.deleted{color:#e45649}.token.operator,.token.punctuation{color:#383a42}.token.builtin,.token.attr-name{color:#c18401}`;
38
+ const TOKENS_DARK = `.token.comment,.token.prolog,.token.cdata{color:#5c6370;font-style:italic}.token.keyword,.token.selector,.token.atrule{color:#c678dd}.token.string,.token.char,.token.attr-value,.token.inserted{color:#98c379}.token.number,.token.boolean,.token.constant,.token.symbol{color:#d19a66}.token.function,.token.class-name{color:#61afef}.token.tag,.token.property,.token.deleted{color:#e06c75}.token.operator,.token.punctuation{color:#abb2bf}.token.builtin,.token.attr-name{color:#e5c07b}`;
39
+
40
+ export const STYLES = `
41
+ :root{color-scheme:light dark;${vars(LIGHT)}}
42
+ ${TOKENS_LIGHT}
43
+ @media (prefers-color-scheme:dark){:root{${vars(DARK)}}${TOKENS_DARK}}
44
+ *{box-sizing:border-box}
45
+ html{-webkit-text-size-adjust:100%}
46
+ body{margin:0;background:hsl(var(--background));color:hsl(var(--foreground));font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
47
+ .wrap{max-width:760px;margin:0 auto;padding:24px 16px 64px}
48
+ header{margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid hsl(var(--border))}
49
+ h1.title{margin:0 0 4px;font-size:20px;line-height:1.3}
50
+ .meta{margin:0;color:hsl(var(--muted-foreground));font-size:13px}
51
+ .msg{margin:16px 0}
52
+ .user{display:flex;justify-content:flex-end}
53
+ .user .bubble{max-width:85%;background:hsl(var(--muted));border-radius:14px;padding:8px 14px;white-space:pre-wrap;overflow-wrap:anywhere}
54
+ .assistant,.plan{overflow-wrap:anywhere}
55
+ .plan{background:hsl(var(--card));border:1px solid hsl(var(--border));border-radius:12px;padding:4px 16px}
56
+ .plan>.kicker{margin-top:12px}
57
+ .kicker{font-size:12px;font-weight:600;color:hsl(var(--muted-foreground));text-transform:uppercase;letter-spacing:.04em}
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
+ .divider{display:flex;align-items:center;gap:12px;color:hsl(var(--muted-foreground));font-size:12px}
60
+ .divider::before,.divider::after{content:"";flex:1;border-top:1px solid hsl(var(--border))}
61
+ details.tool{border:1px solid hsl(var(--border));border-radius:10px;background:hsl(var(--card))}
62
+ details.tool summary{cursor:pointer;padding:8px 12px;font-size:13px;color:hsl(var(--muted-foreground));overflow-wrap:anywhere}
63
+ details.tool pre{margin:0;padding:10px 12px;border-top:1px solid hsl(var(--border));max-height:420px;overflow:auto}
64
+ .assistant>:first-child{margin-top:0}.assistant>:last-child{margin-bottom:0}
65
+ p,ul,ol,blockquote,table,pre{margin:.7em 0}
66
+ a{color:hsl(var(--primary))}
67
+ blockquote{margin-left:0;padding-left:12px;border-left:3px solid hsl(var(--border));color:hsl(var(--muted-foreground))}
68
+ code{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
69
+ :not(pre)>code{background:hsl(var(--muted));border-radius:5px;padding:1px 5px}
70
+ pre{background:hsl(var(--card));border:1px solid hsl(var(--border));border-radius:10px;padding:12px 14px;overflow:auto;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
71
+ pre code{background:none;padding:0}
72
+ table{border-collapse:collapse;display:block;overflow:auto}
73
+ th,td{border:1px solid hsl(var(--border));padding:6px 10px;text-align:left}
74
+ th{background:hsl(var(--muted))}
75
+ img{max-width:100%}
76
+ .artifact{border:1px solid hsl(var(--border));border-radius:12px;overflow:hidden;background:hsl(var(--card))}
77
+ .artifact .kicker{padding:8px 12px;border-bottom:1px solid hsl(var(--border))}
78
+ .artifact iframe{display:block;width:100%;border:0}
79
+ footer{margin-top:40px;padding-top:16px;border-top:1px solid hsl(var(--border));color:hsl(var(--muted-foreground));font-size:12px}
80
+ @media (max-width:520px){.wrap{padding:16px 12px 48px}.user .bubble{max-width:95%}}
81
+ `
82
+ .trim()
83
+ .replace(/\n+/g, "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.560",
3
+ "version": "0.3.561",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,17 @@
1
+ import { proxy } from "valtio";
2
+ import type { ShareTarget } from "@/data/shares";
3
+
4
+ // What the share dialog is open on: a chat or an artifact, started from a
5
+ // menu, the pane or the artifact card.
6
+ export const shareUi = proxy<{
7
+ target: (ShareTarget & { title: string }) | null;
8
+ }>({ target: null });
9
+
10
+ export const shareUiActions = {
11
+ open: (target: ShareTarget & { title: string }) => {
12
+ shareUi.target = target;
13
+ },
14
+ close: () => {
15
+ shareUi.target = null;
16
+ },
17
+ };