@saadnvd1/agent-os 0.3.555 → 0.3.557

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/README.md CHANGED
@@ -27,6 +27,7 @@ and check on all of it from your phone.
27
27
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
28
28
  - **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
29
29
  - **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
30
+ - **Experiments.** Settings → Experiments lists features still being tried, each off until you turn it on, with what it has cost and how often its suggestions were taken. [More](docs/decisions/2026-10-13-experiments.md)
30
31
  - **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
31
32
 
32
33
  ## Documentation
@@ -0,0 +1,39 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { experimentsLocked, setExperiment } from "@/lib/experiments";
3
+ import { isExperimentId } from "@/lib/experiments/registry";
4
+ import { usageDetail } from "@/lib/experiments/meter";
5
+ import { experimentView } from "@/lib/experiments/view";
6
+
7
+ type Ctx = { params: Promise<{ id: string }> };
8
+
9
+ // GET /api/experiments/:id - the experiment with its per-day usage
10
+ export async function GET(_req: NextRequest, { params }: Ctx) {
11
+ const { id } = await params;
12
+ if (!isExperimentId(id))
13
+ return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
14
+ return NextResponse.json({
15
+ ...experimentView(id),
16
+ locked: experimentsLocked(),
17
+ detail: usageDetail(id),
18
+ });
19
+ }
20
+
21
+ // PUT /api/experiments/:id {on} - anyone using the app may switch one
22
+ export async function PUT(request: NextRequest, { params }: Ctx) {
23
+ const { id } = await params;
24
+ if (!isExperimentId(id))
25
+ return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
26
+ const body = (await request.json().catch(() => ({}))) as { on?: unknown };
27
+ if (typeof body.on !== "boolean")
28
+ return NextResponse.json(
29
+ { error: "on must be true or false" },
30
+ { status: 400 }
31
+ );
32
+ if (experimentsLocked())
33
+ return NextResponse.json(
34
+ { error: "Experiments are switched off for this server" },
35
+ { status: 409 }
36
+ );
37
+ setExperiment(id, body.on);
38
+ return NextResponse.json(experimentView(id));
39
+ }
@@ -0,0 +1,51 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { db } from "@/lib/db";
4
+ import { experimentOn } from "@/lib/experiments";
5
+ import { GET } from "./route";
6
+ import * as one from "./[id]/route";
7
+
8
+ const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
9
+ const put = (id: string, body: unknown) =>
10
+ one.PUT(
11
+ new NextRequest(`http://localhost/api/experiments/${id}`, {
12
+ method: "PUT",
13
+ body: JSON.stringify(body),
14
+ }),
15
+ ctx(id)
16
+ );
17
+
18
+ beforeEach(() => {
19
+ db.prepare(`DELETE FROM settings WHERE key LIKE 'experiments.%'`).run();
20
+ });
21
+
22
+ describe("/api/experiments", () => {
23
+ it("lists every experiment, off, with zero usage", async () => {
24
+ const body = await (await GET()).json();
25
+ const e = body.experiments.find(
26
+ (x: { id: string }) => x.id === "learning-cards"
27
+ );
28
+ expect(e).toMatchObject({ on: false, status: "experimental" });
29
+ expect(e.usage.today.calls).toBe(0);
30
+ });
31
+
32
+ it("switches one on and off", async () => {
33
+ expect((await put("learning-cards", { on: true })).status).toBe(200);
34
+ expect(experimentOn("learning-cards")).toBe(true);
35
+ await put("learning-cards", { on: false });
36
+ expect(experimentOn("learning-cards")).toBe(false);
37
+ });
38
+
39
+ it("refuses an unknown id and a bad body", async () => {
40
+ expect((await put("nope", { on: true })).status).toBe(404);
41
+ expect((await put("learning-cards", { on: "yes" })).status).toBe(400);
42
+ });
43
+
44
+ it("gives the detail view", async () => {
45
+ const res = await one.GET(
46
+ new NextRequest("http://localhost/api/experiments/learning-cards"),
47
+ ctx("learning-cards")
48
+ );
49
+ expect((await res.json()).detail.days).toHaveLength(30);
50
+ });
51
+ });
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from "next/server";
2
+ import { experimentsView } from "@/lib/experiments/view";
3
+
4
+ // GET /api/experiments - every experiment, whether it's on, and what it used
5
+ export function GET() {
6
+ return NextResponse.json(experimentsView());
7
+ }
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const doneSession = vi.hoisted(() => vi.fn());
4
+ const deadline = vi.hoisted(() => vi.fn());
5
+ vi.mock("@/lib/done", () => ({
6
+ doneSession,
7
+ getDoneTarget: (id: string) => {
8
+ if (id === "gone") throw new Error("Session not found");
9
+ return { id, name: "Fix pair page menu label" };
10
+ },
11
+ }));
12
+ vi.mock("@/lib/done/deadline", () => ({
13
+ withinDeadline: (...a: unknown[]) => {
14
+ deadline(...a);
15
+ return a[0];
16
+ },
17
+ }));
18
+
19
+ const { POST } = await import("./route");
20
+ const post = (id: string) =>
21
+ POST(new Request("http://x", { method: "POST" }), {
22
+ params: Promise.resolve({ id }),
23
+ });
24
+
25
+ const unhandled: unknown[] = [];
26
+ process.on("unhandledRejection", (e) => unhandled.push(e));
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ unhandled.length = 0;
31
+ });
32
+
33
+ describe("POST /api/sessions/:id/done", () => {
34
+ it("answers 404 for an unknown id, without starting anything or leaving a rejection", async () => {
35
+ const res = await post("gone");
36
+ expect(res.status).toBe(404);
37
+ expect(doneSession).not.toHaveBeenCalled();
38
+ await new Promise((r) => setTimeout(r, 10));
39
+ expect(unhandled).toEqual([]);
40
+ });
41
+
42
+ it("runs a quick change's done through the deadline wrapper", async () => {
43
+ doneSession.mockResolvedValue({ text: "Merged." });
44
+ const res = await post("q1");
45
+ expect(res.status).toBe(200);
46
+ expect(await res.json()).toEqual({ outcome: { text: "Merged." } });
47
+ expect(deadline).toHaveBeenCalledWith(
48
+ expect.any(Promise),
49
+ "Fix pair page menu label"
50
+ );
51
+ });
52
+
53
+ it("returns the refusal, and a second done while one runs is refused", async () => {
54
+ let finish!: (v: unknown) => void;
55
+ doneSession.mockReturnValueOnce(new Promise((r) => (finish = r)));
56
+ const first = post("q2");
57
+ await new Promise((r) => setTimeout(r, 0));
58
+ const second = await post("q2");
59
+ expect(second.status).toBe(409);
60
+ expect((await second.json()).error).toMatch(/already being finished/);
61
+ expect(doneSession).toHaveBeenCalledTimes(1);
62
+ finish({ text: "ok" });
63
+ expect((await first).status).toBe(200);
64
+
65
+ doneSession.mockRejectedValueOnce(new Error("Not done. ci failed"));
66
+ const refused = await post("q2");
67
+ expect(refused.status).toBe(409);
68
+ expect((await refused.json()).error).toMatch(/ci failed/);
69
+ });
70
+ });
@@ -1,19 +1,45 @@
1
1
  import { NextResponse } from "next/server";
2
- import { doneSession } from "@/lib/done";
2
+ import { doneSession, getDoneTarget } from "@/lib/done";
3
+ import { withinDeadline } from "@/lib/done/deadline";
4
+
5
+ // Sessions with a done running. Released when the work ends, not when the
6
+ // request's deadline does, so a retry can't start a parallel run beside one
7
+ // that is still going.
8
+ const g = globalThis as unknown as { __agentosDoneRunning?: Set<string> };
9
+ const running: Set<string> = (g.__agentosDoneRunning ??= new Set());
3
10
 
4
11
  // POST /api/sessions/:id/done - finished work: merge through the gates if
5
- // its PR is open, clean up, archive. 409 with the reason when refused.
12
+ // its PR is open, clean up, archive. 404 for an unknown session, 409 with
13
+ // the reason when refused or already running.
6
14
  export async function POST(
7
15
  _request: Request,
8
16
  { params }: { params: Promise<{ id: string }> }
9
17
  ) {
18
+ const id = (await params).id;
19
+ let name: string;
20
+ try {
21
+ name = getDoneTarget(id).name;
22
+ } catch {
23
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
24
+ }
25
+ if (running.has(id))
26
+ return NextResponse.json(
27
+ { error: `${name} is already being finished: wait for it to end.` },
28
+ { status: 409 }
29
+ );
30
+ running.add(id);
10
31
  try {
11
- const outcome = await doneSession((await params).id, {
12
- by: "direct",
13
- onPeer: true,
14
- });
32
+ const work = doneSession(id, { by: "direct", onPeer: true });
33
+ work.then(
34
+ () => running.delete(id),
35
+ () => running.delete(id)
36
+ );
37
+ const outcome = await withinDeadline(work, name);
15
38
  return NextResponse.json({ outcome });
16
39
  } catch (error) {
40
+ // A done that threw before it started has nothing left to hold.
41
+ if (!(error instanceof Error) || !/taking longer/.test(error.message))
42
+ running.delete(id);
17
43
  const message = error instanceof Error ? error.message : String(error);
18
44
  return NextResponse.json({ error: message }, { status: 409 });
19
45
  }
@@ -0,0 +1,149 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useEffect } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+
8
+ const toast = vi.hoisted(() =>
9
+ Object.assign(vi.fn(), {
10
+ loading: vi.fn(() => "t1"),
11
+ success: vi.fn(),
12
+ error: vi.fn(),
13
+ })
14
+ );
15
+ vi.mock("sonner", () => ({ toast }));
16
+
17
+ const { useDoneAction } = await import("./useDoneAction");
18
+
19
+ (
20
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
21
+ ).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ let root: Root;
24
+ let host: HTMLElement;
25
+ const hook: { current: (s: Session) => void } = { current: () => {} };
26
+ const confirmSpy = vi.fn(() => false);
27
+ const fetchSpy = vi.fn();
28
+
29
+ function Probe() {
30
+ const markDone = useDoneAction();
31
+ useEffect(() => {
32
+ hook.current = markDone;
33
+ });
34
+ return null;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ vi.stubGlobal("confirm", confirmSpy);
40
+ vi.stubGlobal("fetch", fetchSpy);
41
+ host = document.createElement("div");
42
+ document.body.append(host);
43
+ root = createRoot(host);
44
+ act(() =>
45
+ root.render(
46
+ createElement(
47
+ QueryClientProvider,
48
+ { client: new QueryClient() },
49
+ createElement(Probe)
50
+ )
51
+ )
52
+ );
53
+ });
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ host.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ const quick = {
61
+ id: "q1",
62
+ name: "Fix pair page menu label",
63
+ quick_of: "orch",
64
+ task_status: "running",
65
+ pr_status: "open",
66
+ pr_number: 281,
67
+ } as Session;
68
+
69
+ const respond = (status: number, body: object) =>
70
+ fetchSpy.mockResolvedValue({
71
+ ok: status < 400,
72
+ status,
73
+ json: async () => body,
74
+ });
75
+ // Done on an open PR asks first, in a toast; Merge is its action.
76
+ const confirmMerge = () => {
77
+ const [, opts] = toast.mock.calls.at(-1) as unknown as [
78
+ string,
79
+ { action: { onClick: () => void } },
80
+ ];
81
+ act(() => opts.action.onClick());
82
+ };
83
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 0)));
84
+
85
+ describe("Done from a row's menu", () => {
86
+ it("asks in the app, not with confirm(), and does nothing until Merge is chosen", async () => {
87
+ respond(200, {
88
+ outcome: {
89
+ id: "q1",
90
+ name: quick.name,
91
+ merged: "Merged.",
92
+ worktree: { action: "none" },
93
+ text: "Merged. Agent stopped, no worktree.",
94
+ },
95
+ });
96
+ act(() => hook.current(quick));
97
+ expect(fetchSpy).not.toHaveBeenCalled();
98
+ confirmMerge();
99
+ await settle();
100
+ expect(confirmSpy).not.toHaveBeenCalled();
101
+ expect(fetchSpy).toHaveBeenCalledWith(
102
+ "/api/sessions/q1/done",
103
+ expect.objectContaining({ method: "POST" })
104
+ );
105
+ expect(toast.success).toHaveBeenCalledWith(
106
+ "Merged. Agent stopped, no worktree.",
107
+ expect.objectContaining({ id: "t1" })
108
+ );
109
+ });
110
+
111
+ it("shows a refusal as a toast naming the gate, for a quick change and a task", async () => {
112
+ respond(409, {
113
+ error: "Not done, nothing merged.\n- ci failed: CI failed",
114
+ });
115
+ act(() => hook.current(quick));
116
+ confirmMerge();
117
+ await settle();
118
+ act(() => hook.current({ ...quick, id: "t2", quick_of: null } as Session));
119
+ confirmMerge();
120
+ await settle();
121
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
122
+ expect(toast.error).toHaveBeenCalledTimes(2);
123
+ expect(toast.error).toHaveBeenCalledWith(
124
+ expect.stringContaining("ci failed"),
125
+ expect.objectContaining({ id: "t1" })
126
+ );
127
+ });
128
+
129
+ it("ends the Merging toast with an error when the server never answers", async () => {
130
+ vi.useFakeTimers();
131
+ fetchSpy.mockImplementation(
132
+ (_url: string, init: { signal?: AbortSignal }) =>
133
+ new Promise((_, reject) =>
134
+ init.signal?.addEventListener("abort", () =>
135
+ reject(init.signal?.reason)
136
+ )
137
+ )
138
+ );
139
+ act(() => hook.current(quick));
140
+ confirmMerge();
141
+ expect(toast.error).not.toHaveBeenCalled();
142
+ await act(() => vi.advanceTimersByTimeAsync(181_000));
143
+ vi.useRealTimers();
144
+ expect(toast.error).toHaveBeenCalledWith(
145
+ expect.any(String),
146
+ expect.objectContaining({ id: "t1" })
147
+ );
148
+ });
149
+ });
@@ -7,21 +7,12 @@ import {
7
7
  } from "@/data/done";
8
8
  import { useSettleSession } from "@/data/sessions/shelf";
9
9
 
10
- // Done from a session's menu: an open PR merges through the gates, so that
11
- // one asks first; the rest can be undone from the toast.
10
+ // Done from a session's menu: an open PR merges through the gates, after an
11
+ // in-app confirmation; a failing gate comes back as an error toast naming it.
12
12
  export function useDoneAction() {
13
13
  const done = useDoneSession();
14
14
  const settle = useSettleSession();
15
- return (session: Session) => {
16
- const merges =
17
- session.task_status === "running" && session.pr_status === "open";
18
- if (
19
- merges &&
20
- !confirm(
21
- `Merge ${session.name}'s PR through the gates and finish it? A failing gate refuses and nothing merges.`
22
- )
23
- )
24
- return;
15
+ const run = (session: Session, merges: boolean) => {
25
16
  const id = toast.loading(
26
17
  merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
27
18
  );
@@ -45,6 +36,20 @@ export function useDoneAction() {
45
36
  onError: (e) => toast.error(e.message, { id, duration: 12000 }),
46
37
  });
47
38
  };
39
+ return (session: Session) => {
40
+ const merges =
41
+ session.task_status === "running" && session.pr_status === "open";
42
+ if (!merges) return run(session, false);
43
+ // Merging is the one Done that can't be undone: asked in the app, not
44
+ // with confirm(), which a webview drops.
45
+ toast(`Merge ${session.name}'s PR and finish it?`, {
46
+ description:
47
+ "It merges only through the orchestrator's gates; a failing gate refuses and nothing merges.",
48
+ duration: 15000,
49
+ action: { label: "Merge", onClick: () => run(session, true) },
50
+ cancel: { label: "Cancel", onClick: () => {} },
51
+ });
52
+ };
48
53
  }
49
54
 
50
55
  // Archive from a session's menu: puts it away; Unarchive brings it back.
@@ -0,0 +1,89 @@
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, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
7
+ Object.defineProperty(globalThis, key, {
8
+ value: (window as unknown as Record<string, unknown>)[key],
9
+ configurable: true,
10
+ writable: true,
11
+ });
12
+ (
13
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
14
+ ).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ const w = (calls: number) => ({
17
+ calls,
18
+ tokens: calls * 1000,
19
+ costUsd: calls * 0.01,
20
+ scripts: 0,
21
+ shown: 0,
22
+ accepted: 1,
23
+ dismissed: 1,
24
+ acceptRate: 0.5,
25
+ });
26
+ const state = vi.hoisted(() => ({ mutate: vi.fn() }));
27
+ vi.mock("@/data/experiments", () => ({
28
+ useExperimentsQuery: () => ({
29
+ isPending: false,
30
+ isError: false,
31
+ data: {
32
+ locked: false,
33
+ experiments: [
34
+ {
35
+ id: "learning-cards",
36
+ name: "Learning cards",
37
+ description: "Suggest things when a session ends.",
38
+ area: "sessions",
39
+ status: "experimental",
40
+ suggests: true,
41
+ on: false,
42
+ usage: { today: w(1), d7: w(2), d30: w(3) },
43
+ },
44
+ ],
45
+ },
46
+ }),
47
+ useSetExperiment: () => ({ mutate: state.mutate }),
48
+ useExperimentDetailQuery: () => ({ isPending: true }),
49
+ }));
50
+
51
+ const { ExperimentsSection } = await import("./ExperimentsSection");
52
+
53
+ let root: Root;
54
+ let host: HTMLDivElement;
55
+ beforeEach(() => {
56
+ state.mutate.mockReset();
57
+ host = document.createElement("div");
58
+ document.body.appendChild(host);
59
+ root = createRoot(host);
60
+ });
61
+ afterEach(() => {
62
+ act(() => root.unmount());
63
+ host.remove();
64
+ });
65
+
66
+ describe("ExperimentsSection", () => {
67
+ it("lists each experiment, off, with its usage", () => {
68
+ act(() => root.render(createElement(ExperimentsSection)));
69
+ expect(host.textContent).toContain("Learning cards");
70
+ expect(host.textContent).toContain("Suggest things when a session ends.");
71
+ expect(host.textContent).toContain("50% (1/2)");
72
+ expect(
73
+ host.querySelector('[role="switch"]')?.getAttribute("aria-checked")
74
+ ).toBe("false");
75
+ });
76
+
77
+ it("turns one on from its toggle", () => {
78
+ act(() => root.render(createElement(ExperimentsSection)));
79
+ act(() => {
80
+ host
81
+ .querySelector('[role="switch"]')
82
+ ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
83
+ });
84
+ expect(state.mutate).toHaveBeenCalledWith(
85
+ { id: "learning-cards", on: true },
86
+ expect.any(Object)
87
+ );
88
+ });
89
+ });