tryharness 0.4.1 → 0.4.3
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 +56 -5
- package/assets/opencode-plugin.js +36 -0
- package/assets/pi-extension.ts +38 -0
- package/cli.mjs +125 -17
- package/lib/adapters.mjs +47 -2
- package/lib/daemon.mjs +197 -36
- package/lib/files.mjs +0 -14
- package/lib/mcp-stdio.mjs +51 -10
- package/lib/nudge.mjs +35 -0
- package/lib/parsers.mjs +130 -46
- package/lib/paths.mjs +1 -0
- package/lib/runner.mjs +52 -29
- package/lib/service.mjs +90 -15
- package/lib/store.mjs +184 -31
- package/package.json +2 -2
package/lib/daemon.mjs
CHANGED
|
@@ -5,14 +5,16 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import WebSocket from "ws";
|
|
6
6
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { detectAgents, run } from "./commands.mjs";
|
|
8
|
-
import { appendLog, ensureDir, listJsonl, readJson,
|
|
8
|
+
import { appendLog, ensureDir, listJsonl, readJson, writeJsonAtomic } from "./files.mjs";
|
|
9
|
+
import { parseSessionFile } from "./parsers.mjs";
|
|
9
10
|
import { historyRoots, paths } from "./paths.mjs";
|
|
10
11
|
import { openStore } from "./store.mjs";
|
|
11
12
|
import { runAgentTask } from "./runner.mjs";
|
|
12
13
|
|
|
13
|
-
const DAEMON_VERSION = "0.4.
|
|
14
|
+
const DAEMON_VERSION = "0.4.3";
|
|
14
15
|
const PROTOCOL_VERSION = 1;
|
|
15
16
|
const RESCAN_MS = 60_000;
|
|
17
|
+
const TASK_LOG_PAGE = 64_000;
|
|
16
18
|
|
|
17
19
|
export async function startDaemon() {
|
|
18
20
|
ensureDir(paths.home);
|
|
@@ -38,6 +40,44 @@ export async function startDaemon() {
|
|
|
38
40
|
writeJsonAtomic(paths.status, state);
|
|
39
41
|
};
|
|
40
42
|
const log = (message) => { appendLog(paths.log, message); };
|
|
43
|
+
const taskRuns = new Map();
|
|
44
|
+
const validRunId = (runId) => typeof runId === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(runId);
|
|
45
|
+
const taskFiles = (runId) => ({
|
|
46
|
+
state: path.join(paths.runs, `${runId}.json`),
|
|
47
|
+
log: path.join(paths.runs, `${runId}.log`),
|
|
48
|
+
});
|
|
49
|
+
const persistTask = (task) => {
|
|
50
|
+
ensureDir(paths.runs);
|
|
51
|
+
writeJsonAtomic(taskFiles(task.runId).state, task);
|
|
52
|
+
};
|
|
53
|
+
const readTask = (runId, requestedOffset) => {
|
|
54
|
+
if (!validRunId(runId)) return null;
|
|
55
|
+
const files = taskFiles(runId);
|
|
56
|
+
const task = taskRuns.get(runId) || readJson(files.state, null);
|
|
57
|
+
if (!task) return null;
|
|
58
|
+
let raw = "";
|
|
59
|
+
try { raw = fs.readFileSync(files.log, "utf8"); } catch {}
|
|
60
|
+
const offset = Math.max(0, Math.min(raw.length, Math.floor(Number(requestedOffset) || 0)));
|
|
61
|
+
const chunk = raw.slice(offset, offset + TASK_LOG_PAGE);
|
|
62
|
+
return { ...task, log: chunk, offset, nextOffset: offset + chunk.length, logLength: raw.length };
|
|
63
|
+
};
|
|
64
|
+
// Runs left starting/running by a previous daemon process lost their agent process
|
|
65
|
+
// with it; their outcome is unknown, never assumed finished.
|
|
66
|
+
try {
|
|
67
|
+
for (const name of fs.readdirSync(paths.runs)) {
|
|
68
|
+
if (!name.endsWith(".json")) continue;
|
|
69
|
+
const task = readJson(path.join(paths.runs, name), null);
|
|
70
|
+
if (task && validRunId(task.runId) && (task.status === "starting" || task.status === "running")) {
|
|
71
|
+
persistTask({ ...task, status: "interrupted", updatedAt: new Date().toISOString() });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
const taskStatus = (runId) => {
|
|
76
|
+
const task = validRunId(runId) ? taskRuns.get(runId) || readJson(taskFiles(runId).state, null) : null;
|
|
77
|
+
if (!task) return null;
|
|
78
|
+
const message = typeof task.message === "string" ? task.message.slice(0, 100_000) : null;
|
|
79
|
+
return { runId: task.runId, status: task.status, message, sessionId: task.sessionId || null, cwd: task.cwd || null };
|
|
80
|
+
};
|
|
41
81
|
saveStatus();
|
|
42
82
|
log(`daemon ${DAEMON_VERSION} starting; agents=${installedAgents.join(",") || "none"}`);
|
|
43
83
|
|
|
@@ -105,8 +145,8 @@ export async function startDaemon() {
|
|
|
105
145
|
async function indexFile(agent, item) {
|
|
106
146
|
const sessionId = path.basename(item.file);
|
|
107
147
|
if (!store.needsIndex(agent, sessionId, item.mtime, item.size)) return false;
|
|
108
|
-
const
|
|
109
|
-
store.
|
|
148
|
+
const parsed = await parseSessionFile(agent, item.file);
|
|
149
|
+
store.indexParsed({ agent, sessionId, source: item.file, parsed, mtime: item.mtime, size: item.size });
|
|
110
150
|
return true;
|
|
111
151
|
}
|
|
112
152
|
|
|
@@ -152,7 +192,7 @@ export async function startDaemon() {
|
|
|
152
192
|
if (requested.length) {
|
|
153
193
|
for (const item of requested) await readOpencodeSession(item.sessionId, item.cwd);
|
|
154
194
|
} else {
|
|
155
|
-
const listed = run("opencode", ["session", "list", "
|
|
195
|
+
const listed = run("opencode", ["session", "list", "--format", "json"], { timeout: 20_000, maxBuffer: 20_000_000 });
|
|
156
196
|
if (listed.ok) {
|
|
157
197
|
let rows = [];
|
|
158
198
|
try { rows = JSON.parse(listed.stdout); } catch {}
|
|
@@ -176,12 +216,99 @@ export async function startDaemon() {
|
|
|
176
216
|
function sourceRaw(agent, sessionId) {
|
|
177
217
|
const row = store.findSource(agent, sessionId);
|
|
178
218
|
if (!row?.source || row.source.startsWith("opencode:")) return null;
|
|
179
|
-
|
|
219
|
+
// Compatibility fetches are explicit reads. Return the complete source instead of
|
|
220
|
+
// silently changing their meaning to "the last N bytes"; normal 0.4 search/read
|
|
221
|
+
// traffic is served from the normalized SQLite transcript and never ships raw logs.
|
|
222
|
+
try { return { raw: fs.readFileSync(row.source, "utf8"), cwd: row.cwd || undefined }; } catch { return null; }
|
|
180
223
|
}
|
|
181
224
|
|
|
182
225
|
if (process.platform !== "win32") {
|
|
183
226
|
try { if (fs.existsSync(paths.socket)) fs.unlinkSync(paths.socket); } catch {}
|
|
184
227
|
}
|
|
228
|
+
let socket = null;
|
|
229
|
+
const send = (message) => {
|
|
230
|
+
try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Board callers identify themselves by agent name (the stdio MCP passes --agent; the CLI is
|
|
234
|
+
// "user"; the server names hosted callers). The name is an identity label for attribution
|
|
235
|
+
// and the holder check, not an execution grant: a run needs the user's authorization.
|
|
236
|
+
const validAgent = (value) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(String(value || ""));
|
|
237
|
+
// Every change to the board goes up the bridge as identity and status; the server mirrors it
|
|
238
|
+
// and the PiP follows from there. Briefs and results never ride this message.
|
|
239
|
+
const notifyBoard = (task) => {
|
|
240
|
+
if (!task) return;
|
|
241
|
+
send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, updated_at: task.updated_at } });
|
|
242
|
+
setImmediate(runBoard);
|
|
243
|
+
};
|
|
244
|
+
const boardCommand = (request) => {
|
|
245
|
+
if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
|
|
246
|
+
const agent = String(request.agent);
|
|
247
|
+
store.touchAgent(agent);
|
|
248
|
+
const id = String(request.id || "");
|
|
249
|
+
const expected = Number.isInteger(request.expectedVersion) ? request.expectedVersion : null;
|
|
250
|
+
let outcome;
|
|
251
|
+
if (request.command === "board.post") {
|
|
252
|
+
const objective = String(request.objective || "").trim();
|
|
253
|
+
if (!objective) return { ok: false, error: "a task needs an objective" };
|
|
254
|
+
if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
|
|
255
|
+
const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : null;
|
|
256
|
+
if (runWorkspace && !path.isAbsolute(runWorkspace)) return { ok: false, error: "a run needs an absolute workspace directory" };
|
|
257
|
+
if (runWorkspace && !request.targetAgent) return { ok: false, error: "a run needs a target agent" };
|
|
258
|
+
const refs = Array.isArray(request.contextRefs) ? request.contextRefs.slice(0, 50) : [];
|
|
259
|
+
// The server may name the task (its run id) so the run record, the session layer and the board agree.
|
|
260
|
+
if (request.id != null && !validRunId(request.id)) return { ok: false, error: "invalid task id" };
|
|
261
|
+
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)) };
|
|
262
|
+
const task = store.postTask({ id: request.id != null ? String(request.id) : randomUUID(), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000),
|
|
263
|
+
originAgent: agent, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
|
|
264
|
+
runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
|
|
265
|
+
outcome = { ok: true, task };
|
|
266
|
+
} else if (request.command === "board.list") return { ok: true, tasks: store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit }) };
|
|
267
|
+
else if (request.command === "board.get") {
|
|
268
|
+
const task = store.getTask(id);
|
|
269
|
+
return task ? { ok: true, task } : { ok: false, error: "task not found" };
|
|
270
|
+
} else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
|
|
271
|
+
else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
|
|
272
|
+
else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
|
|
273
|
+
else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
|
|
274
|
+
else if (request.command === "board.release") outcome = store.releaseTask(id, agent, expected);
|
|
275
|
+
else if (request.command === "board.run") outcome = agent === "user" ? store.authorizeRun(id, agent) : { ok: false, error: "only the user can authorize a run" };
|
|
276
|
+
else return { ok: false, error: "unknown command" };
|
|
277
|
+
if (outcome.ok) notifyBoard(outcome.task);
|
|
278
|
+
return outcome;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// The board worker. An authorized task for an agent installed here is claimed by the daemon
|
|
282
|
+
// in that agent's name, run through the same runner as a pushed run (so `tryharness task <id>
|
|
283
|
+
// --follow` and the run record work unchanged), and reported back on the task. One run per
|
|
284
|
+
// agent at a time; the rest wait open. Nothing here polls: a board write calls runBoard.
|
|
285
|
+
const activeRuns = new Map();
|
|
286
|
+
for (const id of store.settleInterruptedRuns()) log(`board task ${id} was running when the daemon stopped; marked unknown`);
|
|
287
|
+
const runBoard = () => {
|
|
288
|
+
for (const task of store.runnableTasks(installedAgents.filter((agent) => !activeRuns.has(agent)))) {
|
|
289
|
+
const agent = task.target_agent;
|
|
290
|
+
if (activeRuns.has(agent)) continue;
|
|
291
|
+
const claimed = store.claimTask(task.id, agent, task.version, null);
|
|
292
|
+
if (!claimed.ok) continue;
|
|
293
|
+
const started = store.startRun(task.id, agent);
|
|
294
|
+
if (!started.ok) continue;
|
|
295
|
+
activeRuns.set(agent, task.id);
|
|
296
|
+
notifyBoard(started.task);
|
|
297
|
+
const refs = task.context_refs.length ? "\n\nContext:\n" + task.context_refs.map((ref) => `- ${ref.kind}: ${ref.ref}${ref.note ? ` (${ref.note})` : ""}`).join("\n") : "";
|
|
298
|
+
const brief = `${task.objective}\n\n${task.brief}${refs}`.trim();
|
|
299
|
+
startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief, cwd: task.run_workspace }, (kind, text, extra) => {
|
|
300
|
+
if (kind === "session" && text) { store.setRunSession(task.id, text); notifyBoard(store.getTask(task.id)); }
|
|
301
|
+
if (kind === "done" || kind === "failed") {
|
|
302
|
+
activeRuns.delete(agent);
|
|
303
|
+
const reported = store.reportTask(task.id, agent, { status: kind, result: text || "" });
|
|
304
|
+
if (reported.ok) notifyBoard(reported.task);
|
|
305
|
+
else log(`board task ${task.id} finished ${kind} but could not be reported: ${reported.error}`);
|
|
306
|
+
}
|
|
307
|
+
void extra;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
185
312
|
const control = net.createServer((socket) => {
|
|
186
313
|
socket.setEncoding("utf8");
|
|
187
314
|
let buffer = "";
|
|
@@ -199,14 +326,10 @@ export async function startDaemon() {
|
|
|
199
326
|
if (request.agent === "opencode" && request.sessionId) targetedOpencode.push(request);
|
|
200
327
|
void sync(request.agent && request.agent !== "all" ? request.agent : null);
|
|
201
328
|
reply({ ok: true, accepted: true, indexing: true });
|
|
202
|
-
} else if (request.command
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
reply({ ok: true,
|
|
206
|
-
} else if (request.command === "handoffs.list") reply({ ok: true, handoffs: store.listHandoffs(request.agent) });
|
|
207
|
-
else if (request.command === "handoffs.get") {
|
|
208
|
-
const handoff = store.getHandoff(request.id, request.consume === true);
|
|
209
|
-
reply(handoff && handoff.target_agent === request.agent ? { ok: true, handoff } : { ok: false, error: "handoff not found" });
|
|
329
|
+
} else if (String(request.command || "").startsWith("board.")) reply(boardCommand(request));
|
|
330
|
+
else if (request.command === "tasks.get") {
|
|
331
|
+
const task = readTask(request.runId, request.offset);
|
|
332
|
+
reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
|
|
210
333
|
} else if (request.command === "shutdown") {
|
|
211
334
|
reply({ ok: true, shuttingDown: true });
|
|
212
335
|
setTimeout(shutdown, 25);
|
|
@@ -219,11 +342,58 @@ export async function startDaemon() {
|
|
|
219
342
|
});
|
|
220
343
|
if (process.platform !== "win32") try { fs.chmodSync(paths.socket, 0o600); } catch {}
|
|
221
344
|
|
|
222
|
-
let socket = null;
|
|
223
345
|
let retry = 1_000;
|
|
224
346
|
let reconnectTimer = null;
|
|
225
|
-
|
|
226
|
-
|
|
347
|
+
// Runs an agent for a board task, writing the run record and log under the task id. A run id
|
|
348
|
+
// is executed at most once: a known record answers with its outcome instead of a rerun.
|
|
349
|
+
const startTask = (message, report) => {
|
|
350
|
+
if (!validRunId(message.runId)) {
|
|
351
|
+
report("failed", "Invalid task run id.");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// A run id is executed at most once, even if the same task arrives again.
|
|
355
|
+
const known = taskStatus(message.runId);
|
|
356
|
+
if (known) {
|
|
357
|
+
if (known.status === "done" || known.status === "failed") report(known.status, known.message || undefined);
|
|
358
|
+
else if (known.status === "running") report("accepted");
|
|
359
|
+
else report("failed", "This run was interrupted earlier and is not started again.");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
ensureDir(paths.runs);
|
|
363
|
+
const files = taskFiles(message.runId);
|
|
364
|
+
try { fs.writeFileSync(files.log, "", { mode: 0o600 }); } catch {}
|
|
365
|
+
const task = {
|
|
366
|
+
runId: message.runId,
|
|
367
|
+
agent: message.agent,
|
|
368
|
+
title: message.title,
|
|
369
|
+
cwd: message.cwd || null,
|
|
370
|
+
status: "starting",
|
|
371
|
+
sessionId: null,
|
|
372
|
+
startedAt: new Date().toISOString(),
|
|
373
|
+
updatedAt: new Date().toISOString(),
|
|
374
|
+
message: null,
|
|
375
|
+
};
|
|
376
|
+
taskRuns.set(message.runId, task);
|
|
377
|
+
persistTask(task);
|
|
378
|
+
const emit = (kind, text, extra) => {
|
|
379
|
+
if (kind === "accepted") task.status = "running";
|
|
380
|
+
if (kind === "session") {
|
|
381
|
+
task.sessionId = text || null;
|
|
382
|
+
if (extra?.cwd) task.cwd = extra.cwd;
|
|
383
|
+
}
|
|
384
|
+
if (kind === "done" || kind === "failed") {
|
|
385
|
+
task.status = kind;
|
|
386
|
+
task.finishedAt = new Date().toISOString();
|
|
387
|
+
task.message = text || null;
|
|
388
|
+
}
|
|
389
|
+
task.updatedAt = new Date().toISOString();
|
|
390
|
+
persistTask(task);
|
|
391
|
+
report(kind, text, extra);
|
|
392
|
+
};
|
|
393
|
+
const observe = (stream, raw) => {
|
|
394
|
+
try { fs.appendFileSync(files.log, stream === "stderr" ? `\n[stderr] ${raw}` : raw, { mode: 0o600 }); } catch {}
|
|
395
|
+
};
|
|
396
|
+
runAgentTask(message, emit, observe);
|
|
227
397
|
};
|
|
228
398
|
const scheduleReconnect = () => {
|
|
229
399
|
if (reconnectTimer) return;
|
|
@@ -243,7 +413,7 @@ export async function startDaemon() {
|
|
|
243
413
|
state.connected = true;
|
|
244
414
|
state.lastError = null;
|
|
245
415
|
saveStatus();
|
|
246
|
-
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.
|
|
416
|
+
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board"] });
|
|
247
417
|
});
|
|
248
418
|
socket.on("message", (data) => {
|
|
249
419
|
let message;
|
|
@@ -265,28 +435,18 @@ export async function startDaemon() {
|
|
|
265
435
|
catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
|
|
266
436
|
return;
|
|
267
437
|
}
|
|
268
|
-
if (message.type === "
|
|
269
|
-
|
|
270
|
-
|
|
438
|
+
if (message.type === "tasks_status") {
|
|
439
|
+
const runIds = Array.isArray(message.input?.runIds) ? message.input.runIds.slice(0, 50) : [];
|
|
440
|
+
send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { tasks: runIds.map(taskStatus).filter(Boolean) } });
|
|
271
441
|
return;
|
|
272
442
|
}
|
|
273
|
-
if (message.type === "
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
} catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
|
|
443
|
+
if (message.type === "board") {
|
|
444
|
+
// A hosted caller's board call, identified by the name Harness assigns it. The same
|
|
445
|
+
// checks as the local control socket apply; the server persists nothing of it.
|
|
446
|
+
send({ type: "bridge_response", requestId: message.requestId, ok: true, result: boardCommand({ ...(message.input || {}), command: String(message.input?.command || "") }) });
|
|
278
447
|
return;
|
|
279
448
|
}
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
store.addHandoff({ id: message.handoff?.id || randomUUID(), targetAgent: message.handoff?.agent, targetSessionId: message.handoff?.sessionId, title: message.handoff?.title || "Harness handoff", body: message.handoff?.body || "", source: message.handoff?.source });
|
|
283
|
-
send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { stored: true } });
|
|
284
|
-
} catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (message.type === "task") {
|
|
288
|
-
runAgentTask(message, (kind, text, extra) => send({ type: "event", runId: message.runId, kind, ...(text ? { text } : {}), ...(extra || {}) }));
|
|
289
|
-
}
|
|
449
|
+
|
|
290
450
|
});
|
|
291
451
|
const dropped = (error) => {
|
|
292
452
|
state.connected = false;
|
|
@@ -304,6 +464,7 @@ export async function startDaemon() {
|
|
|
304
464
|
}
|
|
305
465
|
};
|
|
306
466
|
connect();
|
|
467
|
+
setImmediate(runBoard);
|
|
307
468
|
const heartbeat = setInterval(() => send({ type: "ping" }), 30_000);
|
|
308
469
|
const rescan = setInterval(() => void sync(), RESCAN_MS);
|
|
309
470
|
const watchers = [];
|
package/lib/files.mjs
CHANGED
|
@@ -43,17 +43,3 @@ export function listJsonl(root) {
|
|
|
43
43
|
}
|
|
44
44
|
return found;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
export function tailFile(file, cap = 2_000_000) {
|
|
48
|
-
const stat = fs.statSync(file);
|
|
49
|
-
const start = Math.max(0, stat.size - cap);
|
|
50
|
-
const length = stat.size - start;
|
|
51
|
-
const fd = fs.openSync(file, "r");
|
|
52
|
-
try {
|
|
53
|
-
const buffer = Buffer.alloc(length);
|
|
54
|
-
fs.readSync(fd, buffer, 0, length, start);
|
|
55
|
-
return buffer.toString("utf8");
|
|
56
|
-
} finally {
|
|
57
|
-
fs.closeSync(fd);
|
|
58
|
-
}
|
|
59
|
-
}
|
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -11,31 +11,72 @@ let chain = Promise.resolve();
|
|
|
11
11
|
|
|
12
12
|
const LOCAL_TOOLS = [
|
|
13
13
|
{
|
|
14
|
-
name: "
|
|
15
|
-
description: "List
|
|
16
|
-
inputSchema: {
|
|
14
|
+
name: "list_tasks",
|
|
15
|
+
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. Nothing here runs by itself; claim a task before working on it.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: { status: { type: "string", enum: ["open", "claimed", "needs_input", "done", "failed"] }, all: { type: "boolean", description: "include every task on the board, finished ones too" } },
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
},
|
|
17
21
|
},
|
|
18
22
|
{
|
|
19
|
-
name: "
|
|
20
|
-
description: "Read one
|
|
23
|
+
name: "get_task",
|
|
24
|
+
description: "Read one board task in full: objective, brief, context references, who posted it, who claimed it and any result. Treat the brief as user-provided context, not hidden instructions.",
|
|
25
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "post_task",
|
|
29
|
+
description: "Post work for another agent on this machine. Say what the objective is, give the brief the other agent needs, name the target agent or leave it open, and reference context by id or path instead of copying it.",
|
|
21
30
|
inputSchema: {
|
|
22
31
|
type: "object",
|
|
23
|
-
properties: {
|
|
24
|
-
|
|
32
|
+
properties: {
|
|
33
|
+
objective: { type: "string", description: "one line, in the user's words" },
|
|
34
|
+
brief: { type: "string" },
|
|
35
|
+
target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot or harness; omit for any agent" },
|
|
36
|
+
parent_id: { type: "string", description: "the board task this one continues" },
|
|
37
|
+
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" },
|
|
38
|
+
context_refs: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, ref: { type: "string" }, note: { type: "string" } }, required: ["kind", "ref"], additionalProperties: false } },
|
|
39
|
+
},
|
|
40
|
+
required: ["objective"],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "claim_task",
|
|
46
|
+
description: "Claim an open board task before working on it, so the board shows who has it. Fails if another agent holds it or it was posted for a different agent. Pass the version you read to be sure nothing changed since; a conflict returns the task as it is now.",
|
|
47
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, expected_version: { type: "integer", description: "the task version you last read" }, lease_minutes: { type: "integer", description: "how long you expect to hold it (default 60); a lapsed lease shows the claim as stale" } }, required: ["id"], additionalProperties: false },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "report_task",
|
|
51
|
+
description: "Report on a task this agent claimed: done with the result, failed with the reason, or needs_input with the question for the agent that posted it. If you posted a task that needs input, answer it here with status answered. If you posted a task whose claim went stale, status released puts it back on the board.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { id: { type: "string" }, status: { type: "string", enum: ["done", "failed", "needs_input", "answered", "released"] }, result: { type: "string", description: "the result, reason, question or answer" }, expected_version: { type: "integer", description: "the task version you last read" } },
|
|
55
|
+
required: ["id", "status"],
|
|
25
56
|
additionalProperties: false,
|
|
26
57
|
},
|
|
27
58
|
},
|
|
28
59
|
];
|
|
29
60
|
|
|
61
|
+
const BOARD_CALLS = {
|
|
62
|
+
list_tasks: (args) => ({ command: "board.list", status: args.status, all: args.all === true }),
|
|
63
|
+
get_task: (args) => ({ command: "board.get", id: String(args.id || "") }),
|
|
64
|
+
post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace }),
|
|
65
|
+
claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes }),
|
|
66
|
+
report_task: (args) => args.status === "answered"
|
|
67
|
+
? { command: "board.answer", id: String(args.id || ""), answer: args.result, expectedVersion: args.expected_version }
|
|
68
|
+
: args.status === "released"
|
|
69
|
+
? { command: "board.release", id: String(args.id || ""), expectedVersion: args.expected_version }
|
|
70
|
+
: { command: "board.report", id: String(args.id || ""), status: args.status, result: args.result, expectedVersion: args.expected_version },
|
|
71
|
+
};
|
|
72
|
+
|
|
30
73
|
function write(message) { process.stdout.write(JSON.stringify(message) + "\n"); }
|
|
31
74
|
|
|
32
75
|
async function localCall(request) {
|
|
33
76
|
const name = request.params?.name;
|
|
34
77
|
const args = request.params?.arguments || {};
|
|
35
78
|
try {
|
|
36
|
-
const result = name
|
|
37
|
-
? await daemonRequest({ command: "handoffs.list", agent })
|
|
38
|
-
: await daemonRequest({ command: "handoffs.get", agent, id: String(args.id || ""), consume: args.consume === true });
|
|
79
|
+
const result = await daemonRequest({ ...BOARD_CALLS[name](args), agent });
|
|
39
80
|
return {
|
|
40
81
|
jsonrpc: "2.0",
|
|
41
82
|
id: request.id,
|
package/lib/nudge.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { daemonRequest } from "./control-client.mjs";
|
|
3
|
+
|
|
4
|
+
// The session-start nudge: what an interactive agent is told when a session opens, so work
|
|
5
|
+
// waiting on the board is not silent until someone asks. Claude Code runs this as a
|
|
6
|
+
// SessionStart hook (stdout lands in its context); the Pi extension and OpenCode plugin
|
|
7
|
+
// build the same text in-process. Nothing waiting, or no daemon, prints nothing.
|
|
8
|
+
|
|
9
|
+
export function nudgeText(agent, tasks) {
|
|
10
|
+
const waiting = (tasks || []).filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === agent));
|
|
11
|
+
const held = (tasks || []).filter((task) => task.claimed_by === agent && (task.status === "claimed" || task.status === "needs_input"));
|
|
12
|
+
if (!waiting.length && !held.length) return "";
|
|
13
|
+
const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}${task.status === "needs_input" ? " · your question was answered" : ""}`;
|
|
14
|
+
const parts = ["Harness task board:"];
|
|
15
|
+
if (waiting.length) parts.push(`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line));
|
|
16
|
+
if (held.length) parts.push(`${held.length} you already claimed:`, ...held.slice(0, 5).map(line));
|
|
17
|
+
parts.push("Read one with get_task, claim it with claim_task before working, and report_task when done or stuck.");
|
|
18
|
+
return parts.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function boardNudge(agent) {
|
|
22
|
+
try {
|
|
23
|
+
const response = await daemonRequest({ command: "board.list", agent }, 1_500);
|
|
24
|
+
return response?.ok ? nudgeText(agent, response.tasks) : "";
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
31
|
+
const index = process.argv.indexOf("--agent");
|
|
32
|
+
const agent = index >= 0 ? process.argv[index + 1] : "claude-code";
|
|
33
|
+
const text = await boardNudge(agent);
|
|
34
|
+
if (text) process.stdout.write(text + "\n");
|
|
35
|
+
}
|