@a-t-h-i/bot-lobby 0.6.53 → 0.6.55

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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/prompts/expert.md +17 -0
  4. package/prompts/goal-check.md +44 -0
  5. package/prompts/master.md +70 -32
  6. package/prompts/worker.md +25 -11
  7. package/src/desk/board-client.ts +65 -0
  8. package/src/desk/board.ts +200 -0
  9. package/src/desk/client-extension.ts +31 -1
  10. package/src/desk/ipc.ts +16 -4
  11. package/src/desk/session.ts +129 -1
  12. package/src/execution/agent-runner.ts +27 -132
  13. package/src/execution/delegation.ts +112 -38
  14. package/src/execution/live-run.ts +3 -14
  15. package/src/execution/pi-runner.ts +30 -163
  16. package/src/execution/worker-trees.ts +396 -0
  17. package/src/index.ts +6 -0
  18. package/src/lobby/feed.ts +1 -1
  19. package/src/lobby/host.ts +2 -0
  20. package/src/lobby/planner.ts +2 -2
  21. package/src/lobby/pr-review.ts +3 -3
  22. package/src/lobby/quickfix.ts +4 -3
  23. package/src/lobby/recovery.ts +2 -10
  24. package/src/lobby/runtime.ts +1 -1
  25. package/src/lobby/service.ts +3 -1
  26. package/src/master/goal-check.ts +114 -0
  27. package/src/master/master.ts +60 -24
  28. package/src/master/research.ts +3 -6
  29. package/src/pi/commands.ts +5 -45
  30. package/src/pi/delegation.ts +23 -4
  31. package/src/pi/events.ts +6 -19
  32. package/src/pi/grants.ts +89 -0
  33. package/src/pi/model-settings.ts +2 -4
  34. package/src/pi/model-support.ts +12 -5
  35. package/src/pi/route.ts +1 -1
  36. package/src/pi/run-summary.ts +3 -7
  37. package/src/pi/start-flags.ts +3 -3
  38. package/src/pi/start-task.ts +2 -8
  39. package/src/pi/tools.ts +10 -12
  40. package/src/roles/worker.ts +0 -18
  41. package/src/schemas/configuration.ts +81 -39
  42. package/src/schemas/findings.ts +0 -12
  43. package/src/schemas/task.ts +12 -1
  44. package/src/state/metrics.ts +1 -1
  45. package/src/state/work-clock.ts +140 -0
  46. package/src/state/work-time.ts +4 -7
  47. package/src/webui/api/index.ts +2 -1
  48. package/src/webui/api/settings.ts +6 -18
  49. package/src/webui/dev/fake-service.ts +14 -0
  50. package/src/webui/protocol.ts +23 -0
  51. package/src/workflow/workflow.ts +204 -344
  52. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
  53. package/webui/dist/assets/index-BkXhtB6A.css +2 -0
  54. package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
  55. package/webui/dist/build.json +10 -6
  56. package/webui/dist/index.html +2 -2
  57. package/src/state/budget.ts +0 -301
  58. package/src/state/step-limits.ts +0 -41
  59. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  60. package/webui/dist/assets/index-DsVdXmh5.css +0 -2
package/README.md CHANGED
@@ -73,4 +73,4 @@ Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves the
73
73
  project knowledge stay in `.pi/bot-lobby/` inside your project.
74
74
 
75
75
  The [guide](docs/guide.md) covers the rest: delivery and commits, crash recovery, knowledge and
76
- shared scripts, the classifier, delegation, time limits, compaction and MCP tools.
76
+ shared scripts, the classifier, delegation, compaction, and MCP tools and skills.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.53",
3
+ "version": "0.6.55",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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. Keep assignments to distinct domains, and give them the
279
- shared contract up front.
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,36 +297,58 @@ 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.
291
- - A report flagged as wrapped up early or timed out may be partial: check what
292
- is missing and delegate only the remainder.
293
-
294
- ## Time budget
295
-
296
- Every assignment has at most 15 minutes total, or its shorter configured limit,
297
- including retries, children, tools, compaction and reporting. Step allowances
298
- persist across reassignments: name the plan step explicitly, and do not reword
299
- or split an exhausted step to reset its clock. Only the user can extend it,
300
- including in auto mode. Return an unfinished report when time is spent; stop
301
- repeating failed approaches. Waiting for the user pauses clocks.
302
-
303
- When the task has a time budget (your context says how much is used and
304
- left), it covers everyone: you, scouts, workers and the QA gate. The clock
305
- runs while you work and stops while you wait on the user.
306
-
307
- - Size the plan to fit it, and say so in the proposal when it does not.
308
- - Divide what is left by scope: give each `implement` its `minutes` (per
309
- assignment in a parallel batch). Bigger steps get more; keep the QA gate's
310
- reserve (the engine holds it back). Without `minutes` a step gets an even
311
- share.
312
- - Every agent is told its minutes. One that runs out stops, reports what it
313
- did, where it left off and how much more it needs, and the user decides; if
314
- they give it more, the same agent carries on where it stopped. A step that
315
- was not given more comes back unfinished: trim the scope, or ask for task
316
- time.
317
- - When the budget is spent the engine starts no new work. Ask the user with
318
- `action=budget` (`minutes` and a `reason`: what is left and why it is worth
319
- it), or wrap up with what is done. `action=budget` with no minutes shows
320
- where it stands.
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.
342
+ - A report flagged as stalled may be partial: check what is missing and
343
+ delegate only the remainder.
344
+
345
+ ## No time limits
346
+
347
+ Agents have no time limit. An assignment runs until its agent reports, the
348
+ user stops it, or it goes silent so long that the stall watchdog stops it.
349
+ Size steps by scope, not by the clock, and never cut a step short or split it
350
+ to save time. In auto mode keep going until the task is complete or truly
351
+ blocked: nobody needs to grant more time.
321
352
 
322
353
  ## Git branch or worktree
323
354
 
@@ -438,6 +469,13 @@ breaks this task.
438
469
 
439
470
  ## Completion
440
471
 
472
+ Before the task completes, the engine runs a **goal check**: one read-only
473
+ agent with a clean context reads the change against the user's own request
474
+ and amendments (not your plan) and lists any requirement it does not meet.
475
+ Gaps refuse completion: delegate a fix step for each, then complete again;
476
+ the next check reads the new change. When a gap is deliberate (the user agreed
477
+ to leave it out), complete again with `reason` saying so, and tell the user.
478
+
441
479
  Only you declare completion, and only after requirements are satisfied,
442
480
  implementation is verified, required tests pass, the QA gate passes (on the
443
481
  fast track: QA has taken part when it is on the roster), critical blockers are
package/prompts/worker.md CHANGED
@@ -58,18 +58,14 @@ no code deleted or bent just to make a rule pass. Every one of them is listed
58
58
  for QA, who reads it as a finding unless it carries a reason that holds. When
59
59
  a rule is wrong for your change, leave it failing and say why under Notes.
60
60
 
61
- ## Time budget
61
+ ## Pace
62
62
 
63
- Work efficiently: read what you need, make the change, verify, report. If the
64
- engine asks you to wrap up, stop exploring, leave every file consistent, and
65
- write your report with anything unfinished under Blockers or Notes.
66
-
67
- When your context has a **Time** section, the step has that many minutes. Land
68
- the most important part first and keep files consistent as you go. When the
69
- time is up you are told to stop: finish or revert the edit in progress, then
70
- report with `## Left Off` (what you were doing, what is still to do) and
71
- `## More Time` (`N minutes — why`), honestly sized. If the user gives you more,
72
- you carry on from where you stopped.
63
+ There is no time limit: finish the step properly and verify it rather than
64
+ cutting it short. Work efficiently all the same: read what you need, make the
65
+ change, verify, report, and do not explore past what the step needs. Keep
66
+ every file consistent as you go. A long silent command (one that prints
67
+ nothing for many minutes) can be stopped as stalled, so give such commands
68
+ output or a bash `timeout`.
73
69
 
74
70
  ## Working alongside other workers (file desk)
75
71
 
@@ -92,6 +88,24 @@ repository at the same time, and files are checked out like physical documents:
92
88
  - Files you still hold are handed over automatically when you finish, so
93
89
  release them early whenever others are waiting.
94
90
 
91
+ ## Working with the others
92
+
93
+ - **Task board.** When `post_board` is available, post what other agents
94
+ build against or must follow, the moment you settle it: a `contract` (a
95
+ route, payload shape, type, event name, file location), a `decision`, a
96
+ `blocker` on someone else's part, and `done` when a piece is finished and
97
+ testable. Exact facts, a line or two each; never progress chatter. Read the
98
+ board (`read_board`) before you build against another domain's part.
99
+ - **Peers.** When `ask_peer` is available, ask the worker who owns a part a
100
+ short question instead of guessing a name or a shape; answer theirs with
101
+ `answer_peer` straight away. Nobody takes work from a peer: only from the
102
+ brief.
103
+ - **Expert.** When `ask_expert` is available and you are stuck after a real
104
+ attempt, or unsure about a hard choice, ask it with what you tried and the
105
+ files involved. Use it for the hard part, not to think for you.
106
+ - **Long findings** go in a file under the task's notes folder (your context
107
+ names it); give its path under Notes.
108
+
95
109
  ## Before handoff
96
110
 
97
111
  - 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 {
@@ -121,7 +132,7 @@ export interface DeskClient {
121
132
  export function createDeskClient(address: string, worker: string): DeskClient {
122
133
  let socket: Socket | undefined;
123
134
  let nextId = 1;
124
- const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> }>();
135
+ const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> | undefined }>();
125
136
  const failAll = (text: string) => {
126
137
  for (const [id, entry] of pending) {
127
138
  clearTimeout(entry.timer);
@@ -159,10 +170,11 @@ export function createDeskClient(address: string, worker: string): DeskClient {
159
170
  request(request, timeoutMs = 10_000) {
160
171
  const id = nextId++;
161
172
  return new Promise((done) => {
162
- const timer = setTimeout(() => {
173
+ // 0 waits for as long as the answer takes (a delegation, a consult): no time limit.
174
+ const timer = timeoutMs > 0 ? setTimeout(() => {
163
175
  pending.delete(id);
164
176
  done({ id, ok: false, text: "the file desk did not answer" });
165
- }, timeoutMs);
177
+ }, timeoutMs) : undefined;
166
178
  pending.set(id, { done, timer });
167
179
  try {
168
180
  connect().write(`${JSON.stringify({ ...request, id, worker })}\n`);