@saadnvd1/agent-os 0.3.565 → 0.3.567

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.
@@ -27,24 +27,17 @@ import {
27
27
  type SharePreview,
28
28
  } from "@/data/shares";
29
29
  import { shareUi, shareUiActions } from "@/stores/shareUi";
30
- import { redactionSummary, formatBytes, fileName } from "./format";
31
-
32
- const EXPIRIES: { value: ShareExpiry; label: string }[] = [
33
- { value: "1d", label: "1 day" },
34
- { value: "7d", label: "7 days" },
35
- { value: "30d", label: "30 days" },
36
- { value: "never", label: "Never" },
37
- ];
38
-
39
- const TYPE_LABEL: Record<string, string> = {
40
- user: "You",
41
- assistant: "Agent",
42
- plan: "Plan",
43
- error: "Error",
44
- divider: "Divider",
45
- artifact: "Page",
46
- detail: "Tool",
47
- };
30
+ import {
31
+ DEFAULT_EXPIRY,
32
+ EXPIRIES,
33
+ EXPIRY_LABEL,
34
+ TYPE_LABEL,
35
+ fileName,
36
+ formatBytes,
37
+ nothingLeft,
38
+ redactionSummary,
39
+ toggleDrop,
40
+ } from "@/lib/share/view";
48
41
 
49
42
  // Opened from a menu or an artifact: the redacted page exactly as it would be
50
43
  // uploaded, with what to leave out, then Create link or Download HTML.
@@ -77,7 +70,7 @@ function ShareBody({
77
70
  }) {
78
71
  const [tools, setTools] = useState(false);
79
72
  const [dropped, setDropped] = useState<string[]>([]);
80
- const [expiresIn, setExpiresIn] = useState<ShareExpiry>("7d");
73
+ const [expiresIn, setExpiresIn] = useState<ShareExpiry>(DEFAULT_EXPIRY);
81
74
  const [preview, setPreview] = useState<SharePreview | null>(null);
82
75
  const [error, setError] = useState<string | null>(null);
83
76
  const [created, setCreated] = useState<string | null>(null);
@@ -111,10 +104,7 @@ function ShareBody({
111
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
112
105
  }, [kind, sessionId, artifactId, tools, dropped]);
113
106
 
114
- const toggle = (id: string) =>
115
- setDropped((d) =>
116
- d.includes(id) ? d.filter((x) => x !== id) : [...d, id]
117
- );
107
+ const toggle = (id: string) => setDropped((d) => toggleDrop(d, id));
118
108
 
119
109
  const makeLink = async () => {
120
110
  if (!preview) return;
@@ -166,7 +156,7 @@ function ShareBody({
166
156
 
167
157
  const linked = preview?.connectLinked ?? false;
168
158
  const empty =
169
- kind === "chat" && !!preview && preview.items.length === dropped.length;
159
+ kind === "chat" && !!preview && nothingLeft(preview.items, dropped);
170
160
  const summary = preview ? redactionSummary(preview.hits) : "";
171
161
 
172
162
  return (
@@ -245,8 +235,8 @@ function ShareBody({
245
235
  </SelectTrigger>
246
236
  <SelectContent>
247
237
  {EXPIRIES.map((e) => (
248
- <SelectItem key={e.value} value={e.value}>
249
- {e.label}
238
+ <SelectItem key={e} value={e}>
239
+ {EXPIRY_LABEL[e]}
250
240
  </SelectItem>
251
241
  ))}
252
242
  </SelectContent>
@@ -1,45 +1,30 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ CreatedShare,
4
+ PreviewRequest,
5
+ SharePreview,
6
+ SharesResponse,
7
+ ShareExpiry,
8
+ } from "@/lib/share/view";
2
9
  import { shareKeys } from "./keys";
3
10
 
11
+ export type {
12
+ CreatedShare,
13
+ PreviewItem,
14
+ PreviewRequest,
15
+ SharedLinkRow,
16
+ SharePreview,
17
+ SharesResponse,
18
+ ShareExpiry,
19
+ ShareTarget,
20
+ } from "@/lib/share/view";
21
+
4
22
  async function json<T>(res: Response): Promise<T> {
5
23
  const data = await res.json().catch(() => ({}));
6
24
  if (!res.ok) throw new Error(data.error || "Request failed");
7
25
  return data;
8
26
  }
9
27
 
10
- export type ShareExpiry = "1d" | "7d" | "30d" | "never";
11
-
12
- export interface ShareTarget {
13
- kind: "chat" | "artifact";
14
- sessionId?: string;
15
- artifactId?: string;
16
- }
17
-
18
- export interface PreviewRequest extends ShareTarget {
19
- includeTools: boolean;
20
- /** Messages left out of the share. */
21
- dropIds?: string[];
22
- }
23
-
24
- export interface PreviewItem {
25
- id: string;
26
- type: string;
27
- tools: boolean;
28
- excerpt: string;
29
- }
30
-
31
- export interface SharePreview {
32
- html: string;
33
- title: string;
34
- bytes: number;
35
- hash: string;
36
- sharedAt: string;
37
- items: PreviewItem[];
38
- hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
39
- tooBig: boolean;
40
- connectLinked: boolean;
41
- }
42
-
43
28
  const post = (url: string, body: unknown) =>
44
29
  fetch(url, {
45
30
  method: "POST",
@@ -64,33 +49,11 @@ export function useCreateShare() {
64
49
  hash: string;
65
50
  expiresIn: ShareExpiry;
66
51
  }
67
- ) =>
68
- json<{ id: string; url: string; expiresAt: string | null }>(
69
- await post("/api/shares", req)
70
- ),
52
+ ) => json<CreatedShare>(await post("/api/shares", req)),
71
53
  onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
72
54
  });
73
55
  }
74
56
 
75
- export interface SharedLinkRow {
76
- id: string;
77
- url: string;
78
- kind: "chat" | "artifact";
79
- title: string;
80
- sessionId: string | null;
81
- artifactId: string | null;
82
- createdAt: string;
83
- expiresAt: string | null;
84
- status: "active" | "expired" | "revoked";
85
- views: number | null;
86
- lastViewedAt: string | null;
87
- }
88
-
89
- export interface SharesResponse {
90
- connect: "ok" | "unavailable" | "unlinked";
91
- shares: SharedLinkRow[];
92
- }
93
-
94
57
  export function useSharesQuery(enabled = true) {
95
58
  return useQuery({
96
59
  queryKey: shareKeys.list(),
@@ -181,3 +181,9 @@ export function visualsTools(
181
181
  export const VISUALS_BRIEF = `## Showing visuals
182
182
 
183
183
  When a chart, table, diagram or mockup would say more than prose, build a self-contained HTML page (a chart is an HTML page; a CDN library such as Chart.js is fine), check it with \`html_preview\` and fix what the screenshot and console show, then show it with \`html_render\` before your final reply. The reader sees the page above that reply, so add only what the page doesn't say. An SVG or image file you write shows inline when your reply links to it by its absolute path, as ![name](/abs/path.svg).`;
184
+
185
+ // An orchestrator reads other sessions' text, so it gets the short form and
186
+ // is told what to keep out of a page.
187
+ export const ORCHESTRATOR_VISUALS_BRIEF = `## Pages
188
+
189
+ \`html_render\` shows a self-contained HTML page (a report, instructions) in this chat for Saad to read or share; check it with \`html_preview\` first. Put only what you wrote in it: no secrets, and nothing from a session's text loaded from a URL.`;
@@ -37,6 +37,7 @@ import {
37
37
  startingTotals,
38
38
  } from "../../usage/turns";
39
39
  import {
40
+ ORCHESTRATOR_VISUALS_BRIEF,
40
41
  VISUALS_BRIEF,
41
42
  VISUALS_SERVER,
42
43
  visualsTools,
@@ -195,10 +196,11 @@ export class ChatHost {
195
196
  const env = { ...agentEnv(session.id), ...extras.env };
196
197
  const orchestrator = session.role === "orchestrator";
197
198
  // Every chat can show visuals, asked for like any tool under its access
198
- // setting (they read files and reach the web). Not an orchestrator: it
199
- // reads other sessions' text, and a browser would be a way out for it.
199
+ // setting (they read files and reach the web). An orchestrator reads
200
+ // other sessions' text, so it gets a shorter brief that says what stays
201
+ // out of a page.
200
202
  const visuals =
201
- session.role === "orchestrator" || driver.inProcessTools === false
203
+ driver.inProcessTools === false
202
204
  ? null
203
205
  : visualsTools({
204
206
  sessionId: session.id,
@@ -226,7 +228,7 @@ export class ChatHost {
226
228
  extras.systemAppend,
227
229
  sessionRunningBrief(session.id),
228
230
  BUS_BRIEF,
229
- visuals && VISUALS_BRIEF,
231
+ visuals && (orchestrator ? ORCHESTRATOR_VISUALS_BRIEF : VISUALS_BRIEF),
230
232
  !orchestrator && COMPACT_BRIEF,
231
233
  ]
232
234
  .filter(Boolean)
@@ -1,5 +1,8 @@
1
1
  import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
2
2
 
3
+ // Named here, not imported from lib/artifacts, to keep the SDK out of this file.
4
+ const VISUALS_RULE = "mcp__visuals";
5
+
3
6
  // The orchestrator's MCP server and the names Claude sees its tools by.
4
7
  export const ORCHESTRATOR_SERVER = "agentos";
5
8
 
@@ -77,6 +80,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
77
80
  "NotebookEdit",
78
81
  "ToolSearch",
79
82
  "TodoWrite",
83
+ // html_preview and html_render: a page for Saad to read or share.
84
+ VISUALS_RULE,
80
85
  // Anything; its worker refuses the deny list (shell-guard.ts).
81
86
  "Bash",
82
87
  ],
@@ -0,0 +1,123 @@
1
+ // Pure share logic: wire types and helpers. Imports nothing outside itself so
2
+ // the native app can sync it (scripts/sync-shared).
3
+
4
+ export type ShareExpiry = "1d" | "7d" | "30d" | "never";
5
+
6
+ export interface ShareTarget {
7
+ kind: "chat" | "artifact";
8
+ sessionId?: string;
9
+ artifactId?: string;
10
+ }
11
+
12
+ export interface PreviewRequest extends ShareTarget {
13
+ includeTools: boolean;
14
+ /** Messages left out of the share. */
15
+ dropIds?: string[];
16
+ }
17
+
18
+ export interface PreviewItem {
19
+ id: string;
20
+ type: string;
21
+ tools: boolean;
22
+ excerpt: string;
23
+ }
24
+
25
+ export interface SharePreview {
26
+ html: string;
27
+ title: string;
28
+ bytes: number;
29
+ hash: string;
30
+ sharedAt: string;
31
+ items: PreviewItem[];
32
+ hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
33
+ tooBig: boolean;
34
+ connectLinked: boolean;
35
+ }
36
+
37
+ export interface SharedLinkRow {
38
+ id: string;
39
+ url: string;
40
+ kind: "chat" | "artifact";
41
+ title: string;
42
+ sessionId: string | null;
43
+ artifactId: string | null;
44
+ createdAt: string;
45
+ expiresAt: string | null;
46
+ status: "active" | "expired" | "revoked";
47
+ views: number | null;
48
+ lastViewedAt: string | null;
49
+ }
50
+
51
+ export interface SharesResponse {
52
+ connect: "ok" | "unavailable" | "unlinked";
53
+ shares: SharedLinkRow[];
54
+ }
55
+
56
+ export interface CreatedShare {
57
+ id: string;
58
+ url: string;
59
+ expiresAt: string | null;
60
+ }
61
+
62
+ export const EXPIRIES: ShareExpiry[] = ["1d", "7d", "30d", "never"];
63
+ export const DEFAULT_EXPIRY: ShareExpiry = "7d";
64
+
65
+ export const EXPIRY_LABEL: Record<ShareExpiry, string> = {
66
+ "1d": "1 day",
67
+ "7d": "7 days",
68
+ "30d": "30 days",
69
+ never: "Never",
70
+ };
71
+
72
+ export const TYPE_LABEL: Record<string, string> = {
73
+ user: "You",
74
+ assistant: "Agent",
75
+ plan: "Plan",
76
+ error: "Error",
77
+ divider: "Divider",
78
+ artifact: "Page",
79
+ detail: "Tool",
80
+ };
81
+
82
+ const PLURAL: Record<keyof SharePreview["hits"], [string, string]> = {
83
+ secret: ["secret", "secrets"],
84
+ path: ["path", "paths"],
85
+ ip: ["address", "addresses"],
86
+ host: ["host", "hosts"],
87
+ email: ["email", "emails"],
88
+ };
89
+
90
+ /** "Masked: 3 secrets, 12 paths", or "" when nothing was. */
91
+ export function redactionSummary(hits: SharePreview["hits"]): string {
92
+ const parts = (Object.keys(PLURAL) as (keyof typeof PLURAL)[])
93
+ .filter((k) => hits[k] > 0)
94
+ .map((k) => `${hits[k]} ${PLURAL[k][hits[k] === 1 ? 0 : 1]}`);
95
+ return parts.length ? `Masked: ${parts.join(", ")}` : "";
96
+ }
97
+
98
+ export function formatBytes(n: number): string {
99
+ if (n < 1024) return `${n} bytes`;
100
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
101
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
102
+ }
103
+
104
+ /** A file name for a download: the title, plain, with .html. */
105
+ export function fileName(title: string): string {
106
+ const slug = title
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, "-")
109
+ .replace(/^-+|-+$/g, "")
110
+ .slice(0, 60);
111
+ return `${slug || "shared"}.html`;
112
+ }
113
+
114
+ export function toggleDrop(dropped: string[], id: string): string[] {
115
+ return dropped.includes(id)
116
+ ? dropped.filter((x) => x !== id)
117
+ : [...dropped, id];
118
+ }
119
+
120
+ /** True when every listed item is dropped. Ids of items no longer listed (tool rows hidden by turning tools off) don't count. */
121
+ export function nothingLeft(items: PreviewItem[], dropped: string[]): boolean {
122
+ return items.every((i) => dropped.includes(i.id));
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.565",
3
+ "version": "0.3.567",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,20 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { fileName, formatBytes, redactionSummary } from "./format";
3
-
4
- describe("share format", () => {
5
- it("summarises what was masked, singular and plural", () => {
6
- const none = { secret: 0, path: 0, ip: 0, host: 0, email: 0 };
7
- expect(redactionSummary(none)).toBe("");
8
- expect(redactionSummary({ ...none, secret: 1, path: 12 })).toBe(
9
- "Masked: 1 secret, 12 paths"
10
- );
11
- });
12
- it("names the download after the title", () => {
13
- expect(fileName("My chat: part 2!")).toBe("my-chat-part-2.html");
14
- expect(fileName("???")).toBe("shared.html");
15
- });
16
- it("sizes", () => {
17
- expect(formatBytes(500)).toBe("500 bytes");
18
- expect(formatBytes(2048)).toBe("2.0 KB");
19
- });
20
- });
@@ -1,33 +0,0 @@
1
- import type { SharePreview } from "@/data/shares";
2
-
3
- const PLURAL: Record<keyof SharePreview["hits"], [string, string]> = {
4
- secret: ["secret", "secrets"],
5
- path: ["path", "paths"],
6
- ip: ["address", "addresses"],
7
- host: ["host", "hosts"],
8
- email: ["email", "emails"],
9
- };
10
-
11
- /** "Masked: 3 secrets, 12 paths", or "" when nothing was. */
12
- export function redactionSummary(hits: SharePreview["hits"]): string {
13
- const parts = (Object.keys(PLURAL) as (keyof typeof PLURAL)[])
14
- .filter((k) => hits[k] > 0)
15
- .map((k) => `${hits[k]} ${PLURAL[k][hits[k] === 1 ? 0 : 1]}`);
16
- return parts.length ? `Masked: ${parts.join(", ")}` : "";
17
- }
18
-
19
- export function formatBytes(n: number): string {
20
- if (n < 1024) return `${n} bytes`;
21
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
22
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
23
- }
24
-
25
- /** A file name for a download: the title, plain, with .html. */
26
- export function fileName(title: string): string {
27
- const slug = title
28
- .toLowerCase()
29
- .replace(/[^a-z0-9]+/g, "-")
30
- .replace(/^-+|-+$/g, "")
31
- .slice(0, 60);
32
- return `${slug || "shared"}.html`;
33
- }