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/store.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import sqliteWasm from "node-sqlite3-wasm";
|
|
2
2
|
import { parseSession } from "./parsers.mjs";
|
|
3
3
|
|
|
4
|
-
const SCHEMA_VERSION =
|
|
4
|
+
const SCHEMA_VERSION = 4;
|
|
5
|
+
const INDEX_FORMAT_VERSION = 2;
|
|
5
6
|
const { Database } = sqliteWasm;
|
|
6
7
|
|
|
7
8
|
function bindings(values) {
|
|
@@ -33,6 +34,7 @@ export function openStore(file) {
|
|
|
33
34
|
message_count integer not null default 0,
|
|
34
35
|
source_mtime real not null default 0,
|
|
35
36
|
source_size integer not null default 0,
|
|
37
|
+
parser_version integer not null default 0,
|
|
36
38
|
transcript text not null default '',
|
|
37
39
|
indexed_at text not null,
|
|
38
40
|
primary key(agent, session_id)
|
|
@@ -47,17 +49,50 @@ export function openStore(file) {
|
|
|
47
49
|
transcript,
|
|
48
50
|
tokenize='unicode61'
|
|
49
51
|
);
|
|
50
|
-
create table if not exists
|
|
52
|
+
create table if not exists board_tasks (
|
|
51
53
|
id text primary key,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
objective text not null,
|
|
55
|
+
brief text not null default '',
|
|
56
|
+
origin_agent text not null,
|
|
57
|
+
target_agent text,
|
|
58
|
+
status text not null,
|
|
59
|
+
parent_id text,
|
|
60
|
+
context_refs text not null default '[]',
|
|
61
|
+
claimed_by text,
|
|
62
|
+
claimed_at text,
|
|
63
|
+
result text,
|
|
64
|
+
reported_at text,
|
|
65
|
+
holder text,
|
|
66
|
+
version integer not null default 1,
|
|
67
|
+
run_workspace text,
|
|
68
|
+
run_requested_at text,
|
|
69
|
+
run_authorized_by text,
|
|
70
|
+
run_authorized_at text,
|
|
71
|
+
session_id text,
|
|
72
|
+
lease_until text,
|
|
57
73
|
created_at text not null,
|
|
58
|
-
|
|
74
|
+
updated_at text not null
|
|
75
|
+
);
|
|
76
|
+
create index if not exists board_tasks_status_idx on board_tasks(status, created_at desc);
|
|
77
|
+
create table if not exists board_agents (
|
|
78
|
+
agent text primary key,
|
|
79
|
+
last_seen_at text not null
|
|
59
80
|
);
|
|
60
81
|
`);
|
|
82
|
+
const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
|
|
83
|
+
if (!sessionColumns.has("parser_version")) {
|
|
84
|
+
db.exec("alter table sessions add column parser_version integer not null default 0");
|
|
85
|
+
}
|
|
86
|
+
// Boards written before the lock existed: give every task a holder and a version.
|
|
87
|
+
const boardColumns = new Set(db.all("pragma table_info(board_tasks)").map((column) => column.name));
|
|
88
|
+
if (!boardColumns.has("holder")) {
|
|
89
|
+
db.exec(`alter table board_tasks add column holder text;
|
|
90
|
+
alter table board_tasks add column version integer not null default 1;
|
|
91
|
+
update board_tasks set holder = case status when 'open' then target_agent when 'claimed' then claimed_by when 'needs_input' then origin_agent else null end;`);
|
|
92
|
+
}
|
|
93
|
+
for (const column of ["run_workspace", "run_requested_at", "run_authorized_by", "run_authorized_at", "session_id", "lease_until"]) {
|
|
94
|
+
if (!boardColumns.has(column) && boardColumns.size) db.exec(`alter table board_tasks add column ${column} text`);
|
|
95
|
+
}
|
|
61
96
|
|
|
62
97
|
const statements = [];
|
|
63
98
|
const prepare = (sql) => {
|
|
@@ -77,20 +112,21 @@ export function openStore(file) {
|
|
|
77
112
|
const bound = bindings(values);
|
|
78
113
|
return bound === undefined ? db[method](sql) : db[method](sql, bound);
|
|
79
114
|
};
|
|
80
|
-
const existing = prepare("select source_mtime, source_size from sessions where agent = ? and session_id = ?");
|
|
115
|
+
const existing = prepare("select source_mtime, source_size, parser_version from sessions where agent = ? and session_id = ?");
|
|
81
116
|
const upsert = prepare(`
|
|
82
|
-
insert into sessions(agent, session_id, source, cwd, title, started_at, ended_at, message_count, source_mtime, source_size, transcript, indexed_at)
|
|
83
|
-
values(@agent, @sessionId, @source, @cwd, @title, @startedAt, @endedAt, @messageCount, @mtime, @size, @transcript, @indexedAt)
|
|
117
|
+
insert into sessions(agent, session_id, source, cwd, title, started_at, ended_at, message_count, source_mtime, source_size, parser_version, transcript, indexed_at)
|
|
118
|
+
values(@agent, @sessionId, @source, @cwd, @title, @startedAt, @endedAt, @messageCount, @mtime, @size, @parserVersion, @transcript, @indexedAt)
|
|
84
119
|
on conflict(agent, session_id) do update set
|
|
85
120
|
source=excluded.source, cwd=excluded.cwd, title=excluded.title,
|
|
86
121
|
started_at=excluded.started_at, ended_at=excluded.ended_at,
|
|
87
122
|
message_count=excluded.message_count, source_mtime=excluded.source_mtime,
|
|
88
|
-
source_size=excluded.source_size,
|
|
123
|
+
source_size=excluded.source_size, parser_version=excluded.parser_version,
|
|
124
|
+
transcript=excluded.transcript,
|
|
89
125
|
indexed_at=excluded.indexed_at
|
|
90
126
|
`);
|
|
91
127
|
const deleteSearch = prepare("delete from session_fts where agent = ? and session_id = ?");
|
|
92
128
|
const insertSearch = prepare("insert into session_fts(agent, session_id, title, cwd, transcript) values(?, ?, ?, ?, ?)");
|
|
93
|
-
const
|
|
129
|
+
const persistParsed = (record) => {
|
|
94
130
|
db.exec("begin immediate");
|
|
95
131
|
try {
|
|
96
132
|
upsert.run(record);
|
|
@@ -110,19 +146,40 @@ export function openStore(file) {
|
|
|
110
146
|
select agent, session_id, title, cwd, transcript from sessions;`);
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
// Why a conditional write landed nowhere, with the task as it is now so the caller re-reads.
|
|
150
|
+
const conflict = (task, agent, expectedVersion, describe) => {
|
|
151
|
+
if (!task) return { ok: false, error: "task not found" };
|
|
152
|
+
if (expectedVersion != null && Number(expectedVersion) !== task.version) return { ok: false, error: `task changed since version ${expectedVersion}; it is now version ${task.version}`, conflict: true, task };
|
|
153
|
+
return { ok: false, error: describe(task), conflict: true, task };
|
|
154
|
+
};
|
|
155
|
+
|
|
113
156
|
return {
|
|
114
157
|
db,
|
|
115
158
|
needsIndex(agent, sessionId, mtime, size) {
|
|
116
159
|
const row = existing.get(agent, sessionId);
|
|
117
|
-
return !row
|
|
160
|
+
return !row
|
|
161
|
+
|| Number(row.source_mtime) !== Number(mtime)
|
|
162
|
+
|| Number(row.source_size) !== Number(size)
|
|
163
|
+
|| Number(row.parser_version) !== INDEX_FORMAT_VERSION;
|
|
118
164
|
},
|
|
119
165
|
indexRaw({ agent, sessionId, source = null, raw, mtime = Date.now(), size = raw.length, cwd }) {
|
|
120
166
|
const parsed = parseSession(agent, raw);
|
|
121
|
-
|
|
167
|
+
persistParsed({
|
|
122
168
|
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
123
169
|
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
124
170
|
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
125
|
-
mtime, size,
|
|
171
|
+
mtime, size, parserVersion: INDEX_FORMAT_VERSION,
|
|
172
|
+
transcript: parsed.transcript, indexedAt: new Date().toISOString(),
|
|
173
|
+
});
|
|
174
|
+
return parsed.meta;
|
|
175
|
+
},
|
|
176
|
+
indexParsed({ agent, sessionId, source = null, parsed, mtime, size, cwd }) {
|
|
177
|
+
persistParsed({
|
|
178
|
+
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
179
|
+
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
180
|
+
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
181
|
+
mtime, size, parserVersion: INDEX_FORMAT_VERSION,
|
|
182
|
+
transcript: parsed.transcript, indexedAt: new Date().toISOString(),
|
|
126
183
|
});
|
|
127
184
|
return parsed.meta;
|
|
128
185
|
},
|
|
@@ -173,21 +230,117 @@ export function openStore(file) {
|
|
|
173
230
|
order by case when session_id = ? then 0 else 1 end, indexed_at desc limit 1`, agent, sessionHint, sessionHint, sessionHint);
|
|
174
231
|
return found || null;
|
|
175
232
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
233
|
+
// The task board: work any agent posts for another agent on this machine. Pull only; a
|
|
234
|
+
// task runs when its target reads the board and claims it, never on its own. Brief and
|
|
235
|
+
// result never leave the machine unless the claimer sends them somewhere itself.
|
|
236
|
+
//
|
|
237
|
+
// Every task has one holder, the only identity allowed to write it next, and a version
|
|
238
|
+
// that every write bumps. A write is one conditional update: it lands only for the holder,
|
|
239
|
+
// in the expected status, at the version the caller last saw when it names one. Anything
|
|
240
|
+
// else is a conflict that returns the current task, so two agents can never overwrite each
|
|
241
|
+
// other and a caller acting on a stale read finds out instead of winning.
|
|
242
|
+
postTask({ id, objective, brief = "", originAgent, targetAgent = null, parentId = null, contextRefs = [], runWorkspace = null, runAuthorizedBy = null }) {
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
execute("run", `insert into board_tasks(id, objective, brief, origin_agent, target_agent, status, parent_id, context_refs, holder, version,
|
|
245
|
+
run_workspace, run_requested_at, run_authorized_by, run_authorized_at, created_at, updated_at)
|
|
246
|
+
values(@id, @objective, @brief, @originAgent, @targetAgent, 'open', @parentId, @contextRefs, @targetAgent, 1, @runWorkspace, @runRequestedAt, @runAuthorizedBy, @runAuthorizedAt, @now, @now)`,
|
|
247
|
+
{ id, objective, brief, originAgent, targetAgent, parentId, contextRefs: JSON.stringify(contextRefs), runWorkspace, runRequestedAt: runWorkspace ? now : null,
|
|
248
|
+
runAuthorizedBy: runWorkspace ? runAuthorizedBy : null, runAuthorizedAt: runWorkspace && runAuthorizedBy ? now : null, now });
|
|
249
|
+
return this.getTask(id);
|
|
250
|
+
},
|
|
251
|
+
// Run authority is a separate write from the post: an agent may ask for a run, only the user grants it.
|
|
252
|
+
authorizeRun(id, agent) {
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const written = execute("run", `update board_tasks set run_authorized_by = @agent, run_authorized_at = @now, updated_at = @now, version = version + 1
|
|
255
|
+
where id = @id and status = 'open' and run_workspace is not null and run_authorized_at is null`, { id, agent, now });
|
|
256
|
+
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
257
|
+
return conflict(this.getTask(id), agent, null, (task) => task.run_authorized_at ? "the run is already authorized" : !task.run_workspace ? "this task did not ask for a run" : `task is ${task.status}`);
|
|
258
|
+
},
|
|
259
|
+
// Tasks the daemon should run now: authorized, still open, for an agent installed here.
|
|
260
|
+
runnableTasks(agents) {
|
|
261
|
+
if (!agents.length) return [];
|
|
262
|
+
return execute("all", `select * from board_tasks where status = 'open' and run_authorized_at is not null and target_agent in (${agents.map(() => "?").join(", ")})
|
|
263
|
+
order by run_authorized_at asc`, ...agents).map((row) => ({ ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]") }));
|
|
264
|
+
},
|
|
265
|
+
startRun(id, agent) {
|
|
266
|
+
const now = new Date().toISOString();
|
|
267
|
+
const written = execute("run", "update board_tasks set status = 'running', updated_at = @now, version = version + 1 where id = @id and status = 'claimed' and holder = @agent", { id, agent, now });
|
|
268
|
+
return written.changes === 1 ? { ok: true, task: this.getTask(id) } : conflict(this.getTask(id), agent, null, (task) => `task is ${task.status}`);
|
|
269
|
+
},
|
|
270
|
+
setRunSession(id, sessionId) {
|
|
271
|
+
execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
|
|
272
|
+
},
|
|
273
|
+
// A daemon restart lost every agent process it was running; those outcomes are unknown, never assumed.
|
|
274
|
+
settleInterruptedRuns() {
|
|
275
|
+
const now = new Date().toISOString();
|
|
276
|
+
const affected = execute("all", "select id from board_tasks where status = 'running'").map((row) => row.id);
|
|
277
|
+
if (affected.length) execute("run", `update board_tasks set status = 'unknown', holder = null, result = 'The daemon restarted during this run; its outcome is unknown. Check the workspace.', reported_at = @now, updated_at = @now, version = version + 1 where status = 'running'`, { now });
|
|
278
|
+
return affected;
|
|
279
|
+
},
|
|
280
|
+
touchAgent(agent) {
|
|
281
|
+
execute("run", "insert into board_agents(agent, last_seen_at) values(?, ?) on conflict(agent) do update set last_seen_at = excluded.last_seen_at", agent, new Date().toISOString());
|
|
282
|
+
},
|
|
283
|
+
agentsSeen() {
|
|
284
|
+
return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
|
|
285
|
+
},
|
|
286
|
+
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
287
|
+
const clauses = [];
|
|
288
|
+
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
|
289
|
+
if (status) { clauses.push("status = @status"); params.status = status; }
|
|
290
|
+
else if (!all) clauses.push("status in ('open', 'claimed', 'running', 'needs_input')");
|
|
291
|
+
if (agent && !all) {
|
|
292
|
+
clauses.push("(target_agent is null or target_agent = @agent or origin_agent = @agent or claimed_by = @agent)");
|
|
293
|
+
params.agent = agent;
|
|
294
|
+
}
|
|
295
|
+
const now = new Date().toISOString();
|
|
296
|
+
return execute("all", `select id, objective, origin_agent, target_agent, status, parent_id, claimed_by, holder, version, lease_until,
|
|
297
|
+
run_workspace, run_authorized_at, session_id, created_at, updated_at
|
|
298
|
+
from board_tasks ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by created_at desc limit @limit`, params)
|
|
299
|
+
.map((row) => ({ ...row, version: Number(row.version), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
|
|
300
|
+
},
|
|
301
|
+
getTask(id) {
|
|
302
|
+
const row = execute("get", "select * from board_tasks where id = ?", id);
|
|
303
|
+
return row ? { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() } : null;
|
|
304
|
+
},
|
|
305
|
+
// An interactive claim carries a lease; when it lapses the claim shows stale and the origin
|
|
306
|
+
// or the user may release it. It is never released on its own. Daemon runs carry none.
|
|
307
|
+
claimTask(id, agent, expectedVersion = null, leaseMinutes = 60) {
|
|
308
|
+
const now = new Date().toISOString();
|
|
309
|
+
const leaseUntil = leaseMinutes == null ? null : new Date(Date.now() + Math.max(0, Number(leaseMinutes) || 0) * 60_000).toISOString();
|
|
310
|
+
const written = execute("run", `update board_tasks set status = 'claimed', claimed_by = @agent, holder = @agent, claimed_at = @now, lease_until = @leaseUntil, updated_at = @now, version = version + 1
|
|
311
|
+
where id = @id and status = 'open' and (holder is null or holder = @agent) and (@expected is null or version = @expected)`,
|
|
312
|
+
{ id, agent, now, leaseUntil, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
313
|
+
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
314
|
+
return conflict(this.getTask(id), agent, expectedVersion, (task) => task.status !== "open" ? `task is ${task.status}${task.claimed_by ? ` by ${task.claimed_by}` : ""}` : `task is for ${task.holder}`);
|
|
315
|
+
},
|
|
316
|
+
reportTask(id, agent, { status, result = "" }, expectedVersion = null) {
|
|
317
|
+
if (!["done", "failed", "needs_input"].includes(status)) return { ok: false, error: "status must be done, failed or needs_input" };
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
const written = execute("run", `update board_tasks set status = @status, result = @result, reported_at = @now, updated_at = @now, version = version + 1,
|
|
320
|
+
holder = case when @status = 'needs_input' then origin_agent else null end
|
|
321
|
+
where id = @id and status in ('claimed', 'running') and holder = @agent and (@expected is null or version = @expected)`,
|
|
322
|
+
{ id, agent, status, result, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
323
|
+
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
324
|
+
return conflict(this.getTask(id), agent, expectedVersion, (task) => ["claimed", "running"].includes(task.status) ? `task is held by ${task.holder}` : task.status === "open" ? "claim the task before reporting on it" : `task is ${task.status}`);
|
|
325
|
+
},
|
|
326
|
+
releaseTask(id, agent, expectedVersion = null) {
|
|
327
|
+
const now = new Date().toISOString();
|
|
328
|
+
const written = execute("run", `update board_tasks set status = 'open', holder = target_agent, claimed_by = null, claimed_at = null, lease_until = null, result = null, reported_at = null, updated_at = @now, version = version + 1
|
|
329
|
+
where id = @id and status in ('claimed', 'needs_input') and (origin_agent = @agent or @agent = 'user') and (@expected is null or version = @expected)`,
|
|
330
|
+
{ id, agent, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
331
|
+
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
332
|
+
return conflict(this.getTask(id), agent, expectedVersion, (task) => ["claimed", "needs_input"].includes(task.status) ? `only ${task.origin_agent} or the user can release this task` : `task is ${task.status}`);
|
|
333
|
+
},
|
|
334
|
+
answerTask(id, agent, answer, expectedVersion = null) {
|
|
335
|
+
if (!String(answer || "").trim()) return { ok: false, error: "an answer is needed" };
|
|
336
|
+
const now = new Date().toISOString();
|
|
337
|
+
const written = execute("run", `update board_tasks set status = 'claimed', holder = claimed_by, updated_at = @now, version = version + 1,
|
|
338
|
+
brief = brief || char(10) || char(10) || 'Question from ' || claimed_by || ': ' || coalesce(result, '') || char(10) || 'Answer from ' || @agent || ': ' || @answer,
|
|
339
|
+
result = null, reported_at = null
|
|
340
|
+
where id = @id and status = 'needs_input' and holder = @agent and (@expected is null or version = @expected)`,
|
|
341
|
+
{ id, agent, answer, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
342
|
+
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
343
|
+
return conflict(this.getTask(id), agent, expectedVersion, (task) => task.status !== "needs_input" ? `task is ${task.status}` : `only ${task.holder} can answer this task`);
|
|
191
344
|
},
|
|
192
345
|
close() {
|
|
193
346
|
for (const statement of statements) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "One
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "One command puts the agents on this machine in your Harness relay: memory, history, and work handed between them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
7
7
|
},
|