@saadnvd1/agent-os 0.3.762 → 0.3.766

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.
package/README.md CHANGED
@@ -50,7 +50,7 @@ and check on all of it from your phone.
50
50
 
51
51
  The iPhone app, [AgentOS: Coding Agents](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797), is on the App Store.
52
52
 
53
- You need Node.js 22+, tmux, and at least one agent CLI (the installer offers to set up what's missing).
53
+ You need Node.js 22+, git, tmux, and at least one agent CLI (the installer offers to set up what's missing).
54
54
 
55
55
  ```bash
56
56
  npm install -g @saadnvd1/agent-os
@@ -69,11 +69,12 @@ Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](
69
69
 
70
70
  ## Connect
71
71
 
72
- Connect (in development) lets you reach AgentOS from anywhere, without a tailnet or opening a port. Sign in at [runagentos.com](https://runagentos.com), get a code, and run:
72
+ AgentOS Connect is live. It gives your machine a private address at runagentos.com, so your phone reaches it from any network with nothing to open on your router and no VPN.
73
73
 
74
- ```bash
75
- agent-os connect # links this machine with the code from runagentos.com
76
- ```
74
+ - **Free ($0):** Connect for one machine, including phone access from anywhere, on up to 3 devices.
75
+ - **Pro ($4/mo billed yearly, or $6 monthly):** link up to 10 machines, with unlimited devices and 100 GB of traffic a month.
76
+
77
+ [Sign up](https://runagentos.com/sign-in?mode=sign-up), then run `agent-os connect` on the machine and enter the code. The [phone guide](docs/guides/phone.md) has the steps.
77
78
 
78
79
  Reach it from your phone with the [AgentOS iPhone app](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797), now on the App Store.
79
80
 
@@ -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
  });
@@ -1,4 +1,8 @@
1
1
  import { Info } from "lucide-react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { CONNECT_SIGN_UP } from "@/lib/connect/view";
4
+
5
+ const INSTALL_URL = "https://github.com/saadnvd1/agent-os#install";
2
6
 
3
7
  // Where a terminal or a new session would be, in a demo: what this is and
4
8
  // what to look at instead. Fixed text; nothing runs.
@@ -18,6 +22,18 @@ export function DemoNotice() {
18
22
  Open a session from the sidebar to read its chat, its changes and its
19
23
  pull request; its terminal stays closed.
20
24
  </p>
25
+ <div className="flex flex-wrap gap-2 pt-1">
26
+ <Button asChild className="min-h-11 sm:min-h-9">
27
+ <a href={INSTALL_URL} target="_blank" rel="noopener noreferrer">
28
+ Install AgentOS
29
+ </a>
30
+ </Button>
31
+ <Button asChild variant="outline" className="min-h-11 sm:min-h-9">
32
+ <a href={CONNECT_SIGN_UP} target="_blank" rel="noopener noreferrer">
33
+ Get Connect
34
+ </a>
35
+ </Button>
36
+ </div>
21
37
  </div>
22
38
  </div>
23
39
  );
@@ -3,7 +3,11 @@
3
3
  import { Cloud } from "lucide-react";
4
4
  import { Switch } from "@/components/ui/switch";
5
5
  import type { ConnectStatus } from "@/lib/connect/serve";
6
- import { CONNECT_STATE as STATE, connectView } from "@/lib/connect/view";
6
+ import {
7
+ CONNECT_SIGN_UP,
8
+ CONNECT_STATE as STATE,
9
+ connectView,
10
+ } from "@/lib/connect/view";
7
11
 
8
12
  export function ConnectCard({
9
13
  connect,
@@ -38,7 +42,21 @@ export function ConnectCard({
38
42
  ) : enrolled ? (
39
43
  <>Linked. The address appears once the tunnel starts.</>
40
44
  ) : (
41
- <>Not set up on this machine. Run agent-os connect in a terminal.</>
45
+ <>
46
+ Not set up on this machine. Run{" "}
47
+ <code className="text-foreground font-mono">
48
+ agent-os connect
49
+ </code>{" "}
50
+ in a terminal.{" "}
51
+ <a
52
+ href={CONNECT_SIGN_UP}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ className="text-foreground flex min-h-11 w-fit items-center underline underline-offset-2 sm:min-h-6"
56
+ >
57
+ Set up Connect (free)
58
+ </a>
59
+ </>
42
60
  )}
43
61
  {connect.reason && connect.state !== "off"
44
62
  ? ` ${connect.reason.replace(/\.$/, "")}.`
@@ -73,7 +73,7 @@ export function ProviderCard({
73
73
  const builtIn = provider.id === ANTHROPIC;
74
74
  const [open, setOpen] = useState(false);
75
75
  const [kind, setKind] = useState<SecretRef["kind"]>(
76
- provider.auth?.kind ?? "tokenvault"
76
+ provider.auth?.kind ?? "env"
77
77
  );
78
78
  const [a0, b0] = refFields(provider.auth);
79
79
  const [a, setA] = useState(a0);
@@ -3,6 +3,7 @@
3
3
  import { AlertCircle, FolderPlus, Plus, X } from "lucide-react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { ShimmeringLoader } from "@/components/ui/skeleton";
6
+ import { chordHint } from "@/lib/keybindings/chord";
6
7
  import { sidebarUiActions } from "@/stores/sidebarUi";
7
8
  import type { SidebarData } from "./useSidebarData";
8
9
  import { MachineShelves, SessionShelves } from "./SessionShelves";
@@ -99,10 +100,19 @@ export function SidebarBody({
99
100
  <Empty
100
101
  text="Create a project to start your first session"
101
102
  action={
102
- <Button onClick={onNewProject} className="h-11 gap-2 md:h-9">
103
- <FolderPlus className="h-4 w-4" />
104
- Add project
105
- </Button>
103
+ <div className="flex flex-col items-center gap-3">
104
+ <Button onClick={onNewProject} className="h-11 gap-2 md:h-9">
105
+ <FolderPlus className="h-4 w-4" />
106
+ Add project
107
+ </Button>
108
+ <p className="text-muted-foreground/70 text-xs">
109
+ Then press{" "}
110
+ <kbd className="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono">
111
+ {chordHint("alt+n")}
112
+ </kbd>{" "}
113
+ to start a session
114
+ </p>
115
+ </div>
106
116
  }
107
117
  />
108
118
  ) : (
@@ -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"
@@ -13,7 +13,11 @@ import {
13
13
  useNetworkQuery,
14
14
  useUpdateNetwork,
15
15
  } from "@/data/devices";
16
- import { CONNECT_SITE, CONNECT_STATE, connectView } from "@/lib/connect/view";
16
+ import {
17
+ CONNECT_SIGN_UP,
18
+ CONNECT_STATE,
19
+ connectView,
20
+ } from "@/lib/connect/view";
17
21
  import { Block } from "./Block";
18
22
 
19
23
  const COMMAND = "agent-os connect";
@@ -40,7 +44,7 @@ export function LinkForm() {
40
44
  <p className="text-muted-foreground text-xs">
41
45
  Get a code at{" "}
42
46
  <a
43
- href={CONNECT_SITE}
47
+ href={CONNECT_SIGN_UP}
44
48
  target="_blank"
45
49
  rel="noopener noreferrer"
46
50
  className="text-foreground inline-flex items-center gap-1 underline underline-offset-2"
@@ -48,7 +52,7 @@ export function LinkForm() {
48
52
  runagentos.com
49
53
  <ExternalLink className="size-3" />
50
54
  </a>{" "}
51
- (Connect a machine), then paste it here.
55
+ (free account, then Connect a machine), then paste it here.
52
56
  </p>
53
57
  <form
54
58
  className="flex gap-2"
@@ -104,7 +108,7 @@ export function ConnectBlock() {
104
108
  <Block
105
109
  icon={Globe}
106
110
  title="Use it away from home"
107
- detail="Connect gives this machine a private address at runagentos.com, so your phone reaches it from anywhere. Nothing to open on your router."
111
+ detail="Connect is free for one machine. It gives this machine a private address at runagentos.com, so your phone reaches it from anywhere. Nothing to open on your router."
108
112
  >
109
113
  {network.isPending ? (
110
114
  <div className="bg-muted/40 h-12 animate-pulse rounded-lg" />
@@ -28,7 +28,7 @@ const STEPS: Record<
28
28
  agents: {
29
29
  label: "Agents",
30
30
  title: "Connect your agents",
31
- hint: "Install and sign in the coding agents you want to use. You need one.",
31
+ hint: "Install and sign in the coding agents you want to use. You need one, with an account such as Claude or Codex.",
32
32
  },
33
33
  project: {
34
34
  label: "Project",
@@ -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
@@ -1,6 +1,7 @@
1
1
  import type { ConnectStatus } from "./serve";
2
2
 
3
3
  export const CONNECT_SITE = "https://runagentos.com";
4
+ export const CONNECT_SIGN_UP = `${CONNECT_SITE}/sign-in?mode=sign-up`;
4
5
 
5
6
  export const CONNECT_STATE: Record<ConnectStatus["state"], string> = {
6
7
  up: "Connected",
@@ -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.766",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"