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.
- package/README.md +57 -14
- package/assets/opencode-plugin.js +47 -66
- package/assets/pi-extension.ts +91 -90
- package/cli.mjs +115 -9
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +16 -4
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +27 -9
- package/lib/daemon.mjs +790 -93
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +161 -0
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/live.mjs +30 -5
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +107 -7
- package/lib/media.mjs +118 -0
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +13 -5
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +31 -2
- package/lib/places.mjs +61 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +37 -6
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +64 -5
- package/lib/swarm.mjs +89 -26
- package/lib/t3.mjs +7 -4
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/lib/worktrees.mjs +21 -5
- package/package.json +6 -4
- 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
|
-
|
|
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
|
-
|
|
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-]*)
|
|
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
|
|
226
|
-
//
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return
|
|
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
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|
package/lib/watches.mjs
ADDED
|
@@ -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
|
+
}
|