@saadnvd1/agent-os 0.3.758 → 0.3.762

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.
@@ -6,7 +6,7 @@
6
6
  import { agentosHome } from "../agentos-home";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
- import { randomUUID } from "crypto";
9
+ import { createHash, randomUUID } from "crypto";
10
10
  import { db } from "../db";
11
11
 
12
12
  export interface Artifact {
@@ -15,8 +15,31 @@ export interface Artifact {
15
15
  title: string;
16
16
  path: string;
17
17
  created_at: string;
18
+ // The page this is a version of; every version of a page shares it.
19
+ page_id: string;
20
+ version: number;
21
+ // The agent's one line on what changed.
22
+ note: string | null;
23
+ bytes: number | null;
24
+ sha256: string | null;
18
25
  }
19
26
 
27
+ export interface SaveOptions {
28
+ // Any version's id of a page in this session: add a version to it.
29
+ update?: string;
30
+ // With no update, the newest page in this session with the same title.
31
+ sameTitle?: boolean;
32
+ note?: string;
33
+ }
34
+
35
+ export interface Saved {
36
+ artifact: Artifact;
37
+ // False when the page was byte-for-byte its latest version already.
38
+ created: boolean;
39
+ }
40
+
41
+ export const MAX_NOTE_CHARS = 120;
42
+
20
43
  export const MAX_HTML_BYTES = 2 * 1024 * 1024;
21
44
  export const MAX_TITLE_CHARS = 120;
22
45
 
@@ -35,20 +58,92 @@ function sessionDir(sessionId: string): string {
35
58
  export function createArtifact(
36
59
  sessionId: string,
37
60
  title: string,
38
- html: string
61
+ html: string,
62
+ opts: SaveOptions = {}
39
63
  ): Artifact {
40
- if (Buffer.byteLength(html, "utf8") > MAX_HTML_BYTES)
64
+ return saveArtifact(sessionId, title, html, opts).artifact;
65
+ }
66
+
67
+ const oneLine = (s: string, max: number) =>
68
+ s.replace(/\s+/g, " ").trim().slice(0, max);
69
+
70
+ // Saves a page, or a new version of one. Earlier versions are never touched.
71
+ export function saveArtifact(
72
+ sessionId: string,
73
+ title: string,
74
+ html: string,
75
+ opts: SaveOptions = {}
76
+ ): Saved {
77
+ const bytes = Buffer.byteLength(html, "utf8");
78
+ if (bytes > MAX_HTML_BYTES)
41
79
  throw new Error("The page is over 2 MB; make it smaller.");
80
+ const clean = oneLine(title, MAX_TITLE_CHARS) || "Untitled";
81
+ const note = opts.note ? oneLine(opts.note, MAX_NOTE_CHARS) || null : null;
82
+ const sha256 = createHash("sha256").update(html).digest("hex");
42
83
  const id = randomUUID();
43
84
  const dir = sessionDir(sessionId);
44
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
45
85
  const file = path.join(dir, `${id}.html`);
46
- fs.writeFileSync(file, html, { mode: 0o600 });
47
- const clean = title.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE_CHARS);
48
- db.prepare(
49
- `INSERT INTO artifacts (id, session_id, title, path) VALUES (?, ?, ?, ?)`
50
- ).run(id, sessionId, clean || "Untitled", file);
51
- return getArtifact(id)!;
86
+ return db.transaction((): Saved => {
87
+ const pageId = resolvePage(sessionId, clean, opts);
88
+ const latest = pageId ? latestVersion(pageId) : undefined;
89
+ if (latest && latest.sha256 === sha256)
90
+ return { artifact: latest, created: false };
91
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+ fs.writeFileSync(file, html, { mode: 0o600 });
93
+ db.prepare(
94
+ `INSERT INTO artifacts (id, session_id, title, path, page_id, version, note, bytes, sha256)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
96
+ ).run(
97
+ id,
98
+ sessionId,
99
+ clean,
100
+ file,
101
+ pageId ?? id,
102
+ latest ? latest.version + 1 : 1,
103
+ note,
104
+ bytes,
105
+ sha256
106
+ );
107
+ return { artifact: getArtifact(id)!, created: true };
108
+ })();
109
+ }
110
+
111
+ function resolvePage(
112
+ sessionId: string,
113
+ title: string,
114
+ opts: SaveOptions
115
+ ): string | undefined {
116
+ if (opts.update) {
117
+ const base = getArtifact(opts.update);
118
+ if (!base || base.session_id !== sessionId)
119
+ throw new Error(
120
+ `No page ${opts.update} in this chat; leave out update to show a new page.`
121
+ );
122
+ return base.page_id;
123
+ }
124
+ if (!opts.sameTitle) return undefined;
125
+ const row = db
126
+ .prepare(
127
+ `SELECT page_id FROM artifacts WHERE session_id = ? AND lower(title) = lower(?)
128
+ ORDER BY created_at DESC, rowid DESC LIMIT 1`
129
+ )
130
+ .get(sessionId, title) as { page_id: string } | undefined;
131
+ return row?.page_id;
132
+ }
133
+
134
+ // Every version of a page, oldest first.
135
+ export function listVersions(pageId: string): Artifact[] {
136
+ return db
137
+ .prepare(`SELECT * FROM artifacts WHERE page_id = ? ORDER BY version`)
138
+ .all(pageId) as Artifact[];
139
+ }
140
+
141
+ export function latestVersion(pageId: string): Artifact | undefined {
142
+ return db
143
+ .prepare(
144
+ `SELECT * FROM artifacts WHERE page_id = ? ORDER BY version DESC LIMIT 1`
145
+ )
146
+ .get(pageId) as Artifact | undefined;
52
147
  }
53
148
 
54
149
  export function getArtifact(id: string): Artifact | undefined {
@@ -13,8 +13,9 @@ import {
13
13
  type McpSdkServerConfigWithInstance,
14
14
  } from "@anthropic-ai/claude-agent-sdk";
15
15
  import {
16
- createArtifact,
16
+ saveArtifact,
17
17
  MAX_HTML_BYTES,
18
+ MAX_NOTE_CHARS,
18
19
  MAX_TITLE_CHARS,
19
20
  type Artifact,
20
21
  } from "./store";
@@ -84,20 +85,31 @@ export interface VisualsContext {
84
85
  // Saves the page and shows it in the conversation.
85
86
  export async function renderPage(
86
87
  ctx: VisualsContext,
87
- args: { html?: string; path?: string; title: string; height?: number }
88
+ args: {
89
+ html?: string;
90
+ path?: string;
91
+ title: string;
92
+ height?: number;
93
+ update?: string;
94
+ note?: string;
95
+ }
88
96
  ): Promise<CallToolResult> {
89
97
  try {
90
- const artifact = createArtifact(
98
+ const { artifact, created } = saveArtifact(
91
99
  ctx.sessionId,
92
100
  args.title,
93
- readSource(args, ctx.cwd)
101
+ readSource(args, ctx.cwd),
102
+ { update: args.update, note: args.note, sameTitle: true }
94
103
  );
95
- ctx.onRender(artifact, args.height);
104
+ if (created) ctx.onRender(artifact, args.height);
105
+ const name = `"${artifact.title}"${artifact.version > 1 ? ` v${artifact.version}` : ""}`;
96
106
  return {
97
107
  content: [
98
108
  {
99
109
  type: "text",
100
- text: `Shown in the chat as "${artifact.title}" (artifact ${artifact.id}).`,
110
+ text: created
111
+ ? `Shown in the chat as ${name} (artifact ${artifact.id}). To change it, call html_render again with update: "${artifact.id}"; every version is kept.`
112
+ : `Unchanged: the page is the same as ${name} (artifact ${artifact.id}), already in the chat.`,
101
113
  },
102
114
  ],
103
115
  };
@@ -171,6 +183,17 @@ export function visualsTools(
171
183
  .describe(
172
184
  "Cap on the frame's height in CSS pixels; longer pages scroll inside it. Defaults to fitting the page."
173
185
  ),
186
+ update: z
187
+ .string()
188
+ .optional()
189
+ .describe(
190
+ "The artifact id of a page you already showed in this chat, to show a new version of it. A page with the same title is updated too. Every version is kept."
191
+ ),
192
+ note: z
193
+ .string()
194
+ .max(MAX_NOTE_CHARS)
195
+ .optional()
196
+ .describe("For a new version: one line on what changed."),
174
197
  },
175
198
  (args) => renderPage(ctx, args)
176
199
  ),
@@ -0,0 +1,73 @@
1
+ // A page's versions for the version switcher: each with its note, size and
2
+ // the message that asked for it.
3
+
4
+ import { db } from "../db";
5
+ import { getArtifact, listVersions, type Artifact } from "./store";
6
+ import {
7
+ pageActions,
8
+ type ArtifactVersion,
9
+ type PageVersions,
10
+ } from "./actions";
11
+
12
+ const PROMPT_CHARS = 80;
13
+
14
+ const sqliteMs = (t: string) =>
15
+ Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`) || 0;
16
+
17
+ // The user message before the chat item that showed this version.
18
+ function turnOf(a: Artifact): ArtifactVersion["turn"] {
19
+ const row = db
20
+ .prepare(
21
+ `SELECT u.item_id AS itemId,
22
+ json_extract(u.data, '$.text') AS text,
23
+ (SELECT COUNT(*) FROM chat_items c
24
+ WHERE c.session_id = u.session_id AND c.kind = 'user'
25
+ AND c.seq <= u.seq) AS idx
26
+ FROM chat_items u
27
+ WHERE u.session_id = ? AND u.kind = 'user'
28
+ AND u.seq < (SELECT seq FROM chat_items
29
+ WHERE session_id = ? AND item_id = ?)
30
+ ORDER BY u.seq DESC LIMIT 1`
31
+ )
32
+ .get(a.session_id, a.session_id, `artifact-${a.id}`) as
33
+ { itemId: string; text: string | null; idx: number } | undefined;
34
+ if (!row) return undefined;
35
+ const prompt = (row.text ?? "").replace(/\s+/g, " ").trim();
36
+ return {
37
+ itemId: row.itemId,
38
+ index: row.idx,
39
+ prompt:
40
+ prompt.length > PROMPT_CHARS
41
+ ? `${prompt.slice(0, PROMPT_CHARS - 1)}…`
42
+ : prompt,
43
+ };
44
+ }
45
+
46
+ export function toVersion(a: Artifact): ArtifactVersion {
47
+ const turn = turnOf(a);
48
+ return {
49
+ id: a.id,
50
+ version: a.version,
51
+ title: a.title,
52
+ ...(a.note && { note: a.note }),
53
+ createdAt: sqliteMs(a.created_at),
54
+ ...(a.bytes != null && { bytes: a.bytes }),
55
+ ...(turn && { turn }),
56
+ };
57
+ }
58
+
59
+ // Every version of the page that artifactId is a version of.
60
+ export function pageVersions(
61
+ artifactId: string,
62
+ ctx: { pdf: boolean }
63
+ ): PageVersions | undefined {
64
+ const a = getArtifact(artifactId);
65
+ if (!a) return undefined;
66
+ const versions = listVersions(a.page_id).map(toVersion);
67
+ return {
68
+ pageId: a.page_id,
69
+ latestId: versions[versions.length - 1]?.id ?? a.id,
70
+ versions,
71
+ actions: pageActions(ctx),
72
+ };
73
+ }
@@ -11,6 +11,15 @@ export function slashQuery(
11
11
  return { query: m[2].toLowerCase(), start: m.index + m[1].length };
12
12
  }
13
13
 
14
+ // The SDK ends a description with where the command comes from, "(user)",
15
+ // "(project)" or "(plugin-name)"; the menu's group header says that already.
16
+ export function stripSourceSuffix(description: string): string {
17
+ return description.replace(
18
+ /\s*\((?:user|project|builtin|plugin|[\w.-]+@[\w.-]+)\)\s*$/i,
19
+ ""
20
+ );
21
+ }
22
+
14
23
  function isSubsequence(q: string, s: string): boolean {
15
24
  let i = 0;
16
25
  for (const ch of s) if (ch === q[i]) i++;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import type { ChatCommand, ChatItem, DriverEvent } from "../events";
3
3
  import { clipOutput, toolDiff, toolTitle } from "../tools";
4
- import { leadingCommand } from "../commands";
4
+ import { leadingCommand, stripSourceSuffix } from "../commands";
5
5
  import { usageTotals } from "../context";
6
6
 
7
7
  // Loose views of the SDK's message shapes: only what the mapper reads.
@@ -93,7 +93,7 @@ export type SdkCommand = {
93
93
  export function toCommand(c: SdkCommand): ChatCommand {
94
94
  return {
95
95
  name: c.name,
96
- description: c.description ?? "",
96
+ description: stripSourceSuffix(c.description ?? ""),
97
97
  argumentHint: c.argumentHint || undefined,
98
98
  builtin: c.builtin,
99
99
  };
@@ -254,6 +254,10 @@ export type ChatItem =
254
254
  kind: "artifact";
255
255
  artifactId: string;
256
256
  title: string;
257
+ // The page it's a version of, and which; absent before versions (v1 of
258
+ // its own page).
259
+ pageId?: string;
260
+ version?: number;
257
261
  // A cap on the frame's height; it fits the page otherwise.
258
262
  height?: number;
259
263
  })
@@ -215,6 +215,8 @@ export class ChatHost {
215
215
  kind: "artifact",
216
216
  artifactId: artifact.id,
217
217
  title: artifact.title,
218
+ pageId: artifact.page_id,
219
+ version: artifact.version,
218
220
  height,
219
221
  createdAt: Date.now(),
220
222
  }),
@@ -0,0 +1,28 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261029T1200_artifact_versions",
5
+ up: (db) => {
6
+ // Each artifacts row is one version of a page; rows sharing page_id are
7
+ // that page's history. Versions are only ever added, never rewritten.
8
+ const cols = new Set(
9
+ (
10
+ db.prepare(`PRAGMA table_info(artifacts)`).all() as { name: string }[]
11
+ ).map((c) => c.name)
12
+ );
13
+ const add = (name: string, type: string) => {
14
+ if (!cols.has(name))
15
+ db.exec(`ALTER TABLE artifacts ADD COLUMN ${name} ${type}`);
16
+ };
17
+ add("page_id", "TEXT");
18
+ add("version", "INTEGER NOT NULL DEFAULT 1");
19
+ // The agent's one line on what changed.
20
+ add("note", "TEXT");
21
+ add("bytes", "INTEGER");
22
+ add("sha256", "TEXT");
23
+ db.exec(`UPDATE artifacts SET page_id = id WHERE page_id IS NULL`);
24
+ db.exec(
25
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_page_version ON artifacts(page_id, version)`
26
+ );
27
+ },
28
+ } satisfies Migration;
@@ -124,6 +124,7 @@ const READS = [
124
124
  "usage",
125
125
  "vendor/mermaid",
126
126
  "artifacts/[id]",
127
+ "artifacts/[id]/versions",
127
128
  "git/status",
128
129
  "git/file-content",
129
130
  "git/history",
@@ -12,7 +12,7 @@ import { db, queries, type Session } from "../db";
12
12
  import { flushItems } from "../chat/store";
13
13
  import { git } from "../git-exec";
14
14
  import { PLAN_FILE } from "../models/plan-step";
15
- import { listArtifacts } from "../artifacts/store";
15
+ import { listArtifacts, type Artifact } from "../artifacts/store";
16
16
  import { allowedRoots, within } from "./access";
17
17
  import { SCREENSHOT_DIR } from "../tasks/ui-brief";
18
18
  import {
@@ -51,6 +51,7 @@ const ITEMS_SQL = `
51
51
  json_extract(data, '$.input.filePath') AS file_path_camel,
52
52
  json_extract(data, '$.input.changes') AS changes,
53
53
  json_extract(data, '$.artifactId') AS artifact_id,
54
+ json_extract(data, '$.pageId') AS page_id,
54
55
  json_extract(data, '$.title') AS title,
55
56
  json_extract(data, '$.taskId') AS task_id,
56
57
  json_extract(data, '$.outputFile') AS output_file,
@@ -202,16 +203,37 @@ async function build(session: Session): Promise<Entry[]> {
202
203
  const keptBase = sessionKeptDir(session.id);
203
204
  const tracked = new Set(manifest?.tracked);
204
205
  const kept = new Set(manifest?.kept);
205
- const pages = new Map(listArtifacts(session.id).map((a) => [a.id, a.path]));
206
+ const saved = listArtifacts(session.id);
207
+ const byId = new Map(saved.map((a) => [a.id, a]));
208
+ // Each page's newest version and how many it has.
209
+ const newest = new Map<string, Artifact>();
210
+ const count = new Map<string, number>();
211
+ for (const a of saved) {
212
+ count.set(a.page_id, (count.get(a.page_id) ?? 0) + 1);
213
+ if ((newest.get(a.page_id)?.version ?? 0) < a.version)
214
+ newest.set(a.page_id, a);
215
+ }
206
216
  const entries = await Promise.all(
207
217
  candidates.map(async (c): Promise<Entry> => {
208
218
  if (c.source.type === "page") {
209
- const page = pages.get(c.source.artifactId);
210
- const size = page ? await sizeOf(page) : undefined;
211
- return toArtifact(
212
- c,
219
+ const seen = byId.get(c.source.artifactId);
220
+ const page = seen && (newest.get(seen.page_id) ?? seen);
221
+ const size = page ? await sizeOf(page.path) : undefined;
222
+ const c2 = page
223
+ ? { ...c, source: { type: "page" as const, artifactId: page.id } }
224
+ : c;
225
+ const entry = toArtifact(
226
+ c2,
213
227
  size === undefined ? { state: "gone" } : { state: "live", size }
214
228
  );
229
+ if (page)
230
+ Object.assign(entry.artifact, {
231
+ name: page.title,
232
+ artifactId: page.id,
233
+ version: page.version,
234
+ versionCount: count.get(page.page_id) ?? 1,
235
+ });
236
+ return entry;
215
237
  }
216
238
  const file = filePathOf(c);
217
239
  if (!file) return toArtifact(c, null);
@@ -29,6 +29,7 @@ export interface ItemRow {
29
29
  file_path_camel?: string | null;
30
30
  changes?: string | null;
31
31
  artifact_id?: string | null;
32
+ page_id?: string | null;
32
33
  title?: string | null;
33
34
  task_id?: string | null;
34
35
  output_file?: string | null;
@@ -177,7 +178,8 @@ export function fromItems(
177
178
  });
178
179
  }
179
180
  } else if (row.kind === "artifact" && row.artifact_id) {
180
- const key = `page:${row.artifact_id}`;
181
+ // One row per page: a later version replaces an earlier one.
182
+ const key = `page:${row.page_id || row.artifact_id}`;
181
183
  out.set(key, {
182
184
  key,
183
185
  kind: "page",
@@ -1,5 +1,7 @@
1
1
  // What a session produced, as the Artifacts view lists it. Client-safe.
2
2
 
3
+ import type { ArtifactAction } from "../artifacts/actions";
4
+
3
5
  export type ArtifactKind =
4
6
  "plan" | "file" | "page" | "image" | "log" | "pr" | "run" | "session-log";
5
7
 
@@ -30,6 +32,13 @@ export interface SessionArtifact {
30
32
  folder?: string;
31
33
  // A link out (the PR on GitHub).
32
34
  url?: string;
35
+ // A page: its latest version's artifact id, which version that is, and
36
+ // how many there are.
37
+ artifactId?: string;
38
+ version?: number;
39
+ versionCount?: number;
40
+ // What the viewer can do with it; set by the server.
41
+ actions?: ArtifactAction[];
33
42
  }
34
43
 
35
44
  export const openable = (a: Pick<SessionArtifact, "state" | "preview">) =>
package/next.config.ts CHANGED
@@ -7,6 +7,12 @@ const nextConfig: NextConfig = {
7
7
  // so the live server never serves a half-written build.
8
8
  distDir: process.env.AGENTOS_DIST_DIR || ".next",
9
9
  devIndicators: false,
10
+ // Next 16.4 stops `next dev` once under an agent to print an upgrade
11
+ // nudge, which kills every task's dev server. Upgrades go through
12
+ // Dependabot and tasks instead.
13
+ experimental: {
14
+ agentUpgrade: false,
15
+ },
10
16
  turbopack: {
11
17
  root: resolve(import.meta.dirname),
12
18
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.758",
3
+ "version": "0.3.762",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -136,7 +136,7 @@
136
136
  "mermaid": "^12.2.0",
137
137
  "micromark-extension-gfm": "^3.0.0",
138
138
  "monaco-editor": "^0.57.0",
139
- "next": "^16.3.8",
139
+ "next": "^16.4.2",
140
140
  "next-themes": "^0.4.6",
141
141
  "node-pty": "^1.2.0-beta.6",
142
142
  "playwright": "^1.63.0",
@@ -167,7 +167,7 @@
167
167
  "@types/react-dom": "^19.2.3",
168
168
  "@types/ws": "^8.18.1",
169
169
  "eslint": "^9.39.5",
170
- "eslint-config-next": "^16.3.8",
170
+ "eslint-config-next": "^16.4.2",
171
171
  "husky": "^9.1.7",
172
172
  "jsdom": "^30.1.2",
173
173
  "prettier": "^3.7.4",
package/stores/shareUi.ts CHANGED
@@ -3,12 +3,16 @@ import type { ShareTarget } from "@/data/shares";
3
3
 
4
4
  // What the share dialog is open on: a chat or an artifact, started from a
5
5
  // menu, the pane or the artifact card.
6
+ // A page's version, when it has more than one, is named in the title; the
7
+ // share is of that exact version.
8
+ export type ShareUiTarget = ShareTarget & { title: string; version?: number };
9
+
6
10
  export const shareUi = proxy<{
7
- target: (ShareTarget & { title: string }) | null;
11
+ target: ShareUiTarget | null;
8
12
  }>({ target: null });
9
13
 
10
14
  export const shareUiActions = {
11
- open: (target: ShareTarget & { title: string }) => {
15
+ open: (target: ShareUiTarget) => {
12
16
  shareUi.target = target;
13
17
  },
14
18
  close: () => {