@saadnvd1/agent-os 0.3.836 → 0.3.845

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 (81) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/components/Devices/DeviceRow.tsx +83 -69
  5. package/components/Devices/PasskeysSection.test.ts +89 -0
  6. package/components/Devices/PasskeysSection.tsx +36 -26
  7. package/components/Health/HealthPanel.tsx +41 -16
  8. package/components/Merge/GlobalMergeSection.tsx +2 -5
  9. package/components/Merge/MergeSettingsFields.tsx +39 -77
  10. package/components/Merge/ProjectMergeSection.tsx +2 -6
  11. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  12. package/components/Models/AgentRoleCard.tsx +61 -66
  13. package/components/Models/AgentRolesSection.tsx +5 -8
  14. package/components/Models/ModelsSection.tsx +7 -18
  15. package/components/Models/PriceInput.tsx +59 -0
  16. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  17. package/components/SessionList/GroupShelves.tsx +8 -3
  18. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  19. package/components/SessionList/RowParts.tsx +39 -35
  20. package/components/SessionList/RowRename.test.ts +86 -17
  21. package/components/SessionList/RowShell.tsx +2 -5
  22. package/components/SessionList/SessionRow.tsx +0 -1
  23. package/components/Settings/AppWideLimits.tsx +61 -0
  24. package/components/Settings/ContextSection.tsx +96 -0
  25. package/components/Settings/ExperimentsSection.test.ts +9 -7
  26. package/components/Settings/ExperimentsSection.tsx +22 -23
  27. package/components/Settings/InlineConfirm.tsx +51 -0
  28. package/components/Settings/OwnerNameRow.tsx +10 -40
  29. package/components/Settings/PickerSetting.tsx +59 -0
  30. package/components/Settings/SettingRow.tsx +168 -0
  31. package/components/Settings/SettingsDialog.test.ts +1 -0
  32. package/components/Settings/SettingsDialog.tsx +47 -2
  33. package/components/Settings/settings-fields.test.ts +103 -0
  34. package/components/Settings/settings-fields.tsx +428 -0
  35. package/components/Shares/SharedLinksPanel.tsx +62 -53
  36. package/components/Workspaces/HqFolderForm.tsx +22 -53
  37. package/components/Workspaces/McpServersForm.tsx +21 -28
  38. package/components/Workspaces/ProjectTiersForm.tsx +28 -13
  39. package/components/Workspaces/RuleEditor.tsx +86 -0
  40. package/components/Workspaces/RulesForm.test.ts +154 -0
  41. package/components/Workspaces/RulesForm.tsx +116 -117
  42. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  43. package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
  44. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  45. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  46. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  47. package/components/Workspaces/WorkspaceSettings.tsx +63 -52
  48. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  49. package/content/whats-new.json +40 -0
  50. package/data/fresh-start/index.ts +1 -1
  51. package/data/workspaces/index.ts +9 -0
  52. package/hooks/useAutoSave.test.ts +116 -0
  53. package/hooks/useAutoSave.ts +98 -0
  54. package/lib/away/deploy.ts +34 -7
  55. package/lib/away/window-limits.ts +3 -1
  56. package/lib/chat/refresh.ts +10 -6
  57. package/lib/chat/stall-limits.ts +3 -1
  58. package/lib/models/plan-fresh.ts +61 -0
  59. package/lib/models/plan-step.ts +40 -24
  60. package/lib/orchestrator/scope.ts +10 -0
  61. package/lib/settings/defaults.ts +27 -0
  62. package/lib/settings/parse.ts +17 -0
  63. package/lib/settings-url.ts +1 -0
  64. package/lib/updates/banner.ts +2 -0
  65. package/lib/updates/run.ts +9 -0
  66. package/lib/updates/view.ts +28 -6
  67. package/lib/worktree-deps/paths.ts +9 -4
  68. package/lib/worktree-sweep/limits.ts +3 -0
  69. package/lib/worktree-sweep/settings.ts +4 -2
  70. package/lib/worktrees.ts +3 -0
  71. package/package.json +1 -1
  72. package/scripts/autodeploy +11 -0
  73. package/scripts/autodeploy.test.ts +30 -0
  74. package/scripts/lib/update-verify.mjs +11 -0
  75. package/scripts/update-run.mjs +29 -0
  76. package/scripts/update-run.test.ts +62 -4
  77. package/components/Away/AwayWindowForm.tsx +0 -91
  78. package/components/Workspaces/FreshStartForm.tsx +0 -177
  79. package/components/Workspaces/StallForm.tsx +0 -101
  80. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  81. package/components/Workspaces/fresh-start.ts +0 -19
@@ -0,0 +1,116 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useAutoSave, type AutoSave } from "./useAutoSave";
6
+
7
+ (
8
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
+ ).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root;
12
+ let host: HTMLElement;
13
+ afterEach(async () => {
14
+ await act(async () => root.unmount());
15
+ host.remove();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ // A server whose value the hook is given back as `saved`, like a query.
20
+ async function mount(
21
+ save: (n: number) => Promise<unknown>,
22
+ notify = vi.fn(),
23
+ initial = 10
24
+ ) {
25
+ const box: { auto: AutoSave<number> } = {} as never;
26
+ let saved = initial;
27
+ const Probe = ({ value }: { value: number }) => {
28
+ box.auto = useAutoSave({
29
+ saved: value,
30
+ save: async (n) => {
31
+ await save(n);
32
+ saved = n;
33
+ },
34
+ notify,
35
+ });
36
+ return null;
37
+ };
38
+ host = document.createElement("div");
39
+ document.body.appendChild(host);
40
+ root = createRoot(host);
41
+ const render = () =>
42
+ act(async () => root.render(createElement(Probe, { value: saved })));
43
+ await render();
44
+ return { box, render, notify };
45
+ }
46
+
47
+ describe("useAutoSave", () => {
48
+ it("saves a new value and says Saved, then goes quiet", async () => {
49
+ vi.useFakeTimers();
50
+ const save = vi.fn().mockResolvedValue(undefined);
51
+ const { box, render } = await mount(save);
52
+ await act(async () => void (await box.auto.commit(20)));
53
+ await render();
54
+ expect(save).toHaveBeenCalledWith(20);
55
+ expect(box.auto.value).toBe(20);
56
+ expect(box.auto.status).toBe("saved");
57
+ await act(async () => void vi.advanceTimersByTime(2000));
58
+ expect(box.auto.status).toBe("idle");
59
+ });
60
+
61
+ it("sends nothing for the value it already has", async () => {
62
+ const save = vi.fn().mockResolvedValue(undefined);
63
+ const { box } = await mount(save);
64
+ await act(async () => void (await box.auto.commit(10)));
65
+ expect(save).not.toHaveBeenCalled();
66
+ expect(box.auto.status).toBe("idle");
67
+ });
68
+
69
+ it("shows the value while saving", async () => {
70
+ let finish!: () => void;
71
+ const save = vi.fn(() => new Promise<void>((r) => (finish = r)));
72
+ const { box } = await mount(save);
73
+ let done!: Promise<boolean>;
74
+ await act(async () => void (done = box.auto.commit(20)));
75
+ expect(box.auto.status).toBe("saving");
76
+ expect(box.auto.value).toBe(20);
77
+ await act(async () => {
78
+ finish();
79
+ await done;
80
+ });
81
+ });
82
+
83
+ it("rolls back to the saved value and tells the user when the server rejects", async () => {
84
+ const save = vi.fn().mockRejectedValue(new Error("Must be 50 or more"));
85
+ const { box, notify } = await mount(save);
86
+ let ok = true;
87
+ await act(async () => void (ok = await box.auto.commit(20)));
88
+ expect(ok).toBe(false);
89
+ expect(box.auto.value).toBe(10);
90
+ expect(box.auto.status).toBe("idle");
91
+ expect(notify).toHaveBeenCalledWith("Must be 50 or more");
92
+ });
93
+
94
+ it("undoes an undoable change by saving the old value back", async () => {
95
+ const save = vi.fn().mockResolvedValue(undefined);
96
+ const { box, render } = await mount(save);
97
+ await act(async () => void (await box.auto.commit(0, { undoable: true })));
98
+ await render();
99
+ expect(box.auto.status).toBe("undo");
100
+ await act(async () => void (await box.auto.undo()));
101
+ await render();
102
+ expect(save).toHaveBeenLastCalledWith(10);
103
+ expect(box.auto.value).toBe(10);
104
+ expect(box.auto.status).toBe("saved");
105
+ });
106
+
107
+ it("has nothing to undo after a plain change", async () => {
108
+ const save = vi.fn().mockResolvedValue(undefined);
109
+ const { box } = await mount(save);
110
+ await act(async () => void (await box.auto.commit(20)));
111
+ let ok = true;
112
+ await act(async () => void (ok = await box.auto.undo()));
113
+ expect(ok).toBe(false);
114
+ expect(save).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { toast } from "sonner";
5
+
6
+ export type SaveStatus = "idle" | "saving" | "saved" | "undo";
7
+
8
+ const SAVED_MS = 1800;
9
+ const UNDO_MS = 7000;
10
+
11
+ export interface AutoSave<T> {
12
+ // What to show: the value in flight while saving, else the saved one. A
13
+ // rejected save drops the in-flight value, so this is the saved one again.
14
+ value: T;
15
+ status: SaveStatus;
16
+ // Commit a new value. `undoable` keeps the previous one for Undo (large or
17
+ // destructive changes). Resolves false when the server rejected it.
18
+ commit: (next: T, opts?: { undoable?: boolean }) => Promise<boolean>;
19
+ undo: () => Promise<boolean>;
20
+ }
21
+
22
+ /**
23
+ * Save a setting the moment it changes. `saved` is the server's value
24
+ * (the query's); `save` sends one value and throws to reject it.
25
+ */
26
+ export function useAutoSave<T>({
27
+ saved,
28
+ save,
29
+ equals = Object.is,
30
+ notify = (message) => toast.error(message),
31
+ }: {
32
+ saved: T;
33
+ save: (next: T) => Promise<unknown>;
34
+ equals?: (a: T, b: T) => boolean;
35
+ notify?: (message: string) => void;
36
+ }): AutoSave<T> {
37
+ const [pending, setPending] = useState<{ value: T } | null>(null);
38
+ const [status, setStatus] = useState<SaveStatus>("idle");
39
+ const previous = useRef<{ value: T } | null>(null);
40
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
41
+ const seq = useRef(0);
42
+ const latest = useRef({ saved, save, equals, notify });
43
+ useEffect(() => {
44
+ latest.current = { saved, save, equals, notify };
45
+ });
46
+ useEffect(() => () => clearTimeout(timer.current), []);
47
+
48
+ const settle = useCallback((next: SaveStatus, ms: number) => {
49
+ setStatus(next);
50
+ clearTimeout(timer.current);
51
+ timer.current = setTimeout(() => setStatus("idle"), ms);
52
+ }, []);
53
+
54
+ const run = useCallback(
55
+ async (next: T, undoable: boolean, before: T) => {
56
+ const mine = ++seq.current;
57
+ clearTimeout(timer.current);
58
+ setPending({ value: next });
59
+ setStatus("saving");
60
+ try {
61
+ await latest.current.save(next);
62
+ } catch (err) {
63
+ if (mine === seq.current) {
64
+ setPending(null);
65
+ setStatus("idle");
66
+ }
67
+ latest.current.notify(
68
+ err instanceof Error ? err.message : "Couldn't save"
69
+ );
70
+ return false;
71
+ }
72
+ if (mine !== seq.current) return true;
73
+ setPending(null);
74
+ previous.current = undoable ? { value: before } : null;
75
+ settle(undoable ? "undo" : "saved", undoable ? UNDO_MS : SAVED_MS);
76
+ return true;
77
+ },
78
+ [settle]
79
+ );
80
+
81
+ const commit = useCallback(
82
+ (next: T, opts?: { undoable?: boolean }) => {
83
+ const { saved: current, equals: same } = latest.current;
84
+ if (same(next, current)) return Promise.resolve(true);
85
+ return run(next, !!opts?.undoable, current);
86
+ },
87
+ [run]
88
+ );
89
+
90
+ const undo = useCallback(async () => {
91
+ const back = previous.current;
92
+ if (!back) return false;
93
+ previous.current = null;
94
+ return run(back.value, false, latest.current.saved);
95
+ }, [run]);
96
+
97
+ return { value: pending ? pending.value : saved, status, commit, undo };
98
+ }
@@ -33,6 +33,14 @@ export const HEALTH_GRACE_MS = 3 * 60 * 1000;
33
33
  export const GAP_MARGIN_MS = 10 * 60 * 1000;
34
34
  const DEFAULT_GAP_S = 3600;
35
35
 
36
+ type HealthAnswer = {
37
+ ok: boolean;
38
+ why?: string;
39
+ startedAt?: number;
40
+ // The commit the running server was built from, when it says.
41
+ build?: string;
42
+ };
43
+
36
44
  export interface DeployDeps {
37
45
  // When the deploy last restarted the server, in ms; null when unknown.
38
46
  restartedAt: (file: string) => number | null;
@@ -43,9 +51,7 @@ export interface DeployDeps {
43
51
  contains: (repo: string, tip: string, sha: string) => Promise<boolean>;
44
52
  // startedAt, when the server says when it started (AgentOS's
45
53
  // /api/health does), so a deploy that never restarted it is caught.
46
- health: (
47
- url: string
48
- ) => Promise<{ ok: boolean; why?: string; startedAt?: number }>;
54
+ health: (url: string) => Promise<HealthAnswer>;
49
55
  // When GitHub made the merge commit, in ms (0 when unknown): the time a
50
56
  // restart has to come after. Not when AgentOS noticed the merge, which
51
57
  // can be after a restart that already took it.
@@ -78,18 +84,25 @@ async function contains(
78
84
  return ancestor();
79
85
  }
80
86
 
81
- async function health(
82
- url: string
83
- ): Promise<{ ok: boolean; why?: string; startedAt?: number }> {
87
+ async function health(url: string): Promise<HealthAnswer> {
84
88
  try {
85
89
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
86
90
  if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
87
91
  const body = (await res.json().catch(() => null)) as {
88
92
  startedAt?: unknown;
93
+ build?: unknown;
89
94
  } | null;
90
95
  const startedAt =
91
96
  typeof body?.startedAt === "string" ? Date.parse(body.startedAt) : NaN;
92
- return Number.isFinite(startedAt) ? { ok: true, startedAt } : { ok: true };
97
+ const build =
98
+ typeof body?.build === "string" && SHA.test(body.build)
99
+ ? body.build
100
+ : undefined;
101
+ return {
102
+ ok: true,
103
+ ...(Number.isFinite(startedAt) ? { startedAt } : {}),
104
+ ...(build ? { build } : {}),
105
+ };
93
106
  } catch (error) {
94
107
  return {
95
108
  ok: false,
@@ -161,6 +174,20 @@ async function judge(
161
174
  if (!config.health) return { state: "ok", detail: `${short} deployed` };
162
175
  const url = healthUrl(config.health);
163
176
  const answer = await deps.health(url);
177
+ // autodeploy's marker can name a build that isn't running (a rolled-back
178
+ // update); what the server says it runs decides.
179
+ if (
180
+ answer.ok &&
181
+ answer.build &&
182
+ !(await deps.contains(input.repo, answer.build, mergeSha))
183
+ ) {
184
+ if (input.now - deployed.at < HEALTH_GRACE_MS)
185
+ return { state: "pending" };
186
+ return {
187
+ state: "failed",
188
+ detail: `not deployed: the server is running ${answer.build.slice(0, 7)}, which doesn't have ${short}`,
189
+ };
190
+ }
164
191
  const merged =
165
192
  answer.ok && answer.startedAt !== undefined
166
193
  ? await deps.committedAt(input.repo, mergeSha)
@@ -1,6 +1,8 @@
1
+ import { SETTING_DEFAULTS } from "../settings/defaults";
2
+
1
3
  // The Away window's bounds, in minutes, for the server (./activity) and
2
4
  // the Settings form alike. Client-safe: no database.
3
- export const DEFAULT_WINDOW_MINUTES = 60;
5
+ export const DEFAULT_WINDOW_MINUTES = SETTING_DEFAULTS.awayWindowMinutes;
4
6
  export const MIN_WINDOW_MINUTES = 10;
5
7
  export const MAX_WINDOW_MINUTES = 24 * 60;
6
8
 
@@ -6,7 +6,7 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the two sizes at which chats and
9
+ * App-wide, in Settings → Context, with the two sizes at which chats and
10
10
  * tasks compact: mid-turn (Claude Code itself, high so a job keeps its
11
11
  * files) and between turns (lib/chat/stale, earlier, nothing in flight).
12
12
  */
@@ -15,24 +15,28 @@ import { db } from "../db";
15
15
  import { effectiveModels } from "../models/config";
16
16
  import { type ModelRole } from "../models/roles";
17
17
  import { sessionRole } from "../models/session-role";
18
+ import { SETTING_DEFAULTS } from "../settings/defaults";
18
19
  import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
19
20
 
20
21
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
21
22
  export const TASK_KEY = "chat.task_refresh_tokens";
22
23
  // Named for what it first did: compact chats idle past their cache.
23
24
  export const COMPACT_KEY = "chat.stale_compact_tokens";
24
- export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
25
- export const DEFAULT_COMPACT_TOKENS = 150_000;
25
+ export const DEFAULT_ORCHESTRATOR_TOKENS =
26
+ SETTING_DEFAULTS.fresh.orchestratorTokens;
27
+ export const DEFAULT_COMPACT_TOKENS = SETTING_DEFAULTS.fresh.compactTokens;
26
28
  // Claude Code's own mid-turn auto-compact window: high, because a summary
27
29
  // mid-job costs the agent its files and its place.
28
30
  export const AUTO_COMPACT_KEY = "chat.auto_compact_tokens";
29
- export const DEFAULT_AUTO_COMPACT_TOKENS = 300_000;
31
+ export const DEFAULT_AUTO_COMPACT_TOKENS =
32
+ SETTING_DEFAULTS.fresh.autoCompactTokens;
30
33
  // The same window for a person's own (interactive) sessions, which run long
31
34
  // on the biggest model. 300k (DEFAULT_AUTO_COMPACT_TOKENS) was its value
32
35
  // before it had its own setting: set it back to restore that.
33
36
  export const INTERACTIVE_AUTO_COMPACT_KEY =
34
37
  "chat.interactive_auto_compact_tokens";
35
- export const DEFAULT_INTERACTIVE_AUTO_COMPACT_TOKENS = 300_000;
38
+ export const DEFAULT_INTERACTIVE_AUTO_COMPACT_TOKENS =
39
+ SETTING_DEFAULTS.fresh.interactiveAutoCompactTokens;
36
40
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
37
41
 
38
42
  export interface RefreshSettings {
@@ -60,7 +64,7 @@ function read(key: string, fallback: number): number {
60
64
  export function refreshSettings(): RefreshSettings {
61
65
  return {
62
66
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
63
- taskTokens: read(TASK_KEY, 0),
67
+ taskTokens: read(TASK_KEY, SETTING_DEFAULTS.fresh.taskTokens),
64
68
  compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
65
69
  autoCompactTokens: read(AUTO_COMPACT_KEY, DEFAULT_AUTO_COMPACT_TOKENS),
66
70
  interactiveAutoCompactTokens: read(
@@ -1,5 +1,7 @@
1
+ import { SETTING_DEFAULTS } from "../settings/defaults";
2
+
1
3
  // The range the stall alert may take (lib/chat/stall), shared with the
2
4
  // settings form.
3
- export const DEFAULT_STALL_MINUTES = 8;
5
+ export const DEFAULT_STALL_MINUTES = SETTING_DEFAULTS.stallMinutes;
4
6
  export const MIN_STALL_MINUTES = 2;
5
7
  export const MAX_STALL_MINUTES = 120;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Whether the plan file in a task's worktree was written by this task's
3
+ * planning turn. A plan committed to the base branch is checked out with the
4
+ * worktree and reads exactly like a fresh one, so it is refused: tracked by
5
+ * git, older than the worktree's plan folder, or identical to the base's copy.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { git } from "../git-exec";
11
+
12
+ export type PlanVerdict =
13
+ { ok: true; text: string } | { ok: false; why: string };
14
+
15
+ const PLAN_FILE = ".agentos/plan.md";
16
+
17
+ export async function checkPlanFile(
18
+ worktree: string,
19
+ base: string | null | undefined
20
+ ): Promise<PlanVerdict | null> {
21
+ const file = path.join(worktree, PLAN_FILE);
22
+ let text: string;
23
+ let mtime: number;
24
+ try {
25
+ text = fs.readFileSync(file, "utf8");
26
+ mtime = fs.statSync(file).mtimeMs;
27
+ } catch {
28
+ return null;
29
+ }
30
+ const quiet = (args: string[]) =>
31
+ git(worktree, args, { timeout: 15000 }).catch(() => null);
32
+ const tracked = await quiet(["ls-files", "--", PLAN_FILE]);
33
+ // Tracked and untouched is the base's copy; a tracked file the planner
34
+ // rewrote differs from HEAD and is its own.
35
+ if (
36
+ tracked?.trim() &&
37
+ (await quiet(["diff", "--quiet", "HEAD", "--", PLAN_FILE])) !== null
38
+ )
39
+ return { ok: false, why: "it is tracked by git and unchanged" };
40
+ // The plan folder's own .gitignore is written when the task starts.
41
+ const started = (() => {
42
+ try {
43
+ return fs.statSync(
44
+ path.join(worktree, path.dirname(PLAN_FILE), ".gitignore")
45
+ ).mtimeMs;
46
+ } catch {
47
+ return 0;
48
+ }
49
+ })();
50
+ if (started && mtime < started)
51
+ return { ok: false, why: "it was not written since the task started" };
52
+ if (base) {
53
+ for (const ref of [`origin/${base}`, base]) {
54
+ const theirs = await quiet(["show", `${ref}:${PLAN_FILE}`]);
55
+ if (theirs !== null && theirs === text)
56
+ return { ok: false, why: `it is identical to ${ref}'s copy` };
57
+ if (theirs !== null) break;
58
+ }
59
+ }
60
+ return { ok: true, text };
61
+ }
@@ -36,6 +36,7 @@ import {
36
36
  } from "../plans/features";
37
37
  import { advancePlan, setPlan } from "../db/session-parts";
38
38
  import { Owner } from "../owner";
39
+ import { checkPlanFile } from "./plan-fresh";
39
40
 
40
41
  export const PLAN_FILE = ".agentos/plan.md";
41
42
 
@@ -134,9 +135,13 @@ export function planNoteFor(
134
135
  file: string,
135
136
  next: string,
136
137
  again = "",
137
- after = ""
138
+ after = "",
139
+ stale = ""
138
140
  ): string {
139
141
  let head = `Task "${s.name}": no plan file was written${again}; ${next}${after}.`;
142
+ // A plan file left over from the base branch is not this task's plan.
143
+ if (stale)
144
+ return `Task "${s.name}": no usable plan was written (the plan file is stale: ${stale})${again}; ${next}${after}.`;
140
145
  try {
141
146
  const text = fs.readFileSync(file, "utf8");
142
147
  const summary = planSummary(text);
@@ -153,7 +158,12 @@ export function planNoteFor(
153
158
  }
154
159
 
155
160
  // Tells the orchestrator that started a task its plan is written, once.
156
- function announcePlan(s: Session, ref: string | null, retried = false): void {
161
+ function announcePlan(
162
+ s: Session,
163
+ ref: string | null,
164
+ retried = false,
165
+ stale = ""
166
+ ): void {
157
167
  // The machine that placed it announces the plan, from its own copy.
158
168
  if (s.plan_waits_for_caller) return;
159
169
  if (!orchestratorOf(s.started_by) && !s.held_by_device) return;
@@ -167,7 +177,7 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
167
177
  ? `implementing on ${ref}`
168
178
  : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
169
179
  const again = retried ? " (asked once more for it first)" : "";
170
- const head = planNoteFor(s, file, next, again, ref ? " anyway" : "");
180
+ const head = planNoteFor(s, file, next, again, ref ? " anyway" : "", stale);
171
181
  if (s.held_by_device) {
172
182
  setPlan(db, s.id, { plan_note: head });
173
183
  return;
@@ -215,12 +225,14 @@ async function handOffToPieces(s: Session): Promise<void> {
215
225
  }
216
226
  }
217
227
 
218
- function readPlan(s: Session): string | null {
219
- try {
220
- return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
221
- } catch {
222
- return null;
223
- }
228
+ // The plan this task's planning turn wrote: null when there is none, and
229
+ // `stale` says why a file that is there was refused.
230
+ async function readPlan(
231
+ s: Session
232
+ ): Promise<{ text: string | null; stale: string }> {
233
+ const v = await checkPlanFile(s.worktree_path!, s.base_branch);
234
+ if (!v) return { text: null, stale: "" };
235
+ return v.ok ? { text: v.text, stale: "" } : { text: null, stale: v.why };
224
236
  }
225
237
 
226
238
  export const implementMessageId = (sessionId: string) =>
@@ -243,7 +255,8 @@ async function startImplementing(
243
255
  from: "planning" | "awaiting",
244
256
  send: Send,
245
257
  amendment?: string,
246
- retried = false
258
+ retried = false,
259
+ stale = ""
247
260
  ): Promise<string | null> {
248
261
  const sessionId = s.id;
249
262
  const routed = resolveRole("implement", {
@@ -251,7 +264,14 @@ async function startImplementing(
251
264
  override: taskOverride(s),
252
265
  });
253
266
  const id = implementMessageId(sessionId);
254
- const planText = readPlan(s);
267
+ // Only the planning turn's own file is checked for being this task's: a
268
+ // plan waiting for approval was checked when its turn ended.
269
+ const planText =
270
+ from === "planning"
271
+ ? (await readPlan(s)).text
272
+ : fs.existsSync(path.join(s.worktree_path!, PLAN_FILE))
273
+ ? fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8")
274
+ : null;
255
275
  // The branch was cut when the task was created; the plan may have waited.
256
276
  const fresh = await refreshBase({
257
277
  name: s.name,
@@ -313,12 +333,7 @@ async function startImplementing(
313
333
  if (nothingOwn) return true;
314
334
  enqueue(sessionId, {
315
335
  id,
316
- text: implementPrompt(
317
- fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
318
- note,
319
- piece,
320
- baseNote
321
- ),
336
+ text: implementPrompt(planText !== null, note, piece, baseNote),
322
337
  origin: { kind: "decision", label: "Plan step" },
323
338
  });
324
339
  return true;
@@ -326,7 +341,7 @@ async function startImplementing(
326
341
  if (!claimed) return null;
327
342
  if (from === "planning") {
328
343
  try {
329
- announcePlan(s, routed.ref, retried);
344
+ announcePlan(s, routed.ref, retried, stale);
330
345
  } catch (e) {
331
346
  console.error("plan-done event failed:", e);
332
347
  }
@@ -411,7 +426,8 @@ export async function handoffAfterPlan(
411
426
  }
412
427
  // Mid-turn, the turn's own end decides.
413
428
  if (env.turnRunning?.()) return false;
414
- if (!fs.existsSync(path.join(s.worktree_path, PLAN_FILE))) {
429
+ const found = await readPlan(s);
430
+ if (found.text === null) {
415
431
  const w = stateOf(sessionId);
416
432
  const now = (env.now ?? Date.now)();
417
433
  const limit = env.waitMs ?? PLAN_BACKGROUND_WAIT_MS;
@@ -458,7 +474,7 @@ export async function handoffAfterPlan(
458
474
  // A plan-only task never goes on by itself, in No approvals mode too: it
459
475
  // was asked to stop and show the plan. Pieces that couldn't be registered
460
476
  // always hold a plan-first one: building on a guess is the bug.
461
- const planText = readPlan(s);
477
+ const planText = found.text;
462
478
  const pieceErrors =
463
479
  (planText && !s.plan_waits_for_caller
464
480
  ? splitOf(s, planText)?.errors
@@ -469,13 +485,14 @@ export async function handoffAfterPlan(
469
485
  "planning",
470
486
  send,
471
487
  undefined,
472
- retried
488
+ retried,
489
+ found.stale
473
490
  );
474
491
  return ref !== null;
475
492
  }
476
493
  if (!advancePlan(db, sessionId, "planning", "awaiting")) return false;
477
494
  try {
478
- announcePlan(s, null, retried);
495
+ announcePlan(s, null, retried, found.stale);
479
496
  } catch (e) {
480
497
  console.error("plan-done event failed:", e);
481
498
  }
@@ -490,8 +507,7 @@ export async function handoffAfterPlan(
490
507
  // The machine that placed a plan sends its own push.
491
508
  try {
492
509
  if (!s.plan_waits_for_caller) {
493
- const text = readPlan(s);
494
- const planned = text ? planFor(s, text) : null;
510
+ const planned = planText ? planFor(s, planText) : null;
495
511
  (await import("../notify/push/events")).pushPlanAwaiting(
496
512
  s.id,
497
513
  s.name,
@@ -36,6 +36,9 @@ async function cardText(task: Session): Promise<string> {
36
36
  return task.task_prompt ?? task.name;
37
37
  }
38
38
 
39
+ export const isTaskStatePath = (p: string) =>
40
+ p === ".agentos" || p.startsWith(".agentos/");
41
+
39
42
  export async function checkScope(input: {
40
43
  workspaceId: string;
41
44
  task: Session;
@@ -53,6 +56,13 @@ export async function checkScope(input: {
53
56
  // Claimed before the first await: written in the same tick as the
54
57
  // review's verdict, so no review call sees neither.
55
58
  putCheck({ ...base, status: "running", detail: null });
59
+ const taskState = input.files.filter((f) => isTaskStatePath(f.path));
60
+ if (taskState.length)
61
+ return putCheck({
62
+ ...base,
63
+ status: "block",
64
+ detail: `Commits task state under .agentos/ (${taskState.map((f) => f.path).join(", ")}); the plan folder is never part of a change.`,
65
+ });
56
66
  const amended = input.amendments?.length
57
67
  ? `\n\nScope changes recorded since the task started, oldest first; they win over the card, and a later one over an earlier one:\n${fence("amendments", input.amendments.map((a, i) => `${i + 1}. ${a}`).join("\n"))}`
58
68
  : "";
@@ -0,0 +1,27 @@
1
+ // Every setting's default, in one pure module the server's constants and the
2
+ // settings UI both import, so the two can't disagree (lib/settings/defaults.test).
3
+ // Context sizes are in tokens; 0 is off.
4
+
5
+ export const SETTING_DEFAULTS = {
6
+ fresh: {
7
+ orchestratorTokens: 200_000,
8
+ taskTokens: 0,
9
+ compactTokens: 150_000,
10
+ autoCompactTokens: 300_000,
11
+ interactiveAutoCompactTokens: 300_000,
12
+ },
13
+ // What a switch turns on with when the default is off.
14
+ freshOnValue: 200_000,
15
+ stallMinutes: 8,
16
+ awayWindowMinutes: 60,
17
+ // Off by default; what the switch turns on with.
18
+ taskLimitOn: 3,
19
+ sweepDays: 7,
20
+ } as const;
21
+
22
+ export type FreshKey = keyof typeof SETTING_DEFAULTS.fresh;
23
+
24
+ export function freshOnValue(key: FreshKey): number {
25
+ const d = SETTING_DEFAULTS.fresh[key];
26
+ return d > 0 ? d : SETTING_DEFAULTS.freshOnValue;
27
+ }
@@ -0,0 +1,17 @@
1
+ // A typed number, checked before anything is sent. An invalid one stays in
2
+ // the field with this message; nothing reaches the server.
3
+ export function parseSettingNumber(
4
+ text: string,
5
+ { min, max, integer = true }: { min: number; max?: number; integer?: boolean }
6
+ ): { value: number } | { error: string } {
7
+ const t = text.trim();
8
+ const n = Number(t);
9
+ const range = max === undefined ? `${min} or more` : `from ${min} to ${max}`;
10
+ const msg = integer
11
+ ? `Enter a whole number ${range}.`
12
+ : `Enter a number ${range}.`;
13
+ if (!t || !Number.isFinite(n)) return { error: msg };
14
+ if (integer && !Number.isInteger(n)) return { error: msg };
15
+ if (n < min || (max !== undefined && n > max)) return { error: msg };
16
+ return { value: n };
17
+ }
@@ -8,6 +8,7 @@ export const SETTINGS_SECTIONS = [
8
8
  "merging",
9
9
  "approvals",
10
10
  "workspaces",
11
+ "context",
11
12
  "models",
12
13
  "agent-roles",
13
14
  "devices",
@@ -94,6 +94,7 @@ export type UpdateRefusal =
94
94
  | "up_to_date"
95
95
  | "unknown"
96
96
  | "no_restart"
97
+ | "managed"
97
98
  | "disk"
98
99
  | "forbidden";
99
100
 
@@ -173,6 +174,7 @@ export function bannerState({
173
174
  return "updated";
174
175
  }
175
176
  if (view.pending) return "pending";
177
+ if (view.canUpdate.code === "managed") return "hidden";
176
178
  if (
177
179
  view.available &&
178
180
  view.bannerEnabled &&