@saadnvd1/agent-os 0.3.845 → 0.3.851

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 (80) hide show
  1. package/README.md +6 -0
  2. package/app/api/approvals/route.ts +5 -1
  3. package/app/api/health/config/route.ts +2 -0
  4. package/app/api/updates/run/route.ts +7 -3
  5. package/app/globals.css +6 -0
  6. package/components/Audits/AuditsPanel.tsx +42 -35
  7. package/components/Chat/quote-selection.test.ts +6 -0
  8. package/components/Chat/quote-selection.ts +13 -10
  9. package/components/Devices/ConnectCard.tsx +52 -56
  10. package/components/Devices/DeviceGroupRow.tsx +65 -0
  11. package/components/Devices/DeviceRow.tsx +2 -2
  12. package/components/Devices/DevicesPanel.tsx +63 -49
  13. package/components/Devices/NetworkSection.tsx +29 -26
  14. package/components/Devices/TailscaleCard.tsx +9 -10
  15. package/components/Devices/group.test.ts +66 -0
  16. package/components/Devices/group.ts +67 -0
  17. package/components/Health/HealthPanel.tsx +5 -81
  18. package/components/Health/MachineChecks.tsx +20 -0
  19. package/components/Health/SweepRuns.tsx +46 -51
  20. package/components/Health/Thresholds.tsx +50 -0
  21. package/components/Merge/GlobalMergeSection.tsx +5 -6
  22. package/components/Merge/MergeApprovalToggle.tsx +20 -14
  23. package/components/Merge/MergeSettingsFields.tsx +0 -2
  24. package/components/Merge/WorktreeSweepSection.tsx +15 -34
  25. package/components/Models/AgentRoleCard.tsx +7 -7
  26. package/components/Models/ModelSelect.tsx +14 -3
  27. package/components/Models/ModelsSection.tsx +42 -37
  28. package/components/Models/ProviderCard.tsx +1 -1
  29. package/components/Models/RoleModelSelect.tsx +15 -3
  30. package/components/Models/SubagentModels.tsx +60 -45
  31. package/components/PhoneNotify/PhoneNotifyForm.tsx +6 -13
  32. package/components/Recipes/RecipesPanel.tsx +20 -9
  33. package/components/Recipes/RunHistory.tsx +1 -1
  34. package/components/Schedules/SchedulesPanel.tsx +12 -10
  35. package/components/Settings/AppWideLimits.tsx +2 -2
  36. package/components/Settings/{NoApprovalsSection.test.ts → ApprovalsSection.test.ts} +61 -22
  37. package/components/Settings/ApprovalsSection.tsx +93 -0
  38. package/components/Settings/ExperimentsSection.test.ts +20 -4
  39. package/components/Settings/ExperimentsSection.tsx +54 -34
  40. package/components/Settings/GeneralSection.tsx +39 -0
  41. package/components/Settings/ListSection.test.ts +85 -0
  42. package/components/Settings/ListSection.tsx +132 -0
  43. package/components/Settings/NotificationsSection.tsx +31 -29
  44. package/components/Settings/PickerSetting.tsx +5 -1
  45. package/components/Settings/SettingRow.tsx +13 -8
  46. package/components/Settings/SettingsDialog.test.ts +31 -4
  47. package/components/Settings/SettingsDialog.tsx +22 -17
  48. package/components/Settings/ValueLabel.tsx +31 -0
  49. package/components/Settings/settings-fields.test.ts +81 -2
  50. package/components/Settings/settings-fields.tsx +27 -27
  51. package/components/Shares/SharedLinksPanel.tsx +11 -14
  52. package/components/Updates/AutoUpdates.tsx +12 -8
  53. package/components/Updates/UpdateActions.tsx +18 -3
  54. package/components/Updates/UpdateToast.test.ts +17 -0
  55. package/components/Updates/UpdateToast.tsx +15 -5
  56. package/components/Updates/UpdatesSection.tsx +27 -18
  57. package/components/Workspaces/WorkspaceSettings.test.ts +5 -5
  58. package/components/Workspaces/WorkspaceSettings.tsx +0 -2
  59. package/components/ui/switch.tsx +1 -1
  60. package/content/whats-new.json +24 -0
  61. package/data/approvals/index.ts +2 -2
  62. package/data/health/index.ts +3 -0
  63. package/hooks/useAutoSave.ts +18 -5
  64. package/hooks/useReloadWhenUpdated.ts +9 -0
  65. package/hooks/useSettingsUrl.ts +6 -3
  66. package/lib/approvals/describe.ts +64 -0
  67. package/lib/health/config.ts +70 -17
  68. package/lib/models/providers.ts +1 -1
  69. package/lib/recipes/settle.ts +22 -0
  70. package/lib/settings-url.ts +14 -2
  71. package/lib/updates/banner.ts +12 -2
  72. package/lib/updates/request.ts +29 -0
  73. package/lib/updates/run.ts +24 -5
  74. package/lib/updates/view.ts +3 -0
  75. package/package.json +1 -1
  76. package/scripts/autodeploy +25 -3
  77. package/scripts/autodeploy.test.ts +47 -0
  78. package/components/Settings/NoApprovalsSection.tsx +0 -103
  79. package/components/Settings/SettingToggle.tsx +0 -36
  80. package/components/Settings/SetupSection.tsx +0 -46
@@ -19,8 +19,7 @@ function fromAddress() {
19
19
  */
20
20
  export function useSettingsUrl() {
21
21
  useEffect(() => {
22
- fromAddress();
23
- const unsubscribe = subscribe(settingsUi, () => {
22
+ const toAddress = () => {
24
23
  const href = window.location.href;
25
24
  const next = hrefWithSettings(href, {
26
25
  open: settingsUi.open,
@@ -29,7 +28,11 @@ export function useSettingsUrl() {
29
28
  const url = new URL(href);
30
29
  if (next !== url.pathname + url.search + url.hash)
31
30
  window.history.replaceState(window.history.state, "", next);
32
- });
31
+ };
32
+ fromAddress();
33
+ // An old link (?settings=setup) is rewritten to where it landed.
34
+ toAddress();
35
+ const unsubscribe = subscribe(settingsUi, toAddress);
33
36
  window.addEventListener("popstate", fromAddress);
34
37
  return () => {
35
38
  unsubscribe();
@@ -0,0 +1,64 @@
1
+ // What an auto-approval reads as to a person: a sentence, with the command
2
+ // or other raw text behind a "details" toggle. The audit table keeps the raw
3
+ // subject; this is only how it is shown.
4
+
5
+ import type { AuditRow } from "./no-approvals";
6
+
7
+ export interface ApprovalLine {
8
+ id: number;
9
+ at: string;
10
+ // The source in words ("an ask", "a chat").
11
+ where: string;
12
+ summary: string;
13
+ // Raw text (a shell command, a path) for the details toggle.
14
+ details: string | null;
15
+ }
16
+
17
+ const WHERE: Record<AuditRow["source"], string> = {
18
+ ask: "An ask",
19
+ brake: "A brake",
20
+ plan: "A plan",
21
+ chat: "A chat",
22
+ terminal: "A terminal",
23
+ "merge-approval": "A merge",
24
+ mode: "The mode",
25
+ };
26
+
27
+ const READ = new Set(["Read", "Glob", "Grep", "LS"]);
28
+ const CHANGE = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
29
+
30
+ const SHELL_START = /^(bash|run|shell|execute|command)\b[:\s]/i;
31
+ const SHELL_WORDS =
32
+ /(^|\s)(cd|ssh|scp|git|npm|npx|pnpm|rm|curl|sudo|cat|ls|sed|grep|kill|tmux|docker|kubectl|python3?|node)\s/;
33
+ const SHELL_CHARS = /&&|\|\||[|;`]|\$\(|\s\/[\w.-]+\/|~\//;
34
+
35
+ export const looksLikeCommand = (text: string) =>
36
+ SHELL_START.test(text) || SHELL_WORDS.test(text) || SHELL_CHARS.test(text);
37
+
38
+ export function describeApproval(row: AuditRow): ApprovalLine {
39
+ const base = {
40
+ id: row.id,
41
+ at: row.at,
42
+ where: WHERE[row.source] ?? row.source,
43
+ };
44
+ const subject = row.subject.trim();
45
+ if (row.source === "terminal") {
46
+ const tool = subject.replace(/ in a terminal session$/, "");
47
+ const summary =
48
+ tool === "Bash"
49
+ ? "Ran a command"
50
+ : CHANGE.has(tool)
51
+ ? "Changed a file"
52
+ : READ.has(tool)
53
+ ? "Read files"
54
+ : `Used ${tool}`;
55
+ return { ...base, summary, details: null };
56
+ }
57
+ if (row.source === "chat" && looksLikeCommand(subject))
58
+ return {
59
+ ...base,
60
+ summary: "Ran a command",
61
+ details: subject.replace(SHELL_START, "").trim() || subject,
62
+ };
63
+ return { ...base, summary: subject, details: null };
64
+ }
@@ -30,25 +30,78 @@ export const HEALTH_DEFAULTS = {
30
30
  export type HealthKey = keyof typeof HEALTH_DEFAULTS;
31
31
  export type HealthConfig = Record<HealthKey, number>;
32
32
 
33
- export const HEALTH_LABELS: Record<HealthKey, string> = {
34
- loadRedMinutes: "Machine load red for (minutes)",
35
- queueHeldMinutes: "Queued task held for (minutes)",
36
- bgQuietMinutes: "Background job quiet for (minutes)",
37
- portWaitMinutes: "Waiting on a port someone else holds for (minutes)",
38
- pieceDupOverlap: "Plan piece matches merged work (0 to 1 overlap)",
39
- restartsMax: "Server restarts allowed per window",
40
- restartsWindowMinutes: "Restart window (minutes)",
41
- ciSlowFallbackMinutes: "CI pending, when no history yet (minutes)",
42
- armedStuckMinutes: "PR armed without merging for (minutes)",
43
- serverErrorsMin: "Server errors per 10 minutes, at least",
44
- serverErrorsFactor: "Server errors, times the usual rate",
45
- envBurstSessions: "Environment failure: sessions in the window to alert",
46
- envBurstHours: "Environment failure window (hours)",
47
- envQuietDays: "Environment failure back after quiet for (days)",
48
- wakeUsageCeilingPct: "Stop waking watchers above usage (%)",
49
- watchersOn: "Watchers on (1) or off (0)",
33
+ export interface HealthMeta {
34
+ label: string;
35
+ unit?: string;
36
+ min: number;
37
+ max?: number;
38
+ integer?: boolean;
39
+ // A 1/0 value shown as a switch.
40
+ switch?: boolean;
41
+ }
42
+
43
+ // How each threshold reads in Settings: the server owns it, so a new
44
+ // threshold needs no client change.
45
+ export const HEALTH_META: Record<HealthKey, HealthMeta> = {
46
+ loadRedMinutes: { label: "Machine load red for", unit: "min", min: 1 },
47
+ queueHeldMinutes: { label: "Queued task held for", unit: "min", min: 1 },
48
+ bgQuietMinutes: { label: "Background job quiet for", unit: "min", min: 1 },
49
+ portWaitMinutes: {
50
+ label: "Waiting on a port someone else holds for",
51
+ unit: "min",
52
+ min: 1,
53
+ },
54
+ pieceDupOverlap: {
55
+ label: "Plan piece matches merged work",
56
+ min: 0,
57
+ max: 1,
58
+ integer: false,
59
+ },
60
+ restartsMax: { label: "Server restarts allowed per window", min: 1 },
61
+ restartsWindowMinutes: { label: "Restart window", unit: "min", min: 1 },
62
+ ciSlowFallbackMinutes: {
63
+ label: "CI pending, when no history yet",
64
+ unit: "min",
65
+ min: 1,
66
+ },
67
+ armedStuckMinutes: {
68
+ label: "PR armed without merging for",
69
+ unit: "min",
70
+ min: 1,
71
+ },
72
+ serverErrorsMin: {
73
+ label: "Server errors per 10 minutes, at least",
74
+ min: 1,
75
+ },
76
+ serverErrorsFactor: {
77
+ label: "Server errors, times the usual rate",
78
+ unit: "×",
79
+ min: 1,
80
+ integer: false,
81
+ },
82
+ envBurstSessions: {
83
+ label: "Environment failure: sessions in the window to alert",
84
+ min: 1,
85
+ },
86
+ envBurstHours: { label: "Environment failure window", unit: "hours", min: 1 },
87
+ envQuietDays: {
88
+ label: "Environment failure back after quiet for",
89
+ unit: "days",
90
+ min: 1,
91
+ },
92
+ wakeUsageCeilingPct: {
93
+ label: "Stop waking watchers above usage",
94
+ unit: "%",
95
+ min: 1,
96
+ max: 100,
97
+ },
98
+ watchersOn: { label: "Watchers", min: 0, max: 1, switch: true },
50
99
  };
51
100
 
101
+ export const HEALTH_LABELS = Object.fromEntries(
102
+ Object.entries(HEALTH_META).map(([k, m]) => [k, m.label])
103
+ ) as Record<HealthKey, string>;
104
+
52
105
  const settingKey = (k: HealthKey) => `health.${k}`;
53
106
 
54
107
  export function healthConfig(
@@ -190,5 +190,5 @@ export function describeSecretRef(ref: SecretRef | undefined): string {
190
190
  return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
191
191
  if (ref.kind === "keychain")
192
192
  return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
193
- return `$${ref.name}`;
193
+ return "an environment variable";
194
194
  }
@@ -24,6 +24,8 @@ import {
24
24
  import { getRecipe, toRun } from "./store";
25
25
  import type { Recipe, RecipeRun, RunStatus } from "./types";
26
26
 
27
+ // A live run with nothing running behind it for this long is given up on.
28
+ export const LOST_RUN_MS = 24 * 60 * 60_000;
27
29
  // A session run that stops without a RESULT line settles from the model's
28
30
  // reading after it has been idle this long.
29
31
  export const IDLE_SETTLE_MS = 30 * 60_000;
@@ -379,6 +381,26 @@ async function settleOne(
379
381
  session,
380
382
  now
381
383
  );
384
+ // Nothing live behind it for a day (a terminal session, an archived one, a
385
+ // worker that's gone): it isn't still running, so it must not say so.
386
+ if (
387
+ Number.isFinite(startedMs) &&
388
+ now - startedMs > LOST_RUN_MS &&
389
+ state !== "running" &&
390
+ state !== "waiting"
391
+ )
392
+ return void finish(
393
+ run,
394
+ {
395
+ status: "failed",
396
+ result: line,
397
+ source: "state",
398
+ error: "Lost track of it",
399
+ summary: "Lost track of it",
400
+ },
401
+ session,
402
+ now
403
+ );
382
404
  const idle =
383
405
  state === "idle" || session.archived_at != null || state === null;
384
406
  if (idle && session.view === "chat") {
@@ -5,6 +5,7 @@
5
5
  export const SETTINGS_PARAM = "settings";
6
6
 
7
7
  export const SETTINGS_SECTIONS = [
8
+ "general",
8
9
  "merging",
9
10
  "approvals",
10
11
  "workspaces",
@@ -18,13 +19,19 @@ export const SETTINGS_SECTIONS = [
18
19
  "schedules",
19
20
  "audits",
20
21
  "health",
21
- "setup",
22
22
  "updates",
23
23
  "experiments",
24
24
  ] as const;
25
25
 
26
26
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
27
27
 
28
+ // Sections that were folded into another: an old link still lands in the
29
+ // right place.
30
+ export const SECTION_REDIRECTS: Record<string, SettingsSection> = {
31
+ setup: "health",
32
+ "no-approvals": "approvals",
33
+ };
34
+
28
35
  export interface SettingsLocation {
29
36
  open: boolean;
30
37
  // null: the list on a phone, the first section on a wider screen.
@@ -40,7 +47,12 @@ export const isSettingsSection = (v: unknown): v is SettingsSection =>
40
47
  export function settingsFromHref(href: string): SettingsLocation {
41
48
  const value = new URL(href, BASE).searchParams.get(SETTINGS_PARAM);
42
49
  if (value === null) return { open: false, section: null };
43
- return { open: true, section: isSettingsSection(value) ? value : null };
50
+ const section = isSettingsSection(value)
51
+ ? value
52
+ : value !== null && Object.hasOwn(SECTION_REDIRECTS, value)
53
+ ? SECTION_REDIRECTS[value]
54
+ : null;
55
+ return { open: true, section };
44
56
  }
45
57
 
46
58
  /** The same address with Settings set (or removed), as path+query+hash. */
@@ -94,7 +94,7 @@ export type UpdateRefusal =
94
94
  | "up_to_date"
95
95
  | "unknown"
96
96
  | "no_restart"
97
- | "managed"
97
+ | "deploying"
98
98
  | "disk"
99
99
  | "forbidden";
100
100
 
@@ -118,6 +118,12 @@ export interface UpdateView {
118
118
  canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
119
119
  run: UpdateRun | null;
120
120
  pending: { requestedAt: number } | null;
121
+ /** autodeploy deploys this checkout (its heartbeat is fresh). */
122
+ managed: boolean;
123
+ /** An Update now click waiting for autodeploy to pick it up. */
124
+ queued: { requestedAt: number } | null;
125
+ /** What the server said about the click that produced this view. */
126
+ message?: string;
121
127
  working: { sessions: number; reviews: number; setups: number };
122
128
  auto?: AutoUpdateView;
123
129
  }
@@ -126,6 +132,7 @@ export type BannerState =
126
132
  | "hidden"
127
133
  | "available"
128
134
  | "pending"
135
+ | "queued"
129
136
  | "running"
130
137
  | "failed"
131
138
  | "built"
@@ -173,8 +180,8 @@ export function bannerState({
173
180
  )
174
181
  return "updated";
175
182
  }
183
+ if (view.queued) return "queued";
176
184
  if (view.pending) return "pending";
177
- if (view.canUpdate.code === "managed") return "hidden";
178
185
  if (
179
186
  view.available &&
180
187
  view.bannerEnabled &&
@@ -185,5 +192,8 @@ export function bannerState({
185
192
  return "hidden";
186
193
  }
187
194
 
195
+ export const MANAGED_NOTE =
196
+ "Updates on this machine are deployed by autodeploy. Update now deploys it within a minute.";
197
+
188
198
  export const workingTotal = (w: UpdateView["working"]) =>
189
199
  w.sessions + w.reviews + w.setups;
@@ -0,0 +1,29 @@
1
+ // The "deploy now" request a click leaves for scripts/autodeploy, which owns
2
+ // deploys on its machine: $AGENT_OS_HOME/autodeploy/now. The script skips the
3
+ // restart gap and the settle wait for it and removes it once the deploy ran.
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+
8
+ /** autodeploy ignores a request older than this (its find -mmin +30). */
9
+ export const REQUEST_FRESH_MS = 30 * 60_000;
10
+
11
+ export function readDeployRequest(
12
+ file: string,
13
+ now: number
14
+ ): { requestedAt: number } | null {
15
+ try {
16
+ const at = Number(fs.readFileSync(file, "utf8").trim());
17
+ if (Number.isFinite(at) && now - at < REQUEST_FRESH_MS)
18
+ return { requestedAt: at };
19
+ } catch {}
20
+ return null;
21
+ }
22
+
23
+ /** Written to a temp name and renamed, so the script never sees half a file. */
24
+ export function writeDeployRequest(file: string, now: number): void {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true });
26
+ const tmp = `${file}.${process.pid}.tmp`;
27
+ fs.writeFileSync(tmp, `${now}\n`);
28
+ fs.renameSync(tmp, file);
29
+ }
@@ -15,6 +15,7 @@ import { git } from "@/lib/git-exec";
15
15
  import { buildId } from "@/lib/build";
16
16
  import { agentosHome } from "@/lib/agentos-home";
17
17
  import { dbPath } from "@/lib/db/path";
18
+ import { readDeployRequest, writeDeployRequest } from "./request";
18
19
  import {
19
20
  isActivePhase,
20
21
  workingTotal,
@@ -87,6 +88,8 @@ export interface RunDeps {
87
88
  managed: () => boolean;
88
89
  /** True while scripts/autodeploy is deploying (its lock directory exists). */
89
90
  deployLocked: () => boolean;
91
+ /** The file an Update now click leaves for scripts/autodeploy. */
92
+ requestFile: string;
90
93
  /** What a restart would interrupt, as short labels; empty when nothing. */
91
94
  busy: () => Promise<string[]>;
92
95
  /** The database the runner backs up first, and where to. */
@@ -217,6 +220,7 @@ export function realRunDeps(): RunDeps {
217
220
  }
218
221
  },
219
222
  deployLocked: () => fs.existsSync(agentosHome("autodeploy", "lock")),
223
+ requestFile: agentosHome("autodeploy", "now"),
220
224
  busy: busyLabels,
221
225
  dbFile: path.resolve(dbPath()),
222
226
  backupDir: agentosHome("backups"),
@@ -358,12 +362,13 @@ export async function preflight(
358
362
  error:
359
363
  "AgentOS isn't running from a git checkout, so it can't update itself.",
360
364
  };
361
- if (deps.managed() || deps.deployLocked())
365
+ if (deps.deployLocked())
362
366
  return {
363
- code: "managed",
364
- error:
365
- "This machine is updated by its own deploy job, so updating from here would race it. It deploys new versions by itself.",
367
+ code: "deploying",
368
+ error: "A deploy is running on this machine now. Try again when it ends.",
366
369
  };
370
+ // autodeploy owns restarts, disk and the restart command on a machine it manages.
371
+ const managed = deps.managed();
367
372
  const run = readRun(deps);
368
373
  if (run && isActivePhase(run.phase))
369
374
  return {
@@ -404,6 +409,7 @@ export async function preflight(
404
409
  };
405
410
  if (!check.available)
406
411
  return { code: "up_to_date", error: "AgentOS is up to date." };
412
+ if (managed) return null;
407
413
  if (!(await deps.restartCommand()))
408
414
  return { code: "no_restart", error: NO_RESTART };
409
415
  const free = deps.freeBytes();
@@ -418,8 +424,12 @@ export async function preflight(
418
424
 
419
425
  // ── starting ─────────────────────────────────────────────────────────────
420
426
 
427
+ export const QUEUED_MESSAGE =
428
+ "Queued for autodeploy. It will deploy within about a minute.";
429
+
421
430
  export type StartResult =
422
- { ok: true; pending: boolean } | ({ ok: false } & Refusal);
431
+ | { ok: true; pending: boolean; queued?: boolean; message?: string }
432
+ | ({ ok: false } & Refusal);
423
433
 
424
434
  /** Takes the right to start, atomically, so two requests can't both run. */
425
435
  function claimStart(deps: Pick<RunDeps, "stateFile" | "now">): boolean {
@@ -484,6 +494,12 @@ async function begin(
484
494
  await discardGenerated(deps);
485
495
  const refused = await preflight(result, deps);
486
496
  if (refused) return { ok: false, ...refused };
497
+ // autodeploy deploys this checkout: starting a second updater would race
498
+ // it, so the click goes to it as a request instead.
499
+ if (deps.managed()) {
500
+ writeDeployRequest(deps.requestFile, deps.now());
501
+ return { ok: true, pending: false, queued: true, message: QUEUED_MESSAGE };
502
+ }
487
503
  if (when === "idle") {
488
504
  deps.putSetting(
489
505
  PENDING_KEY,
@@ -553,6 +569,9 @@ export function readPending(
553
569
  return null;
554
570
  }
555
571
 
572
+ export const readQueued = (deps: Pick<RunDeps, "requestFile" | "now">) =>
573
+ readDeployRequest(deps.requestFile, deps.now());
574
+
556
575
  export const cancelPending = (
557
576
  deps: Pick<RunDeps, "putSetting"> = realRunDeps()
558
577
  ) => deps.putSetting(PENDING_KEY, null);
@@ -8,6 +8,7 @@ import {
8
8
  preflight,
9
9
  readLog,
10
10
  readPending,
11
+ readQueued,
11
12
  readRun,
12
13
  realRunDeps,
13
14
  recordFinishedRun,
@@ -102,6 +103,8 @@ export async function updateView(
102
103
  : { ok: true },
103
104
  run,
104
105
  pending,
106
+ managed: deps.managed(),
107
+ queued: readQueued(deps),
105
108
  auto: await autoView(deps, check, refusal).then((auto) =>
106
109
  mayUpdate
107
110
  ? auto
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.845",
3
+ "version": "0.3.851",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -9,7 +9,11 @@
9
9
  # Restarts are spaced AGENTOS_MIN_GAP seconds apart (an hour; 0 turns it
10
10
  # off): a head that lands inside the gap waits and is deployed, whatever it
11
11
  # has become by then, when the gap ends. A commit message with [deploy-now]
12
- # (in any commit the deploy would take) skips the gap. Each hold logs one line:
12
+ # (in any commit the deploy would take) skips the gap, and so does the request
13
+ # file $state/now, which the in-app Update now button writes
14
+ # (lib/updates/run.ts requestDeploy): it also skips the settle wait, is removed
15
+ # once the deploy ran or origin/main is already deployed, and is ignored when
16
+ # older than 30 minutes. Each hold logs one line:
13
17
  # HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
14
18
  # Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
15
19
  # only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
@@ -54,12 +58,26 @@ done
54
58
  mkdir "$state/lock" 2>/dev/null || exit 0
55
59
  trap 'rmdir "$state/lock"' EXIT
56
60
 
61
+ # An "Update now" click (lib/updates/run.ts requestDeploy) leaves this file.
62
+ now_file="$state/now"
63
+ want_now=0
64
+ if [ -f "$now_file" ]; then
65
+ if [ -n "$(find "$now_file" -maxdepth 0 -mmin +30)" ]; then
66
+ rm -f "$now_file"
67
+ else
68
+ want_now=1
69
+ fi
70
+ fi
71
+
57
72
  git fetch -q origin main
58
73
  head=$(git rev-parse origin/main)
59
74
  deployed=$(cat "$state/deployed" 2>/dev/null || true)
60
75
  published=$(cat "$state/published" 2>/dev/null || true)
61
76
  [ "${AGENTOS_PUBLISH:-1}" = 0 ] && published=$head
62
- [ "$head" = "$deployed" ] && [ "$head" = "$published" ] && exit 0
77
+ if [ "$head" = "$deployed" ]; then
78
+ rm -f "$now_file"
79
+ [ "$head" = "$published" ] && exit 0
80
+ fi
63
81
  log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
64
82
 
65
83
  # shellcheck source=lib/generated.sh
@@ -75,6 +93,7 @@ if [ "$head" != "$deployed" ] && [ -n "$deployed" ] &&
75
93
  git merge -q --ff-only "$head" 2>/dev/null; then
76
94
  deployed=$head
77
95
  git rev-parse HEAD > "$state/deployed"
96
+ rm -f "$now_file"
78
97
  [ "$head" = "$published" ] && exit 0
79
98
  fi
80
99
 
@@ -82,7 +101,7 @@ fi
82
101
  # minutes) waits for the next run, so a burst is one build and one restart.
83
102
  # Its commit time is the merge's, so this never holds a deploy forever.
84
103
  settle="${AGENTOS_SETTLE:-120}"
85
- if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] &&
104
+ if [ "$head" != "$deployed" ] && [ "$settle" -gt 0 ] && [ "$want_now" = 0 ] &&
86
105
  [ $(( $(date +%s) - $(git log -1 --format=%ct "$head") )) -lt "$settle" ]; then
87
106
  exit 0
88
107
  fi
@@ -98,6 +117,8 @@ if [ "$head" != "$deployed" ] && [ "$gap" -gt 0 ]; then
98
117
  msgs=$(git log --format=%B "${deployed:+$deployed..}$head" 2>/dev/null || git log -1 --format=%B "$head")
99
118
  if printf '%s' "$msgs" | grep -qF '[deploy-now]'; then
100
119
  log "Restart gap skipped for ${head:0:7}: [deploy-now]"
120
+ elif [ "$want_now" = 1 ]; then
121
+ log "Restart gap skipped for ${head:0:7}: update requested"
101
122
  else
102
123
  # Once per head, not once per minute, so the log counts holds.
103
124
  if [ "$(cat "$state/held" 2>/dev/null)" != "$head" ]; then
@@ -136,6 +157,7 @@ if [ "$head" != "$deployed" ]; then
136
157
  # The pull takes what landed while it waited, too.
137
158
  git rev-parse HEAD > "$state/deployed"
138
159
  date +%s > "$state/restarted"
160
+ rm -f "$now_file"
139
161
  # A link into an older clone would run an old aos on this machine.
140
162
  scripts/sync-cli-links || log "CLI link sync failed"
141
163
  fi
@@ -143,6 +143,53 @@ describe("autodeploy restart gap", () => {
143
143
  });
144
144
  });
145
145
 
146
+ describe("autodeploy update request", () => {
147
+ const request = (t: ReturnType<typeof setup>, ageMs = 0) => {
148
+ fs.mkdirSync(t.state, { recursive: true });
149
+ const file = path.join(t.state, "now");
150
+ fs.writeFileSync(file, `${Date.now()}\n`);
151
+ const at = new Date(Date.now() - ageMs);
152
+ fs.utimesSync(file, at, at);
153
+ return file;
154
+ };
155
+
156
+ it("skips the gap and the settle wait, then removes the request", () => {
157
+ const t = setup();
158
+ t.commit("a");
159
+ t.run();
160
+ const b = t.commit("b");
161
+ const file = request(t);
162
+ const out = t.run({ AGENTOS_SETTLE: "3600" });
163
+ expect(out).toMatch(
164
+ /Restart gap skipped for [0-9a-f]{7}: update requested/
165
+ );
166
+ expect(t.deployed()).toBe(b);
167
+ expect(t.restarts()).toBe(2);
168
+ expect(fs.existsSync(file)).toBe(false);
169
+ });
170
+
171
+ it("is removed straight away when origin/main is already deployed", () => {
172
+ const t = setup();
173
+ t.commit("a");
174
+ t.run();
175
+ const file = request(t);
176
+ expect(t.run()).toBe("");
177
+ expect(fs.existsSync(file)).toBe(false);
178
+ expect(t.restarts()).toBe(1);
179
+ });
180
+
181
+ it("ignores, and removes, a request older than 30 minutes", () => {
182
+ const t = setup();
183
+ t.commit("a");
184
+ t.run();
185
+ const b = t.commit("b");
186
+ const file = request(t, 31 * 60_000);
187
+ expect(t.run()).toMatch(/HELD/);
188
+ expect(t.deployed()).not.toBe(b);
189
+ expect(fs.existsSync(file)).toBe(false);
190
+ });
191
+ });
192
+
146
193
  describe("autodeploy changelog commits", () => {
147
194
  const changelog = (t: ReturnType<typeof setup>, also?: string) => {
148
195
  const work = path.join(t.root, "work");