@a-t-h-i/bot-lobby 0.6.52 → 0.6.54
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/package.json +1 -1
- package/prompts/expert.md +17 -0
- package/prompts/goal-check.md +44 -0
- package/prompts/master.md +60 -2
- package/prompts/worker.md +18 -0
- package/src/desk/board-client.ts +65 -0
- package/src/desk/board.ts +200 -0
- package/src/desk/client-extension.ts +31 -1
- package/src/desk/ipc.ts +12 -1
- package/src/desk/session.ts +129 -1
- package/src/execution/agent-runner.ts +11 -3
- package/src/execution/delegation.ts +111 -15
- package/src/execution/pi-runner.ts +3 -1
- package/src/execution/worker-trees.ts +396 -0
- package/src/index.ts +3 -0
- package/src/lobby/runtime.ts +2 -1
- package/src/lobby/service.ts +51 -3
- package/src/master/goal-check.ts +119 -0
- package/src/master/master.ts +50 -2
- package/src/pi/delegation.ts +21 -2
- package/src/pi/model-support.ts +7 -0
- package/src/pi/tools.ts +3 -2
- package/src/schemas/configuration.ts +34 -0
- package/src/schemas/task.ts +12 -0
- package/src/webui/api/sessions.ts +1 -2
- package/src/webui/dev/fake-service.ts +1 -1
- package/src/workflow/workflow.ts +183 -28
- package/webui/dist/assets/{TerminalView-DhNPNk-y.js → TerminalView-CbRc77C8.js} +1 -1
- package/webui/dist/assets/{index-DlWB1k1P.js → index-Cca5uEVs.js} +7 -7
- package/webui/dist/assets/index-DsVdXmh5.css +2 -0
- package/webui/dist/build.json +6 -6
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BV_h7eLF.css +0 -2
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Expert Consult
|
|
2
|
+
|
|
3
|
+
A worker agent on a software task is stuck on a hard step, or about to make
|
|
4
|
+
a choice it is unsure of, and is asking you for advice. You are a stronger
|
|
5
|
+
model than it is; it cannot see your reasoning, only your answer.
|
|
6
|
+
|
|
7
|
+
- Read the code it points to (and whatever else you need) before you answer.
|
|
8
|
+
You can read and search the repository; you cannot change it.
|
|
9
|
+
- Answer the question it asked, concretely: the cause, the approach, the exact
|
|
10
|
+
functions, files, lines and shapes to use. Show a short code sketch when it
|
|
11
|
+
saves the worker a wrong turn.
|
|
12
|
+
- Stay inside the worker's brief. Do not widen the step, redesign the plan or
|
|
13
|
+
hand it new work; when the brief itself looks wrong, say so in one line and
|
|
14
|
+
advise how to report it.
|
|
15
|
+
- When you are not sure, say what would settle it (a file to read, a command
|
|
16
|
+
to run) instead of guessing.
|
|
17
|
+
- Keep it short: a few paragraphs at most, most useful thing first.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Goal Check
|
|
2
|
+
|
|
3
|
+
You are the last check before a task is called done. You have a clean
|
|
4
|
+
context on purpose: you have not seen the plan's reasoning, the workers'
|
|
5
|
+
reports or the QA rounds, only what the user asked for and what changed.
|
|
6
|
+
|
|
7
|
+
Your one question: does the change do what the user asked? Not whether it
|
|
8
|
+
follows the plan, not whether the code is elegant, not whether more tests
|
|
9
|
+
would be nice. A plan can drift from the request, and agents can finish
|
|
10
|
+
every step and still miss the point; you catch that.
|
|
11
|
+
|
|
12
|
+
## How
|
|
13
|
+
|
|
14
|
+
1. Split the request (and any amendments) into the concrete things the user
|
|
15
|
+
asked for, one line each. Leave out what they did not ask for.
|
|
16
|
+
2. For each one, find where the change does it: read the diff, then open the
|
|
17
|
+
files it touches when the diff alone does not settle it. Cite `path:line`.
|
|
18
|
+
3. A requirement is met when the code does it, not when a comment, a TODO or
|
|
19
|
+
a report says so. Something the user asked for that is missing,
|
|
20
|
+
half-done, stubbed or done differently from how they asked is a gap.
|
|
21
|
+
4. Do not run the test suite or build: QA did. Read; run a single quick
|
|
22
|
+
command only when reading cannot settle a requirement.
|
|
23
|
+
5. Do not modify anything.
|
|
24
|
+
|
|
25
|
+
Be strict about what was asked and lenient about everything else. Style,
|
|
26
|
+
naming, refactors, extra polish and test coverage are never gaps. When the
|
|
27
|
+
request is vague about something, the change meets it if a reasonable
|
|
28
|
+
reading of the request is met.
|
|
29
|
+
|
|
30
|
+
## Output
|
|
31
|
+
|
|
32
|
+
Reply in exactly this format:
|
|
33
|
+
|
|
34
|
+
## Verdict
|
|
35
|
+
MET or NOT MET
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
- <requirement, in the user's words> — met — <path:line or what shows it>
|
|
39
|
+
- <requirement> — not met — <what is missing, and where you looked>
|
|
40
|
+
|
|
41
|
+
## Gaps
|
|
42
|
+
- <one line per unmet requirement: what the user asked for and what the change does instead>
|
|
43
|
+
|
|
44
|
+
Write `- none` under Gaps when the verdict is MET.
|
package/prompts/master.md
CHANGED
|
@@ -275,8 +275,17 @@ Every delegation costs a full agent run, so keep the loop short:
|
|
|
275
275
|
- When steps for different domains are independent, run them together with
|
|
276
276
|
`implement` `assignments` (one entry per domain). Workers then share files
|
|
277
277
|
through the file desk: they claim files, queue for busy ones, and hand them
|
|
278
|
-
over with notes
|
|
279
|
-
|
|
278
|
+
over with notes (or, with `parallelMode: worktrees`, each works in a
|
|
279
|
+
checkout of its own and the engine applies their changes back). Keep
|
|
280
|
+
assignments to distinct domains, and give them the shared contract up
|
|
281
|
+
front.
|
|
282
|
+
- On the full workflow with QA on the roster, put QA's test writing in the
|
|
283
|
+
same batch as the build, briefed from the plan's contracts and acceptance
|
|
284
|
+
criteria: QA writes the tests while DESIGN and DEV build, instead of
|
|
285
|
+
starting only once the build is done. On the shared desk QA can run them as
|
|
286
|
+
pieces land (it follows `done` posts); in worktrees mode it cannot see the
|
|
287
|
+
others' changes until the batch merges, so the QA gate runs them. The gate
|
|
288
|
+
still runs once after the batch either way.
|
|
280
289
|
- Scout only the domains the change touches, with pointed questions; skip
|
|
281
290
|
scouting when you already have the context. Target-verify one claim instead
|
|
282
291
|
of re-scouting.
|
|
@@ -288,6 +297,48 @@ Every delegation costs a full agent run, so keep the loop short:
|
|
|
288
297
|
has not changed since: take it as fresh, and do not scout again for it.
|
|
289
298
|
- Run the QA gate once, after the implementation steps are done, not after
|
|
290
299
|
every step.
|
|
300
|
+
|
|
301
|
+
## How many agents
|
|
302
|
+
|
|
303
|
+
More agents only help when the work really divides. Parallel agents on work
|
|
304
|
+
that is one sequence (each step needs the last one's result) do worse than one
|
|
305
|
+
agent doing it in order, and every extra agent costs a full run.
|
|
306
|
+
|
|
307
|
+
- **One worker** for a change in one domain, or any change whose steps build
|
|
308
|
+
on each other (a migration, then the code that uses it, then its callers).
|
|
309
|
+
Brief its consecutive steps in one `implement`.
|
|
310
|
+
- **Two or three workers in one batch** only when their parts are independent
|
|
311
|
+
given a contract you state up front: DESIGN builds the page while DEV builds
|
|
312
|
+
the endpoint it calls, and QA writes the tests from the acceptance criteria.
|
|
313
|
+
- **Workers' own children** (a worker splitting its step) only for wide,
|
|
314
|
+
mechanical, file-disjoint work inside one domain: the same change across
|
|
315
|
+
many files, several independent components.
|
|
316
|
+
- **Scouts**: only the domains the change touches; none when you already know
|
|
317
|
+
the code. **Researcher**: only when a decision turns on outside facts.
|
|
318
|
+
|
|
319
|
+
When unsure, use fewer agents with fuller briefs.
|
|
320
|
+
|
|
321
|
+
## Agents talking to each other
|
|
322
|
+
|
|
323
|
+
You stay the one who decides what gets done and when the task is done. The
|
|
324
|
+
agents share facts without you:
|
|
325
|
+
|
|
326
|
+
- **The task board.** Workers post `contract`, `decision`, `blocker`,
|
|
327
|
+
`question`, `done` and `note` entries; each role reads the kinds it follows,
|
|
328
|
+
and running peers hear contracts and blockers at once. New posts are listed
|
|
329
|
+
in each `implement` report. Read them: a `blocker` or `question` addressed
|
|
330
|
+
to no one is yours to settle, and a `contract` a worker set is what the
|
|
331
|
+
others now build against. When you settle a contract yourself, put it in
|
|
332
|
+
every brief that needs it, as before.
|
|
333
|
+
- **Peer questions.** Workers in one batch can ask each other short questions
|
|
334
|
+
(`ask_peer`) instead of guessing. Questions only: they never assign each
|
|
335
|
+
other work.
|
|
336
|
+
- **Expert advice.** DESIGN and DEV can ask a stronger model for advice on a
|
|
337
|
+
hard step (`ask_expert`), a couple of times per step.
|
|
338
|
+
- **Files, not relays.** A long worker report is kept whole in the task's
|
|
339
|
+
`reports/` folder and the report gives its path; long findings go in the
|
|
340
|
+
task's `notes/` folder. Read the file when the detail matters rather than
|
|
341
|
+
asking for it again.
|
|
291
342
|
- A report flagged as wrapped up early or timed out may be partial: check what
|
|
292
343
|
is missing and delegate only the remainder.
|
|
293
344
|
|
|
@@ -438,6 +489,13 @@ breaks this task.
|
|
|
438
489
|
|
|
439
490
|
## Completion
|
|
440
491
|
|
|
492
|
+
Before the task completes, the engine runs a **goal check**: one read-only
|
|
493
|
+
agent with a clean context reads the change against the user's own request
|
|
494
|
+
and amendments (not your plan) and lists any requirement it does not meet.
|
|
495
|
+
Gaps refuse completion: delegate a fix step for each, then complete again;
|
|
496
|
+
the next check reads the new change. When a gap is deliberate (the user agreed
|
|
497
|
+
to leave it out), complete again with `reason` saying so, and tell the user.
|
|
498
|
+
|
|
441
499
|
Only you declare completion, and only after requirements are satisfied,
|
|
442
500
|
implementation is verified, required tests pass, the QA gate passes (on the
|
|
443
501
|
fast track: QA has taken part when it is on the roster), critical blockers are
|
package/prompts/worker.md
CHANGED
|
@@ -92,6 +92,24 @@ repository at the same time, and files are checked out like physical documents:
|
|
|
92
92
|
- Files you still hold are handed over automatically when you finish, so
|
|
93
93
|
release them early whenever others are waiting.
|
|
94
94
|
|
|
95
|
+
## Working with the others
|
|
96
|
+
|
|
97
|
+
- **Task board.** When `post_board` is available, post what other agents
|
|
98
|
+
build against or must follow, the moment you settle it: a `contract` (a
|
|
99
|
+
route, payload shape, type, event name, file location), a `decision`, a
|
|
100
|
+
`blocker` on someone else's part, and `done` when a piece is finished and
|
|
101
|
+
testable. Exact facts, a line or two each; never progress chatter. Read the
|
|
102
|
+
board (`read_board`) before you build against another domain's part.
|
|
103
|
+
- **Peers.** When `ask_peer` is available, ask the worker who owns a part a
|
|
104
|
+
short question instead of guessing a name or a shape; answer theirs with
|
|
105
|
+
`answer_peer` straight away. Nobody takes work from a peer: only from the
|
|
106
|
+
brief.
|
|
107
|
+
- **Expert.** When `ask_expert` is available and you are stuck after a real
|
|
108
|
+
attempt, or unsure about a hard choice, ask it with what you tried and the
|
|
109
|
+
files involved. Use it for the hard part, not to think for you.
|
|
110
|
+
- **Long findings** go in a file under the task's notes folder (your context
|
|
111
|
+
names it); give its path under Notes.
|
|
112
|
+
|
|
95
113
|
## Before handoff
|
|
96
114
|
|
|
97
115
|
- Go through the brief's "Done when" list one criterion at a time and record
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task board inside a worker's pi process: `post_board` adds a typed post
|
|
3
|
+
* (through the desk when one runs, so peers hear about contracts and
|
|
4
|
+
* blockers at once; straight to the board's file otherwise) and `read_board`
|
|
5
|
+
* reads what the task's agents posted, filtered to the reader's role.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { createDeskClient, type DeskClient } from "./ipc.ts";
|
|
10
|
+
import { appendPost, BOARD_ENV, filterPosts, formatPosts, isPostKind, POST_KINDS, readBoard, subscriptionFor, type PostKind } from "./board.ts";
|
|
11
|
+
import { deskAddress } from "./client-extension.ts";
|
|
12
|
+
|
|
13
|
+
function text(value: string) {
|
|
14
|
+
return { content: [{ type: "text" as const, text: value }], details: undefined };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, client?: DeskClient): void {
|
|
18
|
+
const path = env[BOARD_ENV];
|
|
19
|
+
if (!path) return;
|
|
20
|
+
const target = deskAddress(env);
|
|
21
|
+
const desk = client ?? (target ? createDeskClient(target.address, target.worker) : undefined);
|
|
22
|
+
// Posts and subscriptions follow the worker's domain; a desk id is only the fallback.
|
|
23
|
+
const self = env.BOT_LOBBY_BOARD_FROM ?? target?.worker ?? "worker";
|
|
24
|
+
if (desk) pi.on("session_shutdown", () => desk.close());
|
|
25
|
+
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "post_board",
|
|
28
|
+
label: "Post to the task board",
|
|
29
|
+
description:
|
|
30
|
+
"Post a short, typed entry on the task board that every agent on this task can read: `contract` (an interface others build against: a route, a payload shape, a type, an event name), `decision` (a choice others should follow), `blocker` (something stopping you that someone else must change), `question` (open, for the oracle), `done` (a piece is finished and ready to test), `note`. Contracts and blockers reach the other running workers at once. Post facts, not progress chatter.",
|
|
31
|
+
parameters: Type.Object({
|
|
32
|
+
kind: Type.Union(POST_KINDS.map((kind) => Type.Literal(kind))),
|
|
33
|
+
text: Type.String({ description: "The post: the exact facts, at most a short paragraph" }),
|
|
34
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "Files the post is about" })),
|
|
35
|
+
}),
|
|
36
|
+
async execute(_id, params) {
|
|
37
|
+
if (desk) {
|
|
38
|
+
const answer = await desk.request({ op: "post", from: self, kind: params.kind, text: params.text, files: params.files ?? [] });
|
|
39
|
+
// Only a desk without the board, or one never reached, leaves the post to the file below; any other answer may follow a write.
|
|
40
|
+
if (answer.ok || !/^This task has no board\.$|^the file desk is unavailable$/.test(answer.text)) return text(answer.text);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const post = appendPost(path, { from: self, kind: params.kind, text: params.text, ...(params.files ? { files: params.files } : {}) });
|
|
44
|
+
return text(`Posted #${post.seq}.`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return text(`Not posted: ${(error as Error).message}`);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
pi.registerTool({
|
|
52
|
+
name: "read_board",
|
|
53
|
+
label: "Read the task board",
|
|
54
|
+
description: "Read the task board. By default you see the kinds your role follows, newest last; pass kinds to see others, or after to see only posts newer than one you have read.",
|
|
55
|
+
parameters: Type.Object({
|
|
56
|
+
kinds: Type.Optional(Type.Array(Type.String(), { description: `Any of: ${POST_KINDS.join(", ")}` })),
|
|
57
|
+
after: Type.Optional(Type.Number({ description: "Only posts after this number" })),
|
|
58
|
+
}),
|
|
59
|
+
async execute(_id, params) {
|
|
60
|
+
const kinds: readonly PostKind[] = params.kinds?.filter(isPostKind).length ? params.kinds.filter(isPostKind) : subscriptionFor(self);
|
|
61
|
+
const posts = filterPosts(readBoard(path), { kinds, ...(params.after ? { after: params.after } : {}), limit: 50 });
|
|
62
|
+
return text(posts.length > 0 ? formatPosts(posts) : "Nothing on the board for those kinds yet.");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task board: one append-only log per task where the agents working on
|
|
3
|
+
* it post short, typed entries (a contract they set, a decision, a blocker, a
|
|
4
|
+
* finished piece) and read what the others posted, without routing it through
|
|
5
|
+
* the oracle. Each role reads only the kinds it subscribes to, so the board
|
|
6
|
+
* stays a shared reference rather than a chat everyone has to follow.
|
|
7
|
+
*
|
|
8
|
+
* It lives in the task's directory as `board.jsonl`, so later steps, the QA
|
|
9
|
+
* gate and the oracle read the same record; a worker without a running desk
|
|
10
|
+
* (a single step) appends to the file directly.
|
|
11
|
+
*/
|
|
12
|
+
import { appendFileSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const POST_KINDS = ["contract", "decision", "blocker", "question", "done", "note"] as const;
|
|
16
|
+
export type PostKind = (typeof POST_KINDS)[number];
|
|
17
|
+
|
|
18
|
+
export function isPostKind(value: unknown): value is PostKind {
|
|
19
|
+
return typeof value === "string" && (POST_KINDS as readonly string[]).includes(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BoardPost {
|
|
23
|
+
/** Order on the board, from 1. */
|
|
24
|
+
seq: number;
|
|
25
|
+
/** Who posted it: a domain (`backend`, `designer`, `qa`), a child run id, or `oracle`. */
|
|
26
|
+
from: string;
|
|
27
|
+
kind: PostKind;
|
|
28
|
+
text: string;
|
|
29
|
+
/** Repository files the post is about, when it names any. */
|
|
30
|
+
files?: string[];
|
|
31
|
+
at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Env name that gives a worker process its task board's file. */
|
|
35
|
+
export const BOARD_ENV = "BOT_LOBBY_BOARD";
|
|
36
|
+
|
|
37
|
+
/** Tools every worker gets when its task has a board. */
|
|
38
|
+
export const BOARD_TOOLS: readonly string[] = ["post_board", "read_board"];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What each role reads by default. A builder needs the contracts and
|
|
42
|
+
* decisions it builds against and anything blocking; QA also follows what is
|
|
43
|
+
* done, so it can run its tests against finished pieces.
|
|
44
|
+
*/
|
|
45
|
+
export const SUBSCRIPTIONS: Readonly<Record<string, readonly PostKind[]>> = {
|
|
46
|
+
backend: ["contract", "decision", "blocker", "question"],
|
|
47
|
+
designer: ["contract", "decision", "blocker", "question"],
|
|
48
|
+
qa: ["contract", "decision", "blocker", "done"],
|
|
49
|
+
oracle: POST_KINDS,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const DEFAULT_SUBSCRIPTION: readonly PostKind[] = ["contract", "decision", "blocker"];
|
|
53
|
+
|
|
54
|
+
/** Kinds a running peer is told about the moment they are posted, not only when it next reads. */
|
|
55
|
+
export const PUSHED_KINDS: ReadonlySet<PostKind> = new Set(["contract", "blocker"]);
|
|
56
|
+
|
|
57
|
+
export function subscriptionFor(reader: string): readonly PostKind[] {
|
|
58
|
+
return SUBSCRIPTIONS[reader] ?? DEFAULT_SUBSCRIPTION;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const MAX_POST_CHARS = 1500;
|
|
62
|
+
const MAX_FILES = 20;
|
|
63
|
+
|
|
64
|
+
export function boardPath(taskDir: string): string {
|
|
65
|
+
return join(taskDir, "board.jsonl");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clean(text: string, max: number): string {
|
|
69
|
+
const trimmed = text.trim();
|
|
70
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPost(value: unknown): value is BoardPost {
|
|
74
|
+
if (!value || typeof value !== "object") return false;
|
|
75
|
+
const post = value as Partial<BoardPost>;
|
|
76
|
+
return typeof post.seq === "number" && typeof post.from === "string" && isPostKind(post.kind) && typeof post.text === "string" && typeof post.at === "string";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every post on the board, oldest first; a missing board is empty and a torn line is skipped. */
|
|
80
|
+
export function readBoard(path: string): BoardPost[] {
|
|
81
|
+
let text: string;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(path, "utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const posts: BoardPost[] = [];
|
|
88
|
+
for (const line of text.split("\n")) {
|
|
89
|
+
if (!line.trim()) continue;
|
|
90
|
+
try {
|
|
91
|
+
const post: unknown = JSON.parse(line);
|
|
92
|
+
if (isPost(post)) posts.push(post);
|
|
93
|
+
} catch {
|
|
94
|
+
// A torn line (a crash mid-append) is skipped.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return posts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface NewPost {
|
|
101
|
+
from: string;
|
|
102
|
+
kind: PostKind;
|
|
103
|
+
text: string;
|
|
104
|
+
files?: readonly string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Longest a board path in a post may be. */
|
|
108
|
+
const MAX_FILE_CHARS = 300;
|
|
109
|
+
/** How long a writer waits for another process's append, and when a lock is taken to be left behind. */
|
|
110
|
+
const LOCK_WAIT_MS = 2000;
|
|
111
|
+
const STALE_LOCK_MS = 10_000;
|
|
112
|
+
|
|
113
|
+
function sleep(ms: number): void {
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run `write` holding the board's lock (a directory beside it, made
|
|
119
|
+
* atomically), so posts from several processes get distinct sequence
|
|
120
|
+
* numbers. A lock left by a crashed writer is broken once stale; past the
|
|
121
|
+
* wait the post goes in anyway rather than being lost.
|
|
122
|
+
*/
|
|
123
|
+
function withLock<T>(path: string, write: () => T): T {
|
|
124
|
+
const lock = `${path}.lock`;
|
|
125
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
126
|
+
let held = false;
|
|
127
|
+
while (!held) {
|
|
128
|
+
try {
|
|
129
|
+
mkdirSync(lock);
|
|
130
|
+
held = true;
|
|
131
|
+
} catch {
|
|
132
|
+
try {
|
|
133
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
|
|
134
|
+
} catch {
|
|
135
|
+
// Released meanwhile.
|
|
136
|
+
}
|
|
137
|
+
if (Date.now() > until) break;
|
|
138
|
+
sleep(5);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return write();
|
|
143
|
+
} finally {
|
|
144
|
+
if (held) rmSync(lock, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Append a post and return it as stored. Throws on an empty text, so the caller can tell the agent. */
|
|
149
|
+
export function appendPost(path: string, post: NewPost, now = new Date()): BoardPost {
|
|
150
|
+
const text = clean(post.text, MAX_POST_CHARS);
|
|
151
|
+
if (!text) throw new Error("a board post needs text");
|
|
152
|
+
const files = (post.files ?? []).map((file) => clean(file, MAX_FILE_CHARS)).filter(Boolean).slice(0, MAX_FILES);
|
|
153
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
154
|
+
return withLock(path, () => {
|
|
155
|
+
const seq = (readBoard(path).at(-1)?.seq ?? 0) + 1;
|
|
156
|
+
const stored: BoardPost = { seq, from: post.from, kind: post.kind, text, ...(files.length > 0 ? { files } : {}), at: now.toISOString() };
|
|
157
|
+
appendFileSync(path, `${JSON.stringify(stored)}\n`, "utf8");
|
|
158
|
+
return stored;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface BoardFilter {
|
|
163
|
+
kinds?: readonly PostKind[];
|
|
164
|
+
/** Only posts after this sequence number. */
|
|
165
|
+
after?: number;
|
|
166
|
+
/** Leave out the reader's own posts. */
|
|
167
|
+
notFrom?: string;
|
|
168
|
+
/** The newest N that match. */
|
|
169
|
+
limit?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function filterPosts(posts: readonly BoardPost[], filter: BoardFilter): BoardPost[] {
|
|
173
|
+
const matching = posts.filter(
|
|
174
|
+
(post) => (!filter.kinds || filter.kinds.includes(post.kind)) && post.seq > (filter.after ?? 0) && post.from !== filter.notFrom,
|
|
175
|
+
);
|
|
176
|
+
return filter.limit && matching.length > filter.limit ? matching.slice(-filter.limit) : matching;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA", oracle: "ORACLE" };
|
|
180
|
+
|
|
181
|
+
export function posterName(from: string): string {
|
|
182
|
+
return NAMES[from] ?? (from.length > 12 ? `child ${from.slice(0, 8)}` : from.toUpperCase());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One post on one line: its text is flattened, so a post can never pass for a line of the engine's own. */
|
|
186
|
+
export function formatPost(post: BoardPost): string {
|
|
187
|
+
const files = post.files?.length ? ` (${post.files.join(", ")})` : "";
|
|
188
|
+
return `#${post.seq} [${post.kind}] ${posterName(post.from)}${files}: ${post.text.replace(/\s+/g, " ")}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function formatPosts(posts: readonly BoardPost[]): string {
|
|
192
|
+
return posts.map(formatPost).join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The board as a context section for an agent starting a step: what its role subscribes to, newest last. */
|
|
196
|
+
export function boardContext(path: string, reader: string, limit = 30): string {
|
|
197
|
+
const posts = filterPosts(readBoard(path), { kinds: subscriptionFor(reader), limit });
|
|
198
|
+
if (posts.length === 0) return "";
|
|
199
|
+
return `Task board (what the task's agents posted for each other; ${subscriptionFor(reader).join(", ")} posts):\n${formatPosts(posts)}`;
|
|
200
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
9
|
import { createDeskClient, type DeskClient } from "./ipc.ts";
|
|
10
|
-
import { DESK_ENV, isEditTool, MAX_WAIT_MS } from "./session.ts";
|
|
10
|
+
import { ASK_WAIT_MS, DESK_ENV, isEditTool, MAX_ASK_WAIT_MS, MAX_WAIT_MS } from "./session.ts";
|
|
11
11
|
|
|
12
12
|
function text(value: string) {
|
|
13
13
|
return { content: [{ type: "text" as const, text: value }], details: undefined };
|
|
@@ -98,4 +98,34 @@ export function registerDeskClient(pi: ExtensionAPI, client?: DeskClient): void
|
|
|
98
98
|
return text((await desk.request({ op: "wait", timeoutMs }, timeoutMs + 5000)).text);
|
|
99
99
|
},
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
pi.registerTool({
|
|
103
|
+
name: "ask_peer",
|
|
104
|
+
label: "Ask a peer",
|
|
105
|
+
description:
|
|
106
|
+
"Ask another worker in this batch a short question about its part (a field name, a route, a shape it settled) and wait for its answer, instead of guessing. Name the peer (DEV, DESIGN or QA), or give a file path to ask whoever holds it. Questions only: peers cannot give each other work. Waits up to 90 seconds by default.",
|
|
107
|
+
parameters: Type.Object({
|
|
108
|
+
question: Type.String({ description: "The question, in one or two sentences" }),
|
|
109
|
+
to: Type.Optional(Type.String({ description: "DEV, DESIGN or QA" })),
|
|
110
|
+
path: Type.Optional(Type.String({ description: "Ask whoever holds this file instead" })),
|
|
111
|
+
seconds: Type.Optional(Type.Number({ description: "How long to wait, at most 180 seconds" })),
|
|
112
|
+
}),
|
|
113
|
+
async execute(_id, params) {
|
|
114
|
+
const timeoutMs = Math.min(MAX_ASK_WAIT_MS, Math.max(5, params.seconds ?? ASK_WAIT_MS / 1000) * 1000);
|
|
115
|
+
return text((await desk.request({ op: "ask", text: params.question, to: params.to ?? "", path: params.path ?? "", timeoutMs }, timeoutMs + 5000)).text);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.registerTool({
|
|
120
|
+
name: "answer_peer",
|
|
121
|
+
label: "Answer a peer",
|
|
122
|
+
description: "Answer a question another worker asked you (the desk tells you its id). Answer from what you know or have built, in a line or two, then carry on with your own work.",
|
|
123
|
+
parameters: Type.Object({
|
|
124
|
+
id: Type.Number({ description: "The question's id" }),
|
|
125
|
+
answer: Type.String({ description: "Your answer" }),
|
|
126
|
+
}),
|
|
127
|
+
async execute(_id, params) {
|
|
128
|
+
return text((await desk.request({ op: "answer", questionId: params.id, text: params.answer })).text);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
101
131
|
}
|
package/src/desk/ipc.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createConnection, createServer, type Socket } from "node:net";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait" | "delegate" | "cancel_delegate";
|
|
12
|
+
export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait" | "delegate" | "cancel_delegate" | "consult" | "cancel_consult" | "post" | "ask" | "answer";
|
|
13
13
|
|
|
14
14
|
export interface DeskRequest {
|
|
15
15
|
id: number;
|
|
@@ -20,6 +20,17 @@ export interface DeskRequest {
|
|
|
20
20
|
briefs?: string[];
|
|
21
21
|
note?: string;
|
|
22
22
|
timeoutMs?: number;
|
|
23
|
+
/** post: who posts (the worker's domain; its desk id otherwise), the post's kind and text, and the files it is about. */
|
|
24
|
+
from?: string;
|
|
25
|
+
kind?: string;
|
|
26
|
+
text?: string;
|
|
27
|
+
files?: string[];
|
|
28
|
+
/** ask: who the question is for (DEV, DESIGN, QA); `path` asks whoever holds that file. */
|
|
29
|
+
to?: string;
|
|
30
|
+
/** answer: the question being answered. */
|
|
31
|
+
questionId?: number;
|
|
32
|
+
/** An expert consult's own id, so the worker can cancel it. */
|
|
33
|
+
consultId?: string;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface DeskResponse {
|