@oisin-ee/pi-dogsled 0.14.0 → 0.16.0

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 CHANGED
@@ -11,13 +11,13 @@ Install the binary on each Herdr host. Use the same release tag in each command.
11
11
  Binary:
12
12
 
13
13
  ```sh
14
- mise use github:oisin-ee/dogsled@v0.14.0
14
+ mise use github:oisin-ee/dogsled@v0.16.0
15
15
  ```
16
16
 
17
17
  Pi (extension, tools, and skill), from npm or as part of `@oisin-ee/oisinpi`:
18
18
 
19
19
  ```sh
20
- pi install npm:@oisin-ee/pi-dogsled@0.14.0
20
+ pi install npm:@oisin-ee/pi-dogsled@0.16.0
21
21
  ```
22
22
 
23
23
  At session start the extension compares its version with `dogsled --version` and warns when they differ.
@@ -25,14 +25,14 @@ At session start the extension compares its version with `dogsled --version` and
25
25
  Claude Code (hooks, tools, and skill):
26
26
 
27
27
  ```sh
28
- claude plugin marketplace add oisin-ee/dogsled#v0.14.0
28
+ claude plugin marketplace add oisin-ee/dogsled#v0.16.0
29
29
  claude plugin install dogsled@dogsled
30
30
  ```
31
31
 
32
32
  Codex (hooks, tools, and skill):
33
33
 
34
34
  ```sh
35
- codex plugin marketplace add oisin-ee/dogsled --ref v0.14.0
35
+ codex plugin marketplace add oisin-ee/dogsled --ref v0.16.0
36
36
  codex plugin add dogsled@dogsled
37
37
  ```
38
38
 
@@ -42,7 +42,7 @@ The [Codex CLI reference](https://developers.openai.com/codex/cli/reference#code
42
42
  Herdr (status-board popup):
43
43
 
44
44
  ```sh
45
- herdr plugin install oisin-ee/dogsled --ref v0.14.0 --yes
45
+ herdr plugin install oisin-ee/dogsled --ref v0.16.0 --yes
46
46
  ```
47
47
 
48
48
  Harness plugins use the binary on `PATH`. They do not install it or start host services.
@@ -87,8 +87,10 @@ dogsled items --owner "$HERDR_PANE_ID" --json
87
87
  dogsled cancel TICKET
88
88
  ```
89
89
 
90
- Jobs use a new tab in the caller's workspace. With `--branch`, an agent uses the pane of the worktree's new Herdr workspace.
91
- Remote work uses a workspace with the same name as the caller's workspace, creating it when absent.
90
+ With `--branch`, an agent uses the pane of the worktree's new Herdr workspace.
91
+ Other work in a repository opens a new tab where that repository is shown: the workspace of its worktree, else the main checkout's workspace.
92
+ When no workspace shows the repository, dogsled opens a new workspace for it.
93
+ Work outside any repository uses a new tab in the caller's workspace. On a remote host, that is the workspace with the caller's workspace name, created when absent.
92
94
  Without a branch, a remote non-repository `--cwd` must exist there.
93
95
  Repository directories map to the same path relative to home. A linked-worktree caller needs `--branch` to preserve its branch remotely.
94
96
 
@@ -118,6 +120,7 @@ A wait deadline does not cancel its job or ticket. `cancel` only cancels pending
118
120
  A task with a newline, a leading `-` or `@`, or more than 4000 characters goes to `launches/<id>.task.md`. Pi reads it as `@<file>`; Claude and Codex get a one-line pointer to it.
119
121
  `--message-file PATH` (or `-` for stdin) reads the task from a file.
120
122
  A failed `herdr agent start` is retried with backoff, three tries in all. After the last, the ticket fails and the owner's inbox says so. A refusal or a deadline fails at once.
123
+ Herdr refuses a start before it types into the pane, for example `agent_pane_busy` or `agent_name_taken`. That refusal is final: dogsled records it as the launch result, closes the empty pane it created, and does not retry. Any other failure leaves the outcome unknown, so dogsled keeps the pane and reconciles it on the next try.
121
124
 
122
125
  `--profile NAME` (Pi only) reads `~/.config/dogsled/profiles/NAME/profile.json` on the host that runs the child:
123
126
 
@@ -140,10 +143,13 @@ Closing is driven by events. When a child's turn completes (Herdr's `completion_
140
143
 
141
144
  1. reads its final reply from the transcript and keeps it in the item;
142
145
  2. puts the reply in the owner's inbox (see [Inbox](#inbox));
143
- 3. closes a reader whose worktree is clean with no commits outside the default branch, and any child whose branch has landed.
146
+ 3. if the child called `done` during that turn, closes it when it is a reader whose worktree is clean with no commits outside the default branch, or when its branch has landed.
147
+
148
+ Herdr's `idle` and `done` only mean that the agent is ready for input. A child that asks a question, or that waits for its own children, is idle too. So a child says it has finished with the `done` MCP tool or `dogsled done`, in the turn that completes its task. Dogsled tells each child this at launch: Pi and Claude through `--append-system-prompt`, Codex through `-c developer_instructions`. The mark lives in the host's `done.json`, keyed by the Herdr terminal. A later turn, or an inbox record delivered to the child, ends it. `done` is refused while the caller owns open items or queued tickets.
149
+ A child that ends a turn without `done` stays open, and its reply tells the owner it waits for input.
144
150
 
145
151
  A child with commits to land stays open and shows under TO LAND on the board, and the prompt line says `To land: web-tests (+2)`. Merge or drop the branch; the next sweep closes it.
146
- A `blocked` child stays open, and the owner's inbox gets one `blocked` record per episode. A `working` child, or one that has never completed a turn, is never closed. Nothing closes on a timer.
152
+ A `blocked` child stays open, and the owner's inbox gets one `blocked` record per episode. A `working` child, one that has never completed a turn, and one that has not called `done` are never closed automatically. Nothing closes on a timer.
147
153
 
148
154
  `dogsled board` groups an owner's children, jobs, and queued tickets by what the owner must do: TO LAND, ATTENTION, RUNNING, and WAITING. Each agent row names the harness and model; each job row names its command. `--json` is what the Pi widget draws.
149
155
  `dogsled read TARGET [--host H]` shows a child's or job's newest output and the captured final reply.
@@ -151,6 +157,7 @@ A `blocked` child stays open, and the owner's inbox gets one `blocked` record pe
151
157
  ```sh
152
158
  dogsled close w2:p1
153
159
  dogsled close w9:p1 --host netcup --json
160
+ dogsled done # as a child: my whole task is complete
154
161
  ```
155
162
 
156
163
  `close` ends a child now, for an owner who does not want to wait for the sweep. `--host` defaults to the host of the pane's open item, else local.
@@ -166,6 +173,7 @@ After removal, close drops the worktree's registration, as prune does.
166
173
  The JSON result is `{pane,worktree:{path,branch,workspace,branch_deleted,anchor_closed?}|null,pane_closed}`. A remote host runs the same steps over SSH.
167
174
 
168
175
  The service admits tickets in FIFO order using the same placement policy.
176
+ The order holds per host. A ticket waits only behind earlier tickets that could take one of its hosts. A full host does not hold back a ticket that is pinned to another host with room.
169
177
  It records an admission error rather than silently choosing another host after a clone, prepare, or launch failure.
170
178
  A service interruption during admission leaves an explicit failed ticket for inspection, not an automatic duplicate launch.
171
179
  For non-waiting callers, admission tells the owner through its inbox. An owner without an inbox reader hears once it has one; nothing is typed into its terminal.
@@ -232,6 +240,7 @@ A shared, locked cache permits at most one read per peer per ten seconds, includ
232
240
  Status never probes peers itself.
233
241
 
234
242
  A missing snapshot, failed read, or snapshot older than 30 seconds shows `service: down`.
243
+ When a snapshot exists, the host's reason gives its age, such as `service: down (last snapshot 44s ago)`. A slow tick on a loaded host is tens of seconds old. A stopped service gets older on every read.
235
244
  The last peer measurement remains visible when available. Every discovered host stays in the output.
236
245
  The JSON output contains `hosts`, with service state, reachability, sample time, probe, headroom, errors, and `retry_ms` while a peer read backs off.
237
246
 
@@ -305,13 +314,14 @@ Each path merges on its own. An addition, deletion, or mode change on a path the
305
314
  Results use the remote-tracking branch, so `git fetch` keeps them current.
306
315
  A local branch with no worktree is removable when it is integrated or every commit is on a remote branch. Other branches are report-only. The default branch is never a candidate. Prune deletes a branch with `git branch -D` after it rechecks the planned commit.
307
316
  Prune and the sweep share the same holds: active panes or processes, the caller, an unreadable process table, and pending preparations or launches.
317
+ An unfinished launch holds its checkout while its pane exists, because an interrupted start can still bring up an agent there. When its pane is gone, it holds nothing.
308
318
  An open agent item holds its checkout in prune and the sweep; `close` ends it and keeps the reply. Within `close`, a live owner holds the checkout until the default branch holds its work.
309
- An idle shell, an idle or done agent without an open item, and a workspace alone are not holds. The processes of an idle agent pane are not holds, but a background job that a shell started is. Foreign locks still prevent removal.
319
+ An idle or done agent that has not called `dogsled done` holds its checkout in prune, the sweep, and an automatic close; only an explicit `dogsled close` takes it. An idle shell, a done agent without an open item, and a workspace alone are not holds. The processes of an idle agent pane are not holds, but a background job that a shell started is. Foreign locks still prevent removal.
310
320
  A dirty checkout whose commits are in the default branch gets a `landed_dirty` finding when no hold applies.
311
321
  The finding includes the repository, checkout, branch or HEAD, dirty-path count, and up to ten paths. It is never removable.
312
322
  A registered worktree whose directory is gone is a stale registration. Prune drops the record only.
313
323
  A shell with no foreground command in an ended job's tab is idle at once: the job log keeps its output. So is a shell whose directory no longer exists.
314
- Other panes need more than 24 hours continuously idle or done, based on service observations and a fresh snapshot.
324
+ Other panes need more than 24 hours continuously idle or done, based on service observations and a fresh snapshot. An agent pane also needs a `done` mark.
315
325
  A shell pane is idle when the service found no foreground command at each sample for more than 24 hours, and it still has none. Herdr reports no activity time, so a command that starts and ends between samples does not count.
316
326
  A workspace whose every pane is idle is one `workspaces` entry, and prune closes it whole. The caller's workspace stays, and so does a workspace that a worktree removal closes.
317
327
  Ended jobs expire after 24 hours, together with their logs. Disk usage above 90% lists the largest known dogsled-owned paths.
@@ -343,7 +353,7 @@ Each requested pass builds the doctor plan in sweep scope. It applies only entri
343
353
 
344
354
  - `panes` and `workspaces`: a shell at an empty prompt in an ended job's tab, or in a directory that no longer exists. A workspace closes whole when every pane in it is one of these.
345
355
  - `worktrees` and `branches`: the shared loss rule and holds described above, identical to prune.
346
- - Orphaned children: an idle or done child whose owner is gone and whose checkout passes the shared loss rule. Close runs teardown and removes its workspace, checkout, and item.
356
+ - Orphaned children: a child that called `done`, whose owner is gone, and whose checkout passes the shared loss rule. Close runs teardown and removes its workspace, checkout, and item.
347
357
 
348
358
  The sweep runs the same checks as `close` and `prune`. It never closes the service's own pane or workspace, or a worktree that the service works in. It skips a worktree it cannot check, and a failure on one entry does not stop the others.
349
359
  The service keeps seven days of completed passes in `sweeps.jsonl` and the latest pass in `sweep.json`.
@@ -492,7 +502,7 @@ Plugin commands end with `|| true`. Pi also leaves the prompt unchanged if the b
492
502
 
493
503
  ### Tools
494
504
 
495
- `dogsled mcp` serves the dogsled commands as MCP tools over stdio: `status`, `spawn`, `run`, `send`, `inbox`, `read`, `close`, `stop`, `cancel`, and `place`.
505
+ `dogsled mcp` serves the dogsled commands as MCP tools over stdio: `status`, `spawn`, `run`, `send`, `inbox`, `read`, `close`, `done`, `stop`, `cancel`, and `place`.
496
506
  Each tool calls the same code as its CLI command and returns the same JSON. No tool waits: `spawn` and `run` return the item or the queued ticket at once.
497
507
  The caller is the harness's `HERDR_PANE_ID`. Pi registers the server from its extension (tools `mcp__dogsled__*`); Claude Code reads `plugins/dogsled/.mcp.json`; Codex reads `plugins/dogsled/codex-mcp.json`, which forwards the Herdr and `DOGSLED_*` variables.
498
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-dogsled",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Pi extension for dogsled: the board, the /dogsled panel, the inbox reader, and the dogsled tools. Needs the dogsled binary of the same version.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "orchestration"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=20.6.0"
32
+ "node": ">=22.19.0"
33
33
  },
34
34
  "scripts": {
35
35
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -44,12 +44,12 @@
44
44
  ]
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": "*",
48
- "@earendil-works/pi-tui": "*"
47
+ "@earendil-works/pi-coding-agent": "^1.3.0",
48
+ "@earendil-works/pi-tui": "^1.3.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@earendil-works/pi-coding-agent": "1.0.4",
52
- "@earendil-works/pi-tui": "1.0.4",
51
+ "@earendil-works/pi-coding-agent": "1.3.0",
52
+ "@earendil-works/pi-tui": "1.3.0",
53
53
  "@types/node": "26.6.4",
54
54
  "typescript": "7.0.2"
55
55
  }
package/pi/index.ts CHANGED
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Box, Text, Key } from "@earendil-works/pi-tui";
4
4
  import { type Board, clean, parseBoard } from "./board.ts";
5
5
  import { WorkTodo } from "./work-todo.ts";
6
+ import { UnitBridge } from "./unit-bridge.ts";
6
7
  import { workBoard, workText, type WorkState } from "./work.ts";
7
8
  import { type Details, InboxReader, MESSAGE_TYPE } from "./inbox.ts";
8
9
  import { openPanel } from "./panel.ts";
@@ -46,6 +47,9 @@ export default function dogsled(pi: ExtensionAPI) {
46
47
  }
47
48
 
48
49
  const inbox = new InboxReader(pi, exec);
50
+ const units = new UnitBridge(pi, binary);
51
+ pi.on("tool_call", (event, ctx) => units.call(event, ctx));
52
+ pi.on("tool_execution_end", (event, ctx) => units.ended(event.toolCallId, ctx));
49
53
 
50
54
  pi.registerMessageRenderer<Details>(MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
51
55
  const records = message.details?.records ?? [];
@@ -168,6 +172,7 @@ export default function dogsled(pi: ExtensionAPI) {
168
172
  const current = generation;
169
173
  refresh = (async () => {
170
174
  await inbox.drain(ctx);
175
+ try { await units.ready(ctx); await units.drain(ctx); } catch (error) { ctx.ui.notify(`Dogsled settlement delivery pending: ${String(error)}`, "warning"); }
171
176
  if (ctx.mode !== "tui") return;
172
177
  try {
173
178
  const next = parseBoard(JSON.parse(await exec(["board", "--json"])));
@@ -192,7 +197,8 @@ export default function dogsled(pi: ExtensionAPI) {
192
197
  return refresh;
193
198
  }
194
199
 
195
- pi.on("session_start", async (_event, ctx) => {
200
+ pi.on("session_start", async (event, ctx) => {
201
+ units.start(event.reason);
196
202
  if (ctx.mode === "tui") ctx.ui.setWidget("dogsled", undefined);
197
203
  requestWorkRender = undefined;
198
204
  workPanel?.dispose();
@@ -222,6 +228,7 @@ export default function dogsled(pi: ExtensionAPI) {
222
228
  await update(ctx);
223
229
  });
224
230
  pi.on("session_shutdown", (_event, ctx) => {
231
+ units.stop();
225
232
  workPanel?.dispose();
226
233
  generation++;
227
234
  workAbort?.abort();
@@ -239,7 +246,10 @@ export default function dogsled(pi: ExtensionAPI) {
239
246
  });
240
247
  // A delivered message is in the transcript once Pi ends it: only then is it acknowledged.
241
248
  pi.on("message_end", async event => { await inbox.ended(event.message); });
242
- pi.on("agent_settled", () => { inbox.settled(); });
249
+ pi.on("agent_settled", async (_event, ctx) => {
250
+ inbox.settled();
251
+ try { await units.settled(ctx); } catch (error) { ctx.ui.notify(`Dogsled unit acknowledgement pending: ${String(error)}`, "warning"); }
252
+ });
243
253
  pi.on("agent_before_settle", async event => {
244
254
  if (!owner || event.outcome !== "completed" || !event.context.canContinue || event.continue) return;
245
255
  // The Rust gate owns selection and durable deduplication across all harnesses.
@@ -251,7 +261,9 @@ export default function dogsled(pi: ExtensionAPI) {
251
261
  continue: true,
252
262
  };
253
263
  });
254
- pi.on("before_agent_start", async (event) => {
264
+ pi.on("before_agent_start", async (event, ctx) => {
265
+ if (!units.guard(ctx)) return;
266
+ try { await units.ready(ctx); } catch (error) { ctx.ui.notify(`Dogsled preparation recovery pending: ${String(error)}`, "warning"); }
255
267
  // Context is optional, including when the binary is absent or fails.
256
268
  try {
257
269
  const result = await pi.exec(binary, ["hook", "context", "--harness", "pi"], { timeout: 6_000 });
@@ -0,0 +1,174 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolCallEventResult, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
+ import { acquire, execution, prepared, record, reference, repositoryIdentity, sameExecution, sameRef, unitInput, unwrap, type Execution, type Prepared, type UnitRef } from "./unit-protocol.ts";
3
+ import { UnitWorker, type UnitExec } from "./unit-worker.ts";
4
+
5
+ interface Invocation { origin: UnitRef; execution?: Execution; cwd: string; prepared?: Prepared; ended: boolean }
6
+ const SPAWN = "mcp__dogsled__spawn";
7
+
8
+ /** Execution bookkeeping stays in Dogsled; the task provider owns all task state. */
9
+ export class UnitBridge {
10
+ private readonly calls = new Map<string, Invocation>();
11
+ private epoch = 0;
12
+ private draining: Promise<void> | undefined;
13
+ private recovery: Promise<void> | undefined;
14
+ private recovered = false;
15
+ private readonly worker: UnitWorker;
16
+ private readonly exec: UnitExec;
17
+ private readonly pi: Pick<ExtensionAPI, "events" | "exec" | "getFlag" | "registerFlag">;
18
+ constructor(pi: Pick<ExtensionAPI, "events" | "exec" | "getFlag" | "registerFlag">, binary: string) {
19
+ this.pi = pi;
20
+ this.exec = async (args, cwd) => {
21
+ const result = await pi.exec(binary, args, cwd === undefined ? undefined : { cwd });
22
+ if (result.code !== 0) throw new Error(`dogsled ${args.slice(0, 2).join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || result.code}`);
23
+ return JSON.parse(result.stdout);
24
+ };
25
+ this.worker = new UnitWorker(pi, this.exec);
26
+ }
27
+ start(reason: SessionStartEvent["reason"] = "startup"): void { this.epoch++; this.recovered = false; this.worker.start(reason); }
28
+ stop(): void { this.epoch++; }
29
+ guard(ctx: ExtensionContext): boolean { return this.worker.guard(ctx); }
30
+
31
+ /** Startup handlers have completed before this boundary; no registration-order race. */
32
+ ready(ctx: ExtensionContext): Promise<void> {
33
+ if (this.recovered || !acquire(this.pi.events)) return Promise.resolve();
34
+ if (this.recovery) return this.recovery;
35
+ const epoch = this.epoch;
36
+ this.recovery = this.resume(ctx).then(() => { if (epoch === this.epoch) this.recovered = true; }).finally(() => { this.recovery = undefined; });
37
+ return this.recovery;
38
+ }
39
+
40
+ /** A completed runtime startup establishes that untracked prior invocations cannot execute here. */
41
+ async resume(ctx: ExtensionContext): Promise<void> {
42
+ if (!acquire(this.pi.events)) return;
43
+ const epoch = this.epoch, session = ctx.sessionManager.getSessionId();
44
+ const pending = await this.exec(["unit", "prepared"], ctx.cwd);
45
+ if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: prepared listing returned no list");
46
+ for (const item of pending) {
47
+ this.current(epoch, session, ctx);
48
+ if (!record(item) || !execution(item.execution) || !reference(item.origin) || typeof item.toolCallId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid preparation identity");
49
+ if (item.origin.sessionId !== session) continue;
50
+ const executionRef = item.execution, origin = item.origin;
51
+ if ([...this.calls.values()].some(call => !call.ended && sameRef(call.origin, origin))) continue;
52
+ await this.exec(["unit", "abandon-prepared", "--input", JSON.stringify({ execution: executionRef })], ctx.cwd);
53
+ }
54
+ await this.drain(ctx);
55
+ }
56
+
57
+ async call(event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | undefined> {
58
+ if (event.toolName !== SPAWN) return;
59
+ const input: unknown = event.input;
60
+ if (!record(input)) return { block: true, reason: "REQUEST_CONFLICT: spawn input must be an object" };
61
+ try {
62
+ if ("preparedUnit" in input) {
63
+ const owned = this.calls.get(event.toolCallId)?.prepared;
64
+ if (!owned || Object.keys(input).length !== 1 || JSON.stringify(owned) !== JSON.stringify(input.preparedUnit)) {
65
+ throw new Error("REQUEST_CONFLICT: preparedUnit is reserved for this invocation's mechanical handoff");
66
+ }
67
+ return;
68
+ }
69
+ if (!("unit" in input)) return;
70
+ const request = unitInput(input), epoch = this.epoch, session = ctx.sessionManager.getSessionId();
71
+ await this.ready(ctx);
72
+ this.current(epoch, session, ctx);
73
+ const provider = acquire(this.pi.events);
74
+ if (!provider) throw new Error("PROVIDER_UNAVAILABLE: pi-todo capability is unavailable or ambiguous");
75
+ const contract = unwrap(provider.inspect(request.id));
76
+ if (!record(contract) || !reference(contract.ref) || !record(contract.scope) || typeof contract.scope.repositoryId !== "string") {
77
+ throw new Error("PROVIDER_UNAVAILABLE: invalid inspected contract");
78
+ }
79
+ if (contract.ref.sessionId !== session) throw new Error("STALE_ORIGIN: task belongs to another session");
80
+ const origin = await this.pi.exec("git", ["remote", "get-url", "origin"], { cwd: ctx.cwd });
81
+ if (origin.code !== 0 || repositoryIdentity(origin.stdout) !== contract.scope.repositoryId) throw new Error("REQUEST_CONFLICT: checkout origin differs from repositoryId");
82
+ this.current(epoch, session, ctx);
83
+ // Retain the origin before I/O: prepare may persist while its response is lost.
84
+ const invocation: Invocation = { origin: contract.ref, cwd: ctx.cwd, ended: false };
85
+ this.calls.set(event.toolCallId, invocation);
86
+ const result = await this.exec(["unit", "prepare", "--input", JSON.stringify({ origin: contract.ref, placement: request.placement }), "--tool-call-id", event.toolCallId], ctx.cwd);
87
+ if (!execution(result)) throw new Error("LAUNCH_UNRESOLVED: prepare returned invalid execution");
88
+ invocation.execution = result;
89
+ this.current(epoch, session, ctx);
90
+ // Reacquire after I/O so shutdown and ownership changes cannot revive old authority.
91
+ const current = acquire(this.pi.events);
92
+ if (!current) throw new Error("PROVIDER_UNAVAILABLE: task provider disappeared before claim");
93
+ const claim = unwrap(current.claim({ expected: contract.ref, execution: result }));
94
+ const enriched = record(claim) ? { execution: result, binding: claim.binding, exported: claim.exported } : null;
95
+ if (!prepared(enriched) || !sameRef(enriched.binding.contract, contract.ref) || !sameExecution(enriched.execution, result)) throw new Error("PROVIDER_UNAVAILABLE: claim returned mismatched export");
96
+ invocation.prepared = enriched;
97
+ for (const key of Object.keys(input)) delete input[key];
98
+ input.preparedUnit = enriched;
99
+ return;
100
+ } catch (error) {
101
+ // This hook never committed. Its own failure proves this invocation did not launch.
102
+ const invocation = this.calls.get(event.toolCallId);
103
+ if (invocation?.execution) { invocation.ended = true; await this.recover(event.toolCallId, invocation, ctx); }
104
+ return { block: true, reason: String(error) };
105
+ }
106
+ }
107
+
108
+ private current(epoch: number, session: string, ctx: ExtensionContext): void {
109
+ if (epoch !== this.epoch || session !== ctx.sessionManager.getSessionId() || ctx.signal?.aborted) throw new Error("STALE_ORIGIN: session changed or invocation aborted during handoff");
110
+ }
111
+ async ended(id: string, ctx: ExtensionContext): Promise<void> {
112
+ const invocation = this.calls.get(id);
113
+ if (!invocation) return;
114
+ invocation.ended = true;
115
+ await this.recover(id, invocation, ctx);
116
+ }
117
+ private async recover(id: string, invocation: Invocation, ctx: ExtensionContext): Promise<void> {
118
+ try {
119
+ if (!invocation.ended) return;
120
+ if ([...this.calls.values()].some(other => other !== invocation && !other.ended && sameRef(other.origin, invocation.origin))) return;
121
+ if (!invocation.execution) {
122
+ const pending = await this.exec(["unit", "prepared"], invocation.cwd);
123
+ if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: invalid prepared listing");
124
+ for (const item of pending) {
125
+ if (!record(item) || !execution(item.execution) || !reference(item.origin) || typeof item.toolCallId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid preparation identity");
126
+ // Prepare deduplicates by full origin and retains the first invocation ID on retries.
127
+ if (sameRef(item.origin, invocation.origin)) invocation.execution = item.execution;
128
+ }
129
+ if (!invocation.execution) { this.calls.delete(id); await this.drain(ctx); return; }
130
+ }
131
+ const state = await this.exec(["unit", "observe", "--input", JSON.stringify({ execution: invocation.execution })], invocation.cwd);
132
+ if (!record(state) || !execution(state.execution) || !sameExecution(state.execution, invocation.execution)) throw new Error("LAUNCH_UNRESOLVED: invalid observation");
133
+ if (state.phase === "prepared" && invocation.ended) {
134
+ await this.exec(["unit", "abandon-prepared", "--input", JSON.stringify({ execution: invocation.execution })], invocation.cwd);
135
+ } else if (state.phase === "prepared") return;
136
+ this.calls.delete(id);
137
+ await this.drain(ctx);
138
+ } catch (error) {
139
+ // Unknown outcome keeps the durable claim and in-memory recovery intent.
140
+ ctx.ui.notify(`Dogsled unit recovery pending: ${String(error)}`, "warning");
141
+ }
142
+ }
143
+
144
+ /** Provider settlement acknowledgements are independent of inbox text acknowledgements. */
145
+ drain(ctx: ExtensionContext): Promise<void> {
146
+ if (this.draining) return this.draining;
147
+ const epoch = this.epoch;
148
+ this.draining = (async () => {
149
+ if (!acquire(this.pi.events)) return;
150
+ const pending = await this.exec(["unit", "settlements"], ctx.cwd);
151
+ if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: settlements returned no list");
152
+ for (const event of pending) {
153
+ if (epoch !== this.epoch) return;
154
+ if (!record(event) || typeof event.eventId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid settlement identity");
155
+ const owner = record(event.binding) ? event.binding.execution : event.execution;
156
+ if (!execution(owner)) throw new Error("LAUNCH_UNRESOLVED: missing settlement execution");
157
+ const provider = acquire(this.pi.events);
158
+ if (!provider) return;
159
+ const result = provider.settle(event);
160
+ if (!result.ok) continue; // Wrong branch or revision stays pending in Dogsled.
161
+ if (!record(result.value) || typeof result.value.applied !== "boolean") throw new Error("PROVIDER_UNAVAILABLE: invalid settlement acknowledgement");
162
+ await this.exec(["unit", "ack-settlement", "--input", JSON.stringify({ execution: owner, eventId: event.eventId })], ctx.cwd);
163
+ }
164
+ })().finally(() => { this.draining = undefined; });
165
+ return this.draining;
166
+ }
167
+ async settled(ctx: ExtensionContext): Promise<void> {
168
+ for (const [id, invocation] of this.calls) {
169
+ if (invocation.ended) await this.recover(id, invocation, ctx);
170
+ }
171
+ await this.worker.settled(ctx);
172
+ await this.drain(ctx);
173
+ }
174
+ }
@@ -0,0 +1,85 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface UnitRef { sessionId: string; unitId: string; revision: number; contractEntryId: string }
4
+ export interface Execution { executor: string; ownerId: string; requestId: string }
5
+ export interface Binding { contract: UnitRef; execution: Execution; exportDigest: string }
6
+ export interface Exported { version: number; provider: string; contract: { ref: UnitRef; scope: { repositoryId: string } }; digest: string; [key: string]: unknown }
7
+ export interface Prepared { execution: Execution; binding: Binding; exported: Exported }
8
+ export interface Receipt { version: number; execution: Execution; unitRef: UnitRef; digest: string; localSessionId: string; seal: { entryId: string; disposition: "completed" | "abandoned" } | null }
9
+ export type Result = { ok: true; value: unknown } | { ok: false; error: { code: string; message: string } };
10
+ export interface TaskProvider {
11
+ inspect(id: string): Result;
12
+ claim(input: { expected: UnitRef; execution: Execution }): Result;
13
+ settle(input: unknown): Result;
14
+ imported(): Result;
15
+ }
16
+ export type Placement = { host?: string; branch?: string; base?: string };
17
+ export function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
18
+ function text(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; }
19
+ export function reference(value: unknown): value is UnitRef {
20
+ return record(value) && text(value.sessionId) && text(value.unitId) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && text(value.contractEntryId);
21
+ }
22
+ export function execution(value: unknown): value is Execution {
23
+ return record(value) && value.executor === "dogsled" && text(value.ownerId) && text(value.requestId);
24
+ }
25
+ export function sameRef(a: UnitRef, b: UnitRef): boolean {
26
+ return a.sessionId === b.sessionId && a.unitId === b.unitId && a.revision === b.revision && a.contractEntryId === b.contractEntryId;
27
+ }
28
+ export function sameExecution(a: Execution, b: Execution): boolean {
29
+ return a.executor === b.executor && a.ownerId === b.ownerId && a.requestId === b.requestId;
30
+ }
31
+ export function binding(value: unknown): value is Binding {
32
+ return record(value) && reference(value.contract) && execution(value.execution) && typeof value.exportDigest === "string" && /^[a-f0-9]{64}$/.test(value.exportDigest);
33
+ }
34
+ export function exported(value: unknown): value is Exported {
35
+ return record(value) && value.version === 1 && value.provider === "pi-todo" && record(value.contract)
36
+ && reference(value.contract.ref) && record(value.contract.scope) && text(value.contract.scope.repositoryId)
37
+ && typeof value.digest === "string" && /^[a-f0-9]{64}$/.test(value.digest);
38
+ }
39
+ export function prepared(value: unknown): value is Prepared {
40
+ return record(value) && execution(value.execution) && binding(value.binding) && exported(value.exported)
41
+ && sameExecution(value.execution, value.binding.execution) && sameRef(value.binding.contract, value.exported.contract.ref)
42
+ && value.binding.exportDigest === value.exported.digest;
43
+ }
44
+ export function receipt(value: unknown): value is Receipt {
45
+ return record(value) && value.version === 1 && execution(value.execution) && reference(value.unitRef)
46
+ && typeof value.digest === "string" && /^[a-f0-9]{64}$/.test(value.digest) && text(value.localSessionId)
47
+ && (value.seal === null || (record(value.seal) && text(value.seal.entryId) && ["completed", "abandoned"].includes(String(value.seal.disposition))));
48
+ }
49
+ function provider(value: unknown): value is TaskProvider {
50
+ return record(value) && ["inspect", "claim", "settle", "imported"].every(key => typeof value[key] === "function");
51
+ }
52
+ export function acquire(events: ExtensionAPI["events"]): TaskProvider | undefined {
53
+ const replies: unknown[] = [];
54
+ events.emit("oisin.tasks.acquire.v1", { version: 1, reply: (value: unknown) => { replies.push(value); } });
55
+ if (replies.length !== 1 || !provider(replies[0])) return undefined;
56
+ return replies[0];
57
+ }
58
+ export function unwrap(result: Result): unknown {
59
+ if (!record(result) || typeof result.ok !== "boolean") throw new Error("PROVIDER_UNAVAILABLE: invalid capability result");
60
+ if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
61
+ return result.value;
62
+ }
63
+ export function unitInput(input: Record<string, unknown>): { id: string; placement: Placement } {
64
+ if (Object.keys(input).some(key => !["unit", "host", "branch", "base"].includes(key)) || !record(input.unit)
65
+ || Object.keys(input.unit).length !== 1 || !text(input.unit.id)) throw new Error("REQUEST_CONFLICT: unit spawn cannot include a brief, profile, cwd, or prepared envelope");
66
+ const placement: Placement = {};
67
+ for (const key of ["host", "branch", "base"] as const) {
68
+ if (input[key] === undefined) continue;
69
+ if (!text(input[key])) throw new Error(`REQUEST_CONFLICT: ${key} must be a nonempty string`);
70
+ placement[key] = input[key];
71
+ }
72
+ if (placement.base && !placement.branch) throw new Error("REQUEST_CONFLICT: base requires branch");
73
+ return { id: input.unit.id, placement };
74
+ }
75
+
76
+ /** Clone transport is not repository identity. Keep path case; do not accept local paths. */
77
+ export function repositoryIdentity(origin: string): string {
78
+ const value = origin.trim();
79
+ const scp = value.includes("://") ? null : /^(?:[^/@:\s]+@)?(?<host>[^/:\s]+):(?<path>[^\s]+)$/.exec(value);
80
+ const url = new URL(scp?.groups ? `ssh://${scp.groups["host"]}/${scp.groups["path"]}` : value);
81
+ const path = url.pathname.replace(/^\//, "").replace(/\/$/, "").replace(/\.git$/, "");
82
+ if (!["ssh:", "https:", "http:", "git:"].includes(url.protocol) || !url.hostname || !path || url.search || url.hash
83
+ || path.split("/").some(part => !part || part === "." || part === "..")) throw new Error("REQUEST_CONFLICT: origin must identify a remote repository host/path");
84
+ return `${url.hostname.toLowerCase()}/${path}`;
85
+ }
@@ -0,0 +1,93 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
2
+ import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
3
+ import { acquire, prepared, receipt, record, sameExecution, sameRef, unwrap, type Prepared, type Receipt } from "./unit-protocol.ts";
4
+
5
+ /** Read transport correlation, not task semantics; pi-todo validates the actual contract. */
6
+ function seedFile(path: string): Prepared {
7
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
8
+ try {
9
+ const stat = fstatSync(fd);
10
+ if (!stat.isFile() || stat.size > 1_048_576) throw new Error("invalid seed artifact");
11
+ const bytes = readFileSync(fd);
12
+ if (bytes.length > 1_048_576) throw new Error("seed artifact too large");
13
+ const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
14
+ if (!record(value) || value.version !== 1 || !prepared(value)) throw new Error("invalid seed correlation");
15
+ return value;
16
+ } finally { closeSync(fd); }
17
+ }
18
+
19
+ export type UnitExec = (args: string[], cwd?: string) => Promise<unknown>;
20
+
21
+ /** The loaded guard enforces fresh-session startup, not resume-time compaction. */
22
+ export class UnitWorker {
23
+ private seed: Prepared | undefined;
24
+ private failure: string | undefined;
25
+ private managed = false;
26
+ private reason: SessionStartEvent["reason"] = "startup";
27
+ private readonly pi: Pick<ExtensionAPI, "events" | "getFlag" | "registerFlag">;
28
+ private readonly exec: UnitExec;
29
+ constructor(pi: Pick<ExtensionAPI, "events" | "getFlag" | "registerFlag">, exec: UnitExec) {
30
+ this.pi = pi; this.exec = exec;
31
+ // Dogsled alone owns the launch flag and supplies its path over the event bus.
32
+ // Register it even without pi-todo so the loaded guard can reject missing import.
33
+ pi.registerFlag("todo-unit-file", { type: "string", description: "Executor-owned worker task seed" });
34
+ // Register during extension loading, before any session_start import handler.
35
+ pi.events.on("oisin.tasks.seed-source.v1", input => {
36
+ if (!record(input) || input.version !== 1 || typeof input.reply !== "function") return;
37
+ input.reply({ version: 1, path: pi.getFlag("todo-unit-file") ?? null });
38
+ });
39
+ }
40
+ start(reason: SessionStartEvent["reason"] = "startup"): void {
41
+ this.reason = reason;
42
+ this.seed = undefined;
43
+ this.failure = undefined;
44
+ const path = this.pi.getFlag("todo-unit-file");
45
+ this.managed = path !== undefined;
46
+ if (!this.managed) return;
47
+ try {
48
+ if (typeof path !== "string" || !path) throw new Error("missing seed path");
49
+ this.seed = seedFile(path);
50
+ } catch (error) { this.failure = String(error); }
51
+ }
52
+ private imported(ctx: ExtensionContext): Receipt | null {
53
+ const provider = acquire(this.pi.events);
54
+ if (!provider) {
55
+ if (this.managed) throw new Error("task provider unavailable");
56
+ return null;
57
+ }
58
+ const value = unwrap(provider.imported());
59
+ if (value === null && !this.managed) return null;
60
+ if (!receipt(value) || value.localSessionId !== ctx.sessionManager.getSessionId()
61
+ || value.localSessionId !== value.execution.requestId) throw new Error("missing or mismatched import acknowledgement");
62
+ const recorded = this.reason === "reload" || this.reason === "resume"
63
+ || ctx.sessionManager.getEntries().some(entry => entry.type === "message");
64
+ if (!recorded) {
65
+ if (!this.seed || this.failure) throw new Error(this.failure ?? "missing seed");
66
+ if (!sameExecution(value.execution, this.seed.execution) || !sameRef(value.unitRef, this.seed.binding.contract)
67
+ || value.digest !== this.seed.binding.exportDigest) throw new Error("mismatched seed acknowledgement");
68
+ }
69
+ return value;
70
+ }
71
+ /** Called synchronously before any other before-agent-start await. */
72
+ guard(ctx: ExtensionContext): boolean {
73
+ try {
74
+ const imported = this.imported(ctx);
75
+ if (imported?.seal) throw new Error("worker execution is sealed");
76
+ return true;
77
+ } catch (error) {
78
+ ctx.abort();
79
+ ctx.ui.notify(`IMPORT_FAILED: ${String(error)}`, "error");
80
+ return false;
81
+ }
82
+ }
83
+ async settled(ctx: ExtensionContext): Promise<void> {
84
+ const imported = this.imported(ctx);
85
+ if (!imported?.seal) return;
86
+ const leaf = ctx.sessionManager.getLeafId();
87
+ if (!leaf) throw new Error("IMPORT_FAILED: sealed session has no final leaf");
88
+ await this.exec(["unit", "terminal-ack", "--input", JSON.stringify({
89
+ execution: imported.execution, unitRef: imported.unitRef, digest: imported.digest,
90
+ sealEntryId: imported.seal.entryId, sessionLeafId: leaf,
91
+ })], ctx.cwd);
92
+ }
93
+ }
@@ -5,7 +5,7 @@ description: Start Pi, Claude, and Codex children and jobs on the Herdr host wit
5
5
 
6
6
  # Dogsled: children, jobs, and messages
7
7
 
8
- Use dogsled to delegate, run jobs, and message other agents. Prefer the tools (`status`, `spawn`, `run`, `send`, `inbox`, `read`, `close`, `stop`, `cancel`, `place`); each matches a CLI command and returns the same JSON. In Pi they are `mcp__dogsled__<tool>`.
8
+ Use dogsled to delegate, run jobs, and message other agents. Prefer the tools (`status`, `spawn`, `run`, `send`, `inbox`, `read`, `close`, `done`, `stop`, `cancel`, `place`); each matches a CLI command and returns the same JSON. In Pi they are `mcp__dogsled__<tool>`.
9
9
 
10
10
  Never type into another agent's terminal (`herdr agent prompt`, `herdr pane send-text`, `send-keys`): it mixes your text into the user's draft. Use `send`.
11
11
 
@@ -52,12 +52,14 @@ dogsled inbox list
52
52
 
53
53
  ## Closing
54
54
 
55
- Dogsled closes a finished child for you, after it captures the reply:
55
+ A child is finished when it calls the `done` tool (or `dogsled done`) during its last turn. Dogsled tells each child this at launch. Dogsled then closes the finished child for you, after it captures the reply:
56
56
 
57
- - a reader (no commits outside the default branch, clean tree) closes when it is idle or done;
57
+ - a reader (no commits outside the default branch, clean tree) closes when that turn ends;
58
58
  - a writer closes once its branch has landed. Until then it shows under TO LAND: merge or drop the branch.
59
59
 
60
- Blocked and working children never close, and nothing closes on a timer. To end a child now:
60
+ A child that ends a turn without `done` waits for input, for example after a question: its reply reaches your inbox, and it stays open. Answer it with `send`. Blocked and working children never close, and nothing closes on a timer.
61
+
62
+ When you are a child, call `done` once, in the turn that completes your whole task, then end the turn with your final reply. Do not call it when you ask a question or wait for your own children; `done` is refused while you own open children or queued work. To end a child now:
61
63
 
62
64
  ```sh
63
65
  dogsled close PANE [--host HOST]