@saadnvd1/agent-os 0.3.792 → 0.3.799

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 (75) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/tips/route.test.ts +56 -0
  3. package/app/api/tips/route.ts +45 -0
  4. package/app/api/updates/health/route.ts +22 -0
  5. package/app/api/updates/route.test.ts +53 -0
  6. package/app/api/updates/settings/route.ts +37 -10
  7. package/components/Chat/ChatPanel.tsx +28 -0
  8. package/components/Chat/needs-more-history.test.ts +28 -0
  9. package/components/Chat/needs-more-history.ts +14 -0
  10. package/components/Recipes/TimerFields.tsx +2 -1
  11. package/components/Schedules/ScheduleForm.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/components/SidebarFooter.tsx +2 -0
  14. package/components/Tips/TipButton.tsx +39 -0
  15. package/components/Tips/TipDialog.tsx +148 -0
  16. package/components/Tips/TipHost.tsx +14 -0
  17. package/components/Tips/TipMedia.tsx +71 -0
  18. package/components/Tips/index.ts +2 -0
  19. package/components/Updates/AutoUpdates.tsx +141 -0
  20. package/components/Updates/UpdateBanner.test.ts +39 -1
  21. package/components/Updates/UpdateBanner.tsx +31 -5
  22. package/components/Updates/UpdatesSection.tsx +24 -3
  23. package/content/tips.json +456 -0
  24. package/content/whats-new.json +32 -0
  25. package/data/tips/index.ts +49 -0
  26. package/data/updates/index.ts +5 -0
  27. package/hooks/useReloadWhenUpdated.ts +5 -1
  28. package/lib/busy.ts +23 -1
  29. package/lib/chat/drivers/copilot-approvals.ts +2 -0
  30. package/lib/chat/drivers/copilot-ask.ts +232 -0
  31. package/lib/chat/drivers/copilot-mapper.ts +9 -0
  32. package/lib/chat/drivers/copilot.ts +18 -4
  33. package/lib/db/migrations.ts +21 -0
  34. package/lib/recipes/timer.ts +3 -1
  35. package/lib/schedules/cron.ts +14 -5
  36. package/lib/schedules/store.ts +3 -2
  37. package/lib/security/demo.ts +2 -0
  38. package/lib/tasks/finish.ts +5 -5
  39. package/lib/tips/conditions.ts +38 -0
  40. package/lib/tips/index.ts +2 -0
  41. package/lib/tips/load.ts +26 -0
  42. package/lib/tips/media.ts +101 -0
  43. package/lib/tips/pick.ts +35 -0
  44. package/lib/tips/schema.ts +82 -0
  45. package/lib/tips/state.ts +26 -0
  46. package/lib/tips/view.ts +60 -0
  47. package/lib/updates/auto.ts +149 -0
  48. package/lib/updates/banner.ts +78 -3
  49. package/lib/updates/check.ts +64 -8
  50. package/lib/updates/history.ts +99 -0
  51. package/lib/updates/run.ts +334 -23
  52. package/lib/updates/settings.ts +66 -0
  53. package/lib/updates/view.ts +14 -3
  54. package/package.json +2 -2
  55. package/public/tips/.gitkeep +0 -0
  56. package/public/tips/chat-slash-menu-poster.webp +0 -0
  57. package/public/tips/chat-slash-menu.mp4 +0 -0
  58. package/scripts/autodeploy +4 -0
  59. package/scripts/autodeploy.test.ts +12 -0
  60. package/scripts/docs-links.test.ts +1 -25
  61. package/scripts/lib/commands.sh +32 -1
  62. package/scripts/lib/deps-swap.sh +8 -6
  63. package/scripts/lib/heading-slugs.ts +26 -0
  64. package/scripts/lib/update-verify.mjs +90 -0
  65. package/scripts/redeploy +31 -2
  66. package/scripts/redeploy.test.ts +94 -3
  67. package/scripts/tips-lint.test.ts +77 -0
  68. package/scripts/tips-media.ts +31 -0
  69. package/scripts/update-e2e.test.ts +215 -0
  70. package/scripts/update-recover.mjs +130 -0
  71. package/scripts/update-recover.test.ts +144 -0
  72. package/scripts/update-run.mjs +182 -40
  73. package/scripts/update-run.test.ts +208 -2
  74. package/server.ts +3 -2
  75. package/stores/tips.ts +14 -0
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+
3
+ export const CONDITIONS = [
4
+ "no-paired-device",
5
+ "no-push-device",
6
+ "connect-not-linked",
7
+ "connect-linked",
8
+ "no-schedules",
9
+ "no-recipes",
10
+ "no-shares",
11
+ "no-groups",
12
+ "no-bookmarks",
13
+ ] as const;
14
+ export type Condition = (typeof CONDITIONS)[number];
15
+
16
+ const media = z.discriminatedUnion("kind", [
17
+ z.object({
18
+ kind: z.literal("image"),
19
+ src: z.string().startsWith("/tips/"),
20
+ srcDark: z.string().startsWith("/tips/").optional(),
21
+ alt: z.string().min(1).max(200),
22
+ width: z.number().int().positive(),
23
+ height: z.number().int().positive(),
24
+ }),
25
+ z.object({
26
+ kind: z.literal("video"),
27
+ src: z.string().startsWith("/tips/"),
28
+ poster: z.string().startsWith("/tips/"),
29
+ alt: z.string().min(1).max(200),
30
+ width: z.number().int().positive(),
31
+ height: z.number().int().positive(),
32
+ durationMs: z.number().int().positive(),
33
+ }),
34
+ ]);
35
+ export type Media = z.infer<typeof media>;
36
+
37
+ const mediaBrief = z.object({
38
+ kind: z.enum(["screenshot", "crop", "clip"]),
39
+ shot: z.string().min(1).max(300),
40
+ theme: z.enum(["light", "dark", "both"]).optional(),
41
+ });
42
+ export type MediaBrief = z.infer<typeof mediaBrief>;
43
+
44
+ export const tipSchema = z.object({
45
+ id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
46
+ title: z.string().min(1).max(60),
47
+ body: z.string().min(1).max(240),
48
+ guide: z
49
+ .object({
50
+ file: z.string().regex(/^[a-z0-9-]+$/),
51
+ anchor: z.string().optional(),
52
+ })
53
+ .optional(),
54
+ when: z.array(z.enum(CONDITIONS)).optional(),
55
+ added: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
56
+ hidden: z.boolean().optional(),
57
+ media: media.optional(),
58
+ mediaBrief: mediaBrief.optional(),
59
+ });
60
+ export type Tip = z.infer<typeof tipSchema>;
61
+
62
+ export const fileSchema = z.object({
63
+ v: z.literal(1),
64
+ tips: z.array(tipSchema),
65
+ });
66
+
67
+ /** What a client gets: the guide resolved to a link, the brief left out. */
68
+ export interface ClientTip {
69
+ id: string;
70
+ title: string;
71
+ body: string;
72
+ url?: string;
73
+ media?: Media;
74
+ }
75
+
76
+ export interface TipsView {
77
+ off: boolean;
78
+ /** Setup isn't finished: the button works but shows no dot. */
79
+ quiet: boolean;
80
+ today: { id: string; read: boolean } | null;
81
+ deck: ClientTip[];
82
+ }
@@ -0,0 +1,26 @@
1
+ import { getSetting, putSetting } from "@/lib/settings-store";
2
+ import type { Pinned } from "./pick";
3
+
4
+ const OFF = "tips.off";
5
+ const SEEN = "tips.seen";
6
+ const TODAY = "tips.today";
7
+
8
+ function json<T>(key: string, fallback: T): T {
9
+ try {
10
+ const raw = getSetting(key);
11
+ return raw ? (JSON.parse(raw) as T) : fallback;
12
+ } catch {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ export const tipsOff = (): boolean => getSetting(OFF) === "1";
18
+ export const setTipsOff = (off: boolean) => putSetting(OFF, off ? "1" : null);
19
+
20
+ /** Tip id -> the day it was last shown. */
21
+ export const readSeen = () => json<Record<string, string>>(SEEN, {});
22
+ export const writeSeen = (seen: Record<string, string>) =>
23
+ putSetting(SEEN, JSON.stringify(seen));
24
+
25
+ export const readPinned = () => json<Pinned | null>(TODAY, null);
26
+ export const writePinned = (p: Pinned) => putSetting(TODAY, JSON.stringify(p));
@@ -0,0 +1,60 @@
1
+ import { readCompletedAt } from "@/lib/welcome";
2
+ import { tipApplies } from "./conditions";
3
+ import { loadTips } from "./load";
4
+ import { orderDeck, pickToday } from "./pick";
5
+ import type { ClientTip, Tip, TipsView } from "./schema";
6
+ import {
7
+ readPinned,
8
+ readSeen,
9
+ setTipsOff,
10
+ tipsOff,
11
+ writePinned,
12
+ writeSeen,
13
+ } from "./state";
14
+
15
+ const GUIDES = "https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
16
+
17
+ export const guideUrl = (g: NonNullable<Tip["guide"]>) =>
18
+ `${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
19
+
20
+ const forClient = ({ id, title, body, guide, media }: Tip): ClientTip => ({
21
+ id,
22
+ title,
23
+ body,
24
+ ...(guide && { url: guideUrl(guide) }),
25
+ ...(media && { media }),
26
+ });
27
+
28
+ export const todayLocal = () => new Date().toLocaleDateString("sv");
29
+
30
+ /** The tips that apply now, and which one is today's. */
31
+ export function tipsView(day: string = todayLocal()): TipsView {
32
+ const quiet = readCompletedAt() === null;
33
+ if (tipsOff()) return { off: true, quiet, today: null, deck: [] };
34
+ const deck = orderDeck(loadTips().filter((t) => tipApplies(t)));
35
+ const pinned = readPinned();
36
+ const today = pickToday({ deck, seen: readSeen(), pinned, day });
37
+ if (today && (today.id !== pinned?.id || today.day !== pinned?.day))
38
+ writePinned(today);
39
+ return {
40
+ off: false,
41
+ quiet,
42
+ today: today && { id: today.id, read: today.read },
43
+ deck: deck.map(forClient),
44
+ };
45
+ }
46
+
47
+ /** Records that these tips were shown; today's one becomes read. */
48
+ export function markSeen(ids: string[], day: string = todayLocal()): void {
49
+ const known = new Set(loadTips().map((t) => t.id));
50
+ const seen = Object.fromEntries(
51
+ Object.entries(readSeen()).filter(([id]) => known.has(id))
52
+ );
53
+ for (const id of ids) if (known.has(id)) seen[id] = day;
54
+ writeSeen(seen);
55
+ const pinned = readPinned();
56
+ if (pinned && pinned.day === day && ids.includes(pinned.id) && !pinned.read)
57
+ writePinned({ ...pinned, read: true });
58
+ }
59
+
60
+ export { setTipsOff };
@@ -0,0 +1,149 @@
1
+ // When an automatic update starts, as one pure decision so it can be tested
2
+ // as a table. lib/updates/run.ts gathers the input and acts on the answer.
3
+ //
4
+ // 1. Switched off, pinned off, deployed by autodeploy, or refused (local
5
+ // changes, diverged, dev server, no restart command...) → skip, say why.
6
+ // 2. The target is the newest commit on the remote's first-parent line
7
+ // that has been there `soakMs`, so a bad push can be reverted before an
8
+ // install takes it, and a busy day of merges can't hold it off. A
9
+ // commit that was rolled back, or failed to build recently, is passed
10
+ // over for an older one still newer than what's installed.
11
+ // 3. Nothing a restart would interrupt is in flight (agents working, chat
12
+ // turns, reviews, task setups, simulator starts, merges, stack
13
+ // landings, an update or deploy), and has not been for `idleMs`.
14
+ // 4. Once a release has waited `deadlineMs` past its soak, a lull of
15
+ // `lullMs` is enough. It never interrupts work; it keeps waiting.
16
+
17
+ import type { UpdateAttempt } from "./banner";
18
+ import { blockFor } from "./history";
19
+
20
+ export interface Candidate {
21
+ sha: string;
22
+ /** Commit time, ms. */
23
+ at: number;
24
+ }
25
+
26
+ export interface AutoInput {
27
+ enabled: boolean;
28
+ locked: boolean;
29
+ managed: boolean;
30
+ refusal: string | null;
31
+ /** First-parent commits newer than the installed one, newest first. */
32
+ candidates: Candidate[];
33
+ attempts: UpdateAttempt[];
34
+ /** What is working now; empty when nothing is. */
35
+ busy: string[];
36
+ /** Since when nothing has been working, as far as anyone watched. */
37
+ quietSince: number | null;
38
+ now: number;
39
+ soakMs: number;
40
+ idleMs: number;
41
+ deadlineMs: number;
42
+ lullMs?: number;
43
+ }
44
+
45
+ export type AutoDecision =
46
+ | { action: "start"; to: string }
47
+ | { action: "wait"; code: AutoWait; text: string; to?: string }
48
+ | { action: "skip"; code: AutoSkip; text: string };
49
+
50
+ export type AutoSkip = "off" | "locked" | "managed" | "refused" | "up_to_date";
51
+ export type AutoWait = "soaking" | "blocked" | "busy" | "quiet";
52
+
53
+ export const LULL_MS = 2 * 60_000;
54
+
55
+ const short = (sha: string) => sha.slice(0, 7);
56
+ const mins = (ms: number) => Math.max(1, Math.ceil(ms / 60_000));
57
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
58
+
59
+ /** The commit to update to, or why there's none yet. */
60
+ export function pickTarget(
61
+ input: Pick<AutoInput, "candidates" | "attempts" | "now" | "soakMs">
62
+ ):
63
+ | { to: string; eligibleSince: number }
64
+ | {
65
+ to?: undefined;
66
+ code: "soaking" | "blocked";
67
+ text: string;
68
+ until?: number;
69
+ } {
70
+ const { candidates, attempts, now, soakMs } = input;
71
+ const soaked = candidates.filter((c) => c.at + soakMs <= now);
72
+ if (!soaked.length) {
73
+ const next = Math.min(...candidates.map((c) => c.at + soakMs));
74
+ return {
75
+ code: "soaking",
76
+ text: `A new version came out recently; it updates once it has been out ${plural(mins(soakMs), "minute")}.`,
77
+ until: next,
78
+ };
79
+ }
80
+ let first: string | null = null;
81
+ for (const c of soaked) {
82
+ const block = blockFor(c.sha, attempts, now);
83
+ if (!block)
84
+ return {
85
+ to: c.sha,
86
+ // The oldest waiting release says how long updates have been put off.
87
+ eligibleSince: soaked[soaked.length - 1].at + soakMs,
88
+ };
89
+ first ??= `${short(c.sha)}: ${block.reason}`;
90
+ }
91
+ return {
92
+ code: "blocked",
93
+ text: `Not updating to ${first}. Waiting for a newer version.`,
94
+ };
95
+ }
96
+
97
+ export function decideAutoUpdate(input: AutoInput): AutoDecision {
98
+ if (input.locked)
99
+ return {
100
+ action: "skip",
101
+ code: "locked",
102
+ text: "Automatic updates are turned off by AGENTOS_AUTO_UPDATE.",
103
+ };
104
+ if (!input.enabled)
105
+ return { action: "skip", code: "off", text: "Automatic updates are off." };
106
+ if (input.managed)
107
+ return {
108
+ action: "skip",
109
+ code: "managed",
110
+ text: "Updates on this machine are deployed by autodeploy.",
111
+ };
112
+ if (input.refusal)
113
+ return {
114
+ action: "skip",
115
+ code: "refused",
116
+ text: `Automatic updates are paused. ${input.refusal}`,
117
+ };
118
+ if (!input.candidates.length)
119
+ return {
120
+ action: "skip",
121
+ code: "up_to_date",
122
+ text: "AgentOS is up to date.",
123
+ };
124
+
125
+ const target = pickTarget(input);
126
+ if (!("eligibleSince" in target))
127
+ return { action: "wait", code: target.code, text: target.text };
128
+
129
+ if (input.busy.length)
130
+ return {
131
+ action: "wait",
132
+ code: "busy",
133
+ to: target.to,
134
+ text: `Updates when nothing is working (now: ${input.busy.join(", ")}).`,
135
+ };
136
+ const late = input.now - target.eligibleSince >= input.deadlineMs;
137
+ const need = late ? (input.lullMs ?? LULL_MS) : input.idleMs;
138
+ const quietFor = input.quietSince === null ? 0 : input.now - input.quietSince;
139
+ if (quietFor < need)
140
+ return {
141
+ action: "wait",
142
+ code: "quiet",
143
+ to: target.to,
144
+ text: late
145
+ ? "Has waited a long time for a quiet moment; updates at the next short lull."
146
+ : `Updates after everything has been idle for ${plural(mins(input.idleMs), "minute")} (${plural(mins(need - quietFor), "minute")} to go).`,
147
+ };
148
+ return { action: "start", to: target.to };
149
+ }
@@ -3,22 +3,33 @@
3
3
 
4
4
  export type UpdatePhase =
5
5
  | "starting"
6
+ | "backing_up"
6
7
  | "pulling"
7
8
  | "installing"
8
9
  | "building"
9
10
  | "restarting"
11
+ | "verifying"
12
+ | "rolling_back"
10
13
  | "done"
11
14
  | "built"
15
+ | "rolled_back"
12
16
  | "failed";
13
17
 
14
18
  export const ACTIVE_PHASES: readonly UpdatePhase[] = [
15
19
  "starting",
20
+ "backing_up",
16
21
  "pulling",
17
22
  "installing",
18
23
  "building",
19
24
  "restarting",
25
+ "verifying",
26
+ "rolling_back",
20
27
  ];
21
28
 
29
+ /** How a finished run ended; written by scripts/update-run.mjs. */
30
+ export type UpdateOutcome =
31
+ "done" | "build_failed" | "rolled_back" | "broken" | "interrupted";
32
+
22
33
  export const isActivePhase = (p: UpdatePhase | undefined | null) =>
23
34
  !!p && ACTIVE_PHASES.includes(p);
24
35
 
@@ -32,6 +43,44 @@ export interface UpdateRun {
32
43
  finishedAt?: number;
33
44
  error?: string;
34
45
  log?: string[];
46
+ /** Started by automatic updates, not by someone. */
47
+ auto?: boolean;
48
+ /** The commit it updates to. */
49
+ to?: string;
50
+ outcome?: UpdateOutcome;
51
+ /** How far redeploy got: pulled, deps_swapped, build_swapped. */
52
+ stage?: string;
53
+ /** The runner's heartbeat and pid, so a cut-off run is told from a slow one. */
54
+ updatedAt?: number;
55
+ pid?: number;
56
+ }
57
+
58
+ /** One finished update, kept in the history Settings shows. */
59
+ export interface UpdateAttempt {
60
+ id: string;
61
+ from: string;
62
+ to: string;
63
+ fromVersion: string;
64
+ toVersion: string;
65
+ auto: boolean;
66
+ startedAt: number;
67
+ finishedAt: number;
68
+ outcome: UpdateOutcome;
69
+ error?: string;
70
+ }
71
+
72
+ /** The idle times Settings offers for automatic updates. */
73
+ export const IDLE_MINUTES = [5, 10, 15, 30, 60] as const;
74
+
75
+ /** What automatic updates are doing, for Settings. */
76
+ export interface AutoUpdateView {
77
+ enabled: boolean;
78
+ locked: boolean;
79
+ managed: boolean;
80
+ idleMinutes: number;
81
+ idleLocked: boolean;
82
+ status: { code: string; text: string } | null;
83
+ attempts: UpdateAttempt[];
35
84
  }
36
85
 
37
86
  export type UpdateRefusal =
@@ -45,6 +94,7 @@ export type UpdateRefusal =
45
94
  | "up_to_date"
46
95
  | "unknown"
47
96
  | "no_restart"
97
+ | "disk"
48
98
  | "forbidden";
49
99
 
50
100
  export interface UpdateView {
@@ -68,10 +118,18 @@ export interface UpdateView {
68
118
  run: UpdateRun | null;
69
119
  pending: { requestedAt: number } | null;
70
120
  working: { sessions: number; reviews: number; setups: number };
121
+ auto?: AutoUpdateView;
71
122
  }
72
123
 
73
124
  export type BannerState =
74
- "hidden" | "available" | "pending" | "running" | "failed" | "built";
125
+ | "hidden"
126
+ | "available"
127
+ | "pending"
128
+ | "running"
129
+ | "failed"
130
+ | "built"
131
+ | "updated"
132
+ | "rolled_back";
75
133
 
76
134
  export const DISMISSED_VERSION_KEY = "agentos.update.dismissed";
77
135
  export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
@@ -79,23 +137,40 @@ export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
79
137
  /**
80
138
  * A result of something the person started (running, failed, built) always
81
139
  * shows until its run is dismissed; "available" is the one that can be
82
- * switched off or dismissed, and only for the version dismissed.
140
+ * switched off or dismissed, and only for the version dismissed. An
141
+ * automatic update says it happened ("updated") or that it was rolled back;
142
+ * an automatic one that failed to build changed nothing, so only Settings
143
+ * shows it, unless it left the server broken.
83
144
  */
145
+ export const UPDATED_NOTE_MS = 3 * 24 * 60 * 60_000;
146
+
84
147
  export function bannerState({
85
148
  view,
86
149
  dismissedVersion,
87
150
  dismissedRun,
151
+ now = Date.now(),
88
152
  }: {
89
153
  view: UpdateView | null | undefined;
90
154
  dismissedVersion: string | null;
91
155
  dismissedRun: string | null;
156
+ now?: number;
92
157
  }): BannerState {
93
158
  if (!view) return "hidden";
94
159
  const run = view.run;
95
160
  if (isActivePhase(run?.phase)) return "running";
96
161
  if (run && dismissedRun !== run.id) {
97
- if (run.phase === "failed") return "failed";
162
+ if (run.phase === "rolled_back") return "rolled_back";
163
+ if (run.phase === "failed" && (!run.auto || run.outcome === "broken"))
164
+ return "failed";
98
165
  if (run.phase === "built") return "built";
166
+ // A device that wasn't open for days doesn't need the news.
167
+ if (
168
+ run.phase === "done" &&
169
+ run.auto &&
170
+ view.bannerEnabled &&
171
+ now - (run.finishedAt ?? 0) < UPDATED_NOTE_MS
172
+ )
173
+ return "updated";
99
174
  }
100
175
  if (view.pending) return "pending";
101
176
  if (
@@ -3,10 +3,14 @@
3
3
  // or mirror works too. The working tree is never touched.
4
4
 
5
5
  import { git, GIT_TIMEOUT_MS } from "@/lib/git-exec";
6
- import { getSetting, putSetting, bannerEnabled } from "./settings";
6
+ import type { Candidate } from "./auto";
7
+ import { autoEnabled, bannerEnabled, getSetting, putSetting } from "./settings";
7
8
  import { isNewer, versionFor } from "./version";
8
9
 
9
10
  export const CHECK_EVERY_MS = 6 * 60 * 60_000;
11
+ /** With automatic updates on, a release is seen within about an hour. */
12
+ export const AUTO_CHECK_EVERY_MS = 60 * 60_000;
13
+ const CANDIDATES_MAX = 50;
10
14
  export const FORCE_FLOOR_MS = 60_000;
11
15
  const FIRST_CHECK_MS = 2 * 60_000;
12
16
  const NOTES_MAX = 8;
@@ -26,6 +30,8 @@ export interface CheckResult {
26
30
  branch: string | null;
27
31
  checkedAt: number;
28
32
  error: string | null;
33
+ /** First-parent commits between HEAD and the remote, newest first. */
34
+ candidates?: Candidate[];
29
35
  }
30
36
 
31
37
  export interface CheckDeps {
@@ -74,7 +80,7 @@ const NO_REPO: Omit<CheckResult, "checkedAt"> = {
74
80
  error: null,
75
81
  };
76
82
 
77
- async function versionAt(
83
+ export async function versionAt(
78
84
  g: CheckDeps["git"],
79
85
  ref: string
80
86
  ): Promise<{ version: string; sha: string }> {
@@ -145,6 +151,22 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
145
151
  .map((s) => s.trim())
146
152
  .filter(Boolean)
147
153
  : [];
154
+ const candidates =
155
+ behind > 0 && fastForward
156
+ ? (
157
+ await g([
158
+ "log",
159
+ "--first-parent",
160
+ "--format=%H %ct",
161
+ `-n${CANDIDATES_MAX}`,
162
+ `HEAD..${remoteRef}`,
163
+ ])
164
+ )
165
+ .split("\n")
166
+ .map((l) => l.trim().split(" "))
167
+ .filter(([sha, at]) => /^[0-9a-f]{40}$/.test(sha) && Number(at) > 0)
168
+ .map(([sha, at]) => ({ sha, at: Number(at) * 1000 }))
169
+ : [];
148
170
  const status = behind > 0 && !fastForward ? "diverged" : "ok";
149
171
  return {
150
172
  status,
@@ -160,6 +182,7 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
160
182
  branch,
161
183
  checkedAt,
162
184
  error: null,
185
+ candidates,
163
186
  };
164
187
  }
165
188
 
@@ -188,13 +211,36 @@ export async function reconcile(
188
211
  available: false,
189
212
  behind: 0,
190
213
  current: { version: check.latest.version, sha: head },
214
+ candidates: [],
191
215
  }
192
- : check;
216
+ : {
217
+ ...check,
218
+ // Commits the checkout already has aren't updates any more.
219
+ candidates: await keepNewer(check.candidates, deps),
220
+ };
193
221
  } catch {
194
222
  return check;
195
223
  }
196
224
  }
197
225
 
226
+ async function keepNewer(
227
+ candidates: Candidate[] | undefined,
228
+ deps: Pick<CheckDeps, "git">
229
+ ): Promise<Candidate[]> {
230
+ const out: Candidate[] = [];
231
+ for (const c of candidates ?? []) {
232
+ const has = await deps
233
+ .git(["merge-base", "--is-ancestor", c.sha, "HEAD"])
234
+ .then(
235
+ () => true,
236
+ () => false
237
+ );
238
+ if (has) break;
239
+ out.push(c);
240
+ }
241
+ return out;
242
+ }
243
+
198
244
  let memo: CheckResult | null = null;
199
245
  let inflight: Promise<CheckResult> | null = null;
200
246
 
@@ -214,7 +260,11 @@ export function cachedCheck(deps: CheckDeps = realDeps()): CheckResult | null {
214
260
  * fetch keeps the last good answer and notes the error.
215
261
  */
216
262
  export async function checkForUpdate(
217
- { force = false, always = false }: { force?: boolean; always?: boolean } = {},
263
+ {
264
+ force = false,
265
+ always = false,
266
+ maxAge = CHECK_EVERY_MS,
267
+ }: { force?: boolean; always?: boolean; maxAge?: number } = {},
218
268
  deps: CheckDeps = realDeps()
219
269
  ): Promise<{ result: CheckResult; throttled?: number }> {
220
270
  const last = cachedCheck(deps);
@@ -224,7 +274,7 @@ export async function checkForUpdate(
224
274
  result: last,
225
275
  throttled: Math.ceil((FORCE_FLOOR_MS - age) / 1000),
226
276
  };
227
- if (last && !force && age < CHECK_EVERY_MS) return { result: last };
277
+ if (last && !force && age < maxAge) return { result: last };
228
278
  inflight ??= compare(deps)
229
279
  .catch((error: unknown): CheckResult => {
230
280
  const message = error instanceof Error ? error.message : String(error);
@@ -254,17 +304,23 @@ export function updateChecksEnabled(
254
304
 
255
305
  const g = globalThis as unknown as { __agentosUpdateChecks?: NodeJS.Timeout };
256
306
 
257
- /** First check two minutes after boot, then every six hours (jittered). */
307
+ /**
308
+ * First check two minutes after boot, then every six hours, or every hour
309
+ * with automatic updates on (jittered). Off when both the banner and
310
+ * automatic updates are.
311
+ */
258
312
  export function startUpdateChecks(): void {
259
313
  if (g.__agentosUpdateChecks) return;
260
314
  const schedule = (delay: number): void => {
261
315
  g.__agentosUpdateChecks = setTimeout(async () => {
316
+ const every = autoEnabled() ? AUTO_CHECK_EVERY_MS : CHECK_EVERY_MS;
262
317
  try {
263
- if (bannerEnabled()) await checkForUpdate();
318
+ if (bannerEnabled() || autoEnabled())
319
+ await checkForUpdate({ maxAge: every - 5 * 60_000 });
264
320
  } catch (error) {
265
321
  console.error("Checking for updates failed:", error);
266
322
  }
267
- schedule(CHECK_EVERY_MS + Math.floor(Math.random() * 10 * 60_000));
323
+ schedule(every + Math.floor(Math.random() * 10 * 60_000));
268
324
  }, delay);
269
325
  g.__agentosUpdateChecks.unref();
270
326
  };