@saadnvd1/agent-os 0.3.762 → 0.3.765

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.
@@ -117,7 +117,7 @@ const one = await import("./[id]/route");
117
117
  const { saveItem } = await import("@/lib/chat/store");
118
118
  const { seedSession } = await import("@/lib/orchestrator/testing");
119
119
  const { createProject } = await import("@/lib/projects");
120
- const { createArtifact } = await import("@/lib/artifacts/store");
120
+ const { createArtifact, saveArtifact } = await import("@/lib/artifacts/store");
121
121
  const { getDb } = await import("@/lib/db");
122
122
 
123
123
  const secret = ["gh", "p_", "b".repeat(36)].join("");
@@ -341,6 +341,71 @@ describe("create", () => {
341
341
  expect(html).toContain("noindex");
342
342
  });
343
343
 
344
+ describe("follow latest", () => {
345
+ const page = (html: string) =>
346
+ `<html><head></head><body>${html}</body></html>`;
347
+ async function create(artifactId: string, extra: object = {}) {
348
+ const body = { kind: "artifact", artifactId, ...extra };
349
+ const p = await (
350
+ await preview.POST(post("/api/shares/preview", body))
351
+ ).json();
352
+ return shares.POST(
353
+ post("/api/shares", {
354
+ ...body,
355
+ sharedAt: p.sharedAt,
356
+ hash: p.hash,
357
+ expiresIn: "7d",
358
+ })
359
+ );
360
+ }
361
+ const stored = () =>
362
+ getDb().prepare(`SELECT follow_latest, version FROM shared_links`).get();
363
+
364
+ it("pins the shown version by default", async () => {
365
+ const sid = chat();
366
+ saveArtifact(sid, "Doc", page("one"), { sameTitle: true });
367
+ const v2 = saveArtifact(sid, "Doc", page("two"), {
368
+ sameTitle: true,
369
+ }).artifact;
370
+ expect((await create(v2.id)).status).toBe(201);
371
+ expect(stored()).toEqual({ follow_latest: 0, version: 2 });
372
+ const listed = await (await shares.GET()).json();
373
+ expect(listed.shares[0]).toMatchObject({ follow: false, version: 2 });
374
+ });
375
+
376
+ it("keeps follow: latest on the link and lists it", async () => {
377
+ const sid = chat();
378
+ const v1 = saveArtifact(sid, "Doc", page("one"), {
379
+ sameTitle: true,
380
+ }).artifact;
381
+ expect((await create(v1.id, { follow: "latest" })).status).toBe(201);
382
+ expect(stored()).toEqual({ follow_latest: 1, version: 1 });
383
+ const listed = await (await shares.GET()).json();
384
+ expect(listed.shares[0]).toMatchObject({ follow: true, version: 1 });
385
+ });
386
+
387
+ it("refuses to follow from an older version, and for a chat", async () => {
388
+ const sid = chat();
389
+ const v1 = saveArtifact(sid, "Doc", page("one"), {
390
+ sameTitle: true,
391
+ }).artifact;
392
+ saveArtifact(sid, "Doc", page("two"), { sameTitle: true });
393
+ const old = await create(v1.id, { follow: "latest" });
394
+ expect(old.status).toBe(409);
395
+ expect((await old.json()).error).toMatch(/share v2/);
396
+ const res = await shares.POST(
397
+ post("/api/shares", {
398
+ kind: "chat",
399
+ sessionId: sid,
400
+ follow: "latest",
401
+ hash: "x",
402
+ })
403
+ );
404
+ expect(res.status).toBe(400);
405
+ expect(seen).toHaveLength(0);
406
+ });
407
+ });
408
+
344
409
  it("says so when Connect isn't linked, and for a bad expiry or hash", async () => {
345
410
  const id = chat();
346
411
  const p = await previewOf(id);
@@ -12,6 +12,7 @@ import {
12
12
  buildPage,
13
13
  parseRequest,
14
14
  } from "@/lib/share/service";
15
+ import { getArtifact, latestVersion } from "@/lib/artifacts/store";
15
16
  import { insertSharedLink, listSharedLinks } from "@/lib/share/store";
16
17
  import type { ShareExpiry } from "@/lib/connect/control-api";
17
18
 
@@ -31,6 +32,16 @@ export async function POST(request: Request) {
31
32
  if (typeof body?.hash !== "string") return fail("hash is required.", 400);
32
33
  if (!linked()) return fail(NOT_LINKED, 409);
33
34
 
35
+ const artifact = req.artifactId ? getArtifact(req.artifactId) : undefined;
36
+ if (req.follow && artifact) {
37
+ const latest = latestVersion(artifact.page_id);
38
+ if (latest && latest.id !== artifact.id)
39
+ return fail(
40
+ `v${artifact.version} isn't the latest; share v${latest.version} to follow the page.`,
41
+ 409
42
+ );
43
+ }
44
+
34
45
  const page = await buildPage(req);
35
46
  if (page.tooBig) return fail(TOO_BIG, 413);
36
47
  if (page.hash !== body.hash)
@@ -53,6 +64,8 @@ export async function POST(request: Request) {
53
64
  expires_at: share.expires_at,
54
65
  created_at: share.created_at,
55
66
  revoked_at: null,
67
+ follow_latest: req.follow ? 1 : 0,
68
+ version: artifact?.version ?? null,
56
69
  });
57
70
  return NextResponse.json(
58
71
  { id: share.id, url: share.url, expiresAt: share.expires_at },
@@ -96,6 +109,8 @@ export async function GET() {
96
109
  status,
97
110
  views: r ? r.view_count : null,
98
111
  lastViewedAt: r?.last_viewed_at ?? null,
112
+ follow: l.follow_latest === 1,
113
+ version: l.version,
99
114
  };
100
115
  }),
101
116
  });
@@ -19,6 +19,7 @@ import {
19
19
  SelectValue,
20
20
  } from "@/components/ui/select";
21
21
  import { DownloadMenu } from "./DownloadMenu";
22
+ import { cn } from "@/lib/utils";
22
23
  import { Switch } from "@/components/ui/switch";
23
24
  import { writeClipboard } from "@/hooks/useCopyToClipboard";
24
25
  import {
@@ -76,6 +77,7 @@ function ShareBody({
76
77
  const [preview, setPreview] = useState<SharePreview | null>(null);
77
78
  const [error, setError] = useState<string | null>(null);
78
79
  const [created, setCreated] = useState<string | null>(null);
80
+ const [follow, setFollow] = useState(false);
79
81
  const [copied, setCopied] = useState(false);
80
82
  const [tab, setTab] = useState<"page" | "markdown">("page");
81
83
  const previewReq = useSharePreview();
@@ -121,6 +123,7 @@ function ShareBody({
121
123
  sharedAt: preview.sharedAt,
122
124
  hash: preview.hash,
123
125
  expiresIn,
126
+ follow: follow ? "latest" : undefined,
124
127
  });
125
128
  setCreated(res.url);
126
129
  try {
@@ -235,6 +238,48 @@ function ShareBody({
235
238
  </>
236
239
  )}
237
240
 
241
+ {linked && kind === "artifact" && (
242
+ <fieldset className="space-y-2">
243
+ <legend className="text-muted-foreground mb-1 text-xs font-medium">
244
+ What the link shows
245
+ </legend>
246
+ {[
247
+ {
248
+ value: false,
249
+ title: `This version${target.version ? ` (v${target.version})` : ""}`,
250
+ hint: "Never changes. You review it above before it goes out.",
251
+ },
252
+ {
253
+ value: true,
254
+ title: "Always the latest",
255
+ hint: "Updates when the agent makes a new version. New versions are redacted the same way but publish without a fresh preview.",
256
+ },
257
+ ].map((o) => (
258
+ <label
259
+ key={o.title}
260
+ className={cn(
261
+ "flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border px-3 py-2 text-sm",
262
+ follow === o.value && "border-primary bg-primary/5"
263
+ )}
264
+ >
265
+ <input
266
+ type="radio"
267
+ name="share-follow"
268
+ className="mt-1 h-4 w-4 shrink-0"
269
+ checked={follow === o.value}
270
+ onChange={() => setFollow(o.value)}
271
+ />
272
+ <span className="min-w-0">
273
+ <span className="block font-medium">{o.title}</span>
274
+ <span className="text-muted-foreground block text-xs">
275
+ {o.hint}
276
+ </span>
277
+ </span>
278
+ </label>
279
+ ))}
280
+ </fieldset>
281
+ )}
282
+
238
283
  {linked && (
239
284
  <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
240
285
  <span>Link expires after</span>
@@ -60,6 +60,13 @@ function Row({ share }: { share: SharedLinkRow }) {
60
60
  <div className="truncate text-sm font-medium">{title}</div>
61
61
  <div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
62
62
  <span>{share.kind === "chat" ? "Chat" : "Page"}</span>
63
+ {share.kind === "artifact" && (
64
+ <span>
65
+ {share.follow
66
+ ? `Follows latest${share.version ? ` · now v${share.version}` : ""}`
67
+ : `pinned${share.version ? ` v${share.version}` : ""}`}
68
+ </span>
69
+ )}
63
70
  <span>Created {when(share.createdAt)}</span>
64
71
  <span>
65
72
  {share.status === "revoked"
@@ -19,6 +19,7 @@ import {
19
19
  MAX_TITLE_CHARS,
20
20
  type Artifact,
21
21
  } from "./store";
22
+ import { followLatest } from "../share/follow";
22
23
  import { previewHtml } from "./preview";
23
24
 
24
25
  export const VISUALS_SERVER = "visuals";
@@ -101,7 +102,10 @@ export async function renderPage(
101
102
  readSource(args, ctx.cwd),
102
103
  { update: args.update, note: args.note, sameTitle: true }
103
104
  );
104
- if (created) ctx.onRender(artifact, args.height);
105
+ if (created) {
106
+ ctx.onRender(artifact, args.height);
107
+ if (artifact.version > 1) followLatest(artifact.page_id);
108
+ }
105
109
  const name = `"${artifact.title}"${artifact.version > 1 ? ` v${artifact.version}` : ""}`;
106
110
  return {
107
111
  content: [
@@ -428,6 +428,21 @@ export async function uploadShare(
428
428
  return { ...shareFrom(r), url };
429
429
  }
430
430
 
431
+ /** Replaces a live share's content under the same link. */
432
+ export async function replaceShare(
433
+ config: ConnectConfig,
434
+ privateKey: string,
435
+ id: string,
436
+ share: { title: string; html: string; markdown: string }
437
+ ): Promise<void> {
438
+ if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
439
+ await signed(config, privateKey)(
440
+ "PUT",
441
+ `/api/v1/machines/${config.machineId}/shares/${id}`,
442
+ { title: share.title, html: share.html, markdown: share.markdown }
443
+ );
444
+ }
445
+
431
446
  export async function listShares(
432
447
  config: ConnectConfig,
433
448
  privateKey: string
@@ -0,0 +1,28 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261029T1300_shared_links_follow",
5
+ up: (db) => {
6
+ // follow_latest: the link is re-uploaded whenever its page gets a new
7
+ // version. version: the page version the link shows now (pinned or not).
8
+ const cols = new Set(
9
+ (
10
+ db.prepare(`PRAGMA table_info(shared_links)`).all() as {
11
+ name: string;
12
+ }[]
13
+ ).map((c) => c.name)
14
+ );
15
+ if (!cols.has("follow_latest"))
16
+ db.exec(
17
+ `ALTER TABLE shared_links ADD COLUMN follow_latest INTEGER NOT NULL DEFAULT 0`
18
+ );
19
+ if (!cols.has("version"))
20
+ db.exec(`ALTER TABLE shared_links ADD COLUMN version INTEGER`);
21
+ // Links made before this show the version their artifact row is.
22
+ db.exec(
23
+ `UPDATE shared_links SET version = (
24
+ SELECT a.version FROM artifacts a WHERE a.id = shared_links.artifact_id
25
+ ) WHERE version IS NULL AND artifact_id IS NOT NULL`
26
+ );
27
+ },
28
+ } satisfies Migration;
@@ -5,6 +5,7 @@ import {
5
5
  ApiError,
6
6
  clean,
7
7
  listShares,
8
+ replaceShare,
8
9
  revokeShare,
9
10
  uploadShare,
10
11
  type RemoteShare,
@@ -70,6 +71,15 @@ export async function createRemote(share: {
70
71
  }
71
72
  }
72
73
 
74
+ /** Puts new content under a live link. Throws the raw ApiError, so the caller can tell a dead link from a failed try. */
75
+ export async function replaceRemote(
76
+ id: string,
77
+ content: { title: string; html: string; markdown: string }
78
+ ): Promise<void> {
79
+ const { config, machineKey } = files();
80
+ await replaceShare(config, machineKey, id, content);
81
+ }
82
+
73
83
  export async function listRemote(): Promise<RemoteShare[]> {
74
84
  const { config, machineKey } = files();
75
85
  return listShares(config, machineKey);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Follow-latest share links: when a page gets a new version, every live link
3
+ * that follows it is re-uploaded through Connect's PUT, built the same way as
4
+ * a normal share (same snapshot, same redaction). Nothing here throws into
5
+ * the caller: a failed try is logged and the next version tries again, and
6
+ * the content already on the link stays live meanwhile.
7
+ */
8
+
9
+ import { ApiError } from "@/lib/connect/control-api";
10
+ import { latestVersion } from "@/lib/artifacts/store";
11
+ import { linked, replaceRemote } from "./connect";
12
+ import { BadShare, buildPage } from "./service";
13
+ import { listFollowing, setLinkVersion, stopFollowing } from "./store";
14
+
15
+ // One run per page at a time, so two quick versions can't land out of order.
16
+ const running = new Map<string, Promise<void>>();
17
+
18
+ export function followLatest(pageId: string): Promise<void> {
19
+ const next = (running.get(pageId) ?? Promise.resolve())
20
+ .then(() => run(pageId))
21
+ .catch((err) => console.error("[share-follow]", pageId, err))
22
+ .finally(() => {
23
+ if (running.get(pageId) === next) running.delete(pageId);
24
+ });
25
+ running.set(pageId, next);
26
+ return next;
27
+ }
28
+
29
+ async function run(pageId: string): Promise<void> {
30
+ const latest = latestVersion(pageId);
31
+ if (!latest) return;
32
+ // A link that missed an earlier version is behind too, so this is also the retry.
33
+ const behind = listFollowing(pageId).filter(
34
+ (l) => (l.version ?? 0) < latest.version
35
+ );
36
+ if (!behind.length || !linked()) return;
37
+ let page;
38
+ try {
39
+ page = await buildPage({
40
+ kind: "artifact",
41
+ artifactId: latest.id,
42
+ includeTools: false,
43
+ sharedAt: new Date().toISOString(),
44
+ });
45
+ } catch (err) {
46
+ console.error(
47
+ "[share-follow] could not build",
48
+ latest.id,
49
+ err instanceof BadShare ? err.message : err
50
+ );
51
+ return;
52
+ }
53
+ if (page.tooBig) {
54
+ console.error("[share-follow] v%d is too big to share", latest.version);
55
+ return;
56
+ }
57
+ for (const link of behind) {
58
+ try {
59
+ await replaceRemote(link.id, {
60
+ title: page.title,
61
+ html: page.html,
62
+ markdown: page.markdown,
63
+ });
64
+ setLinkVersion(link.id, latest.version, page.title);
65
+ } catch (err) {
66
+ if (err instanceof ApiError && (err.status === 404 || err.status === 410))
67
+ stopFollowing(link.id);
68
+ console.error(
69
+ "[share-follow] %s stays on v%s:",
70
+ link.id,
71
+ link.version,
72
+ err instanceof Error ? err.message : err
73
+ );
74
+ }
75
+ }
76
+ }
@@ -25,6 +25,7 @@ export interface ShareRequest {
25
25
  includeTools: boolean;
26
26
  /** Messages the reader left out in the preview. */
27
27
  dropIds?: string[];
28
+ follow?: "latest";
28
29
  sharedAt: string;
29
30
  }
30
31
 
@@ -86,6 +87,10 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
86
87
  throw new BadShare("dropIds must be a list of ids", 400);
87
88
  dropIds = b.dropIds as string[];
88
89
  }
90
+ if (b.follow !== undefined && b.follow !== "latest")
91
+ throw new BadShare("follow must be latest", 400);
92
+ if (b.follow && kind !== "artifact")
93
+ throw new BadShare("Only a page can follow its latest version", 400);
89
94
  const at = typeof sharedAt === "string" ? sharedAt : new Date().toISOString();
90
95
  if (Number.isNaN(Date.parse(at)))
91
96
  throw new BadShare("sharedAt is not a date", 400);
@@ -95,6 +100,7 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
95
100
  artifactId,
96
101
  includeTools: b.includeTools === true,
97
102
  dropIds,
103
+ follow: b.follow === "latest" ? "latest" : undefined,
98
104
  sharedAt: at,
99
105
  };
100
106
  }
@@ -10,13 +10,16 @@ export interface SharedLink {
10
10
  expires_at: string | null;
11
11
  created_at: string;
12
12
  revoked_at: string | null;
13
+ follow_latest: number;
14
+ // The page version the link shows now.
15
+ version: number | null;
13
16
  }
14
17
 
15
18
  export function insertSharedLink(link: SharedLink): void {
16
19
  db.prepare(
17
20
  `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)`
21
+ (id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at, follow_latest, version)
22
+ VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at, @follow_latest, @version)`
20
23
  ).run(link);
21
24
  }
22
25
 
@@ -36,3 +39,32 @@ export function markRevoked(id: string): void {
36
39
  `UPDATE shared_links SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`
37
40
  ).run(new Date().toISOString(), id);
38
41
  }
42
+
43
+ /** Live follow-latest links to any version of a page. */
44
+ export function listFollowing(pageId: string): SharedLink[] {
45
+ return db
46
+ .prepare(
47
+ `SELECT l.* FROM shared_links l JOIN artifacts a ON a.id = l.artifact_id
48
+ WHERE a.page_id = ? AND l.follow_latest = 1 AND l.revoked_at IS NULL
49
+ AND (l.expires_at IS NULL OR l.expires_at > ?)
50
+ ORDER BY l.created_at, l.rowid`
51
+ )
52
+ .all(pageId, new Date().toISOString()) as SharedLink[];
53
+ }
54
+
55
+ export function setLinkVersion(
56
+ id: string,
57
+ version: number,
58
+ title: string
59
+ ): void {
60
+ db.prepare(`UPDATE shared_links SET version = ?, title = ? WHERE id = ?`).run(
61
+ version,
62
+ title,
63
+ id
64
+ );
65
+ }
66
+
67
+ /** Connect no longer has it: stop trying. */
68
+ export function stopFollowing(id: string): void {
69
+ db.prepare(`UPDATE shared_links SET follow_latest = 0 WHERE id = ?`).run(id);
70
+ }
package/lib/share/view.ts CHANGED
@@ -7,6 +7,8 @@ export interface ShareTarget {
7
7
  kind: "chat" | "artifact";
8
8
  sessionId?: string;
9
9
  artifactId?: string;
10
+ /** A page link that is re-uploaded when the page gets a new version. */
11
+ follow?: "latest";
10
12
  }
11
13
 
12
14
  export interface PreviewRequest extends ShareTarget {
@@ -52,6 +54,10 @@ export interface SharedLinkRow {
52
54
  status: "active" | "expired" | "revoked";
53
55
  views: number | null;
54
56
  lastViewedAt: string | null;
57
+ /** True when the link follows the page's latest version. */
58
+ follow: boolean;
59
+ /** The page version the link shows now. */
60
+ version: number | null;
55
61
  }
56
62
 
57
63
  export interface SharesResponse {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.762",
3
+ "version": "0.3.765",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"