tryharness 0.7.6 → 0.7.7
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/cli.mjs +20 -17
- package/lib/activity.mjs +191 -0
- package/lib/adapters.mjs +2 -1
- package/lib/claudemem.mjs +90 -0
- package/lib/coordinator.mjs +7 -3
- package/lib/daemon.mjs +316 -66
- package/lib/mcp-stdio.mjs +23 -5
- package/lib/members.json +31 -0
- package/lib/members.mjs +47 -0
- package/lib/onboard.mjs +131 -0
- package/lib/phones.mjs +9 -5
- package/lib/runner.mjs +18 -2
- package/lib/store.mjs +49 -4
- package/lib/swarm.mjs +51 -4
- package/lib/wakes.mjs +196 -0
- package/package.json +3 -3
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. 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.",
|
|
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. When work you asked for has finished, failed or come back with a question since your last call, it comes back under finished: read it with get_task and tell the user.",
|
|
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" } },
|
|
@@ -33,7 +33,7 @@ const LOCAL_TOOLS = [
|
|
|
33
33
|
properties: {
|
|
34
34
|
objective: { type: "string", description: "one line, in the user's words" },
|
|
35
35
|
brief: { type: "string" },
|
|
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" },
|
|
36
|
+
target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot, openclaw, hermes 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
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" },
|
|
@@ -91,8 +91,8 @@ const LOCAL_TOOLS = [
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
name: "post_to_room",
|
|
94
|
-
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did.
|
|
95
|
-
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
|
|
94
|
+
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. To ask another agent for help, put its name in ask (claude-code, codex, pi, opencode, grok-bot, openclaw, hermes, bankr, quotient, muse, or harness to have the thread coordinated), say exactly what you need, then stop: you are woken again when it answers you by name. When you answer someone who asked you, name them (@codex) and they are woken. Any other @name, like a thanks, wakes no one. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
|
|
95
|
+
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, ask: { type: "array", items: { type: "string" }, description: "the agents you are asking for something; each is woken" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: "search_room",
|
|
@@ -103,11 +103,29 @@ const LOCAL_TOOLS = [
|
|
|
103
103
|
description: "Search the shared memory of the user's agents: everything any agent wrote in any thread (findings, answers, questions, results) and the user's coding sessions on this computer. Use it before starting work to build on what another agent already did, and when you need to know how something was done before.",
|
|
104
104
|
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false },
|
|
105
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: "list_agents",
|
|
108
|
+
description: "The user's other agents you can ask, what each is for, and how each answers (some start at once, some are woken, some only pick work up later). Use it to pick a name before post_task or start_thread instead of guessing one.",
|
|
109
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "start_thread",
|
|
113
|
+
description: "Start a shared thread with one or more of the user's agents, named with @ in the message (for example \"@codex @grok-bot compare the two parsers\"). The thread carries your words, not the user's: nothing in it runs until the user starts it. Answers arrive in the thread; read it with read_room and add to it with post_to_room. Give a folder only when the work concerns one.",
|
|
114
|
+
inputSchema: { type: "object", properties: { text: { type: "string" }, folder: { type: "string", description: "absolute folder the coding agents work in; never guessed" } }, required: ["text"], additionalProperties: false },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "stop_thread",
|
|
118
|
+
description: "Stop the runs in a thread you started. The user can resume it.",
|
|
119
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
120
|
+
},
|
|
106
121
|
];
|
|
107
122
|
|
|
108
123
|
const BOARD_CALLS = {
|
|
124
|
+
list_agents: () => ({ command: "board.swarm.agents" }),
|
|
125
|
+
start_thread: (args) => ({ command: "board.swarm.start", text: args.text, folder: args.folder }),
|
|
126
|
+
stop_thread: (args) => ({ command: "board.swarm.stop", id: String(args.id || "") }),
|
|
109
127
|
read_room: (args) => ({ command: "board.room.read", id: String(args.id || ""), since: args.since }),
|
|
110
|
-
post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, shareWithGuests: args.share_with_guests === true }),
|
|
128
|
+
post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, ask: Array.isArray(args.ask) ? args.ask : [], shareWithGuests: args.share_with_guests === true }),
|
|
111
129
|
search_room: (args) => ({ command: "board.room.search", id: String(args.id || ""), query: args.query }),
|
|
112
130
|
search_swarm: (args) => ({ command: "board.swarm.search", query: args.query }),
|
|
113
131
|
search_task_context: (args) => ({ command: "board.context.search", id: String(args.id || ""), query: args.query, limit: args.limit }),
|
package/lib/members.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"members": [
|
|
4
|
+
{ "id": "harness", "name": "Harness", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
5
|
+
"about": "coordinates the thread and asks the person when only they can decide" },
|
|
6
|
+
{ "id": "claude-code", "name": "Claude Code", "aliases": ["claude code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
7
|
+
"about": "reads and changes code in the thread's folder, runs commands and tests" },
|
|
8
|
+
{ "id": "codex", "name": "Codex", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
9
|
+
"about": "reads and changes code in the thread's folder, runs commands and tests" },
|
|
10
|
+
{ "id": "pi", "name": "Pi", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
11
|
+
"about": "reads and changes code in the thread's folder and runs commands" },
|
|
12
|
+
{ "id": "opencode", "name": "OpenCode", "aliases": ["open code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
13
|
+
"about": "reads and changes code in the thread's folder and runs commands" },
|
|
14
|
+
{ "id": "grok-bot", "name": "Grok Bot", "aliases": ["grok", "grok bot"], "road": "hosted", "home": "machine", "runs": "self", "target": true, "wake": "email",
|
|
15
|
+
"about": "research on X and the web" },
|
|
16
|
+
{ "id": "bankr", "name": "Bankr", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
17
|
+
"about": "the person's crypto wallet and trading, always under the person's own approval and limits" },
|
|
18
|
+
{ "id": "quotient", "name": "Quotient", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
19
|
+
"about": "prediction markets research and positions, under the person's own approval and limits" },
|
|
20
|
+
{ "id": "muse", "name": "Muse", "aliases": [], "road": "hosted", "home": "cloud", "runs": "self", "target": true, "wake": "schedule",
|
|
21
|
+
"about": "writing and creative drafts" },
|
|
22
|
+
{ "id": "openclaw", "name": "OpenClaw", "aliases": ["open claw"], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
|
|
23
|
+
"about": "the person's own assistant on this computer, with their chat apps and tools" },
|
|
24
|
+
{ "id": "hermes", "name": "Hermes", "aliases": [], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
|
|
25
|
+
"about": "the person's own assistant on this computer, with their chat apps and tools" },
|
|
26
|
+
{ "id": "claude-app", "name": "Claude app", "aliases": ["claude ai"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
|
|
27
|
+
"about": "the Claude chat app; can ask, cannot be woken" },
|
|
28
|
+
{ "id": "chatgpt", "name": "ChatGPT", "aliases": ["chat gpt"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
|
|
29
|
+
"about": "the ChatGPT app; can ask, cannot be woken" }
|
|
30
|
+
]
|
|
31
|
+
}
|
package/lib/members.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// The swarm's members, one entry each (members.json): who they are, which road they call in on, where their tasks
|
|
4
|
+
// live (home), who runs their work (runs), whether others may hand them work (target) and how they are told work is
|
|
5
|
+
// waiting (wake). The server reads the same file (src/shared/members.ts). A caller on this machine names itself
|
|
6
|
+
// (--agent) and is taken at its word: anything running as the person could read a token kept for it, so none is
|
|
7
|
+
// kept (owner, October 10). A call through Harness is never taken for a member on this machine (hostedCaller).
|
|
8
|
+
const ROSTER = JSON.parse(readFileSync(new URL("./members.json", import.meta.url), "utf8"));
|
|
9
|
+
|
|
10
|
+
export const ROSTER_VERSION = ROSTER.version;
|
|
11
|
+
export const MEMBERS = ROSTER.members;
|
|
12
|
+
const byId = new Map(MEMBERS.map((entry) => [entry.id, entry]));
|
|
13
|
+
|
|
14
|
+
export const member = (id) => byId.get(String(id)) ?? null;
|
|
15
|
+
export const ids = (keep = () => true) => MEMBERS.filter(keep).map((entry) => entry.id);
|
|
16
|
+
/** Members others may hand work to, Harness included. */
|
|
17
|
+
export const TARGETS = ids((entry) => entry.target);
|
|
18
|
+
/** Coding agents the daemon runs on this machine. */
|
|
19
|
+
export const MACHINE_RUNNERS = ids((entry) => entry.runs === "machine");
|
|
20
|
+
/** Targets whose tasks live on Harness's server, not the machine (Harness itself excluded). */
|
|
21
|
+
export const CLOUD_HOMED = ids((entry) => entry.target && entry.home === "cloud" && entry.id !== "harness");
|
|
22
|
+
/** Targets asked by a task rather than started here: they run themselves or on the server. */
|
|
23
|
+
export const ASKED_BY_TASK = ids((entry) => entry.target && entry.runs !== "machine" && entry.id !== "harness");
|
|
24
|
+
export const nameOf = (id) => member(id)?.name ?? String(id);
|
|
25
|
+
export const aboutOf = (id) => {
|
|
26
|
+
const entry = member(id);
|
|
27
|
+
return entry ? `${entry.name}: ${entry.about}` : String(id);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const plain = (value) => String(value || "").trim().toLowerCase().replace(/^@/, "").replace(/[\s_]+/g, " ");
|
|
31
|
+
/**
|
|
32
|
+
* A name as people and agents write it, resolved to one member: an id, a display name or an alias, else the one
|
|
33
|
+
* member whose name it starts. A start shared by several ("claude": Claude Code and the Claude app) comes back
|
|
34
|
+
* ambiguous with the candidates, so the caller asks its person or picks. `extra` admits board names the roster does
|
|
35
|
+
* not list (a connector named by its person), matched exactly.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveMember(name, { extra = [] } = {}) {
|
|
38
|
+
const wanted = plain(name);
|
|
39
|
+
if (!wanted) return { ok: false, candidates: [] };
|
|
40
|
+
const exact = MEMBERS.find((entry) => entry.id === wanted || plain(entry.name) === wanted || entry.aliases.some((alias) => plain(alias) === wanted));
|
|
41
|
+
if (exact) return { ok: true, id: exact.id };
|
|
42
|
+
if (extra.map(String).includes(wanted)) return { ok: true, id: wanted };
|
|
43
|
+
const words = wanted.split(" ");
|
|
44
|
+
const candidates = MEMBERS.filter((entry) => [entry.id.replace(/-/g, " "), plain(entry.name), ...entry.aliases.map(plain)]
|
|
45
|
+
.some((label) => words.every((word, index) => label.split(" ")[index]?.startsWith(word)))).map((entry) => entry.id);
|
|
46
|
+
return candidates.length === 1 ? { ok: true, id: candidates[0] } : { ok: false, candidates };
|
|
47
|
+
}
|
package/lib/onboard.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { detectLocalAgents, setupHermes, setupOpenClaw, localWakes } from "./wakes.mjs";
|
|
4
|
+
|
|
5
|
+
// "Your swarm" in setup: every agent class with how Harness reaches it, the recommended way already chosen, the steps
|
|
6
|
+
// numbered, and a real test at the end that passes only when the agent claims and reports a task. Also reachable on its
|
|
7
|
+
// own as `npx tryharness agents`. Without a terminal to answer in, it only says what is set up and how to finish.
|
|
8
|
+
|
|
9
|
+
const SETUP = { openclaw: setupOpenClaw, hermes: setupHermes };
|
|
10
|
+
const LABEL = { openclaw: "OpenClaw", hermes: "Hermes", "grok-bot": "Grok Bot" };
|
|
11
|
+
const MEASURED = "measured on a real Grok: about 23s by email, 17s by webhook";
|
|
12
|
+
|
|
13
|
+
export function createPrompter({ input = process.stdin, output = process.stdout } = {}) {
|
|
14
|
+
const ask = (question, { hidden = false } = {}) => new Promise((resolve) => {
|
|
15
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
16
|
+
if (hidden) rl._writeToOutput = (text) => { if (text.includes(question)) output.write(text); };
|
|
17
|
+
rl.question(question, (answer) => { rl.close(); if (hidden) output.write("\n"); resolve(answer.trim()); });
|
|
18
|
+
});
|
|
19
|
+
return { ask };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const copy = (text) => {
|
|
23
|
+
const tool = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
|
|
24
|
+
const result = spawnSync(tool, tool === "xclip" ? ["-selection", "clipboard"] : [], { input: text });
|
|
25
|
+
return result.status === 0;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Post a setup check for the agent and wait for its report: the same path real work takes. */
|
|
29
|
+
export async function testAgent({ agent, daemonRequest, out = console.log, waitMs = 10 * 60_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
30
|
+
const posted = await daemonRequest({ command: "board.post", agent: "user", objective: "Setup check from Harness: reply ready", brief: "Harness is checking that it can wake you. Claim this task and report it done with the single word: ready. Do nothing else.", targetAgent: agent });
|
|
31
|
+
if (!posted?.ok) { out(` ! the test task could not be posted: ${posted?.error || "no answer"}`); return false; }
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
out(` Test sent. Waiting for ${LABEL[agent] || agent} to answer (Ctrl-C stops waiting; the test task stays on your board).`);
|
|
34
|
+
while (Date.now() - started < waitMs) {
|
|
35
|
+
await sleep(2_000);
|
|
36
|
+
const task = (await daemonRequest({ command: "board.get", agent: "user", id: posted.task.id }).catch(() => null))?.task;
|
|
37
|
+
if (task?.status === "done") { out(` ✓ ${LABEL[agent] || agent} answered in ${Math.round((Date.now() - started) / 1000)}s. Ready.`); return true; }
|
|
38
|
+
if (task && ["failed", "needs_input"].includes(task.status)) { out(` ! ${LABEL[agent] || agent} answered but the task is ${task.status}: ${task.result || ""}`); return false; }
|
|
39
|
+
}
|
|
40
|
+
out(` ! No answer after ${Math.round(waitMs / 60_000)} minutes. Check that the agent is running, then: npx tryharness agents test ${agent}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out }) {
|
|
45
|
+
const name = LABEL[entry.agent];
|
|
46
|
+
if (localWakes()[entry.agent]) {
|
|
47
|
+
const health = (await daemonRequest({ command: "wake.local" }).catch(() => null))?.agents?.[entry.agent];
|
|
48
|
+
if (!health || health.answering) { out(` ✓ ${name}: woken on this computer by its own webhook`); return; }
|
|
49
|
+
out(` ! ${name}: not answering wakes (${health.lastFailure}). Check that it is running.`);
|
|
50
|
+
if (!prompter || /^n/i.test(await prompter.ask(` Set ${name} up again? [Y/n] `))) { out(` Later: npx tryharness agents setup ${entry.agent}`); return; }
|
|
51
|
+
const result = SETUP[entry.agent]({ runtimeRoot });
|
|
52
|
+
out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
|
|
53
|
+
if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!prompter) { out(` ! ${name}: found. Set it up with: npx tryharness agents setup ${entry.agent}`); return; }
|
|
57
|
+
out(` ${name} found. Harness wakes it with a webhook on this computer (recommended, nothing to copy).`);
|
|
58
|
+
out(` This adds Harness to ${name}'s MCP servers and turns on its webhook with a key made here${entry.agent === "openclaw" ? ", then restarts its gateway" : ""}.`);
|
|
59
|
+
if (/^n/i.test(await prompter.ask(` Set up ${name} now? [Y/n] `))) { out(` Skipped. Later: npx tryharness agents setup ${entry.agent}`); return; }
|
|
60
|
+
const result = SETUP[entry.agent]({ runtimeRoot });
|
|
61
|
+
out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
|
|
62
|
+
if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function setupGrok({ prompter, daemonRequest, out, clipboard }) {
|
|
66
|
+
const listed = await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch((error) => ({ ok: false, error: error.message }));
|
|
67
|
+
if (!listed.ok) { out(` ! Grok Bot: Harness could not be reached (${listed.error}). Later: npx tryharness agents setup grok-bot`); return; }
|
|
68
|
+
const current = (listed.wakes || []).find((wake) => wake.agent === "grok-bot");
|
|
69
|
+
if (current) {
|
|
70
|
+
const how = current.channel === "email" ? `email at ${current.address}` : "its webhook";
|
|
71
|
+
if (current.answering) { out(` ✓ Grok Bot: woken by ${how}`); return; }
|
|
72
|
+
out(` ! Grok Bot: not answering wakes by ${how} (${current.last_failure}).`);
|
|
73
|
+
if (!prompter || /^n/i.test(await prompter.ask(" Set it up again? [Y/n] "))) { out(" Later: npx tryharness agents setup grok-bot"); return; }
|
|
74
|
+
}
|
|
75
|
+
if (!prompter) { out(" Grok Bot: not set up. Set it up with: npx tryharness agents setup grok-bot"); return; }
|
|
76
|
+
out(` Grok Bot. How should Harness wake it? (${MEASURED})`);
|
|
77
|
+
out(" 1) Email nothing to copy back (recommended)");
|
|
78
|
+
out(" 2) Webhook a little faster; you paste its URL and key here");
|
|
79
|
+
out(" 3) Not now");
|
|
80
|
+
const choice = (await prompter.ask(" Choose [1]: ")) || "1";
|
|
81
|
+
if (choice === "3") { out(" Skipped. Later: npx tryharness agents setup grok-bot"); return; }
|
|
82
|
+
const channel = choice === "2" ? "webhook" : "email";
|
|
83
|
+
const message = listed.messages?.["grok-bot"]?.[channel];
|
|
84
|
+
if (!message) { out(" ! Harness did not send the setup message; update tryharness and try again."); return; }
|
|
85
|
+
const copied = clipboard(message);
|
|
86
|
+
out(` 1. ${copied ? "This message is on your clipboard. Paste it into Grok:" : "Paste this message into Grok:"}`);
|
|
87
|
+
out(message.split("\n").map((line) => ` ${line}`).join("\n"));
|
|
88
|
+
out(" 2. Grok opens Harness's approval page. Name the connection Grok Bot and approve.");
|
|
89
|
+
if (channel === "webhook") {
|
|
90
|
+
out(" 3. In Grok, open Routines, then Harness wake, and copy its webhook URL and key.");
|
|
91
|
+
const address = await prompter.ask(" Webhook URL: ");
|
|
92
|
+
const key = await prompter.ask(" Key (hidden): ", { hidden: true });
|
|
93
|
+
const saved = await daemonRequest({ command: "wake.server", input: { action: "save", agent: "grok-bot", channel: "webhook", address, key } });
|
|
94
|
+
if (!saved.ok) { out(` ! ${saved.error}. Try again: npx tryharness agents setup grok-bot`); return; }
|
|
95
|
+
out(" ✓ Saved. The key is stored sealed on Harness and is never shown again.");
|
|
96
|
+
} else {
|
|
97
|
+
out(" 3. Grok registers its own address with Harness. Waiting for it (Ctrl-C stops waiting)...");
|
|
98
|
+
const registered = await waitFor(async () => ((await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch(() => null))?.wakes || []).some((wake) => wake.agent === "grok-bot"));
|
|
99
|
+
if (!registered) { out(" Not registered yet. When Grok has done it: npx tryharness agents test grok-bot"); return; }
|
|
100
|
+
out(" ✓ Grok registered its address.");
|
|
101
|
+
}
|
|
102
|
+
await testAgent({ agent: "grok-bot", daemonRequest, out });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function waitFor(check, waitMs = 10 * 60_000, everyMs = 3_000) {
|
|
106
|
+
const end = Date.now() + waitMs;
|
|
107
|
+
while (Date.now() < end) {
|
|
108
|
+
if (await check()) return true;
|
|
109
|
+
await new Promise((r) => setTimeout(r, everyMs));
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The whole "Your swarm" step. `only` limits it to one agent (npx tryharness agents setup <agent>). */
|
|
115
|
+
export async function setupSwarm({ runtimeRoot, states = [], daemonRequest, prompter = process.stdin.isTTY ? createPrompter() : null, out = console.log, only = null, clipboard = copy }) {
|
|
116
|
+
const want = (agent) => !only || only === agent;
|
|
117
|
+
out("\nYour swarm");
|
|
118
|
+
if (!only) {
|
|
119
|
+
out(" On this computer: Harness starts them when work is posted");
|
|
120
|
+
for (const state of states) out(` ${!state.installed ? "–" : state.configured ? "✓" : "!"} ${state.agent}: ${state.detail}`);
|
|
121
|
+
}
|
|
122
|
+
const local = detectLocalAgents().filter((entry) => entry.found && want(entry.agent));
|
|
123
|
+
if (local.length) out(" Agent apps on this computer: woken by a webhook Harness sets up");
|
|
124
|
+
for (const entry of local) await setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out });
|
|
125
|
+
if (only && LABEL[only] && only !== "grok-bot" && !local.length) out(` ${LABEL[only]} was not found on this computer.`);
|
|
126
|
+
if (want("grok-bot")) {
|
|
127
|
+
out(" Cloud agents: woken by email or webhook");
|
|
128
|
+
await setupGrok({ prompter, daemonRequest, out, clipboard });
|
|
129
|
+
}
|
|
130
|
+
if (!only) out(" Built in: Bankr and Quotient run on Harness, nothing to set up");
|
|
131
|
+
}
|
package/lib/phones.mjs
CHANGED
|
@@ -13,17 +13,17 @@ const OFFER_TTL_MS = 10 * 60_000;
|
|
|
13
13
|
const MAX_OFFERS = 5;
|
|
14
14
|
// A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
|
|
15
15
|
const QUEUED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
|
|
17
|
-
"board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume"]);
|
|
16
|
+
const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.remove", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
|
|
17
|
+
"board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.start_run"]);
|
|
18
18
|
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
19
19
|
const KEY = /^[A-Za-z0-9_-]{80,100}$/;
|
|
20
20
|
const KINDS = new Set(["phone", "browser"]);
|
|
21
21
|
const PHONE_COMMANDS = new Set([
|
|
22
|
-
"board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer",
|
|
22
|
+
"board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
|
|
23
23
|
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
24
|
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
25
25
|
"board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
|
|
26
|
-
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard",
|
|
26
|
+
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run",
|
|
27
27
|
]);
|
|
28
28
|
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
29
29
|
const HARNESS_COMMANDS = new Set([
|
|
@@ -34,7 +34,7 @@ const HARNESS_COMMANDS = new Set([
|
|
|
34
34
|
const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off"]);
|
|
35
35
|
const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
|
|
36
36
|
|
|
37
|
-
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version }) {
|
|
37
|
+
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
|
|
38
38
|
const identity = await loadOrCreateIdentity(identityFile);
|
|
39
39
|
const offers = new Map();
|
|
40
40
|
const keys = new Map();
|
|
@@ -96,6 +96,8 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
96
96
|
if (queued ? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS : Math.abs(age) > MAX_SKEW_MS) return { ok: false, error: "the message is too old or the device's clock is off", code: "stale" };
|
|
97
97
|
if (!store.firstPhoneRequest(device.id, String(envelope.requestId))) return { ok: false, error: "this message was already received", code: "replayed" };
|
|
98
98
|
store.touchPhone(device.id);
|
|
99
|
+
// Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
|
|
100
|
+
onMessage();
|
|
99
101
|
if (request.call?.command === "phone.forget") {
|
|
100
102
|
const forgotten = revoke(device.id);
|
|
101
103
|
return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, { ok: forgotten.ok }) };
|
|
@@ -131,6 +133,8 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
131
133
|
const { as: _as, writtenBy: _written, provenance: _provenance, ...rest } = callInput;
|
|
132
134
|
return settled(() => boardCommand({ ...rest, command, agent: as, ...(writtenBy.length ? { provenance: { trust: "agent", chain: writtenBy } } : {}) }));
|
|
133
135
|
}
|
|
136
|
+
// Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
|
|
137
|
+
if (command === "machine.wake") return { ok: true, machine: machine() };
|
|
134
138
|
if (!PHONE_COMMANDS.has(command)) return { ok: false, error: `a phone cannot call ${command || "that"}` };
|
|
135
139
|
// The phone is the person; whatever it names itself, it acts as "user".
|
|
136
140
|
return settled(() => boardCommand({ ...callInput, command, agent: "user", ...(command === "board.list" && callInput.all === undefined ? { all: true } : {}) }));
|
package/lib/runner.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
|
|
@@ -27,14 +28,29 @@ export function resolveWorkingDirectory(requested) {
|
|
|
27
28
|
return requested;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// A run another agent started without the person's tap (docs/member-runs.md): anything that would ask is refused, file
|
|
32
|
+
// edits only in its worktree, shell commands only inside the operating system's sandbox with no network, and no hooks or
|
|
33
|
+
// settings from the repository. A review gets no edits at all.
|
|
34
|
+
export const RESTRICTED_CLAUDE_SETTINGS = {
|
|
35
|
+
disableAllHooks: true,
|
|
36
|
+
permissions: { deny: ["WebFetch", "WebSearch"] },
|
|
37
|
+
sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, failIfUnavailable: true, excludedCommands: [], network: { allowedDomains: [], strictAllowlist: true } },
|
|
38
|
+
};
|
|
39
|
+
// Only the Harness board MCP, as Claude Code: none of the person's other MCP servers or claude.ai connectors load.
|
|
40
|
+
export const restrictedMcpConfig = () => ({ mcpServers: { harness: { command: process.execPath, args: [fileURLToPath(new URL("./mcp-stdio.mjs", import.meta.url)), "--agent", "claude-code"] } } });
|
|
41
|
+
const restrictedClaude = (task) => ["--permission-mode", "dontAsk", "--allowedTools", task.readOnly ? "mcp__harness" : "Edit Write mcp__harness",
|
|
42
|
+
"--setting-sources", "user", "--settings", JSON.stringify(RESTRICTED_CLAUDE_SETTINGS), "--strict-mcp-config", "--mcp-config", JSON.stringify(restrictedMcpConfig())];
|
|
43
|
+
|
|
30
44
|
export function agentInvocation(task) {
|
|
31
45
|
if (task.agent === "claude-code") {
|
|
32
46
|
return {
|
|
33
47
|
command: "claude",
|
|
34
48
|
// A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
|
|
35
|
-
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
49
|
+
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
36
50
|
};
|
|
37
51
|
}
|
|
52
|
+
// Only Claude Code has a restricted mode; any other agent's restricted run is refused here, whatever let it start.
|
|
53
|
+
if (task.restricted) throw new Error(`${task.agent} has no restricted mode; it runs only after the person's Start`);
|
|
38
54
|
if (task.agent === "codex") {
|
|
39
55
|
return {
|
|
40
56
|
command: "codex",
|
|
@@ -146,7 +162,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
146
162
|
let child;
|
|
147
163
|
try {
|
|
148
164
|
child = spawn(invocation.command, invocation.args, { cwd });
|
|
149
|
-
|
|
165
|
+
child.stdin.end(invocation.stdin);
|
|
150
166
|
} catch (error) {
|
|
151
167
|
running.delete(slot);
|
|
152
168
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
package/lib/store.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export function openStore(file) {
|
|
|
78
78
|
trust text not null default 'agent',
|
|
79
79
|
origin_chain text not null default '[]',
|
|
80
80
|
usage text,
|
|
81
|
+
removed_at text,
|
|
81
82
|
created_at text not null,
|
|
82
83
|
updated_at text not null
|
|
83
84
|
);
|
|
@@ -208,6 +209,7 @@ export function openStore(file) {
|
|
|
208
209
|
}
|
|
209
210
|
if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
|
|
210
211
|
if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
|
|
212
|
+
if (!boardColumns.has("removed_at") && boardColumns.size) db.exec("alter table board_tasks add column removed_at text");
|
|
211
213
|
if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
|
|
212
214
|
|
|
213
215
|
const statements = [];
|
|
@@ -299,6 +301,10 @@ export function openStore(file) {
|
|
|
299
301
|
});
|
|
300
302
|
return parsed.meta;
|
|
301
303
|
},
|
|
304
|
+
endedSince(since) {
|
|
305
|
+
const rows = execute("all", "select status, count(*) as count from board_tasks where kind not in ('goal', 'group') and status in ('done', 'failed', 'unknown') and reported_at >= ? group by status", since);
|
|
306
|
+
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
|
307
|
+
},
|
|
302
308
|
counts() {
|
|
303
309
|
const rows = execute("all", "select agent, count(*) as count from sessions group by agent");
|
|
304
310
|
return Object.fromEntries(rows.map((row) => [row.agent, Number(row.count)]));
|
|
@@ -453,7 +459,7 @@ export function openStore(file) {
|
|
|
453
459
|
paused_reason: row.paused_reason, sessions: JSON.parse(row.sessions || "{}"), guests: JSON.parse(row.guests || "[]"), posts: this.roomCount(id), spend: this.goalSpend(id) };
|
|
454
460
|
},
|
|
455
461
|
goals({ all = false } = {}) {
|
|
456
|
-
return execute("all", `select id from board_tasks where kind = 'goal' ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
|
|
462
|
+
return execute("all", `select id from board_tasks where kind = 'goal' and removed_at is null ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
|
|
457
463
|
},
|
|
458
464
|
setGoalState(id, { status, result, pausedReason, maxPosts }) {
|
|
459
465
|
const now = new Date().toISOString();
|
|
@@ -470,6 +476,11 @@ export function openStore(file) {
|
|
|
470
476
|
const row = execute("all", "select goal_id, guests from board_goals").find((entry) => JSON.parse(entry.guests || "[]").some((guest) => guest.thread === thread));
|
|
471
477
|
return row?.goal_id || null;
|
|
472
478
|
},
|
|
479
|
+
// At posting only: the task's room is part of how it was posted, not a change anyone races.
|
|
480
|
+
setParent(id, parentId) {
|
|
481
|
+
execute("run", "update board_tasks set parent_id = ? where id = ?", parentId, id);
|
|
482
|
+
return this.getTask(id);
|
|
483
|
+
},
|
|
473
484
|
setGoalFolder(id, workspace) {
|
|
474
485
|
execute("run", "update board_goals set workspace = ? where goal_id = ?", workspace, id);
|
|
475
486
|
},
|
|
@@ -534,7 +545,7 @@ export function openStore(file) {
|
|
|
534
545
|
},
|
|
535
546
|
// A thread's runs (wakes and jobs), oldest first, for its transcript.
|
|
536
547
|
threadRuns(goalId) {
|
|
537
|
-
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, usage, created_at, updated_at, reported_at
|
|
548
|
+
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, created_at, updated_at, reported_at
|
|
538
549
|
from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
|
|
539
550
|
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
|
|
540
551
|
},
|
|
@@ -549,8 +560,10 @@ export function openStore(file) {
|
|
|
549
560
|
const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
|
|
550
561
|
return row?.goal_id || null;
|
|
551
562
|
},
|
|
563
|
+
// The rooms an agent's own sessions are told about: threads it was brought into, not every task's room (the daemon
|
|
564
|
+
// runs those, and the task itself is already on its list).
|
|
552
565
|
activeGoalsFor(agent) {
|
|
553
|
-
return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent));
|
|
566
|
+
return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent) && !this.meta(`task-room:${goal.id}`));
|
|
554
567
|
},
|
|
555
568
|
authorizeGoalRun(id) {
|
|
556
569
|
const now = new Date().toISOString();
|
|
@@ -581,6 +594,14 @@ export function openStore(file) {
|
|
|
581
594
|
setRunSession(id, sessionId) {
|
|
582
595
|
execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
|
|
583
596
|
},
|
|
597
|
+
// A run the daemon stopped itself for a safety rule (Lid-Closed Mode): the task goes back on the board, still
|
|
598
|
+
// authorized, and its next run picks up where this one stopped.
|
|
599
|
+
requeueRun(id) {
|
|
600
|
+
const now = new Date().toISOString();
|
|
601
|
+
const written = execute("run", `update board_tasks set status = 'open', holder = target_agent, claimed_by = null, claimed_at = null, lease_until = null, updated_at = @now, version = version + 1
|
|
602
|
+
where id = @id and status = 'running'`, { id, now });
|
|
603
|
+
return written.changes === 1 ? { ok: true, task: this.getTask(id) } : { ok: false, error: `task is ${this.getTask(id)?.status ?? "missing"}` };
|
|
604
|
+
},
|
|
584
605
|
// A daemon restart lost every agent process it was running; those outcomes are unknown, never assumed.
|
|
585
606
|
settleInterruptedRuns() {
|
|
586
607
|
const now = new Date().toISOString();
|
|
@@ -744,6 +765,16 @@ export function openStore(file) {
|
|
|
744
765
|
return { ok: true, thread: this.getThread(taskId), task: this.getTask(taskId) };
|
|
745
766
|
},
|
|
746
767
|
// Messages another person's agent sent on this agent's tasks since it last read the board.
|
|
768
|
+
lastSeenAt(agent) {
|
|
769
|
+
return execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || null;
|
|
770
|
+
},
|
|
771
|
+
// Work an agent asked for that finished, failed or came back with a question since it last called the board: how an
|
|
772
|
+
// asker that cannot be woken (the Claude app, ChatGPT) learns of its answers.
|
|
773
|
+
finishedFor(agent, since) {
|
|
774
|
+
return execute("all", `select id, objective, target_agent, claimed_by, status, updated_at from board_tasks
|
|
775
|
+
where origin_agent = ? and kind = 'task' and removed_at is null and status in ('done', 'failed', 'needs_input') and updated_at > ?
|
|
776
|
+
order by updated_at desc limit 20`, agent, since || "1970-01-01T00:00:00.000Z");
|
|
777
|
+
},
|
|
747
778
|
unreadFor(agent) {
|
|
748
779
|
const seen = execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || "1970-01-01T00:00:00.000Z";
|
|
749
780
|
return execute("all", `select m.task_id, t.objective, m.sender, count(*) as count from board_messages m join board_tasks t on t.id = m.task_id
|
|
@@ -789,10 +820,12 @@ export function openStore(file) {
|
|
|
789
820
|
return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
|
|
790
821
|
},
|
|
791
822
|
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
792
|
-
const clauses = [];
|
|
823
|
+
const clauses = ["b.removed_at is null"];
|
|
793
824
|
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
|
794
825
|
if (status) { clauses.push("b.status = @status"); params.status = status; }
|
|
795
826
|
else if (!all) clauses.push("b.status in ('open', 'claimed', 'running', 'needs_input')");
|
|
827
|
+
// An agent's whole-board list is work, not threads: those it reads as its rooms.
|
|
828
|
+
if (agent && agent !== "user" && all) clauses.push("b.kind not in ('goal', 'wake')");
|
|
796
829
|
if (agent && !all) {
|
|
797
830
|
clauses.push("b.kind not in ('group', 'goal', 'wake') and (b.target_agent is null or b.target_agent = @agent or b.origin_agent = @agent or b.claimed_by = @agent)");
|
|
798
831
|
params.agent = agent;
|
|
@@ -807,6 +840,18 @@ export function openStore(file) {
|
|
|
807
840
|
from board_tasks b left join board_threads th on th.task_id = b.id ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by b.created_at desc limit @limit`, params)
|
|
808
841
|
.map((row) => ({ ...row, origin_chain: JSON.parse(row.origin_chain || "[]"), version: Number(row.version), held_messages: Number(row.held_messages), child_count: Number(row.child_count), child_done: Number(row.child_done), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
|
|
809
842
|
},
|
|
843
|
+
// Off the board for good, row kept: unfinished work and its parts close first, so nothing picks them up again.
|
|
844
|
+
removeTask(id) {
|
|
845
|
+
const now = new Date().toISOString();
|
|
846
|
+
const ids = [id, ...execute("all", "select id from board_tasks where parent_id = ?", id).map((row) => row.id)];
|
|
847
|
+
const running = [];
|
|
848
|
+
for (const taskId of ids) {
|
|
849
|
+
if (execute("get", "select 1 from board_tasks where id = ? and status = 'running'", taskId)) running.push(taskId);
|
|
850
|
+
execute("run", "update board_tasks set status = 'failed', result = 'Removed by you.', holder = null, reported_at = ?, updated_at = ?, version = version + 1 where id = ? and kind != 'goal' and status in ('open', 'claimed', 'needs_input', 'running')", now, now, taskId);
|
|
851
|
+
execute("run", "update board_tasks set removed_at = ?, updated_at = ? where id = ? and removed_at is null", now, now, taskId);
|
|
852
|
+
}
|
|
853
|
+
return { ids, running };
|
|
854
|
+
},
|
|
810
855
|
getTask(id) {
|
|
811
856
|
const row = execute("get", "select * from board_tasks where id = ?", id);
|
|
812
857
|
if (!row) return null;
|