@afokapu/atdd-flow 0.2.2 → 0.3.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
@@ -163,6 +163,39 @@ atdd-flow launch driver.runtime@resolver-os --application tuios --placement reso
163
163
 
164
164
  Launch selects a model from `models.yaml`, starts that model's executable in the seat's declared worktree, passes `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, records the selected model on the live runtime binding, and asks the agent to read its seat. Other hosts remain correct without an adapter; their operator supplies that prompt.
165
165
 
166
+ ### Pi-native Desk mail
167
+
168
+ When Flow launches the `pi` executable, it automatically loads its bundled Pi extension. Pi remains a normal, interactive agent in its host pane, but the extension watches the immutable Desk mail files and wakes Pi internally with `pi.sendMessage()`—not terminal text injection.
169
+
170
+ The Pi seat records both concepts independently:
171
+
172
+ ```yaml
173
+ runtime:
174
+ application: herdr # or tuios
175
+ addresses:
176
+ herdr: w9:p1
177
+ model: pi
178
+ wake: native
179
+ ```
180
+
181
+ `application` identifies the visible pane. `wake: native` tells Flow not to also send a host prompt; the Pi extension reads `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, observes final `M-*.yaml` files, filters recipients, and queues a follow-up turn that tells Pi which durable file to read. Other agents keep `wake: host` and use their normal host notification adapter.
182
+
183
+ Pi’s native wake-up is intentionally lightweight: no daemon, duplicate mailbox, or separate extension installation. The extension is shipped inside the Flow package. Its in-memory message-id guard tolerates duplicate filesystem events; the Desk thread files remain the source of truth.
184
+
185
+ To host Pi in Herdr, create a pane with the durable Desk and seat identity, then start Pi with the extension supplied by the installed Flow package:
186
+
187
+ ```sh
188
+ PI_EXTENSION="$(atdd-flow pi extension-path)"
189
+ herdr pane split --current --direction right --cwd /path/to/worktree --no-focus \
190
+ --env ATDD_WORKFLOW_ROOT=/path/to/desk \
191
+ --env ATDD_WORKFLOW_SEAT=driver.runtime@resolver-os
192
+ # Use the pane id returned above.
193
+ herdr agent start pi-runtime --kind pi --pane <pane-id> -- --extension "$PI_EXTENSION"
194
+ atdd-flow --root /path/to/desk attach driver.runtime@resolver-os --application herdr --wake native
195
+ ```
196
+
197
+ Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API.
198
+
166
199
  With ATDD Bun, enable the Workflow profile:
167
200
 
168
201
  ```yaml
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ type Thread = { participants?: unknown };
8
+ type Mail = { id?: unknown; from?: unknown; to?: unknown };
9
+
10
+ const finalMail = /^M-[^.]+\.yaml$/;
11
+
12
+ function participants(thread: Thread) {
13
+ return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string")
14
+ ? thread.participants as string[]
15
+ : [];
16
+ }
17
+
18
+ export function addressedTo(mail: Mail, thread: Thread, seat: string) {
19
+ if (mail.from === seat) return false;
20
+ if (Array.isArray(mail.to)) return mail.to.includes(seat);
21
+ return mail.to === "all" && participants(thread).includes(seat);
22
+ }
23
+
24
+ /**
25
+ * A Pi-native wake adapter. The Desk remains authoritative: this extension
26
+ * only observes final immutable mail files and tells Pi where to read them.
27
+ */
28
+ export default function (pi: ExtensionAPI) {
29
+ const root = process.env.ATDD_WORKFLOW_ROOT;
30
+ const seat = process.env.ATDD_WORKFLOW_SEAT;
31
+ if (!root || !seat) return;
32
+
33
+ const threads = join(root, "threads");
34
+ const delivered = new Set<string>();
35
+ const pending = new Set<string>();
36
+ const threadWatchers = new Map<string, fs.FSWatcher>();
37
+ let threadsWatcher: fs.FSWatcher | undefined;
38
+
39
+ const deliver = (threadId: string, fileName: string) => {
40
+ const key = `${threadId}/${fileName}`;
41
+ if (delivered.has(key) || pending.has(key)) return;
42
+ pending.add(key);
43
+ setTimeout(async () => {
44
+ try {
45
+ const folder = join(threads, threadId);
46
+ const [rawThread, rawMail] = await Promise.all([
47
+ readFile(join(folder, "thread.yaml"), "utf8"),
48
+ readFile(join(folder, fileName), "utf8"),
49
+ ]);
50
+ const thread = parse(rawThread) as Thread;
51
+ const mail = parse(rawMail) as Mail;
52
+ if (!addressedTo(mail, thread, seat)) return;
53
+ const id = typeof mail.id === "string" ? mail.id : fileName.slice(0, -5);
54
+ const file = join(folder, fileName);
55
+ delivered.add(key);
56
+ pi.sendMessage({
57
+ customType: "atdd-flow-mail",
58
+ content: `SYSTEM: new Flow mail ${id}. Read ${file}`,
59
+ display: true,
60
+ details: { thread: threadId, message: id, path: file },
61
+ }, { triggerTurn: true, deliverAs: "followUp" });
62
+ } catch {
63
+ // A later filesystem event can retry an atomic-write race.
64
+ } finally {
65
+ pending.delete(key);
66
+ }
67
+ }, 25);
68
+ };
69
+
70
+ const watchThread = (threadId: string) => {
71
+ if (threadWatchers.has(threadId)) return;
72
+ try {
73
+ const watcher = fs.watch(join(threads, threadId), (_event, file) => {
74
+ const name = file?.toString();
75
+ if (name && finalMail.test(name)) deliver(threadId, name);
76
+ });
77
+ threadWatchers.set(threadId, watcher);
78
+ } catch {
79
+ // A concurrent thread deletion is harmless.
80
+ }
81
+ };
82
+
83
+ const discoverThreads = async () => {
84
+ try {
85
+ for (const entry of await readdir(threads, { withFileTypes: true })) {
86
+ if (entry.isDirectory() && entry.name.startsWith("T-")) watchThread(entry.name);
87
+ }
88
+ } catch {
89
+ // The Desk may be created after Pi starts.
90
+ }
91
+ };
92
+
93
+ pi.on("session_start", async (_event, ctx) => {
94
+ await discoverThreads();
95
+ try { threadsWatcher = fs.watch(threads, () => { void discoverThreads(); }); }
96
+ catch { /* The startup prompt still makes the missing Desk visible. */ }
97
+ pi.sendMessage({
98
+ customType: "atdd-flow-start",
99
+ content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`,
100
+ display: true,
101
+ details: { seat, root },
102
+ }, { triggerTurn: true, deliverAs: "followUp" });
103
+ if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
104
+ });
105
+
106
+ pi.on("session_shutdown", () => {
107
+ threadsWatcher?.close();
108
+ for (const watcher of threadWatchers.values()) watcher.close();
109
+ threadWatchers.clear();
110
+ });
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "bin",
16
16
  "conventions",
17
+ "extensions",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -42,6 +43,7 @@
42
43
  "@afokapu/atdd-bun": "^0.10.44"
43
44
  },
44
45
  "dependencies": {
45
- "@typesafe-ai/sdk": "^0.6.0"
46
+ "@typesafe-ai/sdk": "^0.6.0",
47
+ "yaml": "2.9.0"
46
48
  }
47
49
  }
package/src/adapters.ts CHANGED
@@ -27,10 +27,11 @@ export function discoverAddress(application: string, environment: Environment =
27
27
  export function notificationCommand(application: string, address: string, notice: string, herdrSession?: string): string[] | undefined {
28
28
  const commands: Record<string, () => string[] | undefined> = {
29
29
  tmux: () => ["tmux", "send-keys", "-t", address, notice, "Enter"],
30
- // A Herdr pane id is the canonical runtime address. `pane send-text` is
31
- // supported by both standalone Herdr and TUIOS's embedded Herdr server;
32
- // `agent send` is not available on every embedded-server version.
33
- herdr: () => ["herdr", ...(herdrSession ? ["--session", herdrSession] : []), "pane", "send-text", address, notice],
30
+ // Herdr resolves the live agent from its stable pane address and submits
31
+ // an ordered prompt through its agent-control API. Do not fall back to
32
+ // pane text injection: a Desk message remains durable when an older host
33
+ // cannot provide agent.prompt.
34
+ herdr: () => ["herdr", ...(herdrSession ? ["--session", herdrSession] : []), "agent", "prompt", address, notice],
34
35
  tuios: () => {
35
36
  const separator = address.indexOf("/");
36
37
  if (separator < 1 || separator === address.length - 1) return undefined;
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { basename, resolve } from "node:path";
4
- import { init, initProject, spawn, bind, useApplication, attach, launch, describe, checkpoint, migrate, openSeat } from "./seats";
4
+ import { init, initProject, spawn, bind, useApplication, attach, launch, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
5
5
  import { addParticipant, openThread, post, receipt, result, startThread } from "./threads";
6
6
  import * as tasks from "./tasks";
7
7
  import { required, values, words } from "./core";
@@ -19,8 +19,9 @@ Usage:
19
19
  atdd-flow desk migrate
20
20
  atdd-flow project init <project>
21
21
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
22
- atdd-flow bind <address> [--application <application>] --address <native-address>
23
- atdd-flow attach <address> [--application <application>]
22
+ atdd-flow bind <address> [--application <application>] --address <native-address> [--wake host|native]
23
+ atdd-flow attach <address> [--application <application>] [--wake host|native]
24
+ atdd-flow pi extension-path
24
25
  atdd-flow launch <address> --application <application> --placement <native-container-address>
25
26
  atdd-flow application use <address> <application>
26
27
  atdd-flow describe <address> --purpose <one-line responsibility>
@@ -73,6 +74,10 @@ async function main() {
73
74
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
74
75
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
75
76
  attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
77
+ pi: async () => {
78
+ if (rest[0] === "extension-path") return console.log(piExtensionPath());
79
+ throw new Error("Use `atdd-flow pi extension-path`.");
80
+ },
76
81
  launch: () => launch(root, required(rest[0], "address"), rest.slice(1)),
77
82
  application: async () => {
78
83
  if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
package/src/core.ts CHANGED
@@ -47,7 +47,8 @@ export type Project = {
47
47
  * are opaque application-owned locators: Workflow records and returns them,
48
48
  * while the relevant bridge is responsible for using their native format.
49
49
  */
50
- export type Runtime = { application: string; addresses: Record<string, string>; attached_at?: string; model?: string };
50
+ /** `native` means the agent runtime wakes itself from the durable Desk. */
51
+ export type Runtime = { application: string; addresses: Record<string, string>; attached_at?: string; model?: string; wake?: "host" | "native" };
51
52
  export type Seat = {
52
53
  schema: string;
53
54
  address: string;
package/src/seats.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readdir } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { discoverAddress, launchedAddress, launchCommand, notify } from "./adapters";
5
5
  import {
6
- type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Seat,
6
+ type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
7
7
  atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
8
8
  required, run, runOutput, seat, words, yaml,
9
9
  } from "./core";
@@ -93,11 +93,14 @@ export async function spawn(root: string, projectName: string, roleName: string,
93
93
  console.log(address);
94
94
  }
95
95
 
96
- export async function bind(root: string, address: string, args: string[], selectedModel?: string) {
96
+ export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"]) {
97
97
  const resolved = await canonicalAddress(root, address);
98
98
  const record = await seat(root, resolved);
99
99
  const config = await desk(root);
100
100
  const application = words(args, "--application") ?? config.application;
101
+ const requestedWake = words(args, "--wake");
102
+ if (requestedWake && requestedWake !== "host" && requestedWake !== "native") throw new Error("Wake must be host or native.");
103
+ const selectedWake = wake ?? requestedWake as Runtime["wake"] | undefined;
101
104
  if (!/^[a-z][a-z0-9_-]*$/.test(application)) throw new Error(`Application must use lowercase letters, numbers, underscores, or hyphens: ${application}`);
102
105
  const nativeAddress = required(words(args, "--address"), "--address");
103
106
  const addresses = record.runtime?.addresses ?? {};
@@ -107,6 +110,7 @@ export async function bind(root: string, address: string, args: string[], select
107
110
  addresses: { ...addresses, [application]: nativeAddress },
108
111
  attached_at: now(),
109
112
  ...(selectedModel ? { model: selectedModel } : {}),
113
+ ...(selectedWake ? { wake: selectedWake } : {}),
110
114
  };
111
115
  await atomicYaml(paths(root).seatFile(resolved), record);
112
116
  console.log(`Bound ${resolved} to ${application}:${nativeAddress}`);
@@ -125,7 +129,8 @@ export async function useApplication(root: string, address: string, application:
125
129
  export async function attach(root: string, address: string, args: string[]) {
126
130
  const application = words(args, "--application") ?? (await desk(root)).application;
127
131
  const nativeAddress = discoverAddress(application);
128
- await bind(root, address, ["--application", application, "--address", nativeAddress]);
132
+ const wake = words(args, "--wake");
133
+ await bind(root, address, ["--application", application, "--address", nativeAddress, ...(wake ? ["--wake", wake] : [])]);
129
134
  }
130
135
 
131
136
  export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
@@ -166,6 +171,11 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
166
171
  export const launchNotice = (address: string) =>
167
172
  `SYSTEM: you are ${address}. Read your durable seat and assigned task with: atdd-flow open ${address}. Use the installed atdd-flow command; never use bunx to replace it or run atdd-flow init against an existing Desk. If your native pane binding differs from the Desk record, report it to the operator or coordinator. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
168
173
 
174
+ /** Pi loads this extension inside its own process, so it can wake without host text injection. */
175
+ export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
176
+ export const isPiExecutable = (agent: string) => basename(agent) === "pi";
177
+ export const piLaunchArgs = (agent: string, args: string[]) => isPiExecutable(agent) ? [...args, "--extension", piExtensionPath()] : args;
178
+
169
179
  export async function launch(root: string, address: string, args: string[]) {
170
180
  const resolved = await canonicalAddress(root, address);
171
181
  const record = await seat(root, resolved);
@@ -187,12 +197,15 @@ export async function launch(root: string, address: string, args: string[]) {
187
197
  agent = resolveExecutable(config, legacyAgent);
188
198
  selectedModel = legacyAgent;
189
199
  }
200
+ const wake: Runtime["wake"] = isPiExecutable(agent) ? "native" : "host";
201
+ modelArgs = piLaunchArgs(agent, modelArgs);
190
202
  const output = await runOutput(launchCommand({
191
203
  application, placement, name: resolved, worktree: record.worktree,
192
204
  agent, args: modelArgs, root, seat: resolved,
193
205
  }));
194
206
  const nativeAddress = launchedAddress(application, placement, output);
195
- await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel);
207
+ await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel, wake);
208
+ if (wake === "native") return;
196
209
  const notice = launchNotice(resolved);
197
210
  try { await notify(application, nativeAddress, notice, config.herdr_session); }
198
211
  catch (error) { console.warn(`Launch notification for ${resolved} was not delivered: ${(error as Error).message}`); }
package/src/threads.ts CHANGED
@@ -65,6 +65,7 @@ async function inject(root: string, address: string, message: Message, threadId:
65
65
  const target = await seat(root, address);
66
66
  const runtime = target.runtime;
67
67
  if (!runtime) return;
68
+ if (runtime.wake === "native") return;
68
69
  const nativeAddress = runtime.addresses[runtime.application];
69
70
  if (!nativeAddress) return;
70
71
  const file = paths(root).message(threadId, message.id);