@saadnvd1/agent-os 0.3.794 → 0.3.807

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 (167) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  3. package/app/api/chat-views/route.test.ts +79 -0
  4. package/app/api/chat-views/route.ts +8 -0
  5. package/app/api/tips/route.test.ts +56 -0
  6. package/app/api/tips/route.ts +45 -0
  7. package/app/api/updates/health/route.ts +22 -0
  8. package/app/api/updates/route.test.ts +53 -0
  9. package/app/api/updates/settings/route.ts +37 -10
  10. package/app/page.tsx +17 -14
  11. package/components/Chat/ChatPanel.tsx +74 -59
  12. package/components/Chat/NewDivider.tsx +15 -0
  13. package/components/Chat/needs-more-history.test.ts +28 -0
  14. package/components/Chat/needs-more-history.ts +14 -0
  15. package/components/Chat/session-link.ts +7 -4
  16. package/components/Chat/useFind.ts +50 -1
  17. package/components/Chat/usePosition.ts +363 -0
  18. package/components/CodeSearchDialog.tsx +90 -0
  19. package/components/CommandPalette/CommandsBody.tsx +12 -4
  20. package/components/CommandPalette/useAppCommands.ts +29 -11
  21. package/components/DevServers/ServerLogsModal.tsx +24 -21
  22. package/components/Nav/HistoryButtons.tsx +128 -0
  23. package/components/Pane/MobileTabBar.tsx +3 -0
  24. package/components/Pane/index.tsx +2 -2
  25. package/components/Recipes/TimerFields.tsx +2 -1
  26. package/components/Schedules/ScheduleForm.tsx +1 -1
  27. package/components/SessionList/useSessionSwitching.ts +3 -2
  28. package/components/Settings/SettingsDialog.tsx +1 -1
  29. package/components/Shortcuts/PendingKeys.tsx +19 -0
  30. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  31. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  32. package/components/SidebarFooter.tsx +4 -0
  33. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  34. package/components/Tips/TipButton.tsx +39 -0
  35. package/components/Tips/TipDialog.tsx +148 -0
  36. package/components/Tips/TipHost.tsx +14 -0
  37. package/components/Tips/TipMedia.tsx +71 -0
  38. package/components/Tips/index.ts +2 -0
  39. package/components/Updates/AutoUpdates.tsx +141 -0
  40. package/components/Updates/UpdateBanner.test.ts +39 -1
  41. package/components/Updates/UpdateBanner.tsx +31 -5
  42. package/components/Updates/UpdatesSection.tsx +24 -3
  43. package/components/views/DesktopView.tsx +7 -9
  44. package/components/views/MobileView.tsx +2 -8
  45. package/components/views/lazy.ts +2 -2
  46. package/content/tips.json +541 -0
  47. package/content/whats-new.json +56 -0
  48. package/data/chat-views/index.ts +2 -0
  49. package/data/chat-views/keys.ts +3 -0
  50. package/data/chat-views/queries.ts +34 -0
  51. package/data/sessions/pin.ts +0 -1
  52. package/data/tips/index.ts +49 -0
  53. package/data/updates/index.ts +5 -0
  54. package/hooks/useChatViewsSync.ts +12 -0
  55. package/hooks/useNavActions.ts +104 -0
  56. package/hooks/useNavKeys.ts +105 -0
  57. package/hooks/useReloadWhenUpdated.ts +5 -1
  58. package/hooks/useSessionUrl.ts +23 -5
  59. package/lib/bus/index.ts +4 -2
  60. package/lib/bus/role-alias.ts +4 -2
  61. package/lib/bus/targets.ts +2 -1
  62. package/lib/busy.ts +23 -1
  63. package/lib/chat/escape.ts +8 -4
  64. package/lib/chat-views/body.ts +41 -0
  65. package/lib/chat-views/store.ts +67 -0
  66. package/lib/chat-views/types.ts +20 -0
  67. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  68. package/lib/db/migrations.ts +21 -0
  69. package/lib/keybindings/focus.ts +17 -0
  70. package/lib/keybindings/keymap.ts +120 -0
  71. package/lib/keybindings/sequence.ts +64 -0
  72. package/lib/live/service.ts +1 -1
  73. package/lib/nav/history-mirror.ts +59 -0
  74. package/lib/nav/history-writer.ts +109 -0
  75. package/lib/nav/position.ts +67 -0
  76. package/lib/nav/recent.ts +34 -0
  77. package/lib/orchestrator/armed.ts +5 -0
  78. package/lib/palette/sections.ts +12 -6
  79. package/lib/recipes/timer.ts +3 -1
  80. package/lib/schedules/cron.ts +14 -5
  81. package/lib/schedules/store.ts +3 -2
  82. package/lib/security/demo.ts +2 -0
  83. package/lib/session-url.ts +11 -5
  84. package/lib/tasks/finish.ts +5 -5
  85. package/lib/tips/conditions.ts +38 -0
  86. package/lib/tips/index.ts +2 -0
  87. package/lib/tips/load.ts +26 -0
  88. package/lib/tips/media.ts +101 -0
  89. package/lib/tips/pick.ts +35 -0
  90. package/lib/tips/schema.ts +82 -0
  91. package/lib/tips/state.ts +26 -0
  92. package/lib/tips/view.ts +60 -0
  93. package/lib/updates/auto.ts +149 -0
  94. package/lib/updates/banner.ts +78 -3
  95. package/lib/updates/check.ts +64 -8
  96. package/lib/updates/history.ts +99 -0
  97. package/lib/updates/run.ts +334 -23
  98. package/lib/updates/settings.ts +66 -0
  99. package/lib/updates/view.ts +14 -3
  100. package/package.json +2 -2
  101. package/public/tips/.gitkeep +0 -0
  102. package/public/tips/chat-access-picker-dark.webp +0 -0
  103. package/public/tips/chat-access-picker.webp +0 -0
  104. package/public/tips/chat-attach-files-poster.webp +0 -0
  105. package/public/tips/chat-attach-files.mp4 +0 -0
  106. package/public/tips/chat-bookmark-dark.webp +0 -0
  107. package/public/tips/chat-bookmark.webp +0 -0
  108. package/public/tips/chat-plan-mode-dark.webp +0 -0
  109. package/public/tips/chat-plan-mode.webp +0 -0
  110. package/public/tips/chat-search-poster.webp +0 -0
  111. package/public/tips/chat-search.mp4 +0 -0
  112. package/public/tips/chat-slash-menu-poster.webp +0 -0
  113. package/public/tips/chat-slash-menu.mp4 +0 -0
  114. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  115. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  116. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  117. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  118. package/public/tips/chat-undo-dark.webp +0 -0
  119. package/public/tips/chat-undo.webp +0 -0
  120. package/public/tips/export-ai-dark.webp +0 -0
  121. package/public/tips/export-ai.webp +0 -0
  122. package/public/tips/net-messages-dark.webp +0 -0
  123. package/public/tips/net-messages.webp +0 -0
  124. package/public/tips/recipe-save-dark.webp +0 -0
  125. package/public/tips/recipe-save.webp +0 -0
  126. package/public/tips/schedule-first-dark.webp +0 -0
  127. package/public/tips/schedule-first.webp +0 -0
  128. package/public/tips/share-check-dark.webp +0 -0
  129. package/public/tips/share-check.webp +0 -0
  130. package/public/tips/sidebar-draft-poster.webp +0 -0
  131. package/public/tips/sidebar-draft.mp4 +0 -0
  132. package/public/tips/sidebar-groups-dark.webp +0 -0
  133. package/public/tips/sidebar-groups.webp +0 -0
  134. package/public/tips/sidebar-load-dark.webp +0 -0
  135. package/public/tips/sidebar-load.webp +0 -0
  136. package/public/tips/sidebar-snooze-dark.webp +0 -0
  137. package/public/tips/sidebar-snooze.webp +0 -0
  138. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  139. package/public/tips/tasks-plan-pieces.webp +0 -0
  140. package/public/tips/tasks-sign-off-dark.webp +0 -0
  141. package/public/tips/tasks-sign-off.webp +0 -0
  142. package/public/tips/tasks-stacks-dark.webp +0 -0
  143. package/public/tips/tasks-stacks.webp +0 -0
  144. package/scripts/autodeploy +4 -0
  145. package/scripts/autodeploy.test.ts +12 -0
  146. package/scripts/check +2 -2
  147. package/scripts/docs-links.test.ts +1 -25
  148. package/scripts/lib/commands.sh +32 -1
  149. package/scripts/lib/deps-swap.sh +8 -6
  150. package/scripts/lib/heading-slugs.ts +26 -0
  151. package/scripts/lib/update-verify.mjs +90 -0
  152. package/scripts/redeploy +31 -2
  153. package/scripts/redeploy.test.ts +94 -3
  154. package/scripts/tips-lint.test.ts +77 -0
  155. package/scripts/tips-media.ts +31 -0
  156. package/scripts/update-e2e.test.ts +215 -0
  157. package/scripts/update-recover.mjs +130 -0
  158. package/scripts/update-recover.test.ts +144 -0
  159. package/scripts/update-run.mjs +182 -40
  160. package/scripts/update-run.test.ts +208 -2
  161. package/server.ts +3 -2
  162. package/stores/chatJump.ts +2 -6
  163. package/stores/chatPositions.ts +65 -0
  164. package/stores/navHistory.ts +98 -0
  165. package/stores/shortcutsUi.ts +20 -0
  166. package/stores/tips.ts +14 -0
  167. package/components/QuickSwitcher.tsx +0 -286
@@ -1,7 +1,10 @@
1
- // Running an update, only when someone asks. The server does no more than
2
- // check the checkout and start scripts/update-run.mjs detached; that runs
3
- // scripts/redeploy (pull, install, build beside the live server, swap,
4
- // restart) and writes where it is to a state file, which the restart outlives.
1
+ // Running an update, when someone asks or, with automatic updates on, once
2
+ // everything has been idle (lib/updates/auto.ts decides when). The server
3
+ // does no more than check the checkout and start scripts/update-run.mjs
4
+ // detached; that backs up the database, runs scripts/redeploy (pull,
5
+ // install, build beside the live server, swap, restart), checks the new
6
+ // server and rolls back when it isn't healthy, writing where it is to a
7
+ // state file, which the restart outlives.
5
8
 
6
9
  import { spawn, execFile } from "child_process";
7
10
  import fs from "fs";
@@ -10,24 +13,53 @@ import path from "path";
10
13
  import { promisify } from "util";
11
14
  import { git } from "@/lib/git-exec";
12
15
  import { buildId } from "@/lib/build";
16
+ import { agentosHome } from "@/lib/agentos-home";
17
+ import { dbPath } from "@/lib/db/path";
13
18
  import {
14
19
  isActivePhase,
15
20
  workingTotal,
21
+ type AutoUpdateView,
16
22
  type UpdateRefusal,
17
23
  type UpdateRun,
18
24
  type UpdateView,
19
25
  } from "./banner";
20
26
  import {
27
+ cachedCheck,
21
28
  checkForUpdate,
22
29
  realDeps as realCheckDeps,
30
+ reconcile,
31
+ versionAt,
23
32
  type CheckDeps,
24
33
  type CheckResult,
25
34
  } from "./check";
26
- import { getSetting, putSetting } from "./settings";
35
+ import { decideAutoUpdate, pickTarget, type AutoDecision } from "./auto";
36
+ import {
37
+ attemptOf,
38
+ failedInARow,
39
+ readAttempts,
40
+ recordAttempt,
41
+ } from "./history";
42
+ import {
43
+ autoEnabled,
44
+ autoLocked,
45
+ deadlineMs,
46
+ getSetting,
47
+ idleMinutes,
48
+ idleMinutesLocked,
49
+ minFreeBytes,
50
+ putSetting,
51
+ soakMs,
52
+ } from "./settings";
27
53
 
28
54
  const exec = promisify(execFile);
29
55
 
30
56
  export const STALE_RUN_MS = 30 * 60_000;
57
+ /** A runner that hasn't written for this long, and whose pid is gone, was cut off. */
58
+ export const HEARTBEAT_STALE_MS = 2 * 60_000;
59
+ /** autodeploy touches its heartbeat every minute or two; older means it stopped. */
60
+ const MANAGED_FRESH_MS = 15 * 60_000;
61
+ /** A refused automatic update isn't asked about again for this long. */
62
+ const AUTO_REFUSED_MS = 30 * 60_000;
31
63
  export const PENDING_EXPIRES_MS = 24 * 60 * 60_000;
32
64
  export const IDLE_TICK_MS = 30_000;
33
65
  const PENDING_KEY = "updates.pending";
@@ -48,6 +80,16 @@ export interface RunDeps {
48
80
  working: () => Promise<UpdateView["working"]>;
49
81
  getSetting: (key: string) => string | null;
50
82
  putSetting: (key: string, value: string | null) => void;
83
+ /** Free bytes on the checkout's disk; null when it can't tell. */
84
+ freeBytes: () => number | null;
85
+ /** True when scripts/autodeploy deploys this checkout. */
86
+ managed: () => boolean;
87
+ /** What a restart would interrupt, as short labels; empty when nothing. */
88
+ busy: () => Promise<string[]>;
89
+ /** The database the runner backs up first, and where to. */
90
+ dbFile: string | null;
91
+ backupDir: string | null;
92
+ notify: (text: string) => void;
51
93
  }
52
94
 
53
95
  const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
@@ -77,14 +119,33 @@ async function startedByCli(cwd: string, env: RunDeps["env"]) {
77
119
  return false;
78
120
  }
79
121
 
122
+ /**
123
+ * False when systemd runs this server from a unit written before it set
124
+ * KillMode=process: restarting that unit would stop terminals, chat workers
125
+ * and the update itself with it.
126
+ */
127
+ function systemdUnitOk(env: RunDeps["env"]): boolean {
128
+ if (!env.INVOCATION_ID) return true;
129
+ try {
130
+ const unit = fs.readFileSync(
131
+ path.join(os.homedir(), ".config/systemd/user/agent-os.service"),
132
+ "utf8"
133
+ );
134
+ return /^KillMode=process$/m.test(unit);
135
+ } catch {
136
+ return true;
137
+ }
138
+ }
139
+
80
140
  /** AGENTOS_RESTART, else the CLI's restart when it started this server, else none. */
81
141
  export async function resolveRestart(
82
142
  cwd: string,
83
143
  env: RunDeps["env"],
84
- cli: () => Promise<boolean> = () => startedByCli(cwd, env)
144
+ cli: () => Promise<boolean> = () => startedByCli(cwd, env),
145
+ unitOk: () => boolean = () => systemdUnitOk(env)
85
146
  ): Promise<string | null> {
86
147
  if (env.AGENTOS_RESTART) return env.AGENTOS_RESTART;
87
- if (await cli())
148
+ if ((await cli()) && unitOk())
88
149
  return `${shellQuote(path.join(cwd, "scripts/agent-os"))} restart`;
89
150
  return null;
90
151
  }
@@ -136,9 +197,55 @@ export function realRunDeps(): RunDeps {
136
197
  working: workingSummary,
137
198
  getSetting,
138
199
  putSetting,
200
+ freeBytes: () => {
201
+ try {
202
+ const st = fs.statfsSync(cwd);
203
+ return st.bavail * st.bsize;
204
+ } catch {
205
+ return null;
206
+ }
207
+ },
208
+ managed: () => {
209
+ try {
210
+ const at = fs.statSync(agentosHome("autodeploy", "heartbeat")).mtimeMs;
211
+ return Date.now() - at < MANAGED_FRESH_MS;
212
+ } catch {
213
+ return false;
214
+ }
215
+ },
216
+ busy: busyLabels,
217
+ dbFile: path.resolve(dbPath()),
218
+ backupDir: agentosHome("backups"),
219
+ notify: (text) => {
220
+ import("@/lib/notify")
221
+ .then(({ notifyPhone }) => notifyPhone("updates", text))
222
+ .catch(() => {});
223
+ },
139
224
  };
140
225
  }
141
226
 
227
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
228
+
229
+ /** Everything a restart would interrupt, for automatic updates. */
230
+ export async function busyLabels(): Promise<string[]> {
231
+ const { inFlight } = await import("@/lib/busy");
232
+ const { sessions } = await workingSummary();
233
+ const f = inFlight();
234
+ const out: string[] = [];
235
+ if (sessions) out.push(`${plural(sessions, "session")} working`);
236
+ if (f.reviews) out.push(plural(f.reviews, "review"));
237
+ if (f.setups) out.push(`${plural(f.setups, "task")} setting up`);
238
+ if (f.simulatorStarts) out.push(plural(f.simulatorStarts, "simulator start"));
239
+ if (f.merges) out.push(plural(f.merges, "merge"));
240
+ if (f.landings) out.push(`${plural(f.landings, "stack")} landing`);
241
+ const cwd = process.cwd();
242
+ if (fs.existsSync(path.join(cwd, ".next-build.lock"))) out.push("a build");
243
+ try {
244
+ if (fs.existsSync(agentosHome("autodeploy", "lock"))) out.push("a deploy");
245
+ } catch {}
246
+ return out;
247
+ }
248
+
142
249
  export async function workingSummary(): Promise<UpdateView["working"]> {
143
250
  const { inFlight } = await import("@/lib/busy");
144
251
  const { collectStatuses } = await import("@/lib/status/collect");
@@ -152,6 +259,24 @@ export async function workingSummary(): Promise<UpdateView["working"]> {
152
259
 
153
260
  // ── run state ────────────────────────────────────────────────────────────
154
261
 
262
+ export function pidAlive(pid: number | undefined): boolean {
263
+ if (!pid) return false;
264
+ try {
265
+ process.kill(pid, 0);
266
+ return true;
267
+ } catch (error) {
268
+ return (error as NodeJS.ErrnoException).code === "EPERM";
269
+ }
270
+ }
271
+
272
+ /** A run in an active phase that nothing is running any more. */
273
+ export function isStaleRun(run: UpdateRun, now: number): boolean {
274
+ if (!isActivePhase(run.phase)) return false;
275
+ if (run.updatedAt)
276
+ return now - run.updatedAt > HEARTBEAT_STALE_MS && !pidAlive(run.pid);
277
+ return now - run.startedAt > STALE_RUN_MS;
278
+ }
279
+
155
280
  export function readRun(
156
281
  deps: Pick<RunDeps, "stateFile" | "now">
157
282
  ): UpdateRun | null {
@@ -161,7 +286,7 @@ export function readRun(
161
286
  ) as UpdateRun;
162
287
  if (!run?.id || !run.phase) return null;
163
288
  // A run that stopped writing was cut off (the machine went down).
164
- if (isActivePhase(run.phase) && deps.now() - run.startedAt > STALE_RUN_MS)
289
+ if (isStaleRun(run, deps.now()))
165
290
  return {
166
291
  ...run,
167
292
  phase: "failed",
@@ -198,7 +323,7 @@ export type Refusal = { code: UpdateRefusal; error: string };
198
323
 
199
324
  /** Why this checkout can't be updated now; null when it can. No network. */
200
325
  export const NO_RESTART =
201
- "This server can't restart itself, so an update would leave the old build running on new files. Set AGENTOS_RESTART to its restart command, or start it with agent-os start.";
326
+ "This server can't restart itself, so an update would leave the old build running on new files. Set AGENTOS_RESTART to its restart command, or start it with agent-os start (started at login on Linux: run agent-os enable again to update its service).";
202
327
 
203
328
  export async function preflight(
204
329
  check: CheckResult | null,
@@ -263,6 +388,13 @@ export async function preflight(
263
388
  return { code: "up_to_date", error: "AgentOS is up to date." };
264
389
  if (!(await deps.restartCommand()))
265
390
  return { code: "no_restart", error: NO_RESTART };
391
+ const free = deps.freeBytes();
392
+ const need = minFreeBytes(deps.env);
393
+ if (free !== null && need > 0 && free < need)
394
+ return {
395
+ code: "disk",
396
+ error: `Not enough free disk space to update: ${(free / 1024 ** 3).toFixed(1)} GB free, an update needs ${(need / 1024 ** 3).toFixed(0)} GB.`,
397
+ };
266
398
  return null;
267
399
  }
268
400
 
@@ -297,7 +429,11 @@ const releaseStart = (deps: Pick<RunDeps, "stateFile">) => {
297
429
  };
298
430
 
299
431
  export async function startUpdate(
300
- { when }: { when: "now" | "idle" },
432
+ {
433
+ when,
434
+ to,
435
+ auto = false,
436
+ }: { when: "now" | "idle"; to?: string; auto?: boolean },
301
437
  deps: RunDeps = realRunDeps()
302
438
  ): Promise<StartResult> {
303
439
  if (!claimStart(deps))
@@ -307,7 +443,7 @@ export async function startUpdate(
307
443
  error: "An update is already starting.",
308
444
  };
309
445
  try {
310
- return await begin(when, deps);
446
+ return await begin(when, deps, to, auto);
311
447
  } finally {
312
448
  releaseStart(deps);
313
449
  }
@@ -315,7 +451,9 @@ export async function startUpdate(
315
451
 
316
452
  async function begin(
317
453
  when: "now" | "idle",
318
- deps: RunDeps
454
+ deps: RunDeps,
455
+ to: string | undefined,
456
+ auto: boolean
319
457
  ): Promise<StartResult> {
320
458
  const { result } = await checkForUpdate({ force: true }, deps.check);
321
459
  // A failed look can't vouch for the cached answer.
@@ -334,6 +472,20 @@ async function begin(
334
472
  );
335
473
  return { ok: true, pending: true };
336
474
  }
475
+ // An automatic update takes a soaked commit, which may be behind the head.
476
+ let target = { sha: result.latest!.sha, version: result.latest!.version };
477
+ if (to && to !== target.sha) {
478
+ if (!result.candidates?.some((c) => c.sha === to))
479
+ return {
480
+ ok: false,
481
+ code: "up_to_date",
482
+ error: `${to.slice(0, 7)} isn't a newer version of this checkout.`,
483
+ };
484
+ target = await versionAt(deps.check.git, to).catch(() => ({
485
+ sha: to,
486
+ version: to.slice(0, 7),
487
+ }));
488
+ }
337
489
  deps.putSetting(PENDING_KEY, null);
338
490
  const id = `${deps.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
339
491
  writeRun(deps.stateFile, {
@@ -341,8 +493,11 @@ async function begin(
341
493
  phase: "starting",
342
494
  from: result.current!.sha,
343
495
  fromVersion: result.current!.version,
344
- toVersion: result.latest!.version,
496
+ to: target.sha,
497
+ toVersion: target.version,
345
498
  startedAt: deps.now(),
499
+ updatedAt: deps.now(),
500
+ ...(auto ? { auto: true } : {}),
346
501
  });
347
502
  const restart = (await deps.restartCommand())!;
348
503
  const env = { ...deps.env } as NodeJS.ProcessEnv;
@@ -352,6 +507,13 @@ async function begin(
352
507
  env.AGENTOS_UPDATE_STATE = deps.stateFile;
353
508
  env.AGENTOS_UPDATE_ID = id;
354
509
  env.AGENTOS_RESTART = restart;
510
+ env.AGENTOS_UPDATE_TO = target.sha;
511
+ delete env.AGENTOS_UPDATE_DB;
512
+ delete env.AGENTOS_UPDATE_BACKUPS;
513
+ if (deps.dbFile && deps.backupDir) {
514
+ env.AGENTOS_UPDATE_DB = deps.dbFile;
515
+ env.AGENTOS_UPDATE_BACKUPS = deps.backupDir;
516
+ }
355
517
  if (deps.env.PORT) env.PORT = deps.env.PORT;
356
518
  deps.spawnRunner(env, deps.logFile);
357
519
  return { ok: true, pending: false };
@@ -376,20 +538,154 @@ export const cancelPending = (
376
538
  deps: Pick<RunDeps, "putSetting"> = realRunDeps()
377
539
  ) => deps.putSetting(PENDING_KEY, null);
378
540
 
541
+ // ── automatic updates ────────────────────────────────────────────────────
542
+
543
+ const PAUSES: ReadonlySet<UpdateRefusal> = new Set<UpdateRefusal>([
544
+ "dev",
545
+ "not_git",
546
+ "dirty",
547
+ "branch",
548
+ "diverged",
549
+ "unknown",
550
+ "no_restart",
551
+ "disk",
552
+ ]);
553
+
554
+ /** What the watcher saw last, for Settings. */
555
+ interface AutoMemory {
556
+ quietSince: number | null;
557
+ busy: string[];
558
+ refusal: { text: string; until: number } | null;
559
+ }
560
+ const gm = globalThis as unknown as { __agentosAutoMemory?: AutoMemory };
561
+ const memory = (): AutoMemory =>
562
+ (gm.__agentosAutoMemory ??= { quietSince: null, busy: [], refusal: null });
563
+ export const resetAutoMemory = () => {
564
+ gm.__agentosAutoMemory = { quietSince: null, busy: [], refusal: null };
565
+ };
566
+
567
+ /** Files a finished run in the history, once, and tells the phone when it matters. */
568
+ export function recordFinishedRun(deps: RunDeps): void {
569
+ const attempt = attemptOf(readRun(deps));
570
+ if (!attempt || !recordAttempt(deps.getSetting, deps.putSetting, attempt))
571
+ return;
572
+ if (!attempt.auto) return;
573
+ const version = attempt.toVersion || attempt.to.slice(0, 7);
574
+ if (attempt.outcome === "broken")
575
+ deps.notify(
576
+ `AgentOS couldn't update to ${version} and rolling back didn't work either. Open Settings → Updates on that machine.`
577
+ );
578
+ else if (failedInARow(readAttempts(deps.getSetting)) === 3)
579
+ deps.notify(
580
+ `AgentOS has failed to update itself 3 times in a row (last: ${version}). It keeps running the version it has; Settings → Updates says why.`
581
+ );
582
+ }
583
+
584
+ /** The cached check, brought up to date with the checkout cheaply. */
585
+ async function currentCheck(deps: RunDeps): Promise<CheckResult | null> {
586
+ const check = cachedCheck(deps.check);
587
+ if (!check?.candidates?.length) return check;
588
+ // The server's own build is HEAD unless someone pulled under it.
589
+ if (check.current?.sha === deps.buildId()) return check;
590
+ return reconcile(check, deps.check);
591
+ }
592
+
593
+ function autoInput(deps: RunDeps, check: CheckResult | null) {
594
+ const mem = memory();
595
+ const now = deps.now();
596
+ const refusal =
597
+ mem.refusal && mem.refusal.until > now ? mem.refusal.text : null;
598
+ return {
599
+ enabled: autoEnabled(deps.env, deps.getSetting),
600
+ locked: autoLocked(deps.env),
601
+ managed: deps.managed(),
602
+ refusal,
603
+ candidates: check?.available ? (check.candidates ?? []) : [],
604
+ attempts: readAttempts(deps.getSetting),
605
+ busy: mem.busy,
606
+ quietSince: mem.quietSince,
607
+ now,
608
+ soakMs: soakMs(deps.env),
609
+ idleMs: idleMinutes(deps.env, deps.getSetting) * 60_000,
610
+ deadlineMs: deadlineMs(deps.env),
611
+ };
612
+ }
613
+
614
+ /** One look: start an automatic update when it's time. */
615
+ async function autoTick(
616
+ deps: RunDeps
617
+ ): Promise<"none" | "waiting" | "started"> {
618
+ const mem = memory();
619
+ const input = autoInput(deps, await currentCheck(deps));
620
+ // Off, managed, refused or up to date: nothing to watch.
621
+ const first = decideAutoUpdate({ ...input, busy: [], quietSince: 0 });
622
+ if (first.action === "skip" || !("eligibleSince" in pickTarget(input))) {
623
+ mem.quietSince = null;
624
+ mem.busy = [];
625
+ return "none";
626
+ }
627
+ const busy = await deps.busy();
628
+ mem.busy = busy;
629
+ mem.quietSince = busy.length ? null : (mem.quietSince ?? input.now);
630
+ const decision = decideAutoUpdate({
631
+ ...input,
632
+ busy,
633
+ quietSince: mem.quietSince,
634
+ });
635
+ if (decision.action !== "start") return "waiting";
636
+ mem.quietSince = null;
637
+ const started = await startUpdate(
638
+ { when: "now", to: decision.to, auto: true },
639
+ deps
640
+ );
641
+ if (!started.ok && PAUSES.has(started.code))
642
+ mem.refusal = { text: started.error, until: input.now + AUTO_REFUSED_MS };
643
+ return started.ok ? "started" : "waiting";
644
+ }
645
+
646
+ /** What automatic updates are doing, for the /api/updates payload. */
647
+ export async function autoView(
648
+ deps: RunDeps,
649
+ check: CheckResult | null,
650
+ refusal: { code: UpdateRefusal; error: string } | null
651
+ ): Promise<AutoUpdateView> {
652
+ const input = autoInput(deps, check);
653
+ const run = readRun(deps);
654
+ let status: AutoDecision | { code: string; text: string };
655
+ if (run && isActivePhase(run.phase))
656
+ status = { code: "running", text: "An update is running." };
657
+ else
658
+ status = decideAutoUpdate({
659
+ ...input,
660
+ refusal:
661
+ refusal && PAUSES.has(refusal.code) ? refusal.error : input.refusal,
662
+ });
663
+ return {
664
+ enabled: input.enabled,
665
+ locked: input.locked,
666
+ managed: input.managed,
667
+ idleMinutes: idleMinutes(deps.env, deps.getSetting),
668
+ idleLocked: idleMinutesLocked(deps.env),
669
+ status:
670
+ "text" in status
671
+ ? { code: status.code, text: status.text }
672
+ : { code: "starting", text: "Starting an update." },
673
+ attempts: input.attempts.slice(0, 5),
674
+ };
675
+ }
676
+
677
+ // ── the watcher ──────────────────────────────────────────────────────────
678
+
379
679
  /**
380
- * Starts a requested "update when idle" once nothing has been working for two
381
- * checks in a row, so a gap between two turns doesn't count as quiet.
680
+ * Every 30 seconds: files finished runs in the history, starts a requested
681
+ * "update when idle" once nothing has been working for two checks in a row
682
+ * (so a gap between two turns doesn't count as quiet), and otherwise runs
683
+ * automatic updates.
382
684
  */
383
685
  export function createIdleWatcher(deps: RunDeps = realRunDeps()) {
384
686
  let quiet = 0;
385
687
  let busy = false;
386
- const tick = async (): Promise<"none" | "waiting" | "started"> => {
387
- if (!readPending(deps)) {
388
- quiet = 0;
389
- return "none";
390
- }
391
- const run = readRun(deps);
392
- if (run && isActivePhase(run.phase)) return "waiting";
688
+ const manual = async (): Promise<"waiting" | "started"> => {
393
689
  quiet = workingTotal(await deps.working()) === 0 ? quiet + 1 : 0;
394
690
  if (quiet < 2) return "waiting";
395
691
  quiet = 0;
@@ -410,6 +706,14 @@ export function createIdleWatcher(deps: RunDeps = realRunDeps()) {
410
706
  }
411
707
  return "started";
412
708
  };
709
+ const tick = async (): Promise<"none" | "waiting" | "started"> => {
710
+ recordFinishedRun(deps);
711
+ const run = readRun(deps);
712
+ if (run && isActivePhase(run.phase)) return "waiting";
713
+ if (readPending(deps)) return manual();
714
+ quiet = 0;
715
+ return autoTick(deps);
716
+ };
413
717
  return {
414
718
  async tick() {
415
719
  if (busy) return "waiting";
@@ -436,14 +740,20 @@ export function startIdleWatcher(): void {
436
740
 
437
741
  // ── after a restart ──────────────────────────────────────────────────────
438
742
 
439
- /** A run that reached "restarting" is done if this server runs a newer build. */
743
+ /**
744
+ * A run that reached "restarting" is done if this server runs a newer build,
745
+ * unless its runner is still alive: then it is checking this server and
746
+ * will say how it ended.
747
+ */
440
748
  export function reconcileOnBoot(deps: RunDeps = realRunDeps()): void {
441
749
  const run = readRun(deps);
442
750
  if (!run || run.phase !== "restarting") return;
751
+ if (run.updatedAt && pidAlive(run.pid)) return;
443
752
  const moved = deps.buildId() !== run.from;
444
753
  writeRun(deps.stateFile, {
445
754
  ...run,
446
755
  phase: moved ? "done" : "failed",
756
+ outcome: moved ? "done" : "build_failed",
447
757
  finishedAt: deps.now(),
448
758
  ...(moved
449
759
  ? {}
@@ -453,4 +763,5 @@ export function reconcileOnBoot(deps: RunDeps = realRunDeps()): void {
453
763
  log: readLog(deps),
454
764
  }),
455
765
  });
766
+ recordFinishedRun(deps);
456
767
  }
@@ -3,6 +3,10 @@ import { getSetting, putSetting } from "../settings-store";
3
3
  // Whether this server shows the update banner, and the checks behind it.
4
4
  // AGENTOS_UPDATE_BANNER=off pins it off, like the network settings.
5
5
  const KEY = "updates.banner";
6
+ // Automatic updates: on unless switched off (an install that never touched
7
+ // the switch is on). AGENTOS_AUTO_UPDATE=off pins it off.
8
+ const AUTO_KEY = "updates.auto";
9
+ const IDLE_KEY = "updates.autoIdleMinutes";
6
10
 
7
11
  type Env = Record<string, string | undefined>;
8
12
 
@@ -17,4 +21,66 @@ export function bannerEnabled(env: Env = process.env): boolean {
17
21
  export const setBannerEnabled = (on: boolean) =>
18
22
  putSetting(KEY, on ? "1" : "0");
19
23
 
24
+ export const autoLocked = (env: Env = process.env) =>
25
+ env.AGENTOS_AUTO_UPDATE === "off";
26
+
27
+ type Get = (key: string) => string | null;
28
+
29
+ export function autoEnabled(
30
+ env: Env = process.env,
31
+ get: Get = getSetting
32
+ ): boolean {
33
+ if (autoLocked(env)) return false;
34
+ return get(AUTO_KEY) !== "0";
35
+ }
36
+
37
+ export const setAutoEnabled = (on: boolean) =>
38
+ putSetting(AUTO_KEY, on ? "1" : "0");
39
+
40
+ export { IDLE_MINUTES } from "./banner";
41
+ export const DEFAULT_IDLE_MINUTES = 10;
42
+
43
+ const minutes = (raw: string | null | undefined): number | null => {
44
+ const n = Number(raw);
45
+ return raw && Number.isFinite(n) && n >= 1 && n <= 24 * 60
46
+ ? Math.round(n)
47
+ : null;
48
+ };
49
+
50
+ export const idleMinutesLocked = (env: Env = process.env) =>
51
+ minutes(env.AGENTOS_AUTO_UPDATE_IDLE_MIN) !== null;
52
+
53
+ /** How long everything must have been idle before an automatic update. */
54
+ export function idleMinutes(
55
+ env: Env = process.env,
56
+ get: Get = getSetting
57
+ ): number {
58
+ return (
59
+ minutes(env.AGENTOS_AUTO_UPDATE_IDLE_MIN) ??
60
+ minutes(get(IDLE_KEY)) ??
61
+ DEFAULT_IDLE_MINUTES
62
+ );
63
+ }
64
+
65
+ export const setIdleMinutes = (n: number) => putSetting(IDLE_KEY, String(n));
66
+
67
+ const envMinutes = (raw: string | undefined, fallback: number) => {
68
+ const n = Number(raw);
69
+ return raw !== undefined && raw !== "" && Number.isFinite(n) && n >= 0
70
+ ? n
71
+ : fallback;
72
+ };
73
+
74
+ /** A release waits this long on the remote before an install takes it. */
75
+ export const soakMs = (env: Env = process.env) =>
76
+ envMinutes(env.AGENTOS_AUTO_UPDATE_SOAK_MIN, 120) * 60_000;
77
+
78
+ /** After this long waiting for a quiet window, a short lull is enough. */
79
+ export const deadlineMs = (env: Env = process.env) =>
80
+ envMinutes(env.AGENTOS_AUTO_UPDATE_DEADLINE_H, 72) * 60 * 60_000;
81
+
82
+ /** Free disk an update needs before it starts. */
83
+ export const minFreeBytes = (env: Env = process.env) =>
84
+ envMinutes(env.AGENTOS_UPDATE_MIN_FREE_GB, 3) * 1024 ** 3;
85
+
20
86
  export { getSetting, putSetting } from "../settings-store";
@@ -1,14 +1,16 @@
1
- // The /api/updates payload: the cached check, the run, what's pending, and
2
- // whether the caller may start an update.
1
+ // The /api/updates payload: the cached check, the run, what's pending,
2
+ // whether the caller may start an update, and what automatic updates are doing.
3
3
 
4
4
  import { isActivePhase, type UpdateView } from "./banner";
5
5
  import { cachedCheck, checkForUpdate, reconcile } from "./check";
6
6
  import {
7
+ autoView,
7
8
  preflight,
8
9
  readLog,
9
10
  readPending,
10
11
  readRun,
11
12
  realRunDeps,
13
+ recordFinishedRun,
12
14
  type RunDeps,
13
15
  } from "./run";
14
16
  import { bannerEnabled, bannerLocked } from "./settings";
@@ -28,6 +30,7 @@ export async function updateView(
28
30
  { mayUpdate }: { mayUpdate: boolean },
29
31
  deps: RunDeps = realRunDeps()
30
32
  ): Promise<UpdateView> {
33
+ recordFinishedRun(deps);
31
34
  const stored = readRun(deps);
32
35
  let check = cachedCheck(deps.check);
33
36
  // An answer from before the update that just finished says the new version
@@ -43,7 +46,10 @@ export async function updateView(
43
46
  check = await reconcile(check, deps.check);
44
47
  // The log can carry paths and remote URLs; only a device that may update sees it.
45
48
  const run =
46
- stored && (stored.phase === "failed" || stored.phase === "built")
49
+ stored &&
50
+ (stored.phase === "failed" ||
51
+ stored.phase === "built" ||
52
+ stored.phase === "rolled_back")
47
53
  ? {
48
54
  ...stored,
49
55
  log: mayUpdate
@@ -74,6 +80,11 @@ export async function updateView(
74
80
  : { ok: true },
75
81
  run,
76
82
  pending,
83
+ auto: await autoView(deps, check, refusal).then((auto) =>
84
+ mayUpdate
85
+ ? auto
86
+ : { ...auto, attempts: auto.attempts.map(({ error: _, ...a }) => a) }
87
+ ),
77
88
  working: wantsWorking
78
89
  ? await deps
79
90
  .working()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.794",
3
+ "version": "0.3.807",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -36,7 +36,7 @@
36
36
  "disk:dry-run": "tsx scripts/disk-dry-run.ts",
37
37
  "prebuild": "node scripts/gen-migrations-index.mjs",
38
38
  "build": "next build",
39
- "prestart": "node scripts/check-native.mjs --rebuild && node scripts/gen-migrations-index.mjs",
39
+ "prestart": "node scripts/update-recover.mjs && node scripts/check-native.mjs --rebuild && node scripts/gen-migrations-index.mjs",
40
40
  "start": "NODE_ENV=production AGENTOS_SERVER=live node --import tsx server.ts",
41
41
  "pretypecheck": "node scripts/gen-migrations-index.mjs",
42
42
  "typecheck": "tsc --noEmit",
File without changes
Binary file
Binary file