@saadnvd1/agent-os 0.3.833 → 0.3.839
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/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/components/Chat/ChatPanel.tsx +41 -29
- package/components/Chat/Forks.tsx +16 -1
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +10 -1
- package/components/Chat/composer/ForkHeader.tsx +26 -15
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -0
- 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/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 +131 -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 +427 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +21 -54
- package/components/Workspaces/McpServersForm.tsx +20 -19
- package/components/Workspaces/ProjectTiersForm.tsx +10 -8
- package/components/Workspaces/RulesForm.test.ts +127 -0
- package/components/Workspaces/RulesForm.tsx +54 -126
- package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
- package/components/Workspaces/WorkspaceSettings.tsx +61 -24
- package/content/whats-new.json +32 -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/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/socket.ts +7 -3
- package/lib/chat/stall-limits.ts +3 -1
- package/lib/chat/start-error.ts +13 -0
- package/lib/chat/worker/cwd.ts +77 -0
- package/lib/chat/worker/host.ts +11 -2
- package/lib/chat/worker/main.ts +11 -2
- package/lib/db/types.ts +2 -0
- package/lib/fork/note.ts +6 -0
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +14 -1
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/package.json +1 -1
- 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
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { settingsUiActions } from "@/stores/settingsUi";
|
|
4
4
|
import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
5
|
-
import { TaskLimitForm } from "./TaskLimitForm";
|
|
6
5
|
import { RulesForm } from "./RulesForm";
|
|
7
|
-
import { FreshStartForm } from "./FreshStartForm";
|
|
8
6
|
import { McpServersForm } from "./McpServersForm";
|
|
9
7
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
8
|
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
11
|
-
import { StallForm } from "./StallForm";
|
|
12
9
|
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
13
10
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
14
|
-
import {
|
|
11
|
+
import { AppWideLimits } from "@/components/Settings/AppWideLimits";
|
|
12
|
+
import {
|
|
13
|
+
NumberSetting,
|
|
14
|
+
SwitchSetting,
|
|
15
|
+
} from "@/components/Settings/settings-fields";
|
|
16
|
+
import { SettingSection } from "@/components/Settings/SettingRow";
|
|
17
|
+
import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
|
|
15
18
|
|
|
16
19
|
// Settings > Workspaces: each workspace's own settings.
|
|
17
20
|
export function WorkspaceSettings() {
|
|
@@ -43,33 +46,67 @@ export function WorkspaceSettings() {
|
|
|
43
46
|
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
44
47
|
>
|
|
45
48
|
<h3 className="truncate text-sm font-medium">{w.name}</h3>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
<SettingSection>
|
|
50
|
+
<NumberSetting
|
|
51
|
+
id={`task-limit-${w.id}`}
|
|
52
|
+
label="Tasks at a time"
|
|
53
|
+
help="Cap on tasks running at once. The rest wait in Queued and start in order."
|
|
54
|
+
value={w.max_running_tasks ?? 0}
|
|
55
|
+
defaultValue={0}
|
|
56
|
+
onSave={(n) =>
|
|
57
|
+
update.mutateAsync({
|
|
58
|
+
id: w.id,
|
|
59
|
+
maxRunningTasks: n === 0 ? null : n,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
min={1}
|
|
63
|
+
integer
|
|
64
|
+
unit="at a time"
|
|
65
|
+
optional={{ off: 0, on: SETTING_DEFAULTS.taskLimitOn }}
|
|
66
|
+
/>
|
|
67
|
+
</SettingSection>
|
|
50
68
|
<WorkspaceModeForm workspaceId={w.id} />
|
|
51
69
|
<RulesForm workspaceId={w.id} />
|
|
52
70
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
53
71
|
<McpServersForm workspace={w} />
|
|
54
72
|
<HqFolderForm workspaceId={w.id} />
|
|
55
73
|
<ProjectTiersForm workspaceId={w.id} />
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
<SettingSection>
|
|
75
|
+
<SwitchSetting
|
|
76
|
+
id={`wake-${w.id}`}
|
|
77
|
+
label="Wake tasks on CI failures and reviews"
|
|
78
|
+
help="A task's agent is messaged once when a check fails on its PR, and when someone reviews or comments."
|
|
79
|
+
checked={w.wake_tasks_on_pr}
|
|
80
|
+
onSave={(on) =>
|
|
81
|
+
update.mutateAsync({ id: w.id, wakeTasksOnPR: on })
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
<SwitchSetting
|
|
85
|
+
id={`full-access-${w.id}`}
|
|
86
|
+
label="Orchestrator full access"
|
|
87
|
+
help="Its shell and file writes aren't checked. Applies when its conversation next restarts."
|
|
88
|
+
checked={w.orch_full_access}
|
|
89
|
+
confirm="Let this orchestrator act without approval? It can push, commit, edit any repository or file on this machine, merge a PR directly, and edit .claude files. Off: the deny list, write limits and merge-only-through-the-gates apply."
|
|
90
|
+
confirmLabel="Turn on full access"
|
|
91
|
+
onSave={(on) =>
|
|
92
|
+
update.mutateAsync({ id: w.id, orchFullAccess: on })
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
</SettingSection>
|
|
68
96
|
</section>
|
|
69
97
|
))}
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
98
|
+
<AppWideLimits />
|
|
99
|
+
<p className="text-muted-foreground text-sm">
|
|
100
|
+
Fresh starts and compaction are app-wide:{" "}
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="text-primary underline-offset-2 hover:underline"
|
|
104
|
+
onClick={() => settingsUiActions.show("context")}
|
|
105
|
+
>
|
|
106
|
+
open Context
|
|
107
|
+
</button>
|
|
108
|
+
.
|
|
109
|
+
</p>
|
|
73
110
|
</div>
|
|
74
111
|
);
|
|
75
112
|
}
|
package/content/whats-new.json
CHANGED
|
@@ -1953,6 +1953,38 @@
|
|
|
1953
1953
|
"area": "AgentOS",
|
|
1954
1954
|
"text": "Select text in a chat reply and tap Fork to hand it to a background copy of the session; its result comes back as a card.",
|
|
1955
1955
|
"url": "https://github.com/saadnvd1/agent-os/pull/558"
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
"id": 560,
|
|
1959
|
+
"date": "2026-11-02",
|
|
1960
|
+
"kind": "fixed",
|
|
1961
|
+
"area": "Chat",
|
|
1962
|
+
"text": "You can ask follow-up questions in a task's chat after its work has merged, with a note that changes need a new task.",
|
|
1963
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/560"
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
"id": 561,
|
|
1967
|
+
"date": "2026-11-02",
|
|
1968
|
+
"kind": "improved",
|
|
1969
|
+
"area": "AgentOS",
|
|
1970
|
+
"text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
|
|
1971
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/561"
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
"id": 562,
|
|
1975
|
+
"date": "2026-11-02",
|
|
1976
|
+
"kind": "improved",
|
|
1977
|
+
"area": "Settings",
|
|
1978
|
+
"text": "Settings now save as you change them, with a clear \"Saved\" note, undo and reset, and Context settings have their own page.",
|
|
1979
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/562"
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
"id": 563,
|
|
1983
|
+
"date": "2026-11-02",
|
|
1984
|
+
"kind": "improved",
|
|
1985
|
+
"area": "Settings",
|
|
1986
|
+
"text": "More settings now save as you change them, and revoking a passkey or link asks inline instead of with a browser pop-up.",
|
|
1987
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/563"
|
|
1956
1988
|
}
|
|
1957
1989
|
]
|
|
1958
1990
|
}
|
|
@@ -24,7 +24,7 @@ export function useUpdateFreshStart() {
|
|
|
24
24
|
mutationFn: async (settings: Partial<RefreshSettings>) =>
|
|
25
25
|
json<RefreshSettings>(
|
|
26
26
|
await fetch("/api/fresh-start", {
|
|
27
|
-
method: "
|
|
27
|
+
method: "PATCH",
|
|
28
28
|
headers: { "Content-Type": "application/json" },
|
|
29
29
|
body: JSON.stringify(settings),
|
|
30
30
|
})
|
package/data/workspaces/index.ts
CHANGED
|
@@ -87,6 +87,9 @@ export function useUpdateWorkspace() {
|
|
|
87
87
|
...updates
|
|
88
88
|
}) => {
|
|
89
89
|
await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
|
|
90
|
+
const previous = queryClient.getQueryData<Workspace[]>(
|
|
91
|
+
workspaceKeys.list()
|
|
92
|
+
);
|
|
90
93
|
queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
|
|
91
94
|
prev?.map((w) =>
|
|
92
95
|
w.id === id
|
|
@@ -109,6 +112,12 @@ export function useUpdateWorkspace() {
|
|
|
109
112
|
: w
|
|
110
113
|
)
|
|
111
114
|
);
|
|
115
|
+
return { previous };
|
|
116
|
+
},
|
|
117
|
+
// A rejected change rolls back to what the server holds.
|
|
118
|
+
onError: (_err, _vars, context) => {
|
|
119
|
+
if (context?.previous)
|
|
120
|
+
queryClient.setQueryData(workspaceKeys.list(), context.previous);
|
|
112
121
|
},
|
|
113
122
|
onSettled: () =>
|
|
114
123
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.all }),
|
|
@@ -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
|
+
}
|
|
@@ -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/socket.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { setComposerModel } from "../models/session-model";
|
|
33
33
|
import { stopChatTurn } from "./stop";
|
|
34
34
|
import { clientSend } from "./client-send";
|
|
35
|
+
import { startFailureMessage } from "./start-error";
|
|
35
36
|
import { searchChat } from "./search";
|
|
36
37
|
import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
|
|
37
38
|
import { titleChatFromMessage } from "../session-titles";
|
|
@@ -97,7 +98,7 @@ export function serveChatSocket(
|
|
|
97
98
|
const fail = (err: unknown) => {
|
|
98
99
|
console.error(`[chat ${sessionId}] message failed:`, err);
|
|
99
100
|
if (!sessionExists(sessionId)) return gone();
|
|
100
|
-
reply(errorItem(
|
|
101
|
+
reply(errorItem(startFailureMessage(err)));
|
|
101
102
|
};
|
|
102
103
|
|
|
103
104
|
if (!sessionExists(sessionId)) return gone();
|
|
@@ -157,9 +158,12 @@ function onChatMessage(
|
|
|
157
158
|
reply({
|
|
158
159
|
type: "send_failed",
|
|
159
160
|
id,
|
|
160
|
-
message:
|
|
161
|
+
message: startFailureMessage(err),
|
|
161
162
|
});
|
|
162
|
-
|
|
163
|
+
// A message with an id shows its failure on itself; the rest of the
|
|
164
|
+
// chat hears of it once, here.
|
|
165
|
+
if (id) console.error(`[chat ${sessionId}] send failed:`, err);
|
|
166
|
+
else fail(err);
|
|
163
167
|
}
|
|
164
168
|
);
|
|
165
169
|
// "Session 4" is named after its first message.
|
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,13 @@
|
|
|
1
|
+
// What a person reads when a chat's worker can't start. The raw error (a
|
|
2
|
+
// socket ENOENT, usually) stays in the server log.
|
|
3
|
+
export function startFailureMessage(error: unknown): string {
|
|
4
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
5
|
+
if (!/^The chat worker didn't start/.test(raw)) return raw;
|
|
6
|
+
const reason = /ENOENT|ECONNREFUSED/.test(raw)
|
|
7
|
+
? "it stopped while starting"
|
|
8
|
+
: raw
|
|
9
|
+
.replace(/^The chat worker didn't start:?\s*/, "")
|
|
10
|
+
.split("\n")[0]
|
|
11
|
+
.slice(0, 120) || "it didn't answer";
|
|
12
|
+
return `Couldn't start the agent: ${reason}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getProject } from "../../projects";
|
|
5
|
+
import type { Session } from "../../db";
|
|
6
|
+
|
|
7
|
+
const expand = (p: string) => p.replace(/^~/, os.homedir());
|
|
8
|
+
const isDir = (p: string) => {
|
|
9
|
+
try {
|
|
10
|
+
return fs.statSync(p).isDirectory();
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const WORKTREE_REMOVED_NOTE =
|
|
17
|
+
"(This task's PR merged and its worktree was removed. You are now running in the project's main checkout: do not edit files or commit here. Questions are fine; if more changes are needed, say a follow-up task is required.)";
|
|
18
|
+
|
|
19
|
+
// The project's own checkout, for a session whose folder is gone; null when
|
|
20
|
+
// that is missing too or the session never had a project.
|
|
21
|
+
function projectRoot(session: Session): string | null {
|
|
22
|
+
const project = session.project_id ? getProject(session.project_id) : null;
|
|
23
|
+
const root = project ? expand(project.working_directory) : null;
|
|
24
|
+
return root && isDir(root) ? root : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Where a session's agent runs. A task's worktree is removed when it
|
|
28
|
+
// merges, but its chat can still be asked a question: it runs in the
|
|
29
|
+
// project's checkout instead.
|
|
30
|
+
export function resolveChatCwd(session: Session): {
|
|
31
|
+
session: Session;
|
|
32
|
+
removedFrom: string | null;
|
|
33
|
+
} {
|
|
34
|
+
const dir = expand(session.working_directory);
|
|
35
|
+
if (isDir(dir)) return { session, removedFrom: null };
|
|
36
|
+
const root = projectRoot(session);
|
|
37
|
+
if (!root) return { session, removedFrom: null };
|
|
38
|
+
return {
|
|
39
|
+
session: { ...session, working_directory: root },
|
|
40
|
+
removedFrom: dir,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Claude finds a conversation under its folder's name in its config dir, so
|
|
45
|
+
// a resume from a new folder needs the transcript copied there.
|
|
46
|
+
export function carryClaudeTranscript(
|
|
47
|
+
from: string,
|
|
48
|
+
to: string,
|
|
49
|
+
claudeSessionId: string | null
|
|
50
|
+
): void {
|
|
51
|
+
if (!claudeSessionId) return;
|
|
52
|
+
const base = path.join(
|
|
53
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"),
|
|
54
|
+
"projects"
|
|
55
|
+
);
|
|
56
|
+
const name = (d: string) => d.replace(/[^a-zA-Z0-9]/g, "-");
|
|
57
|
+
const src = path.join(base, name(from), `${claudeSessionId}.jsonl`);
|
|
58
|
+
const dest = path.join(base, name(to), `${claudeSessionId}.jsonl`);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(src) || fs.existsSync(dest)) return;
|
|
61
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
62
|
+
fs.copyFileSync(src, dest);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(
|
|
65
|
+
"Couldn't carry the transcript to the project folder:",
|
|
66
|
+
error
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A finished task whose worktree is gone: the sidebar's cue for the notice.
|
|
72
|
+
export function worktreeRemoved(session: Session): boolean {
|
|
73
|
+
if (!session.worktree_path || !session.task_status) return false;
|
|
74
|
+
if (session.task_status === "running") return false;
|
|
75
|
+
if (session.host_id && session.host_id !== "local") return false;
|
|
76
|
+
return !isDir(expand(session.worktree_path));
|
|
77
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -8,6 +8,7 @@ import os from "os";
|
|
|
8
8
|
import { db, type Session } from "../../db";
|
|
9
9
|
import { agentEnv } from "../../agents/launch";
|
|
10
10
|
import { forkBrief } from "../../fork/extras";
|
|
11
|
+
import { WORKTREE_REMOVED_NOTE } from "./cwd";
|
|
11
12
|
import { sessionRunningBrief } from "../../project-config/session";
|
|
12
13
|
import { BUS_BRIEF } from "../../agents/brief";
|
|
13
14
|
import { resolveModelForAgent } from "../../model-catalog";
|
|
@@ -196,7 +197,9 @@ export class ChatHost {
|
|
|
196
197
|
private session: Session,
|
|
197
198
|
private emit: (e: WorkerEvent) => void,
|
|
198
199
|
// What a session's role adds: its brief and its own tools.
|
|
199
|
-
extras: RoleExtras = {}
|
|
200
|
+
extras: RoleExtras = {},
|
|
201
|
+
// Its worktree was removed: it runs in the project's checkout.
|
|
202
|
+
private worktreeGone = false
|
|
200
203
|
) {
|
|
201
204
|
this.gate = new GuestGate((note) => {
|
|
202
205
|
if (!this.busy()) return;
|
|
@@ -611,7 +614,13 @@ export class ChatHost {
|
|
|
611
614
|
if (handOver) this.stopsUpTo = this.turnsStarted;
|
|
612
615
|
if (now) this.nowPending++;
|
|
613
616
|
this.stopping = null;
|
|
614
|
-
const note =
|
|
617
|
+
const note =
|
|
618
|
+
[
|
|
619
|
+
this.worktreeGone && WORKTREE_REMOVED_NOTE,
|
|
620
|
+
this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE),
|
|
621
|
+
]
|
|
622
|
+
.filter(Boolean)
|
|
623
|
+
.join("\n") || null;
|
|
615
624
|
const text = agentText(note ? `${note}\n\n${m.text}` : m.text, m.files);
|
|
616
625
|
if (this.refreshedNote) {
|
|
617
626
|
this.refreshedNote = null;
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -41,9 +41,17 @@ async function main(sessionId: string) {
|
|
|
41
41
|
console.error(`No session ${sessionId}`);
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
|
-
const
|
|
44
|
+
const routed = await (
|
|
45
45
|
await import("../../models/launch")
|
|
46
46
|
).routeOrchestrator(row);
|
|
47
|
+
const { resolveChatCwd, carryClaudeTranscript } = await import("./cwd");
|
|
48
|
+
const { session, removedFrom } = resolveChatCwd(routed);
|
|
49
|
+
if (removedFrom && session.agent_type === "claude")
|
|
50
|
+
carryClaudeTranscript(
|
|
51
|
+
removedFrom,
|
|
52
|
+
session.working_directory,
|
|
53
|
+
session.claude_session_id
|
|
54
|
+
);
|
|
47
55
|
|
|
48
56
|
const clients = new Set<net.Socket>();
|
|
49
57
|
const emit = (e: WorkerEvent) => {
|
|
@@ -60,7 +68,8 @@ async function main(sessionId: string) {
|
|
|
60
68
|
emit(e);
|
|
61
69
|
if (e.type === "state") armIdle();
|
|
62
70
|
},
|
|
63
|
-
extras
|
|
71
|
+
extras,
|
|
72
|
+
!!removedFrom
|
|
64
73
|
);
|
|
65
74
|
function armIdle() {
|
|
66
75
|
clearTimeout(idleTimer);
|
package/lib/db/types.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface Session {
|
|
|
87
87
|
// Task priority (null reads as normal) and timed pause (lib/tasks/pause.ts):
|
|
88
88
|
// pausing, paused or resuming, until when (null: until resumed).
|
|
89
89
|
priority?: string | null;
|
|
90
|
+
// Set only by GET /api/sessions: a finished task whose worktree is gone.
|
|
91
|
+
worktree_removed?: boolean;
|
|
90
92
|
pause_state?: "pausing" | "paused" | "resuming" | null;
|
|
91
93
|
paused_until?: string | null;
|
|
92
94
|
pause_id?: number | null;
|