tryharness 0.7.0 → 0.7.1
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 +13 -6
- package/lib/daemon.mjs +24 -3
- package/lib/mcp-stdio.mjs +4 -3
- package/lib/nudge.mjs +12 -0
- package/lib/provenance.mjs +4 -3
- package/lib/store.mjs +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,8 +39,9 @@ npx tryharness disconnect
|
|
|
39
39
|
daemon, the bridge, the database, and agent discovery. `sync` requests a background
|
|
40
40
|
rescan; it is diagnostic, not part of normal setup.
|
|
41
41
|
|
|
42
|
-
Delegated coding tasks start
|
|
43
|
-
|
|
42
|
+
Delegated coding tasks start when the user presses Start on Harness's proposal card, or
|
|
43
|
+
at once when standing permission covers the folder (see Task board); either way the task is
|
|
44
|
+
posted on the machine's board under the run id. They then run non-interactively with each agent's full-authority/auto-approve mode
|
|
44
45
|
inside the exact absolute working directory shown on that card; Harness never falls
|
|
45
46
|
back to the user's home directory. The card immediately shows a
|
|
46
47
|
`npx tryharness task <run-id> --follow` command for watching the machine-local raw output
|
|
@@ -64,8 +65,11 @@ answers from full-text search and says so.
|
|
|
64
65
|
The daemon keeps a task board in its local SQLite database. Any connected agent can post
|
|
65
66
|
work for another agent on the machine through the `post_task` tool, and every agent sees
|
|
66
67
|
the board through `list_tasks`, `get_task`, `claim_task` and `report_task`, which the
|
|
67
|
-
stdio MCP exposes.
|
|
68
|
-
|
|
68
|
+
stdio MCP exposes. A task with no folder waits until its target agent reads the board and
|
|
69
|
+
claims it. A task from Claude Code or Codex for a coding agent, posted without `run_workspace`,
|
|
70
|
+
takes the folder the poster is working in (its session's folder; never home or above it), so it
|
|
71
|
+
can run; `in_session: true` leaves it for an open session instead. Whether it starts without a
|
|
72
|
+
Start is decided as below. A claimer
|
|
69
73
|
reports done, failed or needs_input; the poster answers a question with `report_task`
|
|
70
74
|
and status `answered`, which returns the task to the claimer with the answer in its
|
|
71
75
|
brief. The daemon is also the board's worker. A task posted for a coding agent installed on the
|
|
@@ -77,8 +81,11 @@ One run per agent at a time; a daemon restart marks a running task unknown.
|
|
|
77
81
|
A coding agent also starts, without `board run`, the tasks you or Harness post for it with a workspace in a
|
|
78
82
|
folder it has already worked in (its indexed sessions; your home folder counts only as itself, links are
|
|
79
83
|
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
|
|
81
|
-
|
|
84
|
+
task Claude Code or Codex posts from a session you typed in counts as yours: the prompt hook tells the daemon
|
|
85
|
+
which agent process you typed in, and a post through that process's board MCP is yours. A run the daemon
|
|
86
|
+
started is never your session, and an agent holding a task another agent wrote posts as an agent until it
|
|
87
|
+
reports. Any other task another agent wrote, anything continuing one, or one Harness posts from a turn you did
|
|
88
|
+
not start or while working another agent's task, always waits for you. `tryharness board standing` shows it; `tryharness board standing <agent> <folder>...
|
|
82
89
|
[--cap <n>]` names the folders instead; `tryharness board standing <agent> off` and `on` turn it off or back to
|
|
83
90
|
the default. An interactive
|
|
84
91
|
claim carries a lease (default 60 minutes); a lapsed lease shows the claim as stale and only
|
package/lib/daemon.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { PS } from "./nudge.mjs";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import WebSocket from "ws";
|
|
8
9
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
@@ -351,6 +352,18 @@ export async function startDaemon() {
|
|
|
351
352
|
// as themselves, never as a root. Anything another agent wrote never starts. Links are followed, so no path inside a
|
|
352
353
|
// covered folder leads out of it.
|
|
353
354
|
const RUNNERS = new Set(["claude-code", "codex", "pi", "opencode"]);
|
|
355
|
+
const POSTERS_WITH_FOLDER = new Set(["claude-code", "codex"]);
|
|
356
|
+
// A session the person typed in: its prompt hook names the agent process, the parent of the board MCP it posts through.
|
|
357
|
+
// A run this daemon started is never one (the daemon is its parent), and an agent holding another agent's task writes
|
|
358
|
+
// as an agent until it reports.
|
|
359
|
+
const promptedBy = new Map();
|
|
360
|
+
// Unknown parent: not the person's.
|
|
361
|
+
const parentOf = (pid) => Number(spawnSync(PS, ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).stdout?.trim()) || null;
|
|
362
|
+
const personSession = (agent, pid) => {
|
|
363
|
+
if (!POSTERS_WITH_FOLDER.has(agent) || !Number.isInteger(pid) || promptedBy.get(pid) !== agent) return false;
|
|
364
|
+
const parent = parentOf(pid);
|
|
365
|
+
return parent !== null && parent !== process.pid && !store.holdsAgentTask(agent);
|
|
366
|
+
};
|
|
354
367
|
const runners = () => installedAgents.filter((agent) => RUNNERS.has(agent));
|
|
355
368
|
const startOfToday = () => { const day = new Date(); day.setHours(0, 0, 0, 0); return day.toISOString(); };
|
|
356
369
|
const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return path.resolve(dir); } };
|
|
@@ -571,6 +584,10 @@ export async function startDaemon() {
|
|
|
571
584
|
const expected = Number.isInteger(request.expectedVersion) ? request.expectedVersion : null;
|
|
572
585
|
let outcome;
|
|
573
586
|
if (String(request.command).startsWith("board.goal.") || String(request.command).startsWith("board.room.")) return goalCommand(request, agent);
|
|
587
|
+
if (request.command === "board.prompted") {
|
|
588
|
+
for (const pid of Array.isArray(request.pids) ? request.pids.slice(0, 2) : []) if (Number.isInteger(pid) && pid > 1) promptedBy.set(pid, agent);
|
|
589
|
+
return { ok: true };
|
|
590
|
+
}
|
|
574
591
|
if (request.command === "board.post") {
|
|
575
592
|
const objective = String(request.objective || "").trim();
|
|
576
593
|
if (!objective) return { ok: false, error: "a task needs an objective" };
|
|
@@ -579,7 +596,7 @@ export async function startDaemon() {
|
|
|
579
596
|
const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
|
|
580
597
|
const claimed = request.provenance && typeof request.provenance === "object" && request.provenance.trust === "agent" ? request.provenance : null;
|
|
581
598
|
const upstream = parentTask ? { trust: claimed ? "agent" : parentTask.trust, chain: parentTask.origin_chain } : claimed;
|
|
582
|
-
const provenance = provenanceFor(agent, upstream);
|
|
599
|
+
const provenance = provenanceFor(agent, upstream, null, { personSession: personSession(agent, request.posterPid) });
|
|
583
600
|
const goalParent = parentTask?.kind === "goal" ? parentTask : null;
|
|
584
601
|
if (goalParent) {
|
|
585
602
|
if (!inRoom(goalParent, agent)) return { ok: false, error: `${agent} is not on this goal's team` };
|
|
@@ -595,7 +612,11 @@ export async function startDaemon() {
|
|
|
595
612
|
if (!state.connected) return { ok: false, error: `${request.targetAgent} lives in Harness, and the daemon is not connected to it right now` };
|
|
596
613
|
return forwardToHarness({ ...request, provenance: upstream });
|
|
597
614
|
}
|
|
598
|
-
|
|
615
|
+
// Left out, a task from a coding agent for a coding agent runs in the folder the poster works in (its stdio MCP's,
|
|
616
|
+
// which Claude Code and Codex start in the session's folder); never home or above it.
|
|
617
|
+
const posterFolder = !request.thread && !goalParent && request.inSession !== true && POSTERS_WITH_FOLDER.has(agent) && RUNNERS.has(String(request.targetAgent || ""))
|
|
618
|
+
&& typeof request.posterCwd === "string" && path.isAbsolute(request.posterCwd) && !aboveHome(request.posterCwd) ? request.posterCwd : null;
|
|
619
|
+
const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : goalParent?.goal.workspace && RUNNERS.has(String(request.targetAgent || "")) ? goalParent.goal.workspace : posterFolder;
|
|
599
620
|
if (runWorkspace && !path.isAbsolute(runWorkspace)) return { ok: false, error: "a run needs an absolute workspace directory" };
|
|
600
621
|
if (runWorkspace && !request.targetAgent && !(Array.isArray(request.targetAgents) && request.targetAgents.length)) return { ok: false, error: "a run needs a target agent" };
|
|
601
622
|
const refs = Array.isArray(request.contextRefs) ? request.contextRefs.slice(0, 50) : [];
|
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -13,7 +13,7 @@ let chain = Promise.resolve();
|
|
|
13
13
|
const LOCAL_TOOLS = [
|
|
14
14
|
{
|
|
15
15
|
name: "list_tasks",
|
|
16
|
-
description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id.
|
|
16
|
+
description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id. A task for a coding agent with a folder may run on its own (the user's standing permission or a goal); otherwise claim a task before working on it.",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: "object",
|
|
19
19
|
properties: { status: { type: "string", enum: ["open", "claimed", "needs_input", "done", "failed"] }, all: { type: "boolean", description: "include every task on the board, finished ones too" } },
|
|
@@ -36,7 +36,8 @@ const LOCAL_TOOLS = [
|
|
|
36
36
|
target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot or harness on this machine; bankr, quotient or muse in Harness; omit for any agent" },
|
|
37
37
|
target_agents: { type: "array", items: { type: "string" }, description: "several agents at once: one task with a part for each, closed with every result when the last part finishes" },
|
|
38
38
|
parent_id: { type: "string", description: "the board task this one continues" },
|
|
39
|
-
run_workspace: { type: "string", description: "
|
|
39
|
+
run_workspace: { type: "string", description: "the absolute directory a coding agent runs this task in, non-interactively, once the user authorizes it; the run's result and session come back on the task. Left out, a task for a coding agent runs in the folder you are working in" },
|
|
40
|
+
in_session: { type: "boolean", description: "leave the task for the target to pick up in an open session instead of a run" },
|
|
40
41
|
context_refs: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, ref: { type: "string" }, note: { type: "string" } }, required: ["kind", "ref"], additionalProperties: false } },
|
|
41
42
|
},
|
|
42
43
|
required: ["objective"],
|
|
@@ -110,7 +111,7 @@ const BOARD_CALLS = {
|
|
|
110
111
|
reply_in_thread: (args) => ({ command: "board.thread.post", id: String(args.id || ""), text: args.text, refs: args.refs }),
|
|
111
112
|
list_tasks: (args) => ({ command: "board.list", status: args.status, all: args.all === true }),
|
|
112
113
|
get_task: (args) => ({ command: "board.get", id: String(args.id || "") }),
|
|
113
|
-
post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, targetAgents: args.target_agents, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace }),
|
|
114
|
+
post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, targetAgents: args.target_agents, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace, inSession: args.in_session === true, posterCwd: process.cwd(), posterPid: process.ppid }),
|
|
114
115
|
claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes }),
|
|
115
116
|
report_task: (args) => args.status === "answered"
|
|
116
117
|
? { command: "board.answer", id: String(args.id || ""), answer: args.result, expectedVersion: args.expected_version }
|
package/lib/nudge.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { daemonRequest } from "./control-client.mjs";
|
|
5
6
|
import { paths } from "./paths.mjs";
|
|
6
7
|
|
|
@@ -63,9 +64,20 @@ export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
|
|
|
63
64
|
return digest ? nudgeText(agent, tasks, unread, goals) : (last ? "Harness task board: nothing waits for you now." : "");
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// The prompt hook also tells the daemon the person typed in this session: the agent process is the hook's parent, or
|
|
68
|
+
// its grandparent when the hook runs through a shell.
|
|
69
|
+
export const PS = ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
|
|
70
|
+
|
|
71
|
+
export async function notePrompt(agent) {
|
|
72
|
+
const pids = [process.ppid];
|
|
73
|
+
try { const up = Number(spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], { encoding: "utf8" }).stdout.trim()); if (up > 1) pids.push(up); } catch {}
|
|
74
|
+
try { await daemonRequest({ command: "board.prompted", agent, pids }, 1_500); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
67
78
|
const index = process.argv.indexOf("--agent");
|
|
68
79
|
const agent = index >= 0 ? process.argv[index + 1] : "claude-code";
|
|
80
|
+
if (process.argv.includes("--if-changed")) await notePrompt(agent);
|
|
69
81
|
const text = process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent);
|
|
70
82
|
if (text) process.stdout.write(text + "\n");
|
|
71
83
|
}
|
package/lib/provenance.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// Who a task's words came from, decided by code at post time and never by the poster's text.
|
|
2
|
-
// The person and Harness write as the user
|
|
2
|
+
// The person and Harness write as the user, and so does a coding agent posting from a session the person typed in;
|
|
3
|
+
// every other agent writes as an agent, and an
|
|
3
4
|
// agent-written task taints everything posted under it. A label only ever tightens: a parent's
|
|
4
5
|
// or Harness's 'agent' cannot be lifted by anything below it. The server applies the same rule.
|
|
5
6
|
|
|
6
7
|
const USER_WRITERS = new Set(["user", "harness"]);
|
|
7
8
|
|
|
8
|
-
export function provenanceFor(originAgent, parent = null, claimed = null) {
|
|
9
|
+
export function provenanceFor(originAgent, parent = null, claimed = null, { personSession = false } = {}) {
|
|
9
10
|
// A claim counts only when it tightens: an 'agent' label and its chain. A claim of 'user' adds nothing.
|
|
10
11
|
const taint = claimed?.trust === "agent" ? claimed : null;
|
|
11
12
|
const chain = [...(parent?.chain ?? (Array.isArray(taint?.chain) ? taint.chain.map(String) : [])), originAgent].slice(-16);
|
|
12
|
-
const tainted = parent?.trust === "agent" || Boolean(taint) || !USER_WRITERS.has(originAgent);
|
|
13
|
+
const tainted = parent?.trust === "agent" || Boolean(taint) || (!USER_WRITERS.has(originAgent) && !personSession);
|
|
13
14
|
return { trust: originAgent === "user" || !tainted ? "user" : "agent", chain };
|
|
14
15
|
}
|
|
15
16
|
|
package/lib/store.mjs
CHANGED
|
@@ -693,6 +693,9 @@ export function openStore(file) {
|
|
|
693
693
|
},
|
|
694
694
|
// An interactive claim carries a lease; when it lapses the claim shows stale and the origin
|
|
695
695
|
// or the user may release it. It is never released on its own. Daemon runs carry none.
|
|
696
|
+
holdsAgentTask(agent) {
|
|
697
|
+
return Boolean(execute("get", "select 1 from board_tasks where claimed_by = ? and status in ('claimed', 'needs_input') and trust = 'agent' limit 1", agent));
|
|
698
|
+
},
|
|
696
699
|
claimTask(id, agent, expectedVersion = null, leaseMinutes = 60) {
|
|
697
700
|
if (this.getTask(id)?.kind === "group") return { ok: false, error: "a task for several agents is worked through its parts; claim the part posted for you" };
|
|
698
701
|
const now = new Date().toISOString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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"
|