tryharness 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,14 @@ machine with `--run --workspace <dir>` (or an agent's `run_workspace` request, o
73
73
  authorizes it with `tryharness board run <id>`) is claimed by the daemon in that agent's name,
74
74
  run non-interactively through the same runner as a delegated run, and reported back on the
75
75
  task with the result and the agent's session id; `tryharness task <id> --follow` works on it.
76
- One run per agent at a time; a daemon restart marks a running task unknown. An interactive
76
+ One run per agent at a time; a daemon restart marks a running task unknown.
77
+ A coding agent also starts, without `board run`, the tasks you or Harness post for it with a workspace in a
78
+ folder it has already worked in (its indexed sessions; your home folder counts only as itself, links are
79
+ followed), up to 10 a day. That is on by default, and the daemon says so once when it first starts with it. A
80
+ task another agent wrote, or one Harness posts from a turn you did not start or while working another agent's
81
+ task, always waits for you. `tryharness board standing` shows it; `tryharness board standing <agent> <folder>...
82
+ [--cap <n>]` names the folders instead; `tryharness board standing <agent> off` and `on` turn it off or back to
83
+ the default. An interactive
77
84
  claim carries a lease (default 60 minutes); a lapsed lease shows the claim as stale and only
78
85
  the poster or the user releases it. `tryharness board agents` shows when each agent last read
79
86
  the board. Every change goes up the bridge as identity and status only.
package/cli.mjs CHANGED
@@ -171,6 +171,7 @@ async function connect() {
171
171
  throw new Error(`setup is incomplete for ${failed.map((state) => state.agent).join(", ")}. Nothing was hidden; run npx tryharness doctor after fixing the reported CLI error, then rerun setup.`);
172
172
  }
173
173
  console.log("history: indexing locally in the background");
174
+ console.log("runs: your coding agents start the tasks you ask for in folders they have worked in, up to 10 a day each, without a Start; tasks another agent wrote wait for you. Change it: npx tryharness board standing");
174
175
  console.log("Done. You can close this terminal.");
175
176
  }
176
177
 
@@ -316,6 +317,30 @@ async function board() {
316
317
  for (const entry of response.entries) console.log(`\n[${entry.kind} · ${entry.author}${entry.source ? ` · ${entry.source}` : ""} · ${entry.score}]\n${entry.text.slice(0, 400)}`);
317
318
  return;
318
319
  }
320
+ // Standing permission: a coding agent starts the tasks you or Harness post for it without a Start. On by default
321
+ // for the folders you have used it in; name folders and a daily cap instead, turn it off, or back on to the default.
322
+ // Tasks another agent wrote always wait for you.
323
+ if (sub === "standing") {
324
+ const rest = argv.slice(argv.indexOf(sub) + 1).filter((value, index, all) => !value.startsWith("--") && all[index - 1] !== "--cap" && all[index - 1] !== "--as");
325
+ const [target, ...dirs] = rest;
326
+ const usage = "usage: tryharness board standing [<agent> <folder> [<folder>...] [--cap <runs a day, default 10>] | <agent> off | <agent> on]";
327
+ if (target && !AGENTS.includes(target)) throw new Error(usage);
328
+ const toggle = dirs.length === 1 && ["on", "off"].includes(dirs[0]) ? dirs[0] : null;
329
+ const response = !target ? await daemonRequest({ command: "board.standing", agent })
330
+ : toggle ? await daemonRequest({ command: `board.standing.${toggle}`, agent, targetAgent: target })
331
+ : dirs.length ? await daemonRequest({ command: "board.standing.set", agent, targetAgent: target, workspaces: dirs.map((dir) => path.resolve(dir)), dailyCap: Number(arg("--cap", "10")) })
332
+ : (() => { throw new Error(usage); })();
333
+ if (!response.ok) throw new Error(response.error || "standing permission failed");
334
+ if (!response.standing.length) { console.log("No coding agent is installed on this machine."); return; }
335
+ for (const rule of response.standing) {
336
+ if (rule.mode === "off") { console.log(`${rule.agent} waits for your Start. Turn it on: npx tryharness board standing ${rule.agent} on`); continue; }
337
+ const where = rule.mode === "default"
338
+ ? (rule.workspaces.length ? `in the ${rule.workspaces.length} folder${rule.workspaces.length === 1 ? "" : "s"} you have used it in (${rule.workspaces.slice(0, 5).join(", ")}${rule.workspaces.length > 5 ? ", ..." : ""})` : "in folders you have used it in (none yet)")
339
+ : `in ${rule.workspaces.join(", ")}`;
340
+ console.log(`${rule.agent} starts tasks from you or Harness without asking, ${where}; ${rule.runs_today} of ${rule.daily_cap} today. Turn it off: npx tryharness board standing ${rule.agent} off`);
341
+ }
342
+ return;
343
+ }
319
344
  if (sub === "run" || sub === "release") {
320
345
  const id = String(argv[argv.indexOf(sub) + 1] || "");
321
346
  const response = await daemonRequest({ command: sub === "run" ? "board.run" : "board.release", agent, id });
@@ -379,7 +404,7 @@ async function board() {
379
404
  for (const wait of t.waiting_on) console.log(`waiting: ${wait.id.slice(0, 8)} is ${wait.status}${wait.holder ? ` with ${wait.holder}` : ""}`);
380
405
  return;
381
406
  }
382
- if (sub !== "list") throw new Error("usage: tryharness board [list --all | show <id> | trace <id> | post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>] | run <id> | release <id> | agents]");
407
+ if (sub !== "list") throw new Error("usage: tryharness board [list --all | show <id> | trace <id> | post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>] | run <id> | release <id> | standing [<agent> <folder>... --cap <n> | <agent> off | <agent> on] | agents]");
383
408
  const response = await daemonRequest({ command: "board.list", agent, all: has("--all") });
384
409
  if (!response.ok) throw new Error(response.error || "board unavailable");
385
410
  if (!response.tasks.length) { console.log("The board is empty."); return; }
package/lib/daemon.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
5
7
  import WebSocket from "ws";
6
8
  import { randomBytes, randomUUID } from "node:crypto";
7
9
  import { detectAgents, run } from "./commands.mjs";
@@ -16,15 +18,31 @@ import { provenanceFor, provenanceLine } from "./provenance.mjs";
16
18
  import { createEmbedder, EMBEDDING_VERSION, modelPresent } from "./embedder.mjs";
17
19
  import { fromBlob, toBlob, topK } from "./vectors.mjs";
18
20
 
19
- const DAEMON_VERSION = "0.4.3";
21
+ const DAEMON_VERSION = readJson(fileURLToPath(new URL("../package.json", import.meta.url)), {}).version;
20
22
  const PROTOCOL_VERSION = 1;
21
23
  const RESCAN_MS = 60_000;
22
24
  const TASK_LOG_PAGE = 64_000;
23
25
 
26
+ function daemonAnswers(socketPath) {
27
+ return new Promise((resolve) => {
28
+ const probe = net.createConnection(socketPath);
29
+ probe.once("connect", () => { probe.destroy(); resolve(true); });
30
+ probe.once("error", () => resolve(false));
31
+ });
32
+ }
33
+
24
34
  export async function startDaemon() {
25
35
  ensureDir(paths.home);
26
36
  const config = readJson(paths.config, {});
27
37
  if (!config.origin || !config.token) throw new Error("Harness is not paired. Run: npx tryharness");
38
+ // node-sqlite3-wasm locks with a directory that exists only during a transaction. A daemon
39
+ // killed mid-transaction leaves it behind and every later open fails "database is locked";
40
+ // with no daemon answering, nothing can hold it.
41
+ const lock = `${paths.database}.lock`;
42
+ if (fs.existsSync(lock) && !(await daemonAnswers(paths.socket))) {
43
+ fs.rmdirSync(lock);
44
+ appendLog(paths.log, "removed a stale database lock left by a stopped daemon");
45
+ }
28
46
  const store = openStore(paths.database);
29
47
  const detected = detectAgents();
30
48
  const installedAgents = detected.filter((entry) => entry.path).map((entry) => entry.agent);
@@ -326,6 +344,37 @@ export async function startDaemon() {
326
344
  // Tasks written before the context space existed get their brief and result as entries.
327
345
  for (const id of store.backfillContributions()) log(`context: backfilled task ${id}`);
328
346
 
347
+ // Standing permission, decided when a task is posted: a run the person asked for (themselves or through Harness),
348
+ // for a coding agent installed here, in a folder the permission covers, under today's cap, starts without a Start.
349
+ // By default it covers the folders the person has already used that agent in; home and anything above it count only
350
+ // as themselves, never as a root. Anything another agent wrote never starts. Links are followed, so no path inside a
351
+ // covered folder leads out of it.
352
+ const RUNNERS = new Set(["claude-code", "codex", "pi", "opencode"]);
353
+ const runners = () => installedAgents.filter((agent) => RUNNERS.has(agent));
354
+ const startOfToday = () => { const day = new Date(); day.setHours(0, 0, 0, 0); return day.toISOString(); };
355
+ const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return path.resolve(dir); } };
356
+ const within = (dir, root) => { const rel = path.relative(realDir(root), realDir(dir)); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
357
+ const aboveHome = (dir) => within(os.homedir(), dir);
358
+ const covers = (rule, dir) => rule.workspaces.some((root) => rule.mode === "default" && aboveHome(root) ? realDir(root) === realDir(dir) : within(dir, root));
359
+ const standingRules = () => store.standing(runners(), startOfToday());
360
+ // null when the run may start now; otherwise why it waits for the person.
361
+ const standingVerdict = ({ trust, target, workspace }) => {
362
+ if (trust !== "user") return "Another agent wrote it, so it never starts without the person.";
363
+ const rule = standingRules().find((entry) => entry.agent === target);
364
+ if (!rule) return `${target} is not a coding agent installed on this machine.`;
365
+ if (rule.mode === "off") return `${target} is set to wait for the person's Start.`;
366
+ if (!covers(rule, workspace)) return rule.mode === "default" ? `${target} has not worked in ${workspace} before, so its first run there waits for the person.` : `${workspace} is outside the folders ${target} may run in without asking (${rule.workspaces.join(", ")}).`;
367
+ if (rule.runs_today >= rule.daily_cap) return `${target} already started ${rule.runs_today} runs today without asking, its daily limit.`;
368
+ return null;
369
+ };
370
+ const applyStanding = (task) => {
371
+ if (!task?.run_workspace || task.run_authorized_at) return undefined;
372
+ const start = `It waits for the person's Start on the Harness Board, or: npx tryharness board run ${task.id}`;
373
+ const reason = standingVerdict({ trust: task.trust, target: task.target_agent, workspace: task.run_workspace });
374
+ if (reason) return { started: false, reason: `${reason} ${start}` };
375
+ return store.authorizeStanding(task.id) ? { started: true } : { started: false, reason: `It could not be authorized. ${start}` };
376
+ };
377
+
329
378
  const boardCommand = (request) => {
330
379
  if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
331
380
  const agent = String(request.agent);
@@ -354,6 +403,11 @@ export async function startDaemon() {
354
403
  if (request.id != null && !validRunId(request.id)) return { ok: false, error: "invalid task id" };
355
404
  if (request.id != null && store.getTask(String(request.id))) return { ok: false, error: "a task with this id already exists", task: store.getTask(String(request.id)) };
356
405
  const thread = request.thread && typeof request.thread === "object" ? request.thread : null;
406
+ // A run asked for now, covered or not posted at all: nothing is left waiting that the person did not see.
407
+ if (request.requireStanding === true) {
408
+ const reason = thread || !runWorkspace || !request.targetAgent ? "a run without a Start needs one target agent and a folder" : standingVerdict({ trust: provenance.trust, target: String(request.targetAgent), workspace: runWorkspace });
409
+ if (reason) return { ok: false, error: reason, standing: { started: false, reason } };
410
+ }
357
411
  if (thread && (!validAgent(thread.with) || !["asker", "target"].includes(thread.role))) return { ok: false, error: "a thread needs the other side's agent and a role" };
358
412
  // Several agents at once: one group task with a part per agent.
359
413
  const targetAgents = Array.isArray(request.targetAgents) ? [...new Set(request.targetAgents.map(String))] : null;
@@ -364,7 +418,7 @@ export async function startDaemon() {
364
418
  else {
365
419
  const posted = store.postGroup({ ...(request.id != null ? { id: String(request.id) } : {}), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000), originAgent: agent, provenance,
366
420
  targetAgents, contextRefs: refs, runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
367
- for (const part of posted.children) notifyBoard(store.getTask(part.id));
421
+ for (const part of posted.children) { applyStanding(store.getTask(part.id)); notifyBoard(store.getTask(part.id)); }
368
422
  notifyBoard(posted.group);
369
423
  return { ok: true, task: posted.group };
370
424
  }
@@ -373,7 +427,8 @@ export async function startDaemon() {
373
427
  originAgent: agent, provenance, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
374
428
  runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && agent === "user" ? "user" : null });
375
429
  if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role });
376
- outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null };
430
+ const standing = thread ? undefined : applyStanding(task);
431
+ outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null, ...(standing ? { standing } : {}) };
377
432
  } else if (request.command === "board.list") {
378
433
  const tasks = store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit });
379
434
  return request.unread ? { ok: true, tasks, unread: store.unreadFor(agent) } : { ok: true, tasks };
@@ -421,6 +476,28 @@ export async function startDaemon() {
421
476
  } else if (request.command === "board.children") return { ok: true, tasks: store.children(id).map((child) => store.getTask(child.id)).filter(Boolean) };
422
477
  // A trace reads both homes, and only Harness can: the machine asks it.
423
478
  else if (request.command === "board.trace") return state.connected ? forwardToHarness(request) : { ok: false, error: "a trace reads the whole chain on this machine and in Harness, and the daemon is not connected to Harness right now" };
479
+ else if (request.command === "board.standing") return { ok: true, standing: standingRules() };
480
+ else if (request.command === "board.standing.check") {
481
+ const reason = standingVerdict({ trust: "user", target: String(request.targetAgent || ""), workspace: String(request.workspace || "") });
482
+ return path.isAbsolute(String(request.workspace || "")) ? { ok: true, covered: !reason, ...(reason ? { reason } : {}) } : { ok: false, error: "a folder must be an absolute path" };
483
+ }
484
+ else if (["board.standing.set", "board.standing.off", "board.standing.on"].includes(request.command)) {
485
+ if (agent !== "user") return { ok: false, error: "only the person changes standing permission" };
486
+ const target = String(request.targetAgent || "");
487
+ if (!RUNNERS.has(target)) return { ok: false, error: `standing permission is for a coding agent: ${[...RUNNERS].join(", ")}` };
488
+ if (request.command === "board.standing.off") store.setStanding(target, "off");
489
+ else if (request.command === "board.standing.on") store.resetStanding(target);
490
+ else {
491
+ const workspaces = Array.isArray(request.workspaces) ? [...new Set(request.workspaces.map(String))] : [];
492
+ if (!workspaces.length || workspaces.length > 20) return { ok: false, error: "name one to twenty folders" };
493
+ const bad = workspaces.find((dir) => !path.isAbsolute(dir) || !fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
494
+ if (bad) return { ok: false, error: `${bad} is not an absolute path to a folder` };
495
+ const dailyCap = Number(request.dailyCap);
496
+ if (!Number.isInteger(dailyCap) || dailyCap < 1 || dailyCap > 100) return { ok: false, error: "the daily limit is a whole number from 1 to 100" };
497
+ store.setStanding(target, "named", workspaces.map((dir) => path.resolve(dir)), dailyCap);
498
+ }
499
+ return { ok: true, standing: standingRules() };
500
+ }
424
501
  else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
425
502
  else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
426
503
  else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
@@ -439,6 +516,13 @@ export async function startDaemon() {
439
516
  // agent at a time; the rest wait open. Nothing here polls: a board write calls runBoard.
440
517
  const activeRuns = new Map();
441
518
  for (const id of store.settleInterruptedRuns()) log(`board task ${id} was running when the daemon stopped; marked unknown`);
519
+ // Standing permission is on by default; the person hears so once, on the first start that has it.
520
+ const defaults = standingRules().filter((rule) => rule.mode === "default");
521
+ if (defaults.length && !store.meta("standing_announced")) {
522
+ const one = defaults.length === 1;
523
+ const text = `${defaults.map((rule) => rule.agent).join(", ")} now ${one ? "starts" : "start"} the tasks you ask for in folders ${one ? "it has" : "they have"} worked in, up to ${defaults[0].daily_cap} a day${one ? "" : " each"}, without a Start. Change it: npx tryharness board standing`;
524
+ log(text); notifyDesktop(text); store.meta("standing_announced", new Date().toISOString());
525
+ }
442
526
  const runBoard = () => {
443
527
  for (const task of store.runnableTasks(installedAgents.filter((agent) => !activeRuns.has(agent)))) {
444
528
  const agent = task.target_agent;
package/lib/store.mjs CHANGED
@@ -16,6 +16,8 @@ function bindings(values) {
16
16
  )));
17
17
  }
18
18
 
19
+ export const DEFAULT_DAILY_RUNS = 10;
20
+
19
21
  export function openStore(file) {
20
22
  const db = new Database(file);
21
23
  // The daemon is the database's single writer. DELETE mode is portable across the
@@ -80,6 +82,17 @@ export function openStore(file) {
80
82
  updated_at text not null
81
83
  );
82
84
  create index if not exists board_tasks_status_idx on board_tasks(status, created_at desc);
85
+ create table if not exists board_standing (
86
+ agent text primary key,
87
+ mode text not null,
88
+ workspaces text not null,
89
+ daily_cap integer not null,
90
+ set_at text not null
91
+ );
92
+ create table if not exists board_meta (
93
+ key text primary key,
94
+ value text not null
95
+ );
83
96
  create table if not exists board_agents (
84
97
  agent text primary key,
85
98
  last_seen_at text not null
@@ -133,6 +146,7 @@ export function openStore(file) {
133
146
  }
134
147
  // Boards written before the lock existed: give every task a holder and a version.
135
148
  const boardColumns = new Set(db.all("pragma table_info(board_tasks)").map((column) => column.name));
149
+ if (!db.all("pragma table_info(board_standing)").some((column) => column.name === "mode")) db.exec("alter table board_standing add column mode text not null default 'named'");
136
150
  if (!boardColumns.has("holder")) {
137
151
  db.exec(`alter table board_tasks add column holder text;
138
152
  alter table board_tasks add column version integer not null default 1;
@@ -344,6 +358,38 @@ export function openStore(file) {
344
358
  if (written.changes === 1) return { ok: true, task: this.getTask(id) };
345
359
  return conflict(this.getTask(id), agent, null, (task) => task.run_authorized_at ? "the run is already authorized" : !task.run_workspace ? "this task did not ask for a run" : `task is ${task.status}`);
346
360
  },
361
+ // Standing permission: an agent starts the work the person or Harness posts for it without a Start each time. By
362
+ // default it covers the folders the person has used that agent in (its indexed sessions); the person can name
363
+ // folders instead (mode 'named') or turn it off ('off'). Only the person changes it; the daemon decides each post.
364
+ setStanding(agent, mode, workspaces = [], dailyCap = DEFAULT_DAILY_RUNS) {
365
+ execute("run", `insert into board_standing(agent, mode, workspaces, daily_cap, set_at) values(?, ?, ?, ?, ?)
366
+ on conflict(agent) do update set mode = excluded.mode, workspaces = excluded.workspaces, daily_cap = excluded.daily_cap, set_at = excluded.set_at`,
367
+ agent, mode, JSON.stringify(workspaces), dailyCap, new Date().toISOString());
368
+ },
369
+ resetStanding(agent) {
370
+ return execute("run", "delete from board_standing where agent = ?", agent).changes === 1;
371
+ },
372
+ // One rule per agent installed here, with the runs it started since `since`.
373
+ standing(agents, since) {
374
+ const rows = new Map(execute("all", "select * from board_standing").map((row) => [row.agent, row]));
375
+ return agents.map((agent) => {
376
+ const row = rows.get(agent);
377
+ const mode = row?.mode || "default";
378
+ const workspaces = mode === "default" ? execute("all", "select distinct cwd from sessions where agent = ? and cwd is not null and cwd != '' order by cwd", agent).map((entry) => entry.cwd) : JSON.parse(row.workspaces);
379
+ return { agent, mode, workspaces, daily_cap: row ? Number(row.daily_cap) : DEFAULT_DAILY_RUNS, set_at: row?.set_at || null,
380
+ runs_today: Number(execute("get", "select count(*) as n from board_tasks where target_agent = ? and run_authorized_by = 'standing' and run_authorized_at >= ?", agent, since).n) };
381
+ });
382
+ },
383
+ meta(key, value) {
384
+ if (value === undefined) return execute("get", "select value from board_meta where key = ?", key)?.value ?? null;
385
+ execute("run", "insert into board_meta(key, value) values(?, ?) on conflict(key) do update set value = excluded.value", key, String(value));
386
+ },
387
+ authorizeStanding(id) {
388
+ const now = new Date().toISOString();
389
+ const written = execute("run", `update board_tasks set run_authorized_by = 'standing', run_authorized_at = @now, updated_at = @now, version = version + 1
390
+ where id = @id and status = 'open' and trust = 'user' and run_workspace is not null and run_authorized_at is null and id not in (select task_id from board_threads)`, { id, now });
391
+ return written.changes === 1;
392
+ },
347
393
  // Tasks the daemon should run now: authorized, still open, for an agent installed here.
348
394
  runnableTasks(agents) {
349
395
  if (!agents.length) return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tryharness",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
5
5
  "bin": {
6
6
  "tryharness": "cli.mjs"