@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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/app/api/health/route.ts +4 -2
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- package/components/SessionList/GroupShelves.tsx +8 -3
- package/components/SessionList/OrchestratorPinRow.tsx +2 -2
- package/components/SessionList/RowParts.tsx +39 -35
- package/components/SessionList/RowRename.test.ts +86 -17
- package/components/SessionList/RowShell.tsx +2 -5
- package/components/SessionList/SessionRow.tsx +0 -1
- package/components/Settings/AppWideLimits.tsx +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +168 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +428 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +22 -53
- package/components/Workspaces/McpServersForm.tsx +21 -28
- package/components/Workspaces/ProjectTiersForm.tsx +28 -13
- package/components/Workspaces/RuleEditor.tsx +86 -0
- package/components/Workspaces/RulesForm.test.ts +154 -0
- package/components/Workspaces/RulesForm.tsx +116 -117
- package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
- package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
- package/components/Workspaces/WorkspacePanel.tsx +73 -0
- package/components/Workspaces/WorkspacePicker.tsx +44 -0
- package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
- package/components/Workspaces/WorkspaceSettings.tsx +63 -52
- package/components/Workspaces/useSettingsWorkspace.ts +47 -0
- package/content/whats-new.json +40 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/deploy.ts +34 -7
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/models/plan-fresh.ts +61 -0
- package/lib/models/plan-step.ts +40 -24
- package/lib/orchestrator/scope.ts +10 -0
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +2 -0
- package/lib/updates/run.ts +9 -0
- package/lib/updates/view.ts +28 -6
- package/lib/worktree-deps/paths.ts +9 -4
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/lib/worktrees.ts +3 -0
- package/package.json +1 -1
- package/scripts/autodeploy +11 -0
- package/scripts/autodeploy.test.ts +30 -0
- package/scripts/lib/update-verify.mjs +11 -0
- package/scripts/update-run.mjs +29 -0
- package/scripts/update-run.test.ts +62 -4
- package/components/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- 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
|
+
}
|
package/lib/away/deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -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 →
|
|
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 =
|
|
25
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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(
|
package/lib/chat/stall-limits.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|
package/lib/settings-url.ts
CHANGED
package/lib/updates/banner.ts
CHANGED
|
@@ -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 &&
|