@saadnvd1/agent-os 0.3.782 → 0.3.786

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 (71) hide show
  1. package/app/api/hosts/[id]/agents/route.ts +6 -4
  2. package/app/api/machine/route.ts +5 -3
  3. package/app/api/whats-new/route.ts +47 -0
  4. package/components/Chat/ChatPanel.tsx +2 -0
  5. package/components/Chat/ModelPicker.tsx +82 -42
  6. package/components/Chat/composer/ComposerBody.tsx +3 -0
  7. package/components/Chat/composer/Toolbar.tsx +3 -0
  8. package/components/Draft/AgentModelChip.test.ts +1 -0
  9. package/components/Draft/AgentModelChip.tsx +36 -93
  10. package/components/Draft/Chip.tsx +27 -0
  11. package/components/Draft/DraftChips.tsx +5 -1
  12. package/components/Draft/DraftPanel.tsx +0 -21
  13. package/components/Draft/catalogBlocker.ts +19 -0
  14. package/components/Draft/picker/AgentModelPicker.tsx +152 -0
  15. package/components/Draft/picker/AgentRail.tsx +145 -0
  16. package/components/Draft/picker/ModelList.tsx +136 -0
  17. package/components/Draft/picker/ModelNote.tsx +15 -0
  18. package/components/Draft/picker/PickerSheet.tsx +61 -0
  19. package/components/Draft/picker/SameAsLast.tsx +52 -0
  20. package/components/Draft/picker/SearchField.tsx +48 -0
  21. package/components/Draft/picker/picker.test.ts +152 -0
  22. package/components/Draft/picker/sections.test.ts +115 -0
  23. package/components/Draft/picker/sections.ts +121 -0
  24. package/components/Draft/picker/useListKeys.ts +54 -0
  25. package/components/Draft/picker/useModelSearch.ts +66 -0
  26. package/components/SidebarFooter.tsx +3 -1
  27. package/components/Updates/UpdateBanner.test.ts +1 -1
  28. package/components/Updates/UpdateBanner.tsx +27 -13
  29. package/components/WhatsNew/WhatsNewButton.tsx +36 -0
  30. package/components/WhatsNew/WhatsNewDialog.tsx +169 -0
  31. package/components/WhatsNew/WhatsNewHost.tsx +17 -0
  32. package/components/WhatsNew/WhatsNewRows.tsx +62 -0
  33. package/components/WhatsNew/index.ts +2 -0
  34. package/components/ui/dialog.tsx +12 -1
  35. package/content/whats-new.json +1773 -0
  36. package/data/chat/useChat.ts +3 -0
  37. package/data/machines/index.ts +13 -4
  38. package/data/machines/keys.ts +2 -1
  39. package/data/whats-new/index.ts +44 -0
  40. package/hooks/useWhatsNewSeen.ts +50 -0
  41. package/lib/chat/drivers/copilot.ts +9 -6
  42. package/lib/chat/drivers/opencode.ts +1 -1
  43. package/lib/chat/drivers/pi.ts +1 -1
  44. package/lib/chat/events.ts +6 -0
  45. package/lib/chat/settings.ts +2 -0
  46. package/lib/machines/catalog.ts +5 -1
  47. package/lib/machines/recent.ts +31 -0
  48. package/lib/machines/remote.ts +28 -9
  49. package/lib/machines/report.ts +7 -2
  50. package/lib/machines/types.ts +12 -0
  51. package/lib/orchestrator/review-prompt.ts +2 -0
  52. package/lib/security/demo.ts +2 -0
  53. package/lib/tasks/brief.ts +1 -0
  54. package/lib/whats-new/ahead.ts +28 -0
  55. package/lib/whats-new/areas.ts +76 -0
  56. package/lib/whats-new/claude.ts +35 -0
  57. package/lib/whats-new/collect.ts +68 -0
  58. package/lib/whats-new/generate.ts +107 -0
  59. package/lib/whats-new/lint.ts +69 -0
  60. package/lib/whats-new/load.ts +59 -0
  61. package/lib/whats-new/rows.ts +79 -0
  62. package/lib/whats-new/schema.ts +41 -0
  63. package/lib/whats-new/summarize.ts +91 -0
  64. package/lib/whats-new/unread.ts +14 -0
  65. package/package.json +2 -1
  66. package/scripts/autodeploy +28 -0
  67. package/scripts/autodeploy.test.ts +53 -0
  68. package/scripts/whats-new.ts +118 -0
  69. package/stores/whatsNew.ts +16 -0
  70. package/components/Draft/draftModels.test.ts +0 -31
  71. package/components/Draft/draftModels.ts +0 -17
@@ -0,0 +1,107 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { areaFor, collectCommits, type Git } from "./collect";
4
+ import { lintEntry } from "./lint";
5
+ import { fileSchema, type Entry } from "./schema";
6
+ import { authorLine, fillGaps, type Ask, type Line } from "./summarize";
7
+ import { WHATS_NEW_FILE } from "./load";
8
+
9
+ export interface PrInfo {
10
+ body: string;
11
+ url: string;
12
+ }
13
+
14
+ export interface GenerateDeps {
15
+ git: Git;
16
+ /** Bodies and links of merged PRs; throws when it can't ask, so nothing is guessed. */
17
+ prs: (numbers: number[]) => Map<number, PrInfo>;
18
+ ask: Ask;
19
+ blocklist: string[];
20
+ }
21
+
22
+ export interface GenerateResult {
23
+ added: Entry[];
24
+ /** Merges that had a line but couldn't ship, with the reason. */
25
+ dropped: { pr: number; reason: string }[];
26
+ skipped: number;
27
+ head: string;
28
+ }
29
+
30
+ // `raw` is what is written back for existing entries, so a field the schema
31
+ // doesn't know about survives a run too.
32
+ export function readFile(root: string): { entries: Entry[]; raw: unknown[] } {
33
+ try {
34
+ const json = JSON.parse(
35
+ fs.readFileSync(path.join(root, WHATS_NEW_FILE), "utf-8")
36
+ );
37
+ return { entries: fileSchema.parse(json).entries, raw: json.entries };
38
+ } catch (e) {
39
+ if ((e as NodeJS.ErrnoException).code === "ENOENT")
40
+ return { entries: [], raw: [] };
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * New entries for merges since `through` (or the last `sinceDays`). Append
47
+ * only: an id already in the file is never read, changed or reordered, so a
48
+ * hand edit or "hidden" survives. Writes the file only when there is
49
+ * something to add.
50
+ */
51
+ export async function generate(
52
+ root: string,
53
+ opts: { through?: string | null; sinceDays: number; write: boolean },
54
+ deps: GenerateDeps
55
+ ): Promise<GenerateResult> {
56
+ const { entries, raw } = readFile(root);
57
+ const head = deps.git(["rev-parse", "HEAD"]).trim();
58
+ const commits = collectCommits(
59
+ deps.git,
60
+ opts,
61
+ new Set(entries.map((e) => e.id)),
62
+ WHATS_NEW_FILE
63
+ );
64
+ const result: GenerateResult = { added: [], dropped: [], skipped: 0, head };
65
+ if (!commits.length) return result;
66
+
67
+ const prs = deps.prs(commits.map((c) => c.pr));
68
+ const lines = new Map<number, Line>();
69
+ const gaps = [];
70
+ for (const c of commits) {
71
+ const line = authorLine(prs.get(c.pr)?.body);
72
+ if (line) lines.set(c.pr, line);
73
+ else gaps.push({ n: c.pr, type: c.type, title: c.title });
74
+ }
75
+ if (gaps.length)
76
+ for (const [n, line] of await fillGaps(gaps, deps.ask)) lines.set(n, line);
77
+
78
+ for (const c of commits) {
79
+ const line = lines.get(c.pr);
80
+ if (!line || "skip" in line) {
81
+ result.skipped++;
82
+ continue;
83
+ }
84
+ const entry: Entry = {
85
+ id: c.pr,
86
+ date: c.date,
87
+ kind: line.kind,
88
+ area: areaFor(c),
89
+ text: line.text,
90
+ ...(prs.get(c.pr)?.url && { url: prs.get(c.pr)!.url }),
91
+ };
92
+ const bad = lintEntry(entry, deps.blocklist);
93
+ if (bad) result.dropped.push({ pr: c.pr, reason: bad });
94
+ else result.added.push(entry);
95
+ }
96
+
97
+ if (opts.write && result.added.length) {
98
+ const file = path.join(root, WHATS_NEW_FILE);
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ fs.writeFileSync(
101
+ file,
102
+ JSON.stringify({ v: 1, entries: [...raw, ...result.added] }, null, 2) +
103
+ "\n"
104
+ );
105
+ }
106
+ return result;
107
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { agentosHome } from "@/lib/agentos-home";
4
+ import type { Entry } from "./schema";
5
+
6
+ // The repo is public, so this checks only generic shapes. Names that must
7
+ // never appear (other products) live in a private list outside the repo and
8
+ // are passed in.
9
+ const GENERIC: [string, RegExp][] = [
10
+ ["a user path", /\/Users\/|~\/|\/home\/\w|(?:^|[\s(])\.[a-z][\w-]*\//i],
11
+ ["a file path", /\b[\w.-]+\/[\w.-]+\/[\w./-]+|\/[\w.-]+\/[\w.-]+/],
12
+ [
13
+ "a file name",
14
+ /\b[\w-]+\.(?:tsx?|jsx?|mjs|json|md|sh|py|sql|ya?ml|toml)\b/i,
15
+ ],
16
+ ["an IP address", /\b\d{1,3}(?:\.\d{1,3}){3}\b/],
17
+ [
18
+ "a hostname",
19
+ /\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:ts\.net|local|internal|lan|home|com|io|net|app|dev|org|ai|co)\b/i,
20
+ ],
21
+ ["a token", /\b(?:gh[pousr]_|github_pat_|npm_|sk-|xox[bp]-)[\w-]{8,}/],
22
+ ["a long secret-looking string", /\b[A-Za-z0-9_-]{32,}\b/],
23
+ ["tailnet", /tailnet|tailscale/i],
24
+ ["a CVE", /\bCVE-\d{4}-\d+/i],
25
+ ["a vulnerability", /vulnerab|exploit|\bRCE\b/i],
26
+ ];
27
+
28
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+
30
+ /** Why this text can't ship, or null when it can. */
31
+ export function lintText(
32
+ text: string,
33
+ blocklist: string[] = []
34
+ ): string | null {
35
+ for (const [what, re] of GENERIC)
36
+ if (re.test(text)) return `contains ${what}`;
37
+ for (const name of blocklist) {
38
+ if (
39
+ new RegExp(
40
+ `(?<![\\p{L}\\p{N}])${escape(name)}(?![\\p{L}\\p{N}])`,
41
+ "iu"
42
+ ).test(text)
43
+ )
44
+ return "contains a blocked name";
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export const lintEntry = (e: Entry, blocklist: string[] = []) =>
50
+ lintText(e.text, blocklist) ?? (e.note ? lintText(e.note, blocklist) : null);
51
+
52
+ const BLOCKLIST_HEADER =
53
+ "# One name per line (any name that must never appear in the\n# changelog). Matched whole-word, any case. Lives outside the repo on purpose.\n";
54
+
55
+ export const blocklistPath = () => agentosHome("whats-new-blocklist.txt");
56
+
57
+ /** The private list; created empty (with a comment) when it is missing. */
58
+ export function readBlocklist(file: string = blocklistPath()): string[] {
59
+ if (!fs.existsSync(file)) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, BLOCKLIST_HEADER);
62
+ return [];
63
+ }
64
+ return fs
65
+ .readFileSync(file, "utf-8")
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter((l) => l && !l.startsWith("#"));
69
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileSchema, type Entry, type Head, type Page } from "./schema";
4
+
5
+ export const WHATS_NEW_FILE = "content/whats-new.json";
6
+
7
+ const fileAt = (root: string) => path.join(root, WHATS_NEW_FILE);
8
+
9
+ let cached: { file: string; mtime: number; entries: Entry[] } | null = null;
10
+
11
+ // Newest first: by day, then by PR number within it. The file is append-only
12
+ // and in the order the generator wrote it, so the order is made here.
13
+ export const byNewest = (a: Entry, b: Entry) =>
14
+ a.date === b.date ? b.id - a.id : a.date < b.date ? 1 : -1;
15
+
16
+ /** Every shown entry, newest first. A missing or unreadable file is empty. */
17
+ export function loadEntries(root: string = process.cwd()): Entry[] {
18
+ const file = fileAt(root);
19
+ try {
20
+ const mtime = fs.statSync(file).mtimeMs;
21
+ if (cached?.file === file && cached.mtime === mtime) return cached.entries;
22
+ const parsed = fileSchema.safeParse(
23
+ JSON.parse(fs.readFileSync(file, "utf-8"))
24
+ );
25
+ const entries = parsed.success
26
+ ? parsed.data.entries.filter((e) => !e.hidden).sort(byNewest)
27
+ : [];
28
+ cached = { file, mtime, entries };
29
+ return entries;
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ const RECENT = 10;
36
+
37
+ export function headOf(entries: Entry[]): Head {
38
+ return {
39
+ latestId: entries.reduce((m, e) => Math.max(m, e.id), 0),
40
+ latestDate: entries[0]?.date ?? null,
41
+ count: entries.length,
42
+ recent: entries.slice(0, RECENT).map((e) => e.id),
43
+ };
44
+ }
45
+
46
+ /** `limit` entries older than the `before` day (all days when it is unset). */
47
+ export function pageOf(
48
+ entries: Entry[],
49
+ { before, limit }: { before?: string | null; limit: number }
50
+ ): Page {
51
+ const older = before ? entries.filter((e) => e.date < before) : entries;
52
+ const slice = older.slice(0, limit);
53
+ // Never split a day across pages: the next page starts at a day boundary.
54
+ const last = slice[slice.length - 1];
55
+ const rest = older.slice(limit);
56
+ while (last && rest.length && rest[0].date === last.date)
57
+ slice.push(rest.shift()!);
58
+ return { entries: slice, hasMore: rest.length > 0 };
59
+ }
@@ -0,0 +1,79 @@
1
+ import type { Entry } from "./schema";
2
+
3
+ export type Row =
4
+ | { type: "heading"; key: string; label: string }
5
+ | { type: "day"; key: string; label: string }
6
+ | { type: "entry"; key: string; entry: Entry }
7
+ | { type: "divider"; key: string; label: string };
8
+
9
+ const MONTHS = [
10
+ "Jan",
11
+ "Feb",
12
+ "Mar",
13
+ "Apr",
14
+ "May",
15
+ "Jun",
16
+ "Jul",
17
+ "Aug",
18
+ "Sep",
19
+ "Oct",
20
+ "Nov",
21
+ "Dec",
22
+ ];
23
+
24
+ const day = (iso: string) => {
25
+ const [y, m, d] = iso.split("-").map(Number);
26
+ return new Date(y, m - 1, d);
27
+ };
28
+
29
+ const isoOf = (d: Date) =>
30
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
31
+
32
+ /** "Today", "Yesterday" or "Oct 27" (with the year when it isn't this one). */
33
+ export function dayLabel(iso: string, now: Date = new Date()): string {
34
+ if (iso === isoOf(now)) return "Today";
35
+ const y = new Date(now);
36
+ y.setDate(y.getDate() - 1);
37
+ if (iso === isoOf(y)) return "Yesterday";
38
+ const d = day(iso);
39
+ const base = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
40
+ return d.getFullYear() === now.getFullYear()
41
+ ? base
42
+ : `${base}, ${d.getFullYear()}`;
43
+ }
44
+
45
+ /**
46
+ * Day headings with entries under them, newest first. When some entries are
47
+ * newer than `seen`, a divider marks where the last visit ended.
48
+ */
49
+ export function buildRows(
50
+ entries: Entry[],
51
+ seen: number | null,
52
+ now: Date = new Date()
53
+ ): Row[] {
54
+ const rows: Row[] = [];
55
+ const anyUnread = seen !== null && entries.some((e) => e.id > seen);
56
+ let current: string | null = null;
57
+ let divided = false;
58
+ for (const e of entries) {
59
+ if (anyUnread && !divided && e.id <= seen!) {
60
+ divided = true;
61
+ current = null;
62
+ rows.push({
63
+ type: "divider",
64
+ key: "divider",
65
+ label: `You last looked ${dayLabel(e.date, now)}`,
66
+ });
67
+ }
68
+ if (e.date !== current) {
69
+ current = e.date;
70
+ rows.push({
71
+ type: "day",
72
+ key: `day-${e.date}-${rows.length}`,
73
+ label: dayLabel(e.date, now),
74
+ });
75
+ }
76
+ rows.push({ type: "entry", key: `e-${e.id}`, entry: e });
77
+ }
78
+ return rows;
79
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+
3
+ export const KINDS = ["new", "improved", "fixed"] as const;
4
+ export type Kind = (typeof KINDS)[number];
5
+
6
+ export const KIND_LABEL: Record<Kind, string> = {
7
+ new: "New",
8
+ improved: "Improved",
9
+ fixed: "Fixed",
10
+ };
11
+
12
+ export const entrySchema = z.object({
13
+ id: z.number().int().positive(),
14
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
15
+ kind: z.enum(KINDS),
16
+ area: z.string().min(1).max(40),
17
+ text: z.string().min(1).max(280),
18
+ note: z.string().max(400).optional(),
19
+ url: z.string().optional(),
20
+ hidden: z.boolean().optional(),
21
+ });
22
+ export type Entry = z.infer<typeof entrySchema>;
23
+
24
+ export const fileSchema = z.object({
25
+ v: z.literal(1),
26
+ entries: z.array(entrySchema),
27
+ });
28
+ export type WhatsNewFile = z.infer<typeof fileSchema>;
29
+
30
+ export interface Head {
31
+ latestId: number;
32
+ latestDate: string | null;
33
+ count: number;
34
+ /** Ids of the newest few, so the footer can count unread without the list. */
35
+ recent: number[];
36
+ }
37
+
38
+ export interface Page {
39
+ entries: Entry[];
40
+ hasMore: boolean;
41
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { KINDS, type Kind } from "./schema";
3
+
4
+ export type Line = { skip: true } | { kind: Kind; text: string };
5
+
6
+ const LINE = /^\s*Whats-new:\s*(.+?)\s*$/gim;
7
+ const KIND_BY_NAME: Record<string, Kind> = {
8
+ new: "new",
9
+ improved: "improved",
10
+ fixed: "fixed",
11
+ };
12
+
13
+ export const tidy = (text: string) => {
14
+ const t = text.replace(/\s+/g, " ").trim();
15
+ return /[.!?]$/.test(t) ? t : `${t}.`;
16
+ };
17
+
18
+ /** The author's line from a PR body (the last one wins); null when absent or malformed. */
19
+ export function authorLine(body: string | null | undefined): Line | null {
20
+ if (!body) return null;
21
+ let last: string | undefined;
22
+ for (const m of body.slice(0, 65_536).matchAll(LINE)) last = m[1];
23
+ if (!last) return null;
24
+ if (/^skip\.?$/i.test(last)) return { skip: true };
25
+ const m = /^(new|improved|fixed)\s*[—–:-]+\s*(.+)$/i.exec(last);
26
+ if (!m) return null;
27
+ return { kind: KIND_BY_NAME[m[1].toLowerCase()], text: tidy(m[2]) };
28
+ }
29
+
30
+ export const GAP_SYSTEM = `You write one line of a changelog for people who use a coding-agent app. You get a JSON list of merged changes, each only a commit subject. Answer with a JSON array and nothing else: one object per item, {"n": <n>, "kind": "new" | "improved" | "fixed", "text": "<sentence>"}, or {"n": <n>, "skip": true} when a user would not see the change.
31
+
32
+ Rules for text: one plain sentence under 140 characters that says what a user can now do or what stopped going wrong. Say nothing the subject does not say. No jargon, no file or function names, no paths, no emoji, no dashes used as punctuation, never the word "improved", and no marketing words. "new" is something that did not exist, "fixed" is something that was broken, "improved" is something that already existed and works better.`;
33
+
34
+ export interface GapItem {
35
+ n: number;
36
+ type: string;
37
+ title: string;
38
+ }
39
+
40
+ const answer = z.array(
41
+ z.union([
42
+ z.object({ n: z.number(), skip: z.literal(true) }),
43
+ z.object({
44
+ n: z.number(),
45
+ kind: z.enum(KINDS),
46
+ text: z.string().min(1).max(400),
47
+ }),
48
+ ])
49
+ );
50
+
51
+ export function parseAnswer(raw: string): Map<number, Line> {
52
+ const out = new Map<number, Line>();
53
+ const start = raw.indexOf("[");
54
+ const end = raw.lastIndexOf("]");
55
+ if (start < 0 || end < start) return out;
56
+ let json: unknown;
57
+ try {
58
+ json = JSON.parse(raw.slice(start, end + 1));
59
+ } catch {
60
+ return out;
61
+ }
62
+ const parsed = answer.safeParse(json);
63
+ if (!parsed.success) return out;
64
+ for (const r of parsed.data)
65
+ out.set(
66
+ r.n,
67
+ "skip" in r ? { skip: true } : { kind: r.kind, text: tidy(r.text) }
68
+ );
69
+ return out;
70
+ }
71
+
72
+ export type Ask = (system: string, prompt: string) => Promise<string>;
73
+
74
+ const BATCH = 80;
75
+
76
+ /** Haiku sees the commit subject only: never the diff and never the PR body. */
77
+ export async function fillGaps(
78
+ items: GapItem[],
79
+ ask: Ask
80
+ ): Promise<Map<number, Line>> {
81
+ const out = new Map<number, Line>();
82
+ for (let i = 0; i < items.length; i += BATCH) {
83
+ const batch = items.slice(i, i + BATCH);
84
+ const prompt = JSON.stringify(
85
+ batch.map(({ n, type, title }) => ({ n, type, subject: title }))
86
+ );
87
+ for (const [n, line] of parseAnswer(await ask(GAP_SYSTEM, prompt)))
88
+ if (batch.some((b) => b.n === n)) out.set(n, line);
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,14 @@
1
+ // Read/unread is local to a browser: the id of the newest entry seen.
2
+ // Pure, so a native client can use the same rules.
3
+
4
+ export const SEEN_KEY = "aos:whatsnew:seen";
5
+ export const UNREAD_CAP = 9;
6
+
7
+ /** How many of the newest entries are newer than the seen id (capped by the head's list). */
8
+ export function unreadCount(recent: number[], seen: number | null): number {
9
+ if (seen === null) return 0;
10
+ return recent.filter((id) => id > seen).length;
11
+ }
12
+
13
+ export const unreadLabel = (n: number) =>
14
+ n > UNREAD_CAP ? `${UNREAD_CAP}+` : String(n);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.782",
3
+ "version": "0.3.786",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "app/",
10
10
  "components/",
11
+ "content/",
11
12
  "contexts/",
12
13
  "data/",
13
14
  "hooks/",
@@ -13,6 +13,11 @@
13
13
  # HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
14
14
  # Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
15
15
  # only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
16
+ # The machine that publishes also writes the What's New entries for what
17
+ # merged (scripts/whats-new, before the publish, so the package carries them)
18
+ # and pushes them as a commit that touches only content/whats-new.json. Such a
19
+ # commit is pulled but never rebuilt or restarted for, and the generator
20
+ # ignores it, so it can't loop. AGENTOS_WHATS_NEW=0 turns the step off.
16
21
  set -euo pipefail
17
22
  cd "$(dirname "$0")/.."
18
23
 
@@ -42,6 +47,18 @@ published=$(cat "$state/published" 2>/dev/null || true)
42
47
  [ "$head" = "$deployed" ] && [ "$head" = "$published" ] && exit 0
43
48
  log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
44
49
 
50
+ # A head that differs from the deployed one only in the changelog file needs
51
+ # a pull, not a build or a restart: the file is read from disk when asked.
52
+ changelog=content/whats-new.json
53
+ if [ "$head" != "$deployed" ] && [ -n "$deployed" ] &&
54
+ [ "$(git diff --name-only "$deployed" "$head" 2>/dev/null)" = "$changelog" ] &&
55
+ [ -z "$(git status --porcelain --untracked-files=no)" ] &&
56
+ git merge -q --ff-only "$head" 2>/dev/null; then
57
+ deployed=$head
58
+ git rev-parse HEAD > "$state/deployed"
59
+ [ "$head" = "$published" ] && exit 0
60
+ fi
61
+
45
62
  # Merges land in bursts: one that's under AGENTOS_SETTLE seconds old (2
46
63
  # minutes) waits for the next run, so a burst is one build and one restart.
47
64
  # Its commit time is the merge's, so this never holds a deploy forever.
@@ -104,6 +121,17 @@ if [ "$head" != "$deployed" ]; then
104
121
  scripts/sync-cli-links || log "CLI link sync failed"
105
122
  fi
106
123
 
124
+ # The changelog is written before the publish, from the same checkout, so the
125
+ # package carries it. Best effort: a failure here never holds up the publish.
126
+ if [ "${AGENTOS_PUBLISH:-1}" != 0 ] && [ "${AGENTOS_WHATS_NEW:-1}" != 0 ] &&
127
+ [ "$head" != "$published" ] && [ "$head" = "$(git rev-parse HEAD)" ]; then
128
+ if ! node --import tsx scripts/whats-new.ts --commit \
129
+ --through-file "$state/whats-new-through" >> "$state/whats-new.log" 2>&1; then
130
+ log "What's New step failed; see $state/whats-new.log"
131
+ fi
132
+ head=$(git rev-parse HEAD)
133
+ fi
134
+
107
135
  # Publishing waits minutes for npm to serve a version, so it runs on its own
108
136
  # with its own lock and never holds up the next deploy.
109
137
  if [ "$head" != "$published" ] && mkdir "$state/publish.lock" 2>/dev/null; then
@@ -113,6 +113,59 @@ describe("autodeploy restart gap", () => {
113
113
  });
114
114
  });
115
115
 
116
+ describe("autodeploy changelog commits", () => {
117
+ const changelog = (t: ReturnType<typeof setup>, also?: string) => {
118
+ const work = path.join(t.root, "work");
119
+ fs.mkdirSync(path.join(work, "content"), { recursive: true });
120
+ fs.writeFileSync(path.join(work, "content/whats-new.json"), "{}\n");
121
+ if (also) fs.appendFileSync(path.join(work, "f"), also + "\n");
122
+ sh(work, "add", "-A");
123
+ sh(
124
+ work,
125
+ "-c",
126
+ "user.name=t",
127
+ "-c",
128
+ "user.email=t@t",
129
+ "commit",
130
+ "-qm",
131
+ "chore(whats-new): add 1 entries"
132
+ );
133
+ sh(work, "push", "-q", "origin", "main");
134
+ return sh(work, "rev-parse", "HEAD");
135
+ };
136
+
137
+ it("pulls a commit that touches only the changelog without a restart", () => {
138
+ const t = setup();
139
+ const a = t.commit("a");
140
+ t.run();
141
+ expect(t.restarts()).toBe(1);
142
+
143
+ const b = changelog(t);
144
+ expect(t.run()).toBe("");
145
+ expect(t.deployed()).toBe(b);
146
+ expect(t.deployed()).not.toBe(a);
147
+ expect(t.restarts()).toBe(1);
148
+ expect(fs.existsSync(path.join(t.clone, "content/whats-new.json"))).toBe(
149
+ true
150
+ );
151
+ expect(t.run()).toBe("");
152
+ });
153
+
154
+ it("still redeploys a commit that touches the changelog and something else", () => {
155
+ const t = setup();
156
+ t.commit("a");
157
+ t.run();
158
+ const c = changelog(t, "more");
159
+ fs.writeFileSync(
160
+ path.join(t.state, "restarted"),
161
+ `${Math.floor(Date.now() / 1000) - 3601}\n`
162
+ );
163
+ expect(t.run()).toContain("Deploying");
164
+ expect(t.deployed()).toBe(c);
165
+ expect(t.restarts()).toBe(2);
166
+ });
167
+ });
168
+
116
169
  describe("autodeploy CLI links", () => {
117
170
  it("repoints a link into another clone at the live checkout after a deploy", () => {
118
171
  const t = setup();