@saadnvd1/agent-os 0.3.836 → 0.3.845

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/app/api/health/route.ts +4 -2
  4. package/components/Devices/DeviceRow.tsx +83 -69
  5. package/components/Devices/PasskeysSection.test.ts +89 -0
  6. package/components/Devices/PasskeysSection.tsx +36 -26
  7. package/components/Health/HealthPanel.tsx +41 -16
  8. package/components/Merge/GlobalMergeSection.tsx +2 -5
  9. package/components/Merge/MergeSettingsFields.tsx +39 -77
  10. package/components/Merge/ProjectMergeSection.tsx +2 -6
  11. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  12. package/components/Models/AgentRoleCard.tsx +61 -66
  13. package/components/Models/AgentRolesSection.tsx +5 -8
  14. package/components/Models/ModelsSection.tsx +7 -18
  15. package/components/Models/PriceInput.tsx +59 -0
  16. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  17. package/components/SessionList/GroupShelves.tsx +8 -3
  18. package/components/SessionList/OrchestratorPinRow.tsx +2 -2
  19. package/components/SessionList/RowParts.tsx +39 -35
  20. package/components/SessionList/RowRename.test.ts +86 -17
  21. package/components/SessionList/RowShell.tsx +2 -5
  22. package/components/SessionList/SessionRow.tsx +0 -1
  23. package/components/Settings/AppWideLimits.tsx +61 -0
  24. package/components/Settings/ContextSection.tsx +96 -0
  25. package/components/Settings/ExperimentsSection.test.ts +9 -7
  26. package/components/Settings/ExperimentsSection.tsx +22 -23
  27. package/components/Settings/InlineConfirm.tsx +51 -0
  28. package/components/Settings/OwnerNameRow.tsx +10 -40
  29. package/components/Settings/PickerSetting.tsx +59 -0
  30. package/components/Settings/SettingRow.tsx +168 -0
  31. package/components/Settings/SettingsDialog.test.ts +1 -0
  32. package/components/Settings/SettingsDialog.tsx +47 -2
  33. package/components/Settings/settings-fields.test.ts +103 -0
  34. package/components/Settings/settings-fields.tsx +428 -0
  35. package/components/Shares/SharedLinksPanel.tsx +62 -53
  36. package/components/Workspaces/HqFolderForm.tsx +22 -53
  37. package/components/Workspaces/McpServersForm.tsx +21 -28
  38. package/components/Workspaces/ProjectTiersForm.tsx +28 -13
  39. package/components/Workspaces/RuleEditor.tsx +86 -0
  40. package/components/Workspaces/RulesForm.test.ts +154 -0
  41. package/components/Workspaces/RulesForm.tsx +116 -117
  42. package/components/Workspaces/WorkspaceModeForm.tsx +3 -3
  43. package/components/Workspaces/WorkspaceModelsForm.tsx +36 -36
  44. package/components/Workspaces/WorkspacePanel.tsx +73 -0
  45. package/components/Workspaces/WorkspacePicker.tsx +44 -0
  46. package/components/Workspaces/WorkspaceSettings.test.ts +153 -0
  47. package/components/Workspaces/WorkspaceSettings.tsx +63 -52
  48. package/components/Workspaces/useSettingsWorkspace.ts +47 -0
  49. package/content/whats-new.json +40 -0
  50. package/data/fresh-start/index.ts +1 -1
  51. package/data/workspaces/index.ts +9 -0
  52. package/hooks/useAutoSave.test.ts +116 -0
  53. package/hooks/useAutoSave.ts +98 -0
  54. package/lib/away/deploy.ts +34 -7
  55. package/lib/away/window-limits.ts +3 -1
  56. package/lib/chat/refresh.ts +10 -6
  57. package/lib/chat/stall-limits.ts +3 -1
  58. package/lib/models/plan-fresh.ts +61 -0
  59. package/lib/models/plan-step.ts +40 -24
  60. package/lib/orchestrator/scope.ts +10 -0
  61. package/lib/settings/defaults.ts +27 -0
  62. package/lib/settings/parse.ts +17 -0
  63. package/lib/settings-url.ts +1 -0
  64. package/lib/updates/banner.ts +2 -0
  65. package/lib/updates/run.ts +9 -0
  66. package/lib/updates/view.ts +28 -6
  67. package/lib/worktree-deps/paths.ts +9 -4
  68. package/lib/worktree-sweep/limits.ts +3 -0
  69. package/lib/worktree-sweep/settings.ts +4 -2
  70. package/lib/worktrees.ts +3 -0
  71. package/package.json +1 -1
  72. package/scripts/autodeploy +11 -0
  73. package/scripts/autodeploy.test.ts +30 -0
  74. package/scripts/lib/update-verify.mjs +11 -0
  75. package/scripts/update-run.mjs +29 -0
  76. package/scripts/update-run.test.ts +62 -4
  77. package/components/Away/AwayWindowForm.tsx +0 -91
  78. package/components/Workspaces/FreshStartForm.tsx +0 -177
  79. package/components/Workspaces/StallForm.tsx +0 -101
  80. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  81. package/components/Workspaces/fresh-start.ts +0 -19
@@ -85,6 +85,8 @@ export interface RunDeps {
85
85
  freeBytes: () => number | null;
86
86
  /** True when scripts/autodeploy deploys this checkout. */
87
87
  managed: () => boolean;
88
+ /** True while scripts/autodeploy is deploying (its lock directory exists). */
89
+ deployLocked: () => boolean;
88
90
  /** What a restart would interrupt, as short labels; empty when nothing. */
89
91
  busy: () => Promise<string[]>;
90
92
  /** The database the runner backs up first, and where to. */
@@ -214,6 +216,7 @@ export function realRunDeps(): RunDeps {
214
216
  return false;
215
217
  }
216
218
  },
219
+ deployLocked: () => fs.existsSync(agentosHome("autodeploy", "lock")),
217
220
  busy: busyLabels,
218
221
  dbFile: path.resolve(dbPath()),
219
222
  backupDir: agentosHome("backups"),
@@ -355,6 +358,12 @@ export async function preflight(
355
358
  error:
356
359
  "AgentOS isn't running from a git checkout, so it can't update itself.",
357
360
  };
361
+ if (deps.managed() || deps.deployLocked())
362
+ return {
363
+ code: "managed",
364
+ error:
365
+ "This machine is updated by its own deploy job, so updating from here would race it. It deploys new versions by itself.",
366
+ };
358
367
  const run = readRun(deps);
359
368
  if (run && isActivePhase(run.phase))
360
369
  return {
@@ -1,7 +1,7 @@
1
1
  // The /api/updates payload: the cached check, the run, what's pending,
2
2
  // whether the caller may start an update, and what automatic updates are doing.
3
3
 
4
- import { isActivePhase, type UpdateView } from "./banner";
4
+ import { isActivePhase, type UpdateRun, type UpdateView } from "./banner";
5
5
  import { cachedCheck, checkForUpdate, reconcile } from "./check";
6
6
  import {
7
7
  autoView,
@@ -26,6 +26,27 @@ const redact = (lines: string[]) =>
26
26
  .replace(/\b[A-Za-z0-9_-]{32,}\b/g, "***")
27
27
  );
28
28
 
29
+ /**
30
+ * A failed or rolled-back run stops mattering once the running build has the
31
+ * commit it tried (autodeploy, or someone, put it or something newer there).
32
+ */
33
+ export async function runSuperseded(
34
+ run: UpdateRun | null,
35
+ deps: Pick<RunDeps, "git" | "buildId">
36
+ ): Promise<boolean> {
37
+ if (!run?.to || (run.phase !== "failed" && run.phase !== "rolled_back"))
38
+ return false;
39
+ const build = deps.buildId();
40
+ if (!/^[0-9a-f]{7,40}$/.test(build)) return false;
41
+ if (build === run.to) return true;
42
+ try {
43
+ await deps.git(["merge-base", "--is-ancestor", run.to, build]);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
29
50
  export async function updateView(
30
51
  { mayUpdate }: { mayUpdate: boolean },
31
52
  deps: RunDeps = realRunDeps()
@@ -45,11 +66,12 @@ export async function updateView(
45
66
  .result;
46
67
  check = await reconcile(check, deps.check);
47
68
  // The log can carry paths and remote URLs; only a device that may update sees it.
48
- const run =
49
- stored &&
50
- (stored.phase === "failed" ||
51
- stored.phase === "built" ||
52
- stored.phase === "rolled_back")
69
+ const run = (await runSuperseded(stored, deps))
70
+ ? null
71
+ : stored &&
72
+ (stored.phase === "failed" ||
73
+ stored.phase === "built" ||
74
+ stored.phase === "rolled_back")
53
75
  ? {
54
76
  ...stored,
55
77
  log: mayUpdate
@@ -22,13 +22,16 @@ export async function realExisting(p: string): Promise<string> {
22
22
 
23
23
  export const STAGING_SUFFIX = ".aos-partial";
24
24
 
25
- const EXCLUDE_LINE = `*${STAGING_SUFFIX}`;
25
+ const STAGING_LINE = `*${STAGING_SUFFIX}`;
26
26
 
27
27
  // Staging dirs (`node_modules.aos-partial`) sit beside their target, where git
28
28
  // would list them as untracked work. Written once to the repo's shared
29
29
  // info/exclude, so it covers every worktree and no .gitignore is touched.
30
30
  // Best effort: outside a repository there is nothing to ignore it in.
31
- export async function ignoreStaging(dir: string): Promise<void> {
31
+ export const ignoreStaging = (dir: string) => addExcludes(dir, [STAGING_LINE]);
32
+
33
+ // Lines written once to the shared info/exclude of the repo `dir` is in.
34
+ export async function addExcludes(dir: string, lines: string[]): Promise<void> {
32
35
  try {
33
36
  const common = (
34
37
  await git(dir, ["rev-parse", "--git-common-dir"], { timeout: 5000 })
@@ -36,10 +39,12 @@ export async function ignoreStaging(dir: string): Promise<void> {
36
39
  if (!common) return;
37
40
  const file = path.join(path.resolve(dir, common), "info", "exclude");
38
41
  const current = await fs.promises.readFile(file, "utf8").catch(() => "");
39
- if (current.split(/\r?\n/).some((l) => l.trim() === EXCLUDE_LINE)) return;
42
+ const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
43
+ const missing = lines.filter((l) => !have.has(l));
44
+ if (!missing.length) return;
40
45
  await fs.promises.mkdir(path.dirname(file), { recursive: true });
41
46
  const lead = current && !current.endsWith("\n") ? "\n" : "";
42
- await fs.promises.appendFile(file, `${lead}${EXCLUDE_LINE}\n`);
47
+ await fs.promises.appendFile(file, `${lead}${missing.join("\n")}\n`);
43
48
  } catch {
44
49
  // never fails a setup
45
50
  }
@@ -0,0 +1,3 @@
1
+ // The sweep's day range, client-safe: the server (./settings) and the
2
+ // Settings box share it.
3
+ export const MAX_SWEEP_DAYS = 365;
@@ -5,10 +5,12 @@
5
5
 
6
6
  import { z } from "zod";
7
7
  import { db } from "../db";
8
+ import { SETTING_DEFAULTS } from "../settings/defaults";
9
+ import { MAX_SWEEP_DAYS } from "./limits";
8
10
  import type { ResolvedDays } from "./types";
9
11
 
10
- export const DEFAULT_SWEEP_DAYS = 7;
11
- export const MAX_SWEEP_DAYS = 365;
12
+ export const DEFAULT_SWEEP_DAYS = SETTING_DEFAULTS.sweepDays;
13
+ export { MAX_SWEEP_DAYS } from "./limits";
12
14
 
13
15
  const GLOBAL_KEY = "worktree_sweep";
14
16
  const projectKey = (projectId: string) => `worktree_sweep.project.${projectId}`;
package/lib/worktrees.ts CHANGED
@@ -17,6 +17,7 @@ import { refuseRealStateUnderTest } from "./test-home-guard";
17
17
  import { installWorktreeHooks } from "./worktree-hooks";
18
18
  import { gitResult, isGitError, worktreeList } from "./git-exec";
19
19
  import { addWorktree } from "./worktree-add";
20
+ import { addExcludes } from "./worktree-deps/paths";
20
21
  import {
21
22
  markRemoving,
22
23
  removalKey,
@@ -172,6 +173,8 @@ export async function createWorktree(
172
173
  err
173
174
  )
174
175
  );
176
+ // Task state (the plan) must never be committed from a worktree.
177
+ await addExcludes(worktreePath, [".agentos/"]);
175
178
  return {
176
179
  worktreePath,
177
180
  branchName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.836",
3
+ "version": "0.3.845",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -33,6 +33,17 @@ mkdir -p "$state"
33
33
  # updates stand aside (lib/updates/run.ts managedByAutodeploy). Before the
34
34
  # lock: a long deploy still counts as managing it.
35
35
  touch "$state/heartbeat"
36
+ # An in-app update (lib/updates/run.ts) in progress owns this checkout: its
37
+ # pull would meet ours mid-way. Its state file is rewritten every 15 seconds,
38
+ # so an old one is a run that died. Quiet exit; the next minute retries.
39
+ for f in "${AGENTOS_UPDATE_STATE:-}" "${DB_PATH:+$(dirname "$DB_PATH")/agent-os-update.json}" \
40
+ "$HOME/.agent-os/agent-os-update.json" "$PWD/agent-os-update.json"; do
41
+ [ -n "$f" ] && [ -f "$f" ] || continue
42
+ [ -z "$(find "$f" -maxdepth 0 -mmin +30)" ] || continue
43
+ if grep -Eq '"phase" *: *"(starting|backing_up|pulling|installing|building|restarting|verifying|rolling_back)"' "$f"; then
44
+ exit 0
45
+ fi
46
+ done
36
47
  # A lock older than 30 minutes is from a run that died.
37
48
  for lock in lock publish.lock; do
38
49
  if [ -d "$state/$lock" ] && [ -n "$(find "$state/$lock" -maxdepth 0 -mmin +30)" ]; then
@@ -241,3 +241,33 @@ describe("autodeploy publish", () => {
241
241
  expect(fs.existsSync(lock)).toBe(false);
242
242
  });
243
243
  });
244
+
245
+ describe("autodeploy and an in-app update", () => {
246
+ it("exits quietly while an update is in progress, and deploys once it's done", () => {
247
+ const t = setup();
248
+ t.commit("a");
249
+ fs.mkdirSync(path.join(t.home, ".agent-os"), { recursive: true });
250
+ const file = path.join(t.home, ".agent-os/agent-os-update.json");
251
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "pulling" }));
252
+ expect(t.run()).toBe("");
253
+ expect(t.restarts()).toBe(0);
254
+ expect(fs.existsSync(path.join(t.state, "deployed"))).toBe(false);
255
+ // The heartbeat still says this machine is managed.
256
+ expect(fs.existsSync(path.join(t.state, "heartbeat"))).toBe(true);
257
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "done" }));
258
+ t.run();
259
+ expect(t.restarts()).toBe(1);
260
+ });
261
+
262
+ it("ignores the state file of a run that died long ago", () => {
263
+ const t = setup();
264
+ t.commit("a");
265
+ fs.mkdirSync(path.join(t.home, ".agent-os"), { recursive: true });
266
+ const file = path.join(t.home, ".agent-os/agent-os-update.json");
267
+ fs.writeFileSync(file, JSON.stringify({ id: "r", phase: "building" }));
268
+ const old = new Date(Date.now() - 60 * 60_000);
269
+ fs.utimesSync(file, old, old);
270
+ t.run();
271
+ expect(t.restarts()).toBe(1);
272
+ });
273
+ });
@@ -39,6 +39,9 @@ async function health(base, expect) {
39
39
  if (expect && build && build !== expect && !build.startsWith("v"))
40
40
  return {
41
41
  ok: false,
42
+ wrongBuild: true,
43
+ build,
44
+ startedAt,
42
45
  why: `it is running ${String(build).slice(0, 7)}, not ${expect.slice(0, 7)}`,
43
46
  };
44
47
  if (migrations && migrations.applied < migrations.known)
@@ -77,6 +80,14 @@ export async function verifyServer({ port, expect, env = process.env }) {
77
80
  for (let i = 0; i < checks; i++) {
78
81
  await sleep(every);
79
82
  const again = await health(base, expect);
83
+ // The expected build was up; now another one answers. Something else
84
+ // (a deploy job) restarted it: not a crash, and not ours to roll back over.
85
+ if (!again.ok && again.wrongBuild)
86
+ return {
87
+ ok: false,
88
+ otherRestart: true,
89
+ why: `something else restarted it into ${String(again.build).slice(0, 7)}, not ${expect.slice(0, 7)}`,
90
+ };
80
91
  if (!again.ok)
81
92
  return { ok: false, why: `it stopped answering: ${again.why}` };
82
93
  if (
@@ -9,11 +9,13 @@
9
9
  // AGENTOS_UPDATE_DB the database to back up first, into AGENTOS_UPDATE_BACKUPS
10
10
  // AGENTOS_UPDATE_VERIFY=0 skip the health check (tests)
11
11
  // AGENTOS_UPDATE_REDEPLOY the script to run (tests); default scripts/redeploy
12
+ // AGENTOS_AUTODEPLOY_DIR autodeploy's state dir (tests); default ~/.agent-os/autodeploy
12
13
  // AGENTOS_UPDATE_ROOT the checkout (tests); default this script's
13
14
  // Outcomes: done, built, build_failed (live server untouched), rolled_back,
14
15
  // broken (the rollback didn't come up either).
15
16
  import { execFileSync, spawn } from "node:child_process";
16
17
  import fs from "node:fs";
18
+ import os from "node:os";
17
19
  import path from "node:path";
18
20
  import readline from "node:readline";
19
21
  import { fileURLToPath } from "node:url";
@@ -149,6 +151,24 @@ function resetSource(from) {
149
151
  }
150
152
  }
151
153
 
154
+ /**
155
+ * scripts/autodeploy's "deployed" marker must not claim a build that isn't
156
+ * running: after a rollback it names the build that is. Only where autodeploy
157
+ * runs (its state directory exists).
158
+ */
159
+ function markDeployed(sha) {
160
+ const dir =
161
+ env.AGENTOS_AUTODEPLOY_DIR ||
162
+ path.join(os.homedir(), ".agent-os", "autodeploy");
163
+ if (!sha || !fs.existsSync(dir)) return;
164
+ try {
165
+ fs.writeFileSync(path.join(dir, "deployed"), `${sha}\n`);
166
+ say(`Recorded ${sha.slice(0, 7)} as the deployed build.`);
167
+ } catch (error) {
168
+ say(`Couldn't record the deployed build: ${error.message}`);
169
+ }
170
+ }
171
+
152
172
  async function main() {
153
173
  const heartbeat = setInterval(() => write({}), 15_000);
154
174
  write({ pid: process.pid });
@@ -184,6 +204,14 @@ async function main() {
184
204
  expect: env.AGENTOS_UPDATE_TO || head(),
185
205
  env,
186
206
  });
207
+ if (ok.otherRestart) {
208
+ say(`Not rolling back: ${ok.why}.`);
209
+ return finish(
210
+ "failed",
211
+ "interrupted",
212
+ `The update was built, but ${ok.why}. Nothing was rolled back; check which version is running.`
213
+ );
214
+ }
187
215
  if (!ok.ok) why = ok.why;
188
216
  }
189
217
  if (!why) return finish("done", "done");
@@ -205,6 +233,7 @@ async function main() {
205
233
  say(`The rolled-back version isn't healthy either: ${ok.why}`);
206
234
  }
207
235
  }
236
+ if (backOk) markDeployed(from);
208
237
  return backOk
209
238
  ? finish(
210
239
  "rolled_back",
@@ -78,20 +78,27 @@ describe("scripts/update-run.mjs", () => {
78
78
 
79
79
  // A stand-in for the restarted server: its build comes from a file the fake
80
80
  // redeploy writes, and its startedAt can be made to change (a crash loop).
81
- async function fakeServer(opts: { restarts?: boolean } = {}) {
81
+ async function fakeServer(
82
+ opts: { restarts?: boolean; switchAt?: number } = {}
83
+ ) {
82
84
  let n = 0;
85
+ let calls = 0;
83
86
  const server = http.createServer((req, res) => {
84
- const build = fs.readFileSync(path.join(dir, "build"), "utf8").trim();
87
+ let build = fs.readFileSync(path.join(dir, "build"), "utf8").trim();
85
88
  res.setHeader("content-type", "application/json");
86
- if (req.url === "/api/updates/health")
89
+ if (req.url === "/api/updates/health") {
90
+ // Something else (a deploy job) restarts it into another build.
91
+ const other = opts.switchAt !== undefined && calls++ >= opts.switchAt;
92
+ if (other) build = "other";
87
93
  return res.end(
88
94
  JSON.stringify({
89
95
  ok: true,
90
96
  build,
91
- startedAt: opts.restarts ? `s${n++}` : "s",
97
+ startedAt: opts.restarts ? `s${n++}` : other ? "s-other" : "s",
92
98
  migrations: { known: 3, applied: 3 },
93
99
  })
94
100
  );
101
+ }
95
102
  res.end("{}");
96
103
  });
97
104
  await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
@@ -170,6 +177,57 @@ describe("scripts/update-run.mjs checks the new server", () => {
170
177
  }
171
178
  });
172
179
 
180
+ it("a restart into a different build is left alone: no rollback, nothing marked", async () => {
181
+ fs.writeFileSync(path.join(dir, "next-build"), "new");
182
+ fs.writeFileSync(path.join(dir, "rollback-works"), "");
183
+ const srv = await fakeServer({ switchAt: 2 });
184
+ try {
185
+ const s = await go(REDEPLOY, "x", verifying(srv.port, "new"));
186
+ expect(s).toMatchObject({ phase: "failed", outcome: "interrupted" });
187
+ expect(s.error).toContain("something else restarted it");
188
+ expect(rolled()).toBeNull();
189
+ } finally {
190
+ await srv.close();
191
+ }
192
+ });
193
+
194
+ it("a rollback records the build it went back to as autodeploy's deployed marker", async () => {
195
+ fs.writeFileSync(path.join(dir, "next-build"), "stale");
196
+ fs.writeFileSync(path.join(dir, "rollback-works"), "");
197
+ const state = path.join(dir, "autodeploy");
198
+ fs.mkdirSync(state);
199
+ fs.writeFileSync(path.join(state, "deployed"), "f5054a0\n");
200
+ const srv = await fakeServer();
201
+ try {
202
+ const s = await go(REDEPLOY, "x", {
203
+ ...verifying(srv.port, "new"),
204
+ AGENTOS_AUTODEPLOY_DIR: state,
205
+ });
206
+ expect(s.outcome).toBe("rolled_back");
207
+ expect(fs.readFileSync(path.join(state, "deployed"), "utf8").trim()).toBe(
208
+ "a"
209
+ );
210
+ } finally {
211
+ await srv.close();
212
+ }
213
+ });
214
+
215
+ it("a rollback on a machine without autodeploy creates no marker", async () => {
216
+ fs.writeFileSync(path.join(dir, "next-build"), "stale");
217
+ fs.writeFileSync(path.join(dir, "rollback-works"), "");
218
+ const state = path.join(dir, "autodeploy");
219
+ const srv = await fakeServer();
220
+ try {
221
+ await go(REDEPLOY, "x", {
222
+ ...verifying(srv.port, "new"),
223
+ AGENTOS_AUTODEPLOY_DIR: state,
224
+ });
225
+ expect(fs.existsSync(state)).toBe(false);
226
+ } finally {
227
+ await srv.close();
228
+ }
229
+ });
230
+
173
231
  it("when the rollback doesn't come up either, it ends broken", async () => {
174
232
  fs.writeFileSync(path.join(dir, "next-build"), "stale");
175
233
  const srv = await fakeServer();
@@ -1,91 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { toast } from "sonner";
5
- import { Button } from "@/components/ui/button";
6
- import { Input } from "@/components/ui/input";
7
- import {
8
- useAwaySettingsQuery,
9
- useUpdateAwaySettings,
10
- type AwaySettings,
11
- } from "@/data/away";
12
- import {
13
- MAX_WINDOW_MINUTES,
14
- MIN_WINDOW_MINUTES,
15
- parseWindow,
16
- } from "@/lib/away/window-limits";
17
-
18
- // Settings → Workspaces: how recent a session's work must be for Away mode
19
- // to touch it. App-wide.
20
- export function AwayWindowForm() {
21
- const { data, isPending, isError, error } = useAwaySettingsQuery();
22
- if (isPending)
23
- return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
24
- if (isError)
25
- return <p className="text-destructive text-sm">{error.message}</p>;
26
- return <Form key={data.windowMinutes} saved={data} />;
27
- }
28
-
29
- function Form({ saved }: { saved: AwaySettings }) {
30
- const update = useUpdateAwaySettings();
31
- const [text, setText] = useState(String(saved.windowMinutes));
32
- const minutes = parseWindow(text);
33
- const error =
34
- minutes === null
35
- ? `A whole number from ${MIN_WINDOW_MINUTES} to ${MAX_WINDOW_MINUTES}`
36
- : null;
37
- return (
38
- <form
39
- onSubmit={(e) => {
40
- e.preventDefault();
41
- if (minutes === null) return;
42
- update.mutate(
43
- { windowMinutes: minutes },
44
- {
45
- onSuccess: () => toast.success("Away mode settings saved"),
46
- onError: (err) => toast.error(err.message),
47
- }
48
- );
49
- }}
50
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
51
- >
52
- <h3 className="text-sm font-medium">Away mode</h3>
53
- <p className="text-muted-foreground text-sm">
54
- Away mode works through orchestrators that were busy in this window, and
55
- only lists sessions with a real turn inside it. Older ones are left
56
- alone: a message wakes an agent and re-reads its whole conversation.
57
- </p>
58
- <label className="flex items-center justify-between gap-3 text-sm">
59
- <span className="text-muted-foreground">Recent means the last</span>
60
- <span className="flex items-center gap-2">
61
- <Input
62
- type="number"
63
- inputMode="numeric"
64
- min={MIN_WINDOW_MINUTES}
65
- max={MAX_WINDOW_MINUTES}
66
- step={1}
67
- value={text}
68
- onChange={(e) => setText(e.target.value)}
69
- aria-label="Away window, minutes"
70
- aria-invalid={!!error}
71
- className="h-11 w-24 text-base sm:h-9 md:text-sm"
72
- />
73
- <span className="text-muted-foreground">min</span>
74
- </span>
75
- </label>
76
- {error && <p className="text-destructive text-xs">{error}</p>}
77
- <div className="flex justify-end">
78
- <Button
79
- type="submit"
80
- size="sm"
81
- className="h-11 sm:h-8"
82
- disabled={
83
- !!error || minutes === saved.windowMinutes || update.isPending
84
- }
85
- >
86
- Save
87
- </Button>
88
- </div>
89
- </form>
90
- );
91
- }
@@ -1,177 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { toast } from "sonner";
5
- import { Button } from "@/components/ui/button";
6
- import { Input } from "@/components/ui/input";
7
- import { Switch } from "@/components/ui/switch";
8
- import type { RefreshSettings } from "@/lib/chat/refresh";
9
- import { useFreshStartQuery, useUpdateFreshStart } from "@/data/fresh-start";
10
- import { parseFreshStart } from "./fresh-start";
11
-
12
- // When a chat's agent starts a fresh conversation instead of carrying a
13
- // long one: app-wide, one threshold for orchestrators and one for tasks.
14
- export function FreshStartForm() {
15
- const { data, isPending, isError, error } = useFreshStartQuery();
16
- if (isPending)
17
- return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
18
- if (isError)
19
- return <p className="text-destructive text-sm">{error.message}</p>;
20
- return (
21
- <Form
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}:${data.interactiveAutoCompactTokens}`}
23
- saved={data}
24
- />
25
- );
26
- }
27
-
28
- function Form({ saved }: { saved: RefreshSettings }) {
29
- const update = useUpdateFreshStart();
30
- const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
- const task = useThreshold(saved.taskTokens, 200);
32
- const compact = useThreshold(saved.compactTokens, 150);
33
- const midTurn = useThreshold(saved.autoCompactTokens, 300);
34
- const interactive = useThreshold(saved.interactiveAutoCompactTokens, 200);
35
- const error =
36
- orchestrator.error ??
37
- task.error ??
38
- compact.error ??
39
- midTurn.error ??
40
- interactive.error;
41
- const dirty =
42
- !error &&
43
- (orchestrator.tokens !== saved.orchestratorTokens ||
44
- task.tokens !== saved.taskTokens ||
45
- compact.tokens !== saved.compactTokens ||
46
- midTurn.tokens !== saved.autoCompactTokens ||
47
- interactive.tokens !== saved.interactiveAutoCompactTokens);
48
-
49
- return (
50
- <form
51
- onSubmit={(e) => {
52
- e.preventDefault();
53
- if (error) return;
54
- update.mutate(
55
- {
56
- orchestratorTokens: orchestrator.tokens,
57
- taskTokens: task.tokens,
58
- compactTokens: compact.tokens,
59
- autoCompactTokens: midTurn.tokens,
60
- interactiveAutoCompactTokens: interactive.tokens,
61
- },
62
- {
63
- onSuccess: () => toast.success("Context settings saved"),
64
- onError: (err) => toast.error(err.message),
65
- }
66
- );
67
- }}
68
- className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
69
- >
70
- <h3 className="text-sm font-medium">Fresh starts</h3>
71
- <p className="text-muted-foreground text-sm">
72
- When a conversation&apos;s context passes the threshold, its agent
73
- starts a new one at the next turn boundary, with its brief. An
74
- orchestrator also gets its rules and the workspace&apos;s current state,
75
- so nothing is lost. A task keeps only its brief and its branch.
76
- </p>
77
- <ThresholdRow label="Orchestrators" field={orchestrator} />
78
- <ThresholdRow label="Tasks" field={task} />
79
- <h3 className="pt-2 text-sm font-medium">Compact</h3>
80
- <p className="text-muted-foreground text-sm">
81
- Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
82
- own context when a job runs long: set this high, since it happens while
83
- the agent is holding its files. Between turns, nothing is in flight, so
84
- AgentOS compacts a chat over the smaller size, or idle past its prompt
85
- cache, before the next message; the composer offers to first. Either way
86
- AgentOS hands the agent its task, scope changes, files and todos back.
87
- Your own sessions, in a chat or a terminal, have their own mid-turn
88
- size; a terminal can&apos;t be compacted between turns, so that is the
89
- only compaction it gets. Orchestrators start fresh instead.
90
- </p>
91
- <ThresholdRow
92
- label="Mid-turn, tasks"
93
- field={midTurn}
94
- switchLabel="Let a task's agent compact itself mid-turn"
95
- />
96
- <ThresholdRow
97
- label="Mid-turn, your sessions"
98
- field={interactive}
99
- switchLabel="Let your own sessions compact themselves mid-turn"
100
- />
101
- <ThresholdRow
102
- label="Between turns"
103
- field={compact}
104
- switchLabel="Compact chats and tasks between turns"
105
- />
106
- {error && <p className="text-destructive text-xs">{error}</p>}
107
- <div className="flex justify-end">
108
- <Button
109
- type="submit"
110
- size="sm"
111
- className="h-11 sm:h-8"
112
- disabled={!!error || !dirty || update.isPending}
113
- >
114
- Save
115
- </Button>
116
- </div>
117
- </form>
118
- );
119
- }
120
-
121
- function useThreshold(tokens: number, fallbackK: number) {
122
- const [on, setOn] = useState(tokens > 0);
123
- const [text, setText] = useState(
124
- String(tokens > 0 ? tokens / 1000 : fallbackK)
125
- );
126
- const parsed = parseFreshStart(on, text);
127
- return {
128
- on,
129
- setOn,
130
- text,
131
- setText,
132
- tokens: "tokens" in parsed ? parsed.tokens : tokens,
133
- error: "error" in parsed ? parsed.error : null,
134
- };
135
- }
136
-
137
- function ThresholdRow({
138
- label,
139
- field,
140
- switchLabel,
141
- }: {
142
- label: string;
143
- field: ReturnType<typeof useThreshold>;
144
- switchLabel?: string;
145
- }) {
146
- return (
147
- <div className="space-y-2">
148
- <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
149
- {label}
150
- <Switch
151
- checked={field.on}
152
- onCheckedChange={field.setOn}
153
- aria-label={switchLabel ?? `Fresh starts for ${label.toLowerCase()}`}
154
- />
155
- </label>
156
- {field.on && (
157
- <label className="flex items-center justify-between gap-3 text-sm">
158
- <span className="text-muted-foreground">At</span>
159
- <span className="flex items-center gap-2">
160
- <Input
161
- type="number"
162
- inputMode="numeric"
163
- min={50}
164
- step={10}
165
- value={field.text}
166
- onChange={(e) => field.setText(e.target.value)}
167
- aria-label={`${label} threshold, thousands of tokens`}
168
- aria-invalid={!!field.error}
169
- className="h-11 w-24 text-base sm:h-9 md:text-sm"
170
- />
171
- <span className="text-muted-foreground">k tokens</span>
172
- </span>
173
- </label>
174
- )}
175
- </div>
176
- );
177
- }