@saadnvd1/agent-os 0.3.552 → 0.3.555

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 (39) hide show
  1. package/app/api/updates/check/route.ts +17 -0
  2. package/app/api/updates/route.test.ts +205 -0
  3. package/app/api/updates/route.ts +11 -0
  4. package/app/api/updates/run/route.ts +50 -0
  5. package/app/api/updates/settings/route.ts +25 -0
  6. package/app/pair/page.tsx +3 -2
  7. package/components/Chat/ChatPanel.tsx +18 -5
  8. package/components/Chat/Messages.test.ts +44 -0
  9. package/components/Chat/Messages.tsx +29 -7
  10. package/components/Chat/composer/ComposerBody.tsx +5 -2
  11. package/components/Chat/composer/hook.test.ts +24 -0
  12. package/components/Chat/composer/insert.ts +18 -0
  13. package/components/Settings/SettingsDialog.test.ts +3 -0
  14. package/components/Settings/SettingsDialog.tsx +9 -0
  15. package/components/Settings/commands.ts +9 -0
  16. package/components/Settings/entry-points.test.ts +1 -0
  17. package/components/Updates/UpdateActions.tsx +93 -0
  18. package/components/Updates/UpdateBanner.test.ts +198 -0
  19. package/components/Updates/UpdateBanner.tsx +212 -0
  20. package/components/Updates/UpdatesSection.tsx +150 -0
  21. package/components/Updates/index.ts +2 -0
  22. package/components/views/DesktopView.tsx +2 -0
  23. package/components/views/MobileView.tsx +2 -0
  24. package/data/updates/index.ts +63 -0
  25. package/hooks/useDismissedUpdate.ts +56 -0
  26. package/hooks/useReloadWhenUpdated.ts +19 -0
  27. package/lib/chat/quote.ts +5 -0
  28. package/lib/settings-url.ts +1 -0
  29. package/lib/updates/banner.ts +114 -0
  30. package/lib/updates/check.ts +240 -0
  31. package/lib/updates/run.ts +452 -0
  32. package/lib/updates/settings.ts +38 -0
  33. package/lib/updates/version.ts +30 -0
  34. package/lib/updates/view.ts +80 -0
  35. package/package.json +1 -1
  36. package/scripts/redeploy +4 -0
  37. package/scripts/update-run.mjs +82 -0
  38. package/scripts/update-run.test.ts +64 -0
  39. package/server.ts +16 -0
@@ -0,0 +1,63 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
3
+
4
+ export const updateKeys = { all: ["updates"] as const };
5
+
6
+ async function send(
7
+ method: string,
8
+ url: string,
9
+ body?: object
10
+ ): Promise<UpdateView> {
11
+ const res = await fetch(url, {
12
+ method,
13
+ ...(body && {
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(body),
16
+ }),
17
+ }).catch(() => {
18
+ throw new Error("Couldn't reach AgentOS. Try again.");
19
+ });
20
+ const data = await res.json().catch(() => ({}));
21
+ if (!res.ok)
22
+ throw Object.assign(
23
+ new Error(data.error || `Request failed (${res.status})`),
24
+ { status: res.status }
25
+ );
26
+ return data;
27
+ }
28
+
29
+ // Polled quickly only while an update runs; the server looks for new
30
+ // versions itself, so a half hour is plenty for the rest.
31
+ export function useUpdates() {
32
+ return useQuery({
33
+ queryKey: updateKeys.all,
34
+ queryFn: () => send("GET", "/api/updates"),
35
+ staleTime: 5 * 60_000,
36
+ refetchInterval: (query) =>
37
+ isActivePhase(query.state.data?.run?.phase) ? 2000 : 30 * 60_000,
38
+ });
39
+ }
40
+
41
+ function useUpdateMutation<V>(fn: (v: V) => Promise<UpdateView>) {
42
+ const queryClient = useQueryClient();
43
+ return useMutation({
44
+ mutationFn: fn,
45
+ onSuccess: (view) => queryClient.setQueryData(updateKeys.all, view),
46
+ });
47
+ }
48
+
49
+ export const useCheckForUpdates = () =>
50
+ useUpdateMutation<void>(() => send("POST", "/api/updates/check"));
51
+
52
+ export const useRunUpdate = () =>
53
+ useUpdateMutation<"now" | "idle">((when) =>
54
+ send("POST", "/api/updates/run", { when })
55
+ );
56
+
57
+ export const useCancelUpdate = () =>
58
+ useUpdateMutation<void>(() => send("DELETE", "/api/updates/run"));
59
+
60
+ export const useUpdateBannerSetting = () =>
61
+ useUpdateMutation<boolean>((banner) =>
62
+ send("PUT", "/api/updates/settings", { banner })
63
+ );
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useCallback, useSyncExternalStore } from "react";
4
+ import { DISMISSED_RUN_KEY, DISMISSED_VERSION_KEY } from "@/lib/updates/banner";
5
+
6
+ // What this browser has dismissed: the version whose banner was closed, and
7
+ // the failed or built run whose notice was. In localStorage, so it holds
8
+ // across reloads, and per browser because a tab with no device identity
9
+ // can't be told apart server-side.
10
+ const listeners = new Set<() => void>();
11
+
12
+ function read(key: string): string | null {
13
+ try {
14
+ return localStorage.getItem(key);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function subscribe(fn: () => void) {
21
+ listeners.add(fn);
22
+ window.addEventListener("storage", fn);
23
+ return () => {
24
+ listeners.delete(fn);
25
+ window.removeEventListener("storage", fn);
26
+ };
27
+ }
28
+
29
+ function write(key: string, value: string) {
30
+ try {
31
+ localStorage.setItem(key, value);
32
+ } catch {}
33
+ listeners.forEach((fn) => fn());
34
+ }
35
+
36
+ export function useDismissedUpdate() {
37
+ const version = useSyncExternalStore(
38
+ subscribe,
39
+ () => read(DISMISSED_VERSION_KEY),
40
+ () => null
41
+ );
42
+ const run = useSyncExternalStore(
43
+ subscribe,
44
+ () => read(DISMISSED_RUN_KEY),
45
+ () => null
46
+ );
47
+ const dismissVersion = useCallback(
48
+ (v: string) => write(DISMISSED_VERSION_KEY, v),
49
+ []
50
+ );
51
+ const dismissRun = useCallback(
52
+ (id: string) => write(DISMISSED_RUN_KEY, id),
53
+ []
54
+ );
55
+ return { version, run, dismissVersion, dismissRun };
56
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { isActivePhase, type UpdateView } from "@/lib/updates/banner";
5
+
6
+ /**
7
+ * Reloads once the update this page watched run has finished and the server
8
+ * is back on the new build. A page that never saw it run doesn't reload.
9
+ */
10
+ export function useReloadWhenUpdated(view: UpdateView | undefined) {
11
+ const watched = useRef<string | null>(null);
12
+ const run = view?.run;
13
+ useEffect(() => {
14
+ if (!run) return;
15
+ if (isActivePhase(run.phase)) watched.current = run.id;
16
+ else if (run.phase === "done" && watched.current === run.id)
17
+ window.location.reload();
18
+ }, [run]);
19
+ }
package/lib/chat/quote.ts CHANGED
@@ -9,6 +9,11 @@ export function quoteMarkdown(selection: string): string {
9
9
  return `${lines.map((l) => (l.trim() ? `> ${l}` : ">")).join("\n")}\n\n`;
10
10
  }
11
11
 
12
+ // A whole message to quote: after whatever is already written.
13
+ export function quoteInsert(text: string, at = Date.now()) {
14
+ return { text: quoteMarkdown(text), at, append: true };
15
+ }
16
+
12
17
  // Markdown as editor paragraphs, one per line (the composer writes each
13
18
  // paragraph as a line), to put in at the caret.
14
19
  export function markdownParagraphs(
@@ -15,6 +15,7 @@ export const SETTINGS_SECTIONS = [
15
15
  "schedules",
16
16
  "audits",
17
17
  "setup",
18
+ "updates",
18
19
  ] as const;
19
20
 
20
21
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
@@ -0,0 +1,114 @@
1
+ // What the update banner shows, from what the server says and what this
2
+ // device has dismissed. Pure, so a native client uses the same rules.
3
+
4
+ export type UpdatePhase =
5
+ | "starting"
6
+ | "pulling"
7
+ | "installing"
8
+ | "building"
9
+ | "restarting"
10
+ | "done"
11
+ | "built"
12
+ | "failed";
13
+
14
+ export const ACTIVE_PHASES: readonly UpdatePhase[] = [
15
+ "starting",
16
+ "pulling",
17
+ "installing",
18
+ "building",
19
+ "restarting",
20
+ ];
21
+
22
+ export const isActivePhase = (p: UpdatePhase | undefined | null) =>
23
+ !!p && ACTIVE_PHASES.includes(p);
24
+
25
+ export interface UpdateRun {
26
+ id: string;
27
+ phase: UpdatePhase;
28
+ from: string;
29
+ fromVersion: string;
30
+ toVersion: string;
31
+ startedAt: number;
32
+ finishedAt?: number;
33
+ error?: string;
34
+ log?: string[];
35
+ }
36
+
37
+ export type UpdateRefusal =
38
+ | "dev"
39
+ | "not_git"
40
+ | "running"
41
+ | "building"
42
+ | "dirty"
43
+ | "branch"
44
+ | "diverged"
45
+ | "up_to_date"
46
+ | "unknown"
47
+ | "forbidden";
48
+
49
+ export interface UpdateView {
50
+ current: { version: string; sha: string } | null;
51
+ latest: {
52
+ version: string;
53
+ sha: string;
54
+ notes: string[];
55
+ url: string | null;
56
+ } | null;
57
+ available: boolean;
58
+ behind: number;
59
+ status: "ok" | "diverged" | "unknown";
60
+ checkedAt: number | null;
61
+ checkError: string | null;
62
+ bannerEnabled: boolean;
63
+ bannerLocked: boolean;
64
+ canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
65
+ run: UpdateRun | null;
66
+ pending: { requestedAt: number } | null;
67
+ working: { sessions: number; reviews: number; setups: number };
68
+ }
69
+
70
+ export type BannerState =
71
+ | "hidden"
72
+ | "available"
73
+ | "pending"
74
+ | "running"
75
+ | "failed"
76
+ | "built";
77
+
78
+ export const DISMISSED_VERSION_KEY = "agentos.update.dismissed";
79
+ export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
80
+
81
+ /**
82
+ * A result of something the person started (running, failed, built) always
83
+ * shows until its run is dismissed; "available" is the one that can be
84
+ * switched off or dismissed, and only for the version dismissed.
85
+ */
86
+ export function bannerState({
87
+ view,
88
+ dismissedVersion,
89
+ dismissedRun,
90
+ }: {
91
+ view: UpdateView | null | undefined;
92
+ dismissedVersion: string | null;
93
+ dismissedRun: string | null;
94
+ }): BannerState {
95
+ if (!view) return "hidden";
96
+ const run = view.run;
97
+ if (isActivePhase(run?.phase)) return "running";
98
+ if (run && dismissedRun !== run.id) {
99
+ if (run.phase === "failed") return "failed";
100
+ if (run.phase === "built") return "built";
101
+ }
102
+ if (view.pending) return "pending";
103
+ if (
104
+ view.available &&
105
+ view.bannerEnabled &&
106
+ view.latest &&
107
+ dismissedVersion !== view.latest.version
108
+ )
109
+ return "available";
110
+ return "hidden";
111
+ }
112
+
113
+ export const workingTotal = (w: UpdateView["working"]) =>
114
+ w.sessions + w.reviews + w.setups;
@@ -0,0 +1,240 @@
1
+ // Is a newer AgentOS out? Asked of the install's own remote: fetch the
2
+ // default branch and compare, so there's no service to depend on and a fork
3
+ // or mirror works too. The working tree is never touched.
4
+
5
+ import { git, GIT_TIMEOUT_MS } from "@/lib/git-exec";
6
+ import { getSetting, putSetting, bannerEnabled } from "./settings";
7
+ import { isNewer, versionFor } from "./version";
8
+
9
+ export const CHECK_EVERY_MS = 6 * 60 * 60_000;
10
+ export const FORCE_FLOOR_MS = 60_000;
11
+ const FIRST_CHECK_MS = 2 * 60_000;
12
+ const NOTES_MAX = 8;
13
+ const CACHE_KEY = "updates.check";
14
+
15
+ export interface CheckResult {
16
+ status: "ok" | "diverged" | "unknown";
17
+ current: { version: string; sha: string } | null;
18
+ latest: {
19
+ version: string;
20
+ sha: string;
21
+ notes: string[];
22
+ url: string | null;
23
+ } | null;
24
+ behind: number;
25
+ available: boolean;
26
+ branch: string | null;
27
+ checkedAt: number;
28
+ error: string | null;
29
+ }
30
+
31
+ export interface CheckDeps {
32
+ git: (args: string[]) => Promise<string>;
33
+ now: () => number;
34
+ load: () => CheckResult | null;
35
+ save: (result: CheckResult) => void;
36
+ }
37
+
38
+ export const realDeps = (cwd = process.cwd()): CheckDeps => ({
39
+ git: (args) => git(cwd, args, { timeout: Math.min(GIT_TIMEOUT_MS, 30_000) }),
40
+ now: Date.now,
41
+ load: () => {
42
+ try {
43
+ const raw = getSetting(CACHE_KEY);
44
+ return raw ? (JSON.parse(raw) as CheckResult) : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ },
49
+ save: (result) => putSetting(CACHE_KEY, JSON.stringify(result)),
50
+ });
51
+
52
+ /** A browsable compare link for a GitHub remote, else null. Credentials are dropped. */
53
+ export function compareUrl(
54
+ remote: string,
55
+ from: string,
56
+ to: string
57
+ ): string | null {
58
+ const m =
59
+ /^(?:https?:\/\/(?:[^/@]+@)?|git@|ssh:\/\/git@)github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(
60
+ remote.trim()
61
+ );
62
+ return m
63
+ ? `https://github.com/${m[1]}/${m[2]}/compare/${from.slice(0, 12)}...${to.slice(0, 12)}`
64
+ : null;
65
+ }
66
+
67
+ const NO_REPO: Omit<CheckResult, "checkedAt"> = {
68
+ status: "unknown",
69
+ current: null,
70
+ latest: null,
71
+ behind: 0,
72
+ available: false,
73
+ branch: null,
74
+ error: null,
75
+ };
76
+
77
+ async function versionAt(
78
+ g: CheckDeps["git"],
79
+ ref: string
80
+ ): Promise<{ version: string; sha: string }> {
81
+ const [sha, count, pkg] = await Promise.all([
82
+ g(["rev-parse", ref]),
83
+ g(["rev-list", "--count", ref]),
84
+ g(["show", `${ref}:package.json`]),
85
+ ]);
86
+ const version = versionFor(
87
+ (JSON.parse(pkg) as { version?: string }).version ?? "",
88
+ Number(count.trim())
89
+ );
90
+ if (!version) throw new Error("package.json has no version");
91
+ return { version, sha: sha.trim() };
92
+ }
93
+
94
+ /** Fetches the default branch and compares it with HEAD. Throws when git can't say. */
95
+ export async function compare(deps: CheckDeps): Promise<CheckResult> {
96
+ const g = deps.git;
97
+ const checkedAt = deps.now();
98
+ if ((await g(["rev-parse", "--is-shallow-repository"])).trim() === "true")
99
+ return {
100
+ ...NO_REPO,
101
+ checkedAt,
102
+ error: "This is a shallow clone, so versions can't be counted.",
103
+ };
104
+ const head = await g(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
105
+ .then((s) => s.trim().replace(/^origin\//, ""))
106
+ .catch(() => "main");
107
+ const branch =
108
+ /^[\w./-]+$/.test(head) && !head.startsWith("-") ? head : "main";
109
+ await g([
110
+ "fetch",
111
+ "-q",
112
+ "origin",
113
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
114
+ ]);
115
+ const remoteRef = `refs/remotes/origin/${branch}`;
116
+ const [current, latest, remote] = await Promise.all([
117
+ versionAt(g, "HEAD"),
118
+ versionAt(g, remoteRef),
119
+ g(["remote", "get-url", "origin"]).catch(() => ""),
120
+ ]);
121
+ const behind = Number(
122
+ (await g(["rev-list", "--count", `HEAD..${remoteRef}`])).trim()
123
+ );
124
+ const fastForward = await g([
125
+ "merge-base",
126
+ "--is-ancestor",
127
+ "HEAD",
128
+ remoteRef,
129
+ ]).then(
130
+ () => true,
131
+ () => false
132
+ );
133
+ const notes =
134
+ behind > 0 && fastForward
135
+ ? (
136
+ await g([
137
+ "log",
138
+ "--first-parent",
139
+ "--format=%s",
140
+ `-n${NOTES_MAX}`,
141
+ `HEAD..${remoteRef}`,
142
+ ])
143
+ )
144
+ .split("\n")
145
+ .map((s) => s.trim())
146
+ .filter(Boolean)
147
+ : [];
148
+ const status = behind > 0 && !fastForward ? "diverged" : "ok";
149
+ return {
150
+ status,
151
+ current,
152
+ latest: {
153
+ ...latest,
154
+ notes,
155
+ url: compareUrl(remote, current.sha, latest.sha),
156
+ },
157
+ behind,
158
+ available:
159
+ status === "ok" && behind > 0 && isNewer(latest.version, current.version),
160
+ branch,
161
+ checkedAt,
162
+ error: null,
163
+ };
164
+ }
165
+
166
+ let memo: CheckResult | null = null;
167
+ let inflight: Promise<CheckResult> | null = null;
168
+
169
+ export const resetCheckCache = () => {
170
+ memo = null;
171
+ inflight = null;
172
+ };
173
+
174
+ export function cachedCheck(deps: CheckDeps = realDeps()): CheckResult | null {
175
+ return (memo ??= deps.load());
176
+ }
177
+
178
+ /**
179
+ * The cached answer, or a fresh one when it's older than six hours. `force`
180
+ * asks now, at most once a minute (`throttled`: the seconds left); `always`
181
+ * skips that floor, for a check owed after an update. A failed
182
+ * fetch keeps the last good answer and notes the error.
183
+ */
184
+ export async function checkForUpdate(
185
+ { force = false, always = false }: { force?: boolean; always?: boolean } = {},
186
+ deps: CheckDeps = realDeps()
187
+ ): Promise<{ result: CheckResult; throttled?: number }> {
188
+ const last = cachedCheck(deps);
189
+ const age = last ? deps.now() - last.checkedAt : Infinity;
190
+ if (last && force && !always && age < FORCE_FLOOR_MS)
191
+ return {
192
+ result: last,
193
+ throttled: Math.ceil((FORCE_FLOOR_MS - age) / 1000),
194
+ };
195
+ if (last && !force && age < CHECK_EVERY_MS) return { result: last };
196
+ inflight ??= compare(deps)
197
+ .catch((error: unknown): CheckResult => {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ return last
200
+ ? { ...last, error: message }
201
+ : { ...NO_REPO, checkedAt: deps.now(), error: message };
202
+ })
203
+ .then((result) => {
204
+ memo = result;
205
+ deps.save(result);
206
+ return result;
207
+ })
208
+ .finally(() => {
209
+ inflight = null;
210
+ });
211
+ return { result: await inflight };
212
+ }
213
+
214
+ /** Dev servers and tests don't check unless asked; AGENTOS_UPDATE_CHECK=off pins it off. */
215
+ export function updateChecksEnabled(
216
+ env: Record<string, string | undefined> = process.env
217
+ ): boolean {
218
+ if (env.AGENTOS_UPDATE_CHECK === "off") return false;
219
+ if (env.AGENTOS_UPDATE_CHECK === "on") return true;
220
+ return env.AGENTOS_DEV !== "1" && env.NODE_ENV === "production";
221
+ }
222
+
223
+ const g = globalThis as unknown as { __agentosUpdateChecks?: NodeJS.Timeout };
224
+
225
+ /** First check two minutes after boot, then every six hours (jittered). */
226
+ export function startUpdateChecks(): void {
227
+ if (g.__agentosUpdateChecks) return;
228
+ const schedule = (delay: number): void => {
229
+ g.__agentosUpdateChecks = setTimeout(async () => {
230
+ try {
231
+ if (bannerEnabled()) await checkForUpdate();
232
+ } catch (error) {
233
+ console.error("Checking for updates failed:", error);
234
+ }
235
+ schedule(CHECK_EVERY_MS + Math.floor(Math.random() * 10 * 60_000));
236
+ }, delay);
237
+ g.__agentosUpdateChecks.unref();
238
+ };
239
+ schedule(FIRST_CHECK_MS);
240
+ }