@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.
Files changed (74) 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/sessions/route.ts +7 -1
  4. package/app/globals.css +14 -0
  5. package/components/Chat/ChatPanel.tsx +41 -29
  6. package/components/Chat/Forks.tsx +16 -1
  7. package/components/Chat/Messages.tsx +14 -0
  8. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  9. package/components/Chat/composer/ComposerBody.tsx +10 -1
  10. package/components/Chat/composer/ForkHeader.tsx +26 -15
  11. package/components/Chat/fork-draft.test.ts +57 -0
  12. package/components/Chat/fork-draft.ts +34 -0
  13. package/components/Devices/DeviceRow.tsx +83 -69
  14. package/components/Devices/PasskeysSection.test.ts +89 -0
  15. package/components/Devices/PasskeysSection.tsx +36 -26
  16. package/components/Health/HealthPanel.tsx +41 -16
  17. package/components/Merge/GlobalMergeSection.tsx +2 -5
  18. package/components/Merge/MergeSettingsFields.tsx +39 -77
  19. package/components/Merge/ProjectMergeSection.tsx +2 -6
  20. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  21. package/components/Models/AgentRoleCard.tsx +61 -66
  22. package/components/Models/AgentRolesSection.tsx +5 -8
  23. package/components/Models/ModelsSection.tsx +7 -18
  24. package/components/Models/PriceInput.tsx +59 -0
  25. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  26. package/components/Settings/AppWideLimits.tsx +61 -0
  27. package/components/Settings/ContextSection.tsx +96 -0
  28. package/components/Settings/ExperimentsSection.test.ts +9 -7
  29. package/components/Settings/ExperimentsSection.tsx +22 -23
  30. package/components/Settings/InlineConfirm.tsx +51 -0
  31. package/components/Settings/OwnerNameRow.tsx +10 -40
  32. package/components/Settings/PickerSetting.tsx +59 -0
  33. package/components/Settings/SettingRow.tsx +131 -0
  34. package/components/Settings/SettingsDialog.test.ts +1 -0
  35. package/components/Settings/SettingsDialog.tsx +47 -2
  36. package/components/Settings/settings-fields.test.ts +103 -0
  37. package/components/Settings/settings-fields.tsx +427 -0
  38. package/components/Shares/SharedLinksPanel.tsx +62 -53
  39. package/components/Workspaces/HqFolderForm.tsx +21 -54
  40. package/components/Workspaces/McpServersForm.tsx +20 -19
  41. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  42. package/components/Workspaces/RulesForm.test.ts +127 -0
  43. package/components/Workspaces/RulesForm.tsx +54 -126
  44. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  45. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  46. package/content/whats-new.json +32 -0
  47. package/data/fresh-start/index.ts +1 -1
  48. package/data/workspaces/index.ts +9 -0
  49. package/hooks/useAutoSave.test.ts +116 -0
  50. package/hooks/useAutoSave.ts +98 -0
  51. package/lib/away/window-limits.ts +3 -1
  52. package/lib/chat/refresh.ts +10 -6
  53. package/lib/chat/socket.ts +7 -3
  54. package/lib/chat/stall-limits.ts +3 -1
  55. package/lib/chat/start-error.ts +13 -0
  56. package/lib/chat/worker/cwd.ts +77 -0
  57. package/lib/chat/worker/host.ts +11 -2
  58. package/lib/chat/worker/main.ts +11 -2
  59. package/lib/db/types.ts +2 -0
  60. package/lib/fork/note.ts +6 -0
  61. package/lib/fork/start.ts +6 -2
  62. package/lib/fork/types.ts +2 -0
  63. package/lib/fork/view.ts +14 -1
  64. package/lib/settings/defaults.ts +27 -0
  65. package/lib/settings/parse.ts +17 -0
  66. package/lib/settings-url.ts +1 -0
  67. package/lib/worktree-sweep/limits.ts +3 -0
  68. package/lib/worktree-sweep/settings.ts +4 -2
  69. package/package.json +1 -1
  70. package/components/Away/AwayWindowForm.tsx +0 -91
  71. package/components/Workspaces/FreshStartForm.tsx +0 -177
  72. package/components/Workspaces/StallForm.tsx +0 -101
  73. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  74. package/components/Workspaces/fresh-start.ts +0 -19
@@ -1,17 +1,20 @@
1
1
  "use client";
2
2
 
3
- import { SettingToggle } from "@/components/Settings/SettingToggle";
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 { AwayWindowForm } from "@/components/Away/AwayWindowForm";
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
- <TaskLimitForm
47
- key={`${w.id}:${w.max_running_tasks ?? "off"}`}
48
- workspace={w}
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
- <SettingToggle
57
- title="Wake tasks on CI failures and reviews"
58
- detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
59
- checked={w.wake_tasks_on_pr}
60
- onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
61
- />
62
- <SettingToggle
63
- title="Orchestrator full access"
64
- detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly, and edit .claude files (skills, settings, hooks). Applies when its conversation next restarts. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
65
- checked={w.orch_full_access}
66
- onChange={(on) => update.mutate({ id: w.id, orchFullAccess: on })}
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
- <FreshStartForm />
71
- <StallForm />
72
- <AwayWindowForm />
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
  }
@@ -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: "PUT",
27
+ method: "PATCH",
28
28
  headers: { "Content-Type": "application/json" },
29
29
  body: JSON.stringify(settings),
30
30
  })
@@ -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 = 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(
@@ -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(err instanceof Error ? err.message : String(err)));
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: err instanceof Error ? err.message : String(err),
161
+ message: startFailureMessage(err),
161
162
  });
162
- fail(err);
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.
@@ -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,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
+ }
@@ -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 = this.refreshedNote ?? (this.restartedNote && RESTARTED_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;
@@ -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 session = await (
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;