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 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 only after the user presses Start on Harness's proposal
43
- card, which posts the task on the machine's board under the run id. They then run non-interactively with each agent's full-authority/auto-approve mode
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. The board is pull-only: a task waits until
68
- its target agent reads the board and claims it, and nothing starts by itself. A claimer
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 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>...
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
- const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : goalParent?.goal.workspace && RUNNERS.has(String(request.targetAgent || "")) ? goalParent.goal.workspace : null;
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. Nothing here runs by itself; claim a task before working on it.",
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: "ask Harness to run this task non-interactively in this absolute directory once the user authorizes it; the run's result and session come back on the task" },
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
  }
@@ -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; every other agent writes as an agent, and an
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.0",
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"