@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.
@@ -0,0 +1,225 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { ArrowLeft } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
8
+ import { formatTokens } from "@/lib/chat/context";
9
+ import {
10
+ useExperimentDetailQuery,
11
+ useExperimentsQuery,
12
+ useSetExperiment,
13
+ type ExperimentView,
14
+ } from "@/data/experiments";
15
+
16
+ const usd = (n: number) => `$${n.toFixed(2)}`;
17
+ const pct = (n: number | null) =>
18
+ n === null ? "-" : `${Math.round(n * 100)}%`;
19
+
20
+ const WINDOWS = [
21
+ ["today", "Today"],
22
+ ["d7", "7 days"],
23
+ ["d30", "30 days"],
24
+ ] as const;
25
+
26
+ function Usage({ e }: { e: ExperimentView }) {
27
+ return (
28
+ <table className="w-full text-xs tabular-nums">
29
+ <thead className="text-muted-foreground">
30
+ <tr>
31
+ <th className="py-1 text-left font-normal" />
32
+ <th className="py-1 text-right font-normal">Calls</th>
33
+ <th className="py-1 text-right font-normal">Tokens</th>
34
+ <th className="py-1 text-right font-normal">Cost</th>
35
+ {e.suggests && (
36
+ <th className="py-1 text-right font-normal">Accepted</th>
37
+ )}
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ {WINDOWS.map(([key, label]) => {
42
+ const w = e.usage[key];
43
+ return (
44
+ <tr key={key} className="border-border border-t">
45
+ <td className="py-1">{label}</td>
46
+ <td className="py-1 text-right">{w.calls}</td>
47
+ <td className="py-1 text-right">{formatTokens(w.tokens)}</td>
48
+ <td className="py-1 text-right">{usd(w.costUsd)}</td>
49
+ {e.suggests && (
50
+ <td className="py-1 text-right">
51
+ {pct(w.acceptRate)}
52
+ {w.accepted + w.dismissed > 0 &&
53
+ ` (${w.accepted}/${w.accepted + w.dismissed})`}
54
+ </td>
55
+ )}
56
+ </tr>
57
+ );
58
+ })}
59
+ </tbody>
60
+ </table>
61
+ );
62
+ }
63
+
64
+ function Detail({ id, onBack }: { id: string; onBack: () => void }) {
65
+ const { data, isPending, isError, error } = useExperimentDetailQuery(id);
66
+ return (
67
+ <div className="space-y-4">
68
+ <Button
69
+ variant="ghost"
70
+ size="sm"
71
+ onClick={onBack}
72
+ className="min-h-11 md:min-h-0"
73
+ >
74
+ <ArrowLeft className="h-4 w-4" /> Experiments
75
+ </Button>
76
+ {isPending && (
77
+ <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />
78
+ )}
79
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
80
+ {data && (
81
+ <>
82
+ <div>
83
+ <h3 className="text-sm font-medium">{data.name}</h3>
84
+ <p className="text-muted-foreground text-xs">{data.description}</p>
85
+ </div>
86
+ <Usage e={data} />
87
+ <Breakdowns title="By trigger" rows={data.detail.byTrigger} />
88
+ <Breakdowns title="By model" rows={data.detail.byModel} />
89
+ <section>
90
+ <h4 className="text-muted-foreground mb-1 text-xs">Last 30 days</h4>
91
+ <table className="w-full text-xs tabular-nums">
92
+ <tbody>
93
+ {data.detail.days
94
+ .filter((d) => d.calls || d.accepted || d.dismissed)
95
+ .reverse()
96
+ .map((d) => (
97
+ <tr key={d.day} className="border-border border-t">
98
+ <td className="py-1">{d.day}</td>
99
+ <td className="py-1 text-right">{d.calls} calls</td>
100
+ <td className="py-1 text-right">
101
+ {formatTokens(d.tokens)}
102
+ </td>
103
+ <td className="py-1 text-right">{usd(d.costUsd)}</td>
104
+ <td className="py-1 text-right">
105
+ {d.accepted}/{d.accepted + d.dismissed}
106
+ </td>
107
+ </tr>
108
+ ))}
109
+ <tr className="border-border border-t font-medium">
110
+ <td className="py-1">Total</td>
111
+ <td className="py-1 text-right">
112
+ {data.detail.days.reduce((t, d) => t + d.calls, 0)} calls
113
+ </td>
114
+ <td className="py-1 text-right">
115
+ {formatTokens(
116
+ data.detail.days.reduce((t, d) => t + d.tokens, 0)
117
+ )}
118
+ </td>
119
+ <td className="py-1 text-right">
120
+ {usd(data.detail.days.reduce((t, d) => t + d.costUsd, 0))}
121
+ </td>
122
+ <td />
123
+ </tr>
124
+ </tbody>
125
+ </table>
126
+ </section>
127
+ {data.detail.recent.length > 0 && (
128
+ <section>
129
+ <h4 className="text-muted-foreground mb-1 text-xs">Recent</h4>
130
+ <ul className="space-y-1 text-xs">
131
+ {data.detail.recent.map((r, i) => (
132
+ <li key={i} className="flex justify-between gap-3">
133
+ <span className="min-w-0 truncate">
134
+ {new Date(r.at).toLocaleString()} · {r.event} ·{" "}
135
+ {r.trigger}
136
+ {r.detail ? ` · ${r.detail}` : ""}
137
+ </span>
138
+ <span className="text-muted-foreground tabular-nums">
139
+ {r.tokens ? formatTokens(r.tokens) : ""}
140
+ </span>
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ </section>
145
+ )}
146
+ </>
147
+ )}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ function Breakdowns({
153
+ title,
154
+ rows,
155
+ }: {
156
+ title: string;
157
+ rows: { key: string; calls: number; tokens: number; costUsd: number }[];
158
+ }) {
159
+ if (!rows.length) return null;
160
+ return (
161
+ <section>
162
+ <h4 className="text-muted-foreground mb-1 text-xs">{title}</h4>
163
+ <table className="w-full text-xs tabular-nums">
164
+ <tbody>
165
+ {rows.map((r) => (
166
+ <tr key={r.key} className="border-border border-t">
167
+ <td className="py-1">{r.key}</td>
168
+ <td className="py-1 text-right">{r.calls} calls</td>
169
+ <td className="py-1 text-right">{formatTokens(r.tokens)}</td>
170
+ <td className="py-1 text-right">{usd(r.costUsd)}</td>
171
+ </tr>
172
+ ))}
173
+ </tbody>
174
+ </table>
175
+ </section>
176
+ );
177
+ }
178
+
179
+ export function ExperimentsSection() {
180
+ const { data, isPending, isError, error } = useExperimentsQuery();
181
+ const set = useSetExperiment();
182
+ const [open, setOpen] = useState<string | null>(null);
183
+
184
+ if (open) return <Detail id={open} onBack={() => setOpen(null)} />;
185
+ if (isPending)
186
+ return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
187
+ if (isError)
188
+ return <p className="text-destructive text-sm">{error.message}</p>;
189
+
190
+ return (
191
+ <div className="space-y-5">
192
+ <p className="text-muted-foreground text-sm">
193
+ Features still being tried. Each is off until you turn it on, does no
194
+ work while off, and shows what it costs here.
195
+ </p>
196
+ {data.experiments.map((e) => (
197
+ <div key={e.id} className="space-y-2">
198
+ <SettingToggle
199
+ title={`${e.name} · ${e.status === "beta" ? "Beta" : "Experimental"}`}
200
+ detail={e.description}
201
+ checked={e.on}
202
+ locked={data.locked}
203
+ onChange={(on) =>
204
+ set.mutate(
205
+ { id: e.id, on },
206
+ { onError: (err) => toast.error(err.message) }
207
+ )
208
+ }
209
+ />
210
+ <div className="px-3">
211
+ <Usage e={e} />
212
+ <Button
213
+ variant="link"
214
+ size="sm"
215
+ className="min-h-11 px-0 md:min-h-0"
216
+ onClick={() => setOpen(e.id)}
217
+ >
218
+ Details
219
+ </Button>
220
+ </div>
221
+ </div>
222
+ ))}
223
+ </div>
224
+ );
225
+ }
@@ -47,6 +47,9 @@ vi.mock("@/components/Audits/AuditsPanel", () => ({
47
47
  vi.mock("@/components/Updates/UpdatesSection", () => ({
48
48
  UpdatesSection: stub("updates"),
49
49
  }));
50
+ vi.mock("./ExperimentsSection", () => ({
51
+ ExperimentsSection: stub("experiments"),
52
+ }));
50
53
  vi.mock("./NotificationsSection", () => ({
51
54
  NotificationsSection: stub("notifications"),
52
55
  }));
@@ -14,6 +14,7 @@ import {
14
14
  LayoutGrid,
15
15
  ListChecks,
16
16
  ShieldOff,
17
+ FlaskConical,
17
18
  RefreshCw,
18
19
  Smartphone,
19
20
  X,
@@ -33,6 +34,7 @@ import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
33
34
  import { ModelsSection } from "@/components/Models/ModelsSection";
34
35
  import { SchedulesPanel } from "@/components/Schedules";
35
36
  import { UpdatesSection } from "@/components/Updates/UpdatesSection";
37
+ import { ExperimentsSection } from "./ExperimentsSection";
36
38
  import { WorkspaceSettings } from "@/components/Workspaces";
37
39
  import { useSettingsUrl } from "@/hooks/useSettingsUrl";
38
40
  import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
@@ -106,6 +108,11 @@ export const SECTION_INFO: Record<
106
108
  hint: "Version, check for updates, the update banner",
107
109
  icon: RefreshCw,
108
110
  },
111
+ experiments: {
112
+ label: "Experiments",
113
+ hint: "Try features that aren't finished, and see what they cost",
114
+ icon: FlaskConical,
115
+ },
109
116
  };
110
117
 
111
118
  function SectionBody({
@@ -138,6 +145,8 @@ function SectionBody({
138
145
  return <SetupSection />;
139
146
  case "updates":
140
147
  return <UpdatesSection />;
148
+ case "experiments":
149
+ return <ExperimentsSection />;
141
150
  }
142
151
  }
143
152
 
@@ -3,6 +3,7 @@ import {
3
3
  ClipboardList,
4
4
  Clock,
5
5
  Cpu,
6
+ FlaskConical,
6
7
  GitMerge,
7
8
  LayoutGrid,
8
9
  RefreshCw,
@@ -111,5 +112,13 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
111
112
  icon: RefreshCw,
112
113
  run: () => settingsUiActions.open("updates"),
113
114
  },
115
+ {
116
+ id: "app.experiments",
117
+ title: "Experiments",
118
+ group,
119
+ keywords: ["experiment", "labs", "beta", "feature", "try"],
120
+ icon: FlaskConical,
121
+ run: () => settingsUiActions.open("experiments"),
122
+ },
114
123
  ];
115
124
  }
@@ -28,6 +28,7 @@ describe("⌘K entry points", () => {
28
28
  ["app.schedules", "schedules"],
29
29
  ["app.audits", "audits"],
30
30
  ["app.updates", "updates"],
31
+ ["app.experiments", "experiments"],
31
32
  ] as const)("%s opens Settings at %s", (id, section) => {
32
33
  run(id);
33
34
  expect(settingsUi.open).toBe(true);
@@ -17,12 +17,34 @@ async function json<T>(res: Response): Promise<T> {
17
17
  return data;
18
18
  }
19
19
 
20
- const post = (url: string, body?: object) =>
21
- fetch(url, {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: body ? JSON.stringify(body) : undefined,
25
- });
20
+ // Longer than the server's own deadline for done (lib/done/deadline.ts), so
21
+ // the pending toast always ends even if the server never answers.
22
+ export const DONE_REQUEST_TIMEOUT_MS = 180_000;
23
+
24
+ async function post(url: string, body?: object, timeoutMs?: number) {
25
+ const abort = new AbortController();
26
+ const timer = timeoutMs
27
+ ? setTimeout(
28
+ () =>
29
+ abort.abort(
30
+ new Error(
31
+ "The server didn't answer in time. It may still be working: check the row, then try again."
32
+ )
33
+ ),
34
+ timeoutMs
35
+ )
36
+ : undefined;
37
+ try {
38
+ return await fetch(url, {
39
+ signal: abort.signal,
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ });
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
26
48
 
27
49
  function useDoneMutation<V, R>(fn: (v: V) => Promise<R>) {
28
50
  const queryClient = useQueryClient();
@@ -40,7 +62,11 @@ export function useDoneSession() {
40
62
  async (id: string) =>
41
63
  (
42
64
  await json<{ outcome: DoneOutcome }>(
43
- await post(`/api/sessions/${id}/done`)
65
+ await post(
66
+ `/api/sessions/${id}/done`,
67
+ undefined,
68
+ DONE_REQUEST_TIMEOUT_MS
69
+ )
44
70
  )
45
71
  ).outcome
46
72
  );
@@ -0,0 +1,67 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { ExperimentView } from "@/lib/experiments/view";
3
+ import type { UsageDetail } from "@/lib/experiments/meter";
4
+ import type { ExperimentId } from "@/lib/experiments/registry";
5
+
6
+ export type { ExperimentView, UsageDetail };
7
+
8
+ export interface ExperimentsList {
9
+ locked: boolean;
10
+ experiments: ExperimentView[];
11
+ }
12
+
13
+ export interface ExperimentDetail extends ExperimentView {
14
+ locked: boolean;
15
+ detail: UsageDetail;
16
+ }
17
+
18
+ export const experimentsKeys = {
19
+ all: ["experiments"] as const,
20
+ detail: (id: string) => ["experiments", id] as const,
21
+ };
22
+
23
+ async function json<T>(res: Response): Promise<T> {
24
+ const data = await res.json();
25
+ if (!res.ok) throw new Error(data.error || "Request failed");
26
+ return data;
27
+ }
28
+
29
+ export function useExperimentsQuery() {
30
+ return useQuery({
31
+ queryKey: experimentsKeys.all,
32
+ queryFn: async () => json<ExperimentsList>(await fetch("/api/experiments")),
33
+ staleTime: 10_000,
34
+ });
35
+ }
36
+
37
+ export function useExperimentDetailQuery(id: string | null) {
38
+ return useQuery({
39
+ queryKey: experimentsKeys.detail(id ?? ""),
40
+ queryFn: async () =>
41
+ json<ExperimentDetail>(await fetch(`/api/experiments/${id}`)),
42
+ enabled: !!id,
43
+ staleTime: 10_000,
44
+ });
45
+ }
46
+
47
+ // The client's one check: false until the list has loaded.
48
+ export function useExperiment(id: ExperimentId): boolean {
49
+ const { data } = useExperimentsQuery();
50
+ return !!data?.experiments.find((e) => e.id === id)?.on;
51
+ }
52
+
53
+ export function useSetExperiment() {
54
+ const queryClient = useQueryClient();
55
+ return useMutation({
56
+ mutationFn: async ({ id, on }: { id: string; on: boolean }) =>
57
+ json<ExperimentView>(
58
+ await fetch(`/api/experiments/${id}`, {
59
+ method: "PUT",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ on }),
62
+ })
63
+ ),
64
+ onSuccess: () =>
65
+ queryClient.invalidateQueries({ queryKey: experimentsKeys.all }),
66
+ });
67
+ }
@@ -2028,6 +2028,39 @@ const migrations: Migration[] = [
2028
2028
  `);
2029
2029
  },
2030
2030
  },
2031
+ {
2032
+ id: 85,
2033
+ name: "experiment_usage",
2034
+ up: (db) => {
2035
+ // What each experiment did: model calls with their cost, and the
2036
+ // non-model work (scripts run, suggestions shown and decided).
2037
+ db.exec(`
2038
+ CREATE TABLE IF NOT EXISTS experiment_usage (
2039
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2040
+ experiment_id TEXT NOT NULL,
2041
+ event TEXT NOT NULL,
2042
+ trigger TEXT NOT NULL,
2043
+ session_id TEXT,
2044
+ workspace_id TEXT,
2045
+ model TEXT,
2046
+ provider TEXT,
2047
+ input_tokens INTEGER NOT NULL DEFAULT 0,
2048
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
2049
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
2050
+ output_tokens INTEGER NOT NULL DEFAULT 0,
2051
+ cost_usd REAL NOT NULL DEFAULT 0,
2052
+ duration_ms INTEGER,
2053
+ ok INTEGER NOT NULL DEFAULT 1,
2054
+ detail TEXT,
2055
+ at INTEGER NOT NULL
2056
+ );
2057
+ CREATE INDEX IF NOT EXISTS idx_experiment_usage_exp_at
2058
+ ON experiment_usage(experiment_id, at);
2059
+ CREATE INDEX IF NOT EXISTS idx_experiment_usage_session
2060
+ ON experiment_usage(session_id, experiment_id, event);
2061
+ `);
2062
+ },
2063
+ },
2031
2064
  ];
2032
2065
 
2033
2066
  // `upTo`: stop after this id (tests that start from an older database).
@@ -0,0 +1,45 @@
1
+ // Done merges, stops the agent and cleans up; any step can stall on a lock or
2
+ // a slow remote. A request must always end: past this it answers that it is
3
+ // still going, rather than leaving the row on "Merging…".
4
+ export const DONE_DEADLINE_MS = 150_000;
5
+
6
+ export async function withinDeadline<T>(
7
+ work: Promise<T>,
8
+ name: string,
9
+ ms = DONE_DEADLINE_MS
10
+ ): Promise<T> {
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+ const late = new Promise<never>((_, reject) => {
13
+ timer = setTimeout(
14
+ () =>
15
+ reject(
16
+ new Error(
17
+ `Done on ${name} is taking longer than ${Math.round(ms / 1000)}s and is still running in the background. Check the row in a moment; if it hasn't moved, try Done again.`
18
+ )
19
+ ),
20
+ ms
21
+ );
22
+ });
23
+ // The work keeps going; its late failure must not be an unhandled rejection.
24
+ work.catch(() => {});
25
+ try {
26
+ return await Promise.race([work, late]);
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+
32
+ // Best-effort: waits for `work` up to `ms`, then carries on without it.
33
+ export async function withTimeout(
34
+ work: Promise<unknown>,
35
+ ms: number
36
+ ): Promise<void> {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ work.catch(() => {});
39
+ await Promise.race([
40
+ work,
41
+ new Promise((resolve) => {
42
+ timer = setTimeout(resolve, ms);
43
+ }),
44
+ ]).finally(() => clearTimeout(timer));
45
+ }
package/lib/done/index.ts CHANGED
@@ -22,6 +22,7 @@ import { archiveSession } from "./archive";
22
22
  import { settleSession } from "../sidebar/shelf-state";
23
23
  import { demoMode } from "../security/demo";
24
24
  import { planDone } from "./plan";
25
+ import { withTimeout } from "./deadline";
25
26
  import { deleteMergedRemote } from "./remote";
26
27
  import { settleWorktree, type WorktreeFate } from "./worktree";
27
28
  import { sessionMergePolicy } from "../tasks/finish";
@@ -31,6 +32,7 @@ import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
31
32
  import { hostLink } from "../hosts/remote-api";
32
33
  import { doneHeldTask } from "../tasks/held-remote";
33
34
  import { captureMergedTask } from "../session-logs";
35
+ import { sessionEnded } from "../experiments/hooks";
34
36
  import { pushMerged } from "../notify/push/events";
35
37
 
36
38
  export interface DoneOptions {
@@ -111,6 +113,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
111
113
  : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
112
114
  ).run(s.id);
113
115
  syncTaskCardInBackground(s, status, pr);
116
+ void sessionEnded(s.id, status);
114
117
  if (status === "merged") {
115
118
  pushMerged(s.id);
116
119
  void captureMergedTask(s.id, { head: pr?.head });
@@ -151,7 +154,9 @@ export async function doneSession(
151
154
  // A sign-off just before this (the orchestrator's sign_off, then done)
152
155
  // is still cleaning up in the background: read what it leaves.
153
156
  await mergeSettled(s.id);
154
- await statusDetector.refreshCache();
157
+ // A quick change has no tmux session, and a listing that stalls (a slow
158
+ // ssh host, a loaded tmux) must not hold up a merge that doesn't read it.
159
+ if (!s.quick_of) await withTimeout(statusDetector.refreshCache(), 10_000);
155
160
  const plan = await planDone(s, opts.callerId);
156
161
  if (plan.action === "refuse") throw new Error(plan.reason);
157
162
  if (plan.action === "merge" && opts.noMerge)
@@ -0,0 +1,31 @@
1
+ import { experimentOn } from ".";
2
+ import { EXPERIMENTS, type ExperimentId } from "./registry";
3
+
4
+ export type EndHow = "done" | "merged" | "recipe-settled";
5
+ export type EndHook = (sessionId: string, how: EndHow) => Promise<void> | void;
6
+
7
+ // What each experiment does when a session ends. An experiment adds itself
8
+ // here; one that is off is never called.
9
+ export const END_HOOKS: Partial<Record<ExperimentId, EndHook>> = {};
10
+
11
+ // A merge is seen again on every poll; each session is handed on once.
12
+ const seen = new Set<string>();
13
+ export const resetEndHooks = () => seen.clear();
14
+
15
+ // Called where sessions end. Never makes the caller wait or fail.
16
+ export function sessionEnded(sessionId: string, how: EndHow): Promise<void> {
17
+ if (seen.has(sessionId)) return Promise.resolve();
18
+ seen.add(sessionId);
19
+ return (async () => {
20
+ await new Promise<void>((r) => setImmediate(r));
21
+ for (const { id } of EXPERIMENTS) {
22
+ const hook = END_HOOKS[id];
23
+ if (!hook || !experimentOn(id)) continue;
24
+ try {
25
+ await hook(sessionId, how);
26
+ } catch (error) {
27
+ console.error(`[experiments] ${id} on ${sessionId}:`, error);
28
+ }
29
+ }
30
+ })();
31
+ }
@@ -0,0 +1,19 @@
1
+ import { getSetting, putSetting } from "../settings-store";
2
+ import { type ExperimentId } from "./registry";
3
+
4
+ type Env = Record<string, string | undefined>;
5
+
6
+ const key = (id: ExperimentId) => `experiments.${id}`;
7
+
8
+ // AGENTOS_EXPERIMENTS=off pins every experiment off, like the update banner.
9
+ export const experimentsLocked = (env: Env = process.env) =>
10
+ env.AGENTOS_EXPERIMENTS === "off";
11
+
12
+ // The one check, on the server. Off by default: no row means off.
13
+ export function experimentOn(id: ExperimentId, env: Env = process.env) {
14
+ if (experimentsLocked(env)) return false;
15
+ return getSetting(key(id)) === "1";
16
+ }
17
+
18
+ export const setExperiment = (id: ExperimentId, on: boolean) =>
19
+ putSetting(key(id), on ? "1" : null);