tryharness 0.7.18 → 0.7.23

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 (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
package/lib/swarm.mjs CHANGED
@@ -1,24 +1,26 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { readRunSteps } from "./runner.mjs";
5
- import { mainCheckout } from "./worktrees.mjs";
6
6
  import { nameOf, resolveMember } from "./members.mjs";
7
- import { setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
7
+ import { modelOf, setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
8
+ import { DEFAULT_ROUTING, PICKABLE, choiceOf, listingOf, picksIn, routingOf, setChoice, setRouting } from "./models.mjs";
8
9
 
9
10
  // The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
10
11
  // it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
11
12
  // agents it names (or the only agent in the thread), each resuming its own session; their runs' steps are read back
12
13
  // from the run logs. The person's own terminal sessions are listed too and continue in a thread once idle.
13
14
 
14
- const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
15
+ export const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
15
16
  const SAY_LIMIT = 4_000;
16
17
  const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
17
18
 
18
19
  // The id an agent resumes a session by: the index keys sessions by their file name.
19
20
  export function nativeSessionId(agent, sessionId) {
20
21
  const bare = String(sessionId).replace(/\.jsonl$/, "");
21
- return agent === "codex" ? bare.match(UUID)?.[0] ?? bare : bare;
22
+ // Codex and pi name a session's file by its time and id; the id is what resumes it.
23
+ return agent === "codex" || agent === "pi" ? bare.match(UUID)?.[0] ?? bare : bare;
22
24
  }
23
25
 
24
26
  // The folder each running coding agent works in, by agent, leaving out the runs the daemon started itself.
@@ -50,7 +52,7 @@ export function agentProcessCwds(exclude = new Set()) {
50
52
  return cwds;
51
53
  }
52
54
 
53
- export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun }) {
55
+ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun, pickable, listModels, folders, runsIn, threadFiles = () => ({ files: [], rendering: [] }) }) {
54
56
  const awaitingStart = (run) => run.status === "open" && Boolean(run.run_workspace) && !run.run_authorized_at;
55
57
  const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
56
58
  const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
@@ -116,19 +118,27 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
116
118
  // The agent a request names in `to`: null for none, false for one no thread can have.
117
119
  const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
118
120
 
119
- const lastFolder = (agent) => store.recentSessions(100).find((session) => session.agent === agent && isDir(session.cwd))?.cwd ?? null;
121
+ // A thread needs a folder when a coding agent is in it, or when no one is named and the coordinator picks who works.
122
+ const needsFolder = (team) => !team.length || team.some((name) => PROCESS_NAMES[name]);
123
+ // Where each coding agent in a thread would run its turn there: in T3 Code, or here, and why not T3 when the person uses it there.
124
+ // `lastWorked`: where its agent last worked, when Harness picked another folder from the message, for the person to take.
125
+ const placeOf = (folder, team, by, lastWorked = null) => ({ ok: true, folder, name: folder ? path.basename(folder) : null, by,
126
+ lastWorked: lastWorked ? { folder: lastWorked, name: path.basename(lastWorked) } : null,
127
+ runs: folder ? team.filter((name) => PROCESS_NAMES[name]).map((name) => ({ agent: name, ...runsIn(name, folder) })) : [] });
120
128
 
121
- const read = (thread) => {
129
+ const read = (thread, hostedCaller = false) => {
122
130
  const runs = store.threadRuns(thread.id).map((run) => ({
123
131
  id: run.id, agent: run.claimed_by || run.target_agent, kind: run.kind, status: run.status, awaitingStart: awaitingStart(run), startedAt: run.created_at, endedAt: run.reported_at,
124
- sessionId: run.session_id, folder: run.run_workspace, usage: run.usage,
132
+ sessionId: run.session_id, folder: run.run_workspace, usage: run.usage, model: run.run_model,
125
133
  steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
126
134
  }));
127
- return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
135
+ return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs, ...threadFiles(thread, hostedCaller) };
128
136
  };
129
137
 
130
138
  // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
131
139
  const post = (thread, text, to = null) => {
140
+ const picked = picksIn(store, text, roomAgents);
141
+ if (picked.error) return { ok: false, error: picked.error };
132
142
  // A new name in the person's message joins the thread.
133
143
  const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
134
144
  if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
@@ -160,6 +170,19 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
160
170
  const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
161
171
  return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
162
172
  }
173
+ // Each coding agent's models as it lists them, the person's choice for it and their routing words (models.mjs). Open
174
+ // to every caller, so an agent names a model that exists: post_task's model, or `@codex:model/effort` in a post.
175
+ if (command === "board.swarm.models") {
176
+ const named = request.target ? resolveMember(request.target) : null;
177
+ if (named && !named.ok) return { ok: false, error: `no agent called "${request.target}"` };
178
+ const ids = pickable().filter((id) => !named || id === named.id);
179
+ if (named && !ids.length) return { ok: false, error: `the relay sets the model of ${[...PICKABLE].join(", ")} when they are installed here, not ${nameOf(named.id)}` };
180
+ const listing = () => ({ ok: true, routing: routingOf(store), defaultRouting: DEFAULT_ROUTING, agents: ids.map((id) => {
181
+ const listed = listingOf(store, id);
182
+ return { id, name: nameOf(id), choice: choiceOf(store, id), ownModel: modelOf(id), listedAt: listed?.at ?? null, ...(listed?.error ? { error: listed.error } : {}), models: listed?.models ?? [] };
183
+ }) });
184
+ return request.refresh === true && agent === "user" ? { pending: listModels().then(listing) } : listing();
185
+ }
163
186
  // Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
164
187
  // nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
165
188
  // are paid) until the person sets a spend ceiling.
@@ -169,8 +192,10 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
169
192
  const folder = request.folder ? String(request.folder) : null;
170
193
  if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
171
194
  const team = new Set(mentionsIn(text, roomAgents));
195
+ const picked = picksIn(store, text, roomAgents);
196
+ if (picked.error) return { ok: false, error: picked.error };
172
197
  if (!team.size) return { ok: false, error: `name who the thread is for with @, one of: ${roomAgents.filter((name) => name !== agent && name !== "harness").join(", ")}` };
173
- const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
198
+ const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").slice(0, 120) || text.slice(0, 120);
174
199
  const thread = store.createGoal({ objective, originAgent: agent, provenance: provenanceFor(agent), team: [...team, agent, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
175
200
  if (request.hostedCaller) store.meta(`hosted-thread:${thread.id}`, "1");
176
201
  const first = store.roomPost({ goalId: thread.id, author: agent, kind: "post", text, mentions: addressed(store.getTask(thread.id), text) });
@@ -186,12 +211,37 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
186
211
  return { ok: true, thread: summary(store.getTask(thread.id)) };
187
212
  }
188
213
  if (agent !== "user") return { ok: false, error: "only the person writes in, stops or resumes a thread they did not start; post in it with post_to_room" };
214
+ // Where a thread works, for the composer (folders.mjs): the folders the person works in, any folder on the computer
215
+ // from a typed path, and where a message would work: the folder chosen (`folder`) or the one Harness suggests, and
216
+ // where each coding agent named in it (`text`, `to`, `agents`) runs its turns there.
217
+ if (command === "board.swarm.places") return { ok: true, places: folders.known(), home: os.homedir() };
218
+ if (command === "board.swarm.browse") return folders.browse(request.path);
219
+ if (command === "board.swarm.place") {
220
+ const text = String(request.text || "").slice(0, 20_000);
221
+ const to = recipient(request);
222
+ const team = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : []), ...(Array.isArray(request.agents) ? request.agents.map(String).filter((name) => roomAgents.includes(name)) : [])])];
223
+ if (request.folder) {
224
+ const folder = String(request.folder);
225
+ return isDir(folder) ? placeOf(path.resolve(folder), team, "you") : { ok: false, error: `${folder} is not a folder on this computer` };
226
+ }
227
+ if (!needsFolder(team)) return placeOf(null, team, null);
228
+ return { pending: folders.suggest(text, team).then(({ folder, by, lastWorked }) => placeOf(folder, team, by, lastWorked)) };
229
+ }
189
230
  // The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
190
231
  if (command === "board.swarm.note") {
191
232
  const named = resolveMember(request.target);
192
233
  if (!named.ok) return { ok: false, error: named.candidates.length ? `"${request.target}" could be ${named.candidates.join(" or ")}; name one` : `no agent called "${request.target}"` };
193
234
  return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
194
235
  }
236
+ // The person's choice of model and effort for one agent: a model it lists, "auto", or "default" for its own settings.
237
+ if (command === "board.swarm.model") {
238
+ const named = resolveMember(request.target);
239
+ if (!named.ok || !PICKABLE.has(named.id)) return { ok: false, error: `the relay sets the model of ${[...PICKABLE].join(", ")}, not "${request.target}"` };
240
+ const set = setChoice(store, named.id, request.model, request.effort);
241
+ return set.ok ? { ok: true, agent: named.id, choice: set.choice } : set;
242
+ }
243
+ // The person's words on how Auto picks models for swarm work; no text goes back to the default words.
244
+ if (command === "board.swarm.routing") return { ok: true, routing: setRouting(store, request.text), defaultRouting: DEFAULT_ROUTING };
195
245
  // The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
196
246
  if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
197
247
  if (command === "board.swarm.coordinator") {
@@ -211,6 +261,8 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
211
261
  const to = recipient(request);
212
262
  if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
213
263
  const team = new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]);
264
+ const picked = picksIn(store, text, roomAgents);
265
+ if (picked.error) return { ok: false, error: picked.error };
214
266
  if (from) {
215
267
  const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
216
268
  if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
@@ -222,23 +274,23 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
222
274
  team.add(agentName);
223
275
  folder ??= isDir(session.cwd) ? session.cwd : null;
224
276
  }
225
- // Where the work happens: the named agent's last folder, else the latest coding session's on this computer; one in
226
- // a worktree (a thread's copy here, T3 Code's) is the repository's own checkout, where the person works.
227
- const last = [...team].map((name) => PROCESS_NAMES[name] ? lastFolder(name) : null).find(Boolean)
228
- ?? store.recentSessions(100).find((session) => PROCESS_NAMES[session.agent] && isDir(session.cwd))?.cwd ?? null;
229
- folder ??= last ? mainCheckout(last) : null;
230
- const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
231
- const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...team, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
232
- if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
233
- const opened = store.getTask(thread.id);
234
- const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
235
- wakeFor(thread.id, first, "user");
236
- notifyBoard(store.getTask(thread.id));
237
- return { ok: true, thread: summary(store.getTask(thread.id)) };
277
+ // Where the work happens: the folder the person chose, else one picked from what they wrote, else where its agent
278
+ // last worked (folders.mjs); never the home folder or a hidden one.
279
+ const begin = (where) => {
280
+ const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").slice(0, 120) || text.slice(0, 120);
281
+ const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...new Set([...team, "harness"])], facilitator: "harness", workspace: where ? path.resolve(where) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
282
+ if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
283
+ const opened = store.getTask(thread.id);
284
+ const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
285
+ wakeFor(thread.id, first, "user");
286
+ notifyBoard(store.getTask(thread.id));
287
+ return { ok: true, thread: summary(store.getTask(thread.id)) };
288
+ };
289
+ return folder || !needsFolder([...team]) ? begin(folder) : { pending: folders.suggest(text, [...team]).then((suggested) => begin(suggested.folder)) };
238
290
  }
239
291
  const thread = store.getTask(String(request.id || ""));
240
292
  if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
241
- if (command === "board.swarm.read") return read(thread);
293
+ if (command === "board.swarm.read") return read(thread, request.hostedCaller === true);
242
294
  if (command === "board.swarm.diff") return worktrees.diff(thread.id, request.runId ? String(request.runId) : null);
243
295
  if (command === "board.swarm.merge" || command === "board.swarm.discard") {
244
296
  // Both change the person's repository, so both wait for the person, and never while an agent is changing files.
@@ -276,10 +328,21 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
276
328
  notifyBoard(store.getTask(thread.id));
277
329
  return { ok: true, thread: summary(store.getTask(thread.id)) };
278
330
  }
331
+ // The person moves the thread to another folder between turns. Each agent starts a new session there, as a session
332
+ // and a T3 thread belong to the folder they ran in.
279
333
  if (command === "board.swarm.folder") {
280
334
  const folder = String(request.folder || "");
281
- if (!isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
282
- store.setGoalFolder(thread.id, path.resolve(folder));
335
+ if (!isDir(folder)) return { ok: false, error: `${folder} is not a folder on this computer` };
336
+ const where = path.resolve(folder);
337
+ if (where === thread.goal.workspace) return { ok: true, thread: summary(thread) };
338
+ if (store.threadRuns(thread.id).some((run) => ["open", "claimed", "running"].includes(run.status))) return { ok: false, error: "an agent is still working in this thread; stop it or wait for it first" };
339
+ const moved = worktrees.moved(thread.id);
340
+ if (!moved.ok) return moved;
341
+ store.setGoalFolder(thread.id, where);
342
+ for (const name of Object.keys(thread.goal.sessions || {})) store.setGoalSession(thread.id, name, null);
343
+ for (const name of thread.goal.team) if (store.meta(`t3-thread:${thread.id}:${name}`)) store.meta(`t3-thread:${thread.id}:${name}`, "");
344
+ store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: `Now working in ${where}.` });
345
+ notifyBoard(store.getTask(thread.id));
283
346
  return { ok: true, thread: summary(store.getTask(thread.id)) };
284
347
  }
285
348
  return { ok: false, error: "unknown command" };
package/lib/t3.mjs CHANGED
@@ -201,7 +201,7 @@ function stepOf(item) {
201
201
  /**
202
202
  * A coding agent's turn in a Harness thread, carried out by T3 Code, so the person finds it in T3 like any thread there.
203
203
  * The agent's first turn launches its own T3 thread (`launch`: T3's project, workspace and the model the person last
204
- * chose for the agent there; T3 applies the mode the person approved Harness with); later turns go into that thread,
204
+ * chose for the agent there, unless the relay chose one, `model`; T3 applies the mode the person approved Harness with); later turns go into that thread,
205
205
  * queued behind whatever it is doing. It reports as runner.mjs does: accepted, its session (the T3 thread, which is
206
206
  * how Open finds it), each step, then done with the agent's last words or failed; Stop interrupts it in T3.
207
207
  */
@@ -245,9 +245,12 @@ export function runInT3(run, emit, observe) {
245
245
  emit("accepted");
246
246
  void (async () => {
247
247
  try {
248
- if (threadId) runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
249
- else {
250
- const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch });
248
+ // A model the relay chose for this turn (models.mjs) is the thread's selection from this turn on.
249
+ if (threadId) {
250
+ if (run.model) await t3Tool("t3_thread_configure", { threadId, modelSelection: run.model });
251
+ runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
252
+ } else {
253
+ const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch, ...(run.model ? { modelSelection: run.model } : {}) });
251
254
  threadId = launched.threadId;
252
255
  runId = launched.runId ?? null;
253
256
  run.onThread?.(threadId);
package/lib/trails.mjs ADDED
@@ -0,0 +1,177 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+
5
+ // What a coding session actually did, read from the agent's own session file: the person's first request, every tool
6
+ // call in order (the paths it named, the files it changed, commands with their exit codes) and the tokens each model
7
+ // call used. This is the record the context engine learns from (what a run needed against what its brief carried) and
8
+ // the evidence receipts check claims against. Read only; the parsers never decide anything about intent.
9
+
10
+ const PATH_TOKEN = /(?:\.{0,2}\/)?[\w@+-][\w@+.-]*(?:\/[\w@+.-]+)+|[\w@+-][\w@+-]*\.[A-Za-z][A-Za-z0-9]{0,7}\b/g;
11
+ const PATCH_FILE = /\*\*\* (?:Update|Add|Delete) File: ([^\n"\\]+)/g;
12
+ const REDIRECT_FILE = /(?:^|[\s;&|])(?:>>?|tee(?:\s+-a)?)\s+(["']?)([^\s"'|;&<>]+)\1/g;
13
+ const SED_INPLACE = /\bsed\s+(?:-[a-zA-Z]*i[a-zA-Z]*(?:\s+''|\s+"")?|--in-place)\b[^|;&]*?\s(["']?)([^\s"'|;&]+)\1\s*(?:$|[|;&])/gm;
14
+ // Test runners by their command, for receipts: a command's own exit code is the evidence, never the agent's words.
15
+ const TEST_COMMAND = /\b(?:npm(?:\s+run)?\s+(?:test|smoke|e2e|eval|lint|typecheck)|npx\s+(?:jest|vitest|tsx\s+tests\/|playwright\s+test)|jest|vitest|pytest|cargo\s+(?:test|check|clippy)|go\s+test|forge\s+test|bun\s+test|deno\s+test|swift\s+test|mvn\s+test|gradlew?\s+test|make\s+(?:test|check)|node\s+(?:--test\b|[\w./-]*\btests?\/)|tsc(?:\s|$))/;
16
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "edit", "write", "apply_patch"]);
17
+ const BRIEF_SKIP = /^<(?:command-|local-command|system-reminder|task-notification|user-prompt-submit-hook)|^Caveat:/;
18
+
19
+ const parseJson = (line) => { try { const value = JSON.parse(line); return value && typeof value === "object" ? value : null; } catch { return null; } };
20
+ const textOf = (content) => typeof content === "string" ? content
21
+ : Array.isArray(content) ? content.map((part) => (typeof part?.text === "string" ? part.text : "")).filter(Boolean).join("\n") : "";
22
+ const ms = (value) => { const at = typeof value === "number" ? value * (value < 1e12 ? 1000 : 1) : Date.parse(value); return Number.isFinite(at) ? at : null; };
23
+
24
+ /** Path-like words in a tool call's input or output, as written (relative or absolute). */
25
+ export function pathTokens(text) {
26
+ return [...new Set(String(text).match(PATH_TOKEN) || [])].map((token) => token.replace(/[.,:;)\]}'"`]+$/, "")).filter((token) => token.length > 2 && token.length < 300);
27
+ }
28
+
29
+ /** Files a tool call changed: edit tools' own paths, patch headers, sed -i, redirects and tee. */
30
+ export function editedPaths(tool, input) {
31
+ const out = new Set();
32
+ if (EDIT_TOOLS.has(tool) && typeof input?.file_path === "string") out.add(input.file_path);
33
+ if (EDIT_TOOLS.has(tool) && typeof input === "string") { const found = input.match(/"(?:file_path|path)":"([^"]+)"/); if (found && !found[1].endsWith("…")) out.add(found[1]); }
34
+ if (EDIT_TOOLS.has(tool) && typeof input?.path === "string") out.add(input.path);
35
+ if (tool === "NotebookEdit" && typeof input?.notebook_path === "string") out.add(input.notebook_path);
36
+ const text = typeof input === "string" ? input : JSON.stringify(input ?? "").replace(/\\n/g, "\n").replace(/\\"/g, "\"");
37
+ for (const match of text.matchAll(PATCH_FILE)) out.add(match[1].trim());
38
+ for (const match of text.matchAll(SED_INPLACE)) out.add(match[2]);
39
+ for (const match of text.matchAll(REDIRECT_FILE)) if (!/^\/dev\/|^&\d$/.test(match[2])) out.add(match[2]);
40
+ return [...out];
41
+ }
42
+
43
+ export const isTestCommand = (command) => TEST_COMMAND.test(String(command));
44
+
45
+ function exitCodeOf(text) {
46
+ const found = String(text).match(/"?exit_code"?\s*[:=]\s*(-?\d+)|Exit code:?\s*(-?\d+)|Process exited with code (-?\d+)/i);
47
+ return found ? Number(found[1] ?? found[2] ?? found[3]) : null;
48
+ }
49
+
50
+ /**
51
+ * A session's trail: { agent, sessionId, cwd, brief, startedAt, endedAt, calls, usage, turns }. A call is
52
+ * { at, tool, input (clipped text), named (path words), edited (paths), command, exitCode, error }; usage is one row
53
+ * per model call { at, input, cached, output }; turns are the person's messages and stops, { at, interrupted }. Claude Code and Codex are read in full; other agents fall back to none.
54
+ */
55
+ export async function readTrail(agent, file) {
56
+ if (!["claude-code", "codex"].includes(agent) || !fs.existsSync(file)) return null;
57
+ const trail = { agent, sessionId: null, cwd: null, brief: null, startedAt: null, endedAt: null, calls: [], usage: [], turns: [] };
58
+ const byId = new Map();
59
+ const add = (at, tool, input, id) => {
60
+ const text = typeof input === "string" ? input : JSON.stringify(input ?? {});
61
+ const command = typeof input?.command === "string" ? input.command : Array.isArray(input?.command) ? input.command.join(" ") : typeof input?.cmd === "string" ? input.cmd
62
+ : [...text.matchAll(/cmd\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1]).join("\n") || null;
63
+ const call = { at, tool, input: text.slice(0, 4_000), named: pathTokens(text), edited: editedPaths(tool, input), command, exitCode: null, error: false, output: "" };
64
+ trail.calls.push(call);
65
+ if (id) byId.set(id, call);
66
+ };
67
+ const result = (id, text, error) => {
68
+ const call = byId.get(id);
69
+ if (!call) return;
70
+ call.output = String(text).slice(0, 4_000);
71
+ call.named = [...new Set([...call.named, ...pathTokens(String(text).slice(0, 20_000))])];
72
+ call.exitCode = exitCodeOf(text);
73
+ call.error = Boolean(error) || (call.exitCode !== null && call.exitCode !== 0);
74
+ };
75
+ const lines = readline.createInterface({ input: fs.createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
76
+ for await (const line of lines) {
77
+ const row = parseJson(line);
78
+ if (!row) continue;
79
+ const at = ms(row.timestamp ?? row.payload?.timestamp);
80
+ if (at) { trail.startedAt ??= at; trail.endedAt = at; }
81
+ if (agent === "claude-code") {
82
+ trail.sessionId ??= row.sessionId ?? null;
83
+ trail.cwd ??= row.cwd ?? null;
84
+ const content = row.message?.content;
85
+ if (row.type === "user" && !row.isMeta) {
86
+ const words = (typeof content === "string" ? content : Array.isArray(content) && !content.some((part) => part?.type === "tool_result") ? textOf(content) : "").trim();
87
+ // The person's turns: each message starts one; Esc leaves "[Request interrupted by user...]" where it stopped one.
88
+ if (words.startsWith("[Request interrupted by user")) trail.turns.push({ at, interrupted: true });
89
+ else if (words && !BRIEF_SKIP.test(words)) { trail.turns.push({ at, interrupted: false }); trail.brief ??= words; }
90
+ }
91
+ if (Array.isArray(content)) for (const part of content) {
92
+ if (part?.type === "tool_use") add(at, part.name, part.input, part.id);
93
+ if (part?.type === "tool_result") result(part.tool_use_id, textOf(part.content) || JSON.stringify(row.toolUseResult ?? ""), part.is_error);
94
+ }
95
+ const usage = row.type === "assistant" ? row.message?.usage : null;
96
+ if (usage) trail.usage.push({ at, input: (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0), cached: usage.cache_read_input_tokens || 0, output: usage.output_tokens || 0 });
97
+ } else {
98
+ const payload = row.payload ?? {};
99
+ if (row.type === "session_meta") { trail.sessionId ??= payload.id ?? null; trail.cwd ??= payload.cwd ?? null; }
100
+ if (row.type === "response_item" && payload.type === "message" && payload.role === "user") {
101
+ const words = textOf(payload.content).trim();
102
+ if (words && !/^<(?:environment_context|user_instructions|permissions|turn_aborted)/.test(words) && !words.startsWith("# AGENTS.md")) { trail.turns.push({ at, interrupted: false }); trail.brief ??= words; }
103
+ }
104
+ if (row.type === "event_msg" && payload.type === "turn_aborted" && payload.reason === "interrupted") trail.turns.push({ at, interrupted: true });
105
+ if (row.type === "response_item" && ["function_call", "custom_tool_call", "local_shell_call"].includes(payload.type)) {
106
+ const input = payload.input ?? parseJson(payload.arguments) ?? payload.arguments ?? payload.action;
107
+ add(at, payload.name ?? payload.type, input, payload.call_id);
108
+ }
109
+ if (row.type === "response_item" && ["function_call_output", "custom_tool_call_output"].includes(payload.type)) {
110
+ const text = typeof payload.output === "string" ? payload.output : textOf(payload.output) || JSON.stringify(payload.output ?? "");
111
+ result(payload.call_id, text, false);
112
+ }
113
+ if (row.type === "token_usage_record" && payload.usage) trail.usage.push({ at, input: payload.usage.input_tokens || 0, cached: payload.usage.cached_input_tokens || 0, output: payload.usage.output_tokens || 0 });
114
+ }
115
+ }
116
+ return trail;
117
+ }
118
+
119
+ const RESULT_EDIT = /(?:The file (\S+) has been (?:updated|created)|File created successfully at: (\S+?)[\s)\]])/g;
120
+
121
+ /**
122
+ * A trail rebuilt from the relay's own copy of a session (store.mjs keeps every message with tool calls clipped), for
123
+ * sessions whose source file the agent has since deleted. It has the brief, the calls with the paths they named and the
124
+ * files they changed (the edit tools' own confirmations carry the whole path), but no token counts or timestamps.
125
+ */
126
+ export function trailFromTranscript(agent, transcript, { cwd = null, startedAt = null } = {}) {
127
+ const trail = { agent, sessionId: null, cwd, brief: null, startedAt, endedAt: startedAt, calls: [], usage: [] };
128
+ for (const block of String(transcript).split(/\n\n(?=(?:User|Agent): )/)) {
129
+ const user = block.startsWith("User: ");
130
+ const text = block.replace(/^(?:User|Agent): /, "");
131
+ if (user && !trail.brief && !text.startsWith("[tool result") && !BRIEF_SKIP.test(text.trim()) && !text.includes("<local-command-caveat>")) trail.brief = text.trim();
132
+ for (const match of text.matchAll(/\[tool: (\S+) ([\s\S]*?)\](?=\n|$)/g)) {
133
+ trail.calls.push({ at: null, tool: match[1], input: match[2], named: pathTokens(match[2]), edited: editedPaths(match[1], match[2]).filter((file) => !file.endsWith("…")), command: null, exitCode: null, error: false, output: "" });
134
+ }
135
+ if (user && text.startsWith("[tool result")) {
136
+ const last = trail.calls.at(-1);
137
+ if (!last) continue;
138
+ last.named = [...new Set([...last.named, ...pathTokens(text)])];
139
+ for (const match of text.matchAll(RESULT_EDIT)) last.edited = [...new Set([...last.edited, match[1] || match[2]])];
140
+ }
141
+ }
142
+ return trail;
143
+ }
144
+
145
+ /** A path as written in a call, made relative to root when it lies inside it; null when it points elsewhere. */
146
+ export function insideRoot(token, root, cwd = root) {
147
+ const absolute = path.isAbsolute(token) ? token : path.resolve(cwd || root, token);
148
+ const rel = path.relative(root, absolute);
149
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null;
150
+ }
151
+
152
+ /**
153
+ * What a trail shows a run needed, against the files that existed: { edited, read, firstEditAt, beforeFirstEdit }.
154
+ * `read` is every existing file the run named before its first edit (opened, listed, grepped into); `beforeFirstEdit`
155
+ * counts the calls and tokens spent before that edit, which is what a better brief can save.
156
+ */
157
+ export function needs(trail, root, files) {
158
+ const known = (token) => { const rel = insideRoot(token, root, trail.cwd); return rel && files.has(rel) ? rel : null; };
159
+ const edited = new Set();
160
+ const read = new Set();
161
+ let firstEditAt = null;
162
+ let editing = false;
163
+ let callsBefore = 0;
164
+ for (const call of trail.calls) {
165
+ const changed = call.edited.map(known).filter(Boolean);
166
+ if (changed.length && !editing) { editing = true; firstEditAt = call.at; }
167
+ for (const file of changed) edited.add(file);
168
+ if (!editing) { callsBefore += 1; for (const token of call.named) { const file = known(token); if (file) read.add(file); } }
169
+ }
170
+ const before = trail.usage.filter((row) => !editing || firstEditAt === null || (row.at ?? 0) < firstEditAt);
171
+ return {
172
+ edited: [...edited], read: [...read], firstEditAt,
173
+ beforeFirstEdit: { calls: callsBefore, modelCalls: before.length, inputTokens: before.reduce((sum, row) => sum + row.input, 0),
174
+ uncachedInputTokens: before.reduce((sum, row) => sum + Math.max(0, row.input - row.cached), 0), outputTokens: before.reduce((sum, row) => sum + row.output, 0),
175
+ ms: firstEditAt !== null && trail.startedAt !== null ? firstEditAt - trail.startedAt : null },
176
+ };
177
+ }
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ // Watches: something the person wants kept an eye on, read by a command on this computer on a schedule (a log, a status
5
+ // command, a balance), and said out loud only when it is off. Each check reads one number from the command's output (a
6
+ // field like "ageSeconds" or the count of lines matching a pattern like "critical") and compares it with a limit the
7
+ // watch names, or, without one, with its own usual range once it has enough history. A falling number with a floor gets
8
+ // a run-out estimate. Plain arithmetic, no model. An agent may suggest a watch; it runs only once the person starts it.
9
+ // The October 13 keeper outage is why: its own health job logged "no recent successful keeper run" every 5 minutes for
10
+ // 41 hours and nobody saw it.
11
+
12
+ const HISTORY = 500;
13
+ const USUAL_AFTER = 12;
14
+ const MIN_EVERY_SECONDS = 5;
15
+ const RUN_OUT_HOURS = 24;
16
+
17
+ const median = (values) => { const sorted = [...values].sort((a, b) => a - b); const mid = sorted.length >> 1; return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; };
18
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+
20
+ /**
21
+ * The number a check reads from output: how many lines hold a phrase (count), seconds since a named field's last
22
+ * timestamp (age: unix seconds, milliseconds or an ISO date), a named field's last value (field), or the last number in
23
+ * it. null when absent.
24
+ */
25
+ export function readNumber(output, measure, now = Date.now()) {
26
+ const text = String(output ?? "");
27
+ if (measure.count) return text.split("\n").filter((line) => line.toLowerCase().includes(String(measure.count).toLowerCase())).length;
28
+ if (measure.age) {
29
+ const found = [...text.matchAll(new RegExp(`["']?${escape(measure.age)}["']?\\s*[:=]\\s*"?([0-9][0-9:.TZ+-]*)`, "g"))].at(-1)?.[1];
30
+ if (!found) return null;
31
+ const at = /^\d+(?:\.\d+)?$/.test(found) ? Number(found) * (Number(found) < 1e12 ? 1000 : 1) : Date.parse(found);
32
+ return Number.isFinite(at) ? Math.max(0, Math.round((now - at) / 1000)) : null;
33
+ }
34
+ if (measure.field) {
35
+ const found = [...text.matchAll(new RegExp(`["']?${escape(measure.field)}["']?\\s*[:=]\\s*"?(-?\\d+(?:\\.\\d+)?)`, "g"))].at(-1);
36
+ return found ? Number(found[1]) : null;
37
+ }
38
+ const found = text.match(/-?\d+(?:\.\d+)?(?=\D*$)/);
39
+ return found ? Number(found[0]) : null;
40
+ }
41
+
42
+ /**
43
+ * Whether the newest sample is off, and why: past a limit the watch names, outside its usual range (median and spread of
44
+ * its history, once it has some), or, falling toward its floor, due to cross it within a day. null when it is fine.
45
+ */
46
+ export function offPattern(samples, { above = null, below = null } = {}) {
47
+ const values = samples.map((sample) => sample.value).filter((value) => value !== null && Number.isFinite(value));
48
+ const latest = samples.at(-1);
49
+ if (!latest || latest.value === null) return latest?.error ? { kind: "failed", text: `its check failed: ${latest.error}` } : null;
50
+ if (above !== null && latest.value > above) return { kind: "limit", text: `is ${latest.value}, above ${above}` };
51
+ if (below !== null && latest.value < below) return { kind: "limit", text: `is ${latest.value}, below ${below}` };
52
+ if (below !== null && samples.length >= 4) {
53
+ // Falling toward the floor: a straight line through the recent samples, and when it reaches it.
54
+ const recent = samples.slice(-12).filter((sample) => sample.value !== null);
55
+ const t0 = recent[0].at;
56
+ const xs = recent.map((sample) => (sample.at - t0) / 3_600_000), ys = recent.map((sample) => sample.value);
57
+ const mx = xs.reduce((a, b) => a + b, 0) / xs.length, my = ys.reduce((a, b) => a + b, 0) / ys.length;
58
+ const slope = xs.reduce((sum, x, i) => sum + (x - mx) * (ys[i] - my), 0) / (xs.reduce((sum, x) => sum + (x - mx) ** 2, 0) || 1);
59
+ if (slope < 0) {
60
+ const hours = (latest.value - below) / -slope;
61
+ if (hours <= RUN_OUT_HOURS) return { kind: "run-out", text: `is falling by ${Math.abs(slope).toPrecision(2)} an hour and reaches ${below} in about ${hours < 1 ? `${Math.max(1, Math.round(hours * 60))} minutes` : `${Math.round(hours)} hour${Math.round(hours) === 1 ? "" : "s"}`}` };
62
+ }
63
+ }
64
+ if (above === null && below === null && values.length > USUAL_AFTER) {
65
+ const history = values.slice(0, -1).slice(-100);
66
+ const middle = median(history);
67
+ // A number that takes two values (a sawtooth, a flag) has no median deviation; the mean deviation stands in.
68
+ const deviations = history.map((value) => Math.abs(value - middle));
69
+ const spread = median(deviations) || deviations.reduce((a, b) => a + b, 0) / deviations.length || Math.max(1e-9, Math.abs(middle) * 0.05);
70
+ if (Math.abs(latest.value - middle) > 6 * spread) return { kind: "unusual", text: `is ${latest.value}; it is usually about ${Number(middle.toPrecision(4))}` };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Runs a check's command in its folder; its output, or an error. A check never runs longer than its own interval. */
76
+ function runCheck(watch, shell = process.env.SHELL || "/bin/sh") {
77
+ return new Promise((resolve) => {
78
+ const started = performance.now();
79
+ let out = "", err = "", done = false;
80
+ const child = spawn(shell, ["-lc", watch.command], { cwd: watch.folder || undefined, stdio: ["ignore", "pipe", "pipe"] });
81
+ const timer = setTimeout(() => { child.kill("SIGKILL"); finish(null, `it took longer than its ${watch.everySeconds} s interval`); }, watch.everySeconds * 1000);
82
+ const finish = (output, error) => { if (done) return; done = true; clearTimeout(timer); resolve({ output, error, ms: Math.round(performance.now() - started) }); };
83
+ child.stdout.on("data", (chunk) => { out = (out + chunk).slice(-200_000); });
84
+ child.stderr.on("data", (chunk) => { err = (err + chunk).slice(-4_000); });
85
+ child.on("error", (error) => finish(null, error.message));
86
+ child.on("close", (code) => finish(code === 0 || out ? out : null, code === 0 || out ? null : (err.trim().split("\n").at(-1) || `it exited with code ${code}`)));
87
+ });
88
+ }
89
+
90
+ export function createWatches({ store, notify = () => {}, log = () => {}, run = runCheck }) {
91
+ const list = () => { try { return JSON.parse(store.meta("watches") || "[]"); } catch { return []; } };
92
+ const save = (watches) => store.meta("watches", JSON.stringify(watches));
93
+ const samplesOf = (id) => { try { return JSON.parse(store.meta(`watch-samples:${id}`) || "[]"); } catch { return []; } };
94
+ const timers = new Map();
95
+
96
+ const check = async (id) => {
97
+ const watch = list().find((entry) => entry.id === id);
98
+ if (!watch || watch.state !== "on") return null;
99
+ const result = await run(watch);
100
+ const value = result.output === null ? null : readNumber(result.output, watch.measure);
101
+ const samples = [...samplesOf(id), { at: Date.now(), value, ms: result.ms, ...(result.error ? { error: result.error.slice(0, 300) } : value === null ? { error: "its output had no number to read" } : {}) }].slice(-HISTORY);
102
+ store.meta(`watch-samples:${id}`, JSON.stringify(samples));
103
+ const off = offPattern(samples, watch.measure);
104
+ const current = list().find((entry) => entry.id === id);
105
+ if (!current) return null;
106
+ // Said once when it goes off and once when it is back; not on every check while it stays off.
107
+ if (off && !current.off) {
108
+ const alert = { at: new Date().toISOString(), text: `${current.name} ${off.text}.`, kind: off.kind };
109
+ save(list().map((entry) => (entry.id === id ? { ...entry, off: alert } : entry)));
110
+ store.meta("watch-alerts", JSON.stringify([...(JSON.parse(store.meta("watch-alerts") || "[]")), { watch: id, ...alert }].slice(-200)));
111
+ notify(alert.text, { watch: current, alert });
112
+ log(`watch ${current.name}: ${off.text}`);
113
+ } else if (!off && current.off) {
114
+ save(list().map((entry) => (entry.id === id ? { ...entry, off: null } : entry)));
115
+ notify(`${current.name} is back to normal.`, { watch: current, alert: null });
116
+ }
117
+ return { value, off, ms: result.ms };
118
+ };
119
+
120
+ const schedule = (watch) => {
121
+ clearTimeout(timers.get(watch.id));
122
+ if (watch.state !== "on") { timers.delete(watch.id); return; }
123
+ const tick = () => { void check(watch.id).catch((error) => log(`watch ${watch.name}: ${error.message}`)).finally(() => { if (timers.has(watch.id)) timers.set(watch.id, setTimeout(tick, watch.everySeconds * 1000).unref?.() ?? null); }); };
124
+ timers.set(watch.id, setTimeout(tick, 0));
125
+ };
126
+
127
+ return {
128
+ list: () => list().map((watch) => ({ ...watch, samples: samplesOf(watch.id).slice(-20) })),
129
+ alerts: (since = "") => { try { return JSON.parse(store.meta("watch-alerts") || "[]").filter((alert) => alert.at > since); } catch { return []; } },
130
+ /** A new watch: the person's starts at once; an agent's waits for the person's start. */
131
+ add({ name, command, folder = null, everySeconds = 300, measure = {}, by = "user" }) {
132
+ if (!String(name || "").trim() || !String(command || "").trim()) return { ok: false, error: "a watch needs a name and a command to read" };
133
+ const every = Math.max(MIN_EVERY_SECONDS, Math.round(Number(everySeconds) || 300));
134
+ const clean = {
135
+ ...(measure.field ? { field: String(measure.field).slice(0, 100) } : {}), ...(measure.count ? { count: String(measure.count).slice(0, 100) } : {}), ...(measure.age ? { age: String(measure.age).slice(0, 100) } : {}),
136
+ above: Number.isFinite(Number(measure.above)) && measure.above !== null && measure.above !== "" ? Number(measure.above) : null,
137
+ below: Number.isFinite(Number(measure.below)) && measure.below !== null && measure.below !== "" ? Number(measure.below) : null,
138
+ };
139
+ const watch = { id: randomUUID(), name: String(name).trim().slice(0, 120), command: String(command).slice(0, 2_000), folder, everySeconds: every, measure: clean, by, state: by === "user" ? "on" : "suggested", off: null, createdAt: new Date().toISOString() };
140
+ save([...list(), watch]);
141
+ if (watch.state === "on") schedule(watch);
142
+ return { ok: true, watch };
143
+ },
144
+ start(id) {
145
+ const watch = list().find((entry) => entry.id === id);
146
+ if (!watch) return { ok: false, error: "no such watch" };
147
+ const on = { ...watch, state: "on" };
148
+ save(list().map((entry) => (entry.id === id ? on : entry)));
149
+ schedule(on);
150
+ return { ok: true, watch: on };
151
+ },
152
+ remove(id) {
153
+ if (!list().some((entry) => entry.id === id)) return { ok: false, error: "no such watch" };
154
+ clearTimeout(timers.get(id)); timers.delete(id);
155
+ save(list().filter((entry) => entry.id !== id));
156
+ store.meta(`watch-samples:${id}`, "[]");
157
+ return { ok: true };
158
+ },
159
+ check,
160
+ resume() { for (const watch of list()) if (watch.state === "on") schedule(watch); },
161
+ stop() { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); },
162
+ };
163
+ }