@afokapu/atdd-flow 0.3.6 → 0.3.8

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
@@ -50,12 +50,9 @@ an absolute command path when the host does not guarantee a shared `PATH`:
50
50
  ```yaml
51
51
  schema: atdd-workflow/desk/v1
52
52
  desk: desk
53
- application: tuios
53
+ application: herdr
54
54
  executables:
55
- claude: /Users/you/.local/bin/claude
56
- codex: /opt/homebrew/bin/codex
57
55
  pi: /opt/homebrew/bin/pi
58
- kimi: /Users/you/.kimi-code/bin/kimi
59
56
  ```
60
57
 
61
58
  The executable registry is transport configuration, not model allocation. New seats do not pin an
@@ -64,25 +61,18 @@ agent. Instead, `models.yaml` declares the launchable model portfolio in descend
64
61
  ```yaml
65
62
  schema: atdd-workflow/models/v1
66
63
  models:
67
- - id: frontier
68
- executable: claude
69
- args: [--model, opus]
70
- - id: standard
71
- executable: claude
72
- args: [--model, sonnet]
73
- - id: codex
74
- executable: codex
75
- - id: glm
76
- executable: glm
77
- enabled: false
64
+ - id: pi
65
+ executable: pi
66
+ # Add Pi --model arguments here when local policy selects a specific Pi model.
78
67
  ```
79
68
 
80
- Order is policy: strongest first, weakest last. Entries whose executable is unavailable, or whose
69
+ Order is policy: strongest first, weakest last. New Desks use Pi for every entry; add Pi `--model`
70
+ arguments when local policy selects a specific model. Entries whose executable is unavailable, or whose
81
71
  `enabled` flag is false, are excluded. For ordinary phase work, Jev sees the seat's active work and
82
- selects the weakest available model sufficient for that responsibility. If model selection is unavailable
83
- or low-confidence, Workflow conservatively launches the strongest available model. Final behavioral
84
- review has its own bounded routing step described below. Older Desks without `models.yaml` continue
85
- to honor a legacy seat `agent` through the executable registry.
72
+ selects the weakest available configured Pi model sufficient for that responsibility. If model selection is
73
+ unavailable or low-confidence, Workflow conservatively selects the strongest available configured Pi
74
+ model. Final behavioral review has its own bounded routing step described below. Older Desks without
75
+ `models.yaml` continue to honor a legacy seat `agent` through the executable registry.
86
76
 
87
77
  ## Configure worktrees and seats
88
78
 
@@ -124,9 +114,7 @@ atdd-flow task add resolver-os runtime-rollout --title 'Complete runtime rollout
124
114
  atdd-flow task start resolver-os runtime-rollout --by driver.runtime@resolver-os
125
115
  atdd-flow task prove resolver-os runtime-rollout --by driver.runtime@resolver-os --item 1 --proof 'CI run 42'
126
116
  atdd-flow task review resolver-os runtime-rollout --by driver.runtime@resolver-os
127
- atdd-flow behavioral-review launch resolver-os runtime-rollout --by coordinator@resolver-os \
128
- --application tuios --placement resolver-os --gate 'CI run 42'
129
- # the reviewer persists APPROVE, RETURN, or ESCALATE through behavioral-review record
117
+ # An independently attached reviewer persists APPROVE, RETURN, or ESCALATE through behavioral-review record
130
118
  atdd-flow task done resolver-os runtime-rollout --by coordinator@resolver-os
131
119
  ```
132
120
 
@@ -140,10 +128,19 @@ Threads are the durable inbox/outbox. Workflow persists a message before a best-
140
128
  atdd-flow thread start --with coordinator@resolver-os,driver.runtime@resolver-os \
141
129
  --subject 'Runtime rollout' --task resolver-os/runtime-rollout
142
130
  atdd-flow post T-... --from coordinator@resolver-os --to driver.runtime@resolver-os \
143
- --expects-result --body 'Implement the task and return proof references.'
144
- atdd-flow result T-... M-... --from driver.runtime@resolver-os --body 'CI run 42; PR #81.'
131
+ --label 'implementation request' --expects-result --body 'Implement the task and return proof references.'
132
+ atdd-flow result T-... M-... --from driver.runtime@resolver-os --label 'proof returned' --body 'CI run 42; PR #81.'
145
133
  ```
146
134
 
135
+ New durable thread and message IDs are human-readable: `T-` or `M-`, a UTC-second
136
+ stamp (`YYYYMMDDTHHMMSSZ`), a kebab-case slug, and a short random suffix, such as
137
+ `M-20261010T122049Z-rollover-review_7e5e12ab`. Thread slugs derive from `--subject`.
138
+ `post`, `receipt`, and `result` optionally accept `--label` for the message slug; it
139
+ defaults to the message kind (`message`, `receipt`, or `result`). Labels are durable
140
+ metadata, so provide only non-sensitive text. Message bodies are never used in IDs.
141
+ Existing Desk thread and message filenames retain their legacy IDs unchanged and remain
142
+ readable.
143
+
147
144
  For a shared boundary: driver → coordinator → affected coordinator(s) → minimum agreement in a thread → driver. `--to all` broadcasts; requested results remain outstanding until every recipient replies. Checkpoints are short handoffs, not logs; update at responsibility transitions and before replacing an agent.
148
145
 
149
146
  ## Inspect, host, and guide agents
@@ -154,24 +151,38 @@ atdd-flow status seat driver.runtime@resolver-os
154
151
  atdd-flow open driver.runtime@resolver-os
155
152
  ```
156
153
 
157
- TUIOS is the primary live host; tmux and Herdr have notification adapters. TUIOS launch targets the named session, never the focused session:
154
+ Herdr and tmux can notify an already attached host pane. Flow does not create host panes: the operator starts the agent in its declared worktree with `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, then attaches that pane deterministically.
158
155
 
159
156
  ```sh
160
- atdd-flow attach driver.runtime@resolver-os --application tuios
161
- atdd-flow launch driver.runtime@resolver-os --application tuios --placement resolver-os
157
+ atdd-flow attach driver.runtime@resolver-os --application herdr
162
158
  ```
163
159
 
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.
160
+ ### Optional Herdr worktree projection
161
+
162
+ `multiplexer/herdr.yaml` is a compact, instance-free policy: it describes only primary versus linked worktree role placement. The Desk remains authoritative for projects, seats, tasks, branches, and worktrees. Herdr is an optional display/runtime projection and never becomes a second registry.
163
+
164
+ An operator must select the target session explicitly, or run from a Herdr pane that supplies `HERDR_SESSION`; Flow never chooses a Desk-wide or focused session. Status is read-only and apply uses `--no-focus`:
165
+
166
+ ```sh
167
+ atdd-flow multiplexer status herdr --session forge
168
+ atdd-flow multiplexer apply herdr --session forge
169
+ # Inside a Herdr pane, the inherited HERDR_SESSION is sufficient:
170
+ atdd-flow multiplexer apply herdr
171
+ ```
172
+
173
+ For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and drivers with a non-done assigned task become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. Unbound worktrees are ignored. Apply neither focuses, closes, nor guesses about other workspaces or sessions.
174
+
175
+ New Herdr attachments store both the inherited session and pane id. Older scalar pane bindings remain readable and use their legacy Desk session only when one exists, so a bare `w1:p1` from one session cannot be mistaken for the same pane in another newly attached session.
165
176
 
166
177
  ### Pi-native Desk mail
167
178
 
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.
179
+ When Pi starts with Flow’s bundled extension, it 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
180
 
170
181
  The Pi seat records both concepts independently:
171
182
 
172
183
  ```yaml
173
184
  runtime:
174
- application: herdr # or tuios
185
+ application: herdr
175
186
  addresses:
176
187
  herdr: w9:p1
177
188
  model: pi
@@ -188,7 +199,11 @@ atdd-flow message read M-...
188
199
 
189
200
  The output includes only the message and its thread ID/subject; Flow rejects missing or ambiguous message IDs.
190
201
 
191
- 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.
202
+ Pi’s native wake-up is intentionally lightweight: no daemon, duplicate mailbox, or separate extension installation. The extension is shipped inside the Flow package. Desk thread files remain the source of truth.
203
+
204
+ At session start, the extension records one local, token-fenced **advisory** runtime observation at `.atdd-flow/pi-runtime/<encoded-seat>.yaml`. It contains only its opaque owner token, PID, model, cwd, start time, and heartbeat. A replacement Pi process atomically supersedes the prior observation; the old process can neither heartbeat, clear, nor consume queued mail after it loses its token. Shutdown and expired-heartbeat cleanup remove only the matching advisory observation. This file never selects recipients or changes a Desk seat, task, thread, checkpoint, branch, worktree, or immutable mail. `atdd-flow status seat` may display its active/stale activity as read-only information.
205
+
206
+ Native mail is loss-tolerant rather than watcher-dependent. When Flow persists a message for a native or Pi-designated unbound seat, it also appends a small per-seat reference to an ordered, fixed-size pending segment under `.atdd-flow/pi-inbox/<encoded-seat>`; immutable thread `M-*.yaml` files remain the authoritative message source. On session start/reload and at a bounded interval, the extension reads only the durable queue head and consumes a fixed batch in `created_at`/message-ID order, removing a reference only after Pi accepts delivery. The pending queue shrinks after delivery, so periodic recovery neither scans nor retains the complete Desk history. `fs.watch` on that queue remains a low-latency fast path; an unavailable watcher or restarted Pi is recovered by reconciliation. Existing Pi processes still require `/reload` or restart after a Flow package upgrade to load the bundled extension.
192
207
 
193
208
  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:
194
209
 
@@ -202,7 +217,9 @@ herdr agent start pi-runtime --kind pi --pane <pane-id> -- --extension "$PI_EXTE
202
217
  atdd-flow --root /path/to/desk attach driver.runtime@resolver-os --application herdr --wake native
203
218
  ```
204
219
 
205
- 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.
220
+ 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. The runtime lifecycle has one internal bounded inbox transport; it does not add a second watcher, registry, mailbox, reservation system, or routing path.
221
+
222
+ The lifecycle and recovery design was informed by [nicobailon/pi-messenger](https://github.com/nicobailon/pi-messenger) at `09937ed647a1b07a3b595bf75943feacb80ff123` (MIT). No pi-messenger source is copied here. Flow keeps Desk identity, recipient selection, immutable mail, and governance authoritative; any future copied source must carry its MIT notice.
206
223
 
207
224
  With ATDD Bun, enable the Workflow profile:
208
225
 
@@ -1,18 +1,54 @@
1
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";
2
+ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { parse, stringify } from "yaml";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
6
7
 
7
- type Thread = { participants?: unknown; subject?: unknown };
8
- type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown };
8
+ type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
9
+ export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
10
+ type InboxMail = Mail & { id: string; created_at: string };
11
+ type PendingReference = { schema?: unknown; thread?: unknown; message?: unknown; created_at?: unknown };
12
+ type PendingSegment = { schema?: unknown; entries?: unknown; next?: unknown };
13
+ type PendingQueue = { schema?: unknown; head?: unknown; tail?: unknown };
14
+ type WakeAction = () => void | Promise<void>;
15
+ type SendIfOwned = (action: WakeAction) => Promise<boolean>;
16
+ type InboxOptions = {
17
+ root: string;
18
+ seat: string;
19
+ /** Return false when this runtime has been replaced; the durable reference remains queued. */
20
+ deliver: (mail: InboxMail, thread: Thread, path: string, sendIfOwned?: SendIfOwned) => boolean | void | Promise<boolean | void>;
21
+ batchSize?: number;
22
+ intervalMs?: number;
23
+ };
9
24
 
10
- const finalMail = /^M-[^.]+\.yaml$/;
25
+ /** The sole internal delivery transport: one durable queue with watcher-as-fast-path recovery. */
26
+ export type FlowInboxTransport = {
27
+ reconcile: () => Promise<void>;
28
+ start: () => Promise<void>;
29
+ stop: () => void;
30
+ watermarkFile: string;
31
+ };
32
+
33
+ export type PiRuntimeOptions = {
34
+ root: string;
35
+ seat: string;
36
+ pid?: number;
37
+ model?: string;
38
+ cwd?: string;
39
+ heartbeatMs?: number;
40
+ staleAfterMs?: number;
41
+ deliver: InboxOptions["deliver"];
42
+ createTransport?: (options: InboxOptions) => FlowInboxTransport;
43
+ };
44
+
45
+ const defaultBatchSize = 32;
46
+ const defaultIntervalMs = 30_000;
47
+ const defaultHeartbeatMs = 15_000;
48
+ const defaultStaleAfterMs = 60_000;
11
49
 
12
50
  function participants(thread: Thread) {
13
- return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string")
14
- ? thread.participants as string[]
15
- : [];
51
+ return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
16
52
  }
17
53
 
18
54
  export function addressedTo(mail: Mail, thread: Thread, seat: string) {
@@ -30,91 +66,226 @@ export function mailNotice(threadId: string, mail: Mail) {
30
66
  return `SYSTEM: Flow mail ${id} | thread ${threadId}${subject ? ` (${subject})` : ""} | ${from} → ${recipients}. Read: atdd-flow message read ${id}`;
31
67
  }
32
68
 
69
+ function inboxDirectory(root: string, seat: string) {
70
+ return join(root, ".atdd-flow", "pi-inbox", encodeURIComponent(seat));
71
+ }
72
+
73
+ function pendingDirectory(root: string, seat: string) { return join(inboxDirectory(root, seat), "pending"); }
74
+ function queuePath(root: string, seat: string) { return join(inboxDirectory(root, seat), "queue.yaml"); }
75
+ function segmentPath(root: string, seat: string, segment: string) { return join(pendingDirectory(root, seat), `${segment}.yaml`); }
76
+
77
+ async function atomicYaml(path: string, value: unknown) {
78
+ await mkdir(dirname(path), { recursive: true });
79
+ const temporary = join(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
80
+ await writeFile(temporary, stringify(value), "utf8");
81
+ await rename(temporary, path);
82
+ }
83
+
84
+ const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
85
+
86
+ async function withInboxLock<T>(directory: string, action: () => Promise<T>) {
87
+ await mkdir(directory, { recursive: true });
88
+ const lock = join(directory, "queue.lock");
89
+ for (let attempt = 0; attempt < 200; attempt += 1) {
90
+ try {
91
+ await mkdir(lock);
92
+ try { return await action(); }
93
+ finally { await rm(lock, { recursive: true, force: true }); }
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
96
+ try {
97
+ if (Date.now() - (await stat(lock)).mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
98
+ else await pause(5);
99
+ } catch { await pause(5); }
100
+ }
101
+ }
102
+ throw new Error(`Timed out waiting for inbox queue lock ${directory}`);
103
+ }
104
+
105
+ function validMail(value: Mail): value is InboxMail {
106
+ return typeof value.id === "string" && typeof value.created_at === "string";
107
+ }
108
+
109
+ function validReference(value: PendingReference): value is Required<Pick<PendingReference, "thread" | "message" | "created_at">> {
110
+ return typeof value.thread === "string" && typeof value.message === "string" && typeof value.created_at === "string";
111
+ }
112
+
113
+ function validQueue(value: PendingQueue): value is Required<Pick<PendingQueue, "head">> {
114
+ return typeof value.head === "string";
115
+ }
116
+
117
+ function references(value: PendingSegment): PendingReference[] {
118
+ return Array.isArray(value.entries) ? value.entries.filter((entry): entry is PendingReference => Boolean(entry) && typeof entry === "object") : [];
119
+ }
120
+
33
121
  /**
34
- * A Pi-native wake adapter. The Desk remains authoritative: this extension
35
- * only observes final immutable mail files and tells Pi where to read them.
122
+ * Reconciles a bounded, durable queue of immutable Desk-message references.
123
+ * The queue is appended when Flow persists mail, so recovery never rescans historical threads.
36
124
  */
37
- export default function (pi: ExtensionAPI) {
38
- const root = process.env.ATDD_WORKFLOW_ROOT;
39
- const seat = process.env.ATDD_WORKFLOW_SEAT;
40
- if (!root || !seat) return;
41
-
42
- const threads = join(root, "threads");
125
+ export function createInboxTransport({ root, seat, deliver, batchSize = defaultBatchSize, intervalMs = defaultIntervalMs }: InboxOptions): FlowInboxTransport {
126
+ let watcher: fs.FSWatcher | undefined;
127
+ let interval: ReturnType<typeof setInterval> | undefined;
43
128
  const delivered = new Set<string>();
44
- const pending = new Set<string>();
45
- const threadWatchers = new Map<string, fs.FSWatcher>();
46
- let threadsWatcher: fs.FSWatcher | undefined;
47
-
48
- const deliver = (threadId: string, fileName: string) => {
49
- const key = `${threadId}/${fileName}`;
50
- if (delivered.has(key) || pending.has(key)) return;
51
- pending.add(key);
52
- setTimeout(async () => {
129
+ let serial = Promise.resolve();
130
+
131
+ const reconcileNow = async () => withInboxLock(inboxDirectory(root, seat), async () => {
132
+ let queue: PendingQueue;
133
+ try { queue = parse(await readFile(queuePath(root, seat), "utf8")) as PendingQueue; }
134
+ catch { return; }
135
+ if (!validQueue(queue)) return;
136
+ const path = segmentPath(root, seat, queue.head);
137
+ let segment: PendingSegment;
138
+ try { segment = parse(await readFile(path, "utf8")) as PendingSegment; }
139
+ catch { return; }
140
+ const entries = references(segment);
141
+ // A segment contains at most 32 references. A tick reads one durable head segment and at most batchSize mail records.
142
+ for (let count = 0; count < batchSize && entries.length; count += 1) {
143
+ const reference = entries[0];
144
+ if (!validReference(reference)) { entries.shift(); await atomicYaml(path, { ...segment, entries }); continue; }
145
+ const key = `${reference.thread}/${reference.message}`;
53
146
  try {
54
- const folder = join(threads, threadId);
147
+ const folder = join(root, "threads", reference.thread);
55
148
  const [rawThread, rawMail] = await Promise.all([
56
149
  readFile(join(folder, "thread.yaml"), "utf8"),
57
- readFile(join(folder, fileName), "utf8"),
150
+ readFile(join(folder, `${reference.message}.yaml`), "utf8"),
58
151
  ]);
59
152
  const thread = parse(rawThread) as Thread;
60
153
  const mail = parse(rawMail) as Mail;
61
- if (!addressedTo(mail, thread, seat)) return;
62
- const id = typeof mail.id === "string" ? mail.id : fileName.slice(0, -5);
63
- const file = join(folder, fileName);
154
+ if (validMail(mail) && addressedTo(mail, thread, seat) && !delivered.has(key)) {
155
+ const accepted = await deliver(mail, thread, join(folder, `${reference.message}.yaml`));
156
+ // A replaced runtime must not consume the shared durable reference.
157
+ if (accepted === false) return;
158
+ }
64
159
  delivered.add(key);
65
- pi.sendMessage({
66
- customType: "atdd-flow-mail",
67
- content: mailNotice(threadId, { ...mail, id, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
68
- display: true,
69
- details: { thread: threadId, message: id, path: file },
70
- }, { triggerTurn: true, deliverAs: "followUp" });
71
- } catch {
72
- // A later filesystem event can retry an atomic-write race.
73
- } finally {
74
- pending.delete(key);
160
+ entries.shift();
161
+ await atomicYaml(path, { ...segment, entries });
162
+ } catch (error) {
163
+ // A crash before message persistence leaves an unpublished prepare record. Retain it briefly for an in-flight post, then discard only the non-authoritative orphan.
164
+ const age = Date.now() - Date.parse(reference.created_at);
165
+ if (reference.published !== true && (error as NodeJS.ErrnoException).code === "ENOENT") {
166
+ entries.shift();
167
+ if (Number.isFinite(age) && age > 30_000) {
168
+ await atomicYaml(path, { ...segment, entries });
169
+ continue;
170
+ }
171
+ // A fresh pre-message intent is not mail. Move it behind ready entries so a crashed writer never head-of-line blocks durable Desk mail.
172
+ entries.push(reference);
173
+ await atomicYaml(path, { ...segment, entries });
174
+ }
175
+ // Keep authoritative references until Pi accepts them; a fresh intent is retried on the next bounded tick.
176
+ return;
75
177
  }
76
- }, 25);
77
- };
78
-
79
- const watchThread = (threadId: string) => {
80
- if (threadWatchers.has(threadId)) return;
81
- try {
82
- const watcher = fs.watch(join(threads, threadId), (_event, file) => {
83
- const name = file?.toString();
84
- if (name && finalMail.test(name)) deliver(threadId, name);
85
- });
86
- threadWatchers.set(threadId, watcher);
87
- } catch {
88
- // A concurrent thread deletion is harmless.
89
178
  }
179
+ if (entries.length) return;
180
+ const next = typeof segment.next === "string" ? segment.next : undefined;
181
+ await unlink(path);
182
+ if (next) {
183
+ await atomicYaml(queuePath(root, seat), { ...queue, head: next });
184
+ } else {
185
+ await unlink(queuePath(root, seat));
186
+ }
187
+ });
188
+
189
+ const reconcile = () => {
190
+ const scheduled = serial.then(reconcileNow, reconcileNow);
191
+ serial = scheduled.catch(() => undefined);
192
+ return scheduled;
193
+ };
194
+ const start = async () => {
195
+ await reconcile();
196
+ try { watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); }); }
197
+ catch { /* Periodic reconciliation recovers unavailable watchers. */ }
198
+ interval = setInterval(() => { void reconcile(); }, intervalMs);
199
+ };
200
+ const stop = () => {
201
+ watcher?.close();
202
+ watcher = undefined;
203
+ if (interval) clearInterval(interval);
204
+ interval = undefined;
90
205
  };
206
+ return { reconcile, start, stop, watermarkFile: pendingDirectory(root, seat) };
207
+ }
91
208
 
92
- const discoverThreads = async () => {
93
- try {
94
- for (const entry of await readdir(threads, { withFileTypes: true })) {
95
- if (entry.isDirectory() && entry.name.startsWith("T-")) watchThread(entry.name);
96
- }
97
- } catch {
98
- // The Desk may be created after Pi starts.
99
- }
209
+ /** Backward-compatible name for the one internal FlowInboxTransport implementation. */
210
+ export const createInboxReconciler = createInboxTransport;
211
+
212
+ /**
213
+ * Binds a Pi process lifecycle to one non-authoritative, token-fenced runtime
214
+ * observation. Losing the token stops this instance from consuming mail; the
215
+ * single durable inbox transport remains the only delivery path.
216
+ */
217
+ export function createPiRuntime({
218
+ root, seat, pid = process.pid, model = process.env.PI_MODEL ?? "pi", cwd = process.cwd(),
219
+ heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport,
220
+ }: PiRuntimeOptions) {
221
+ const ownerToken = runtimeOwnerToken();
222
+ let transport: FlowInboxTransport | undefined;
223
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
224
+
225
+ const owned = () => ownsRuntimeState(root, seat, ownerToken);
226
+ const stop = async () => {
227
+ if (heartbeat) clearInterval(heartbeat);
228
+ heartbeat = undefined;
229
+ transport?.stop();
230
+ transport = undefined;
231
+ await clearRuntimeState(root, seat, ownerToken);
232
+ };
233
+ const reconcile = async () => {
234
+ if (!transport || !await owned()) return;
235
+ await transport.reconcile();
236
+ };
237
+ const start = async () => {
238
+ // This removes only an expired local observation. Registration itself is
239
+ // replacement-safe and does not alter the authoritative Desk seat.
240
+ await clearStaleRuntimeState(root, seat, staleAfterMs);
241
+ await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
242
+ transport = createTransport({
243
+ root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
244
+ deliver: async (mail, thread, path) => {
245
+ // The application supplies the wake action, but this shared filesystem
246
+ // lock performs the final ownership check immediately around it.
247
+ let ownership: boolean | undefined;
248
+ const accepted = await deliver(mail, thread, path, async (wake) => {
249
+ if (ownership !== undefined) return false;
250
+ const result = await withOwnedRuntimeState(root, seat, ownerToken, wake);
251
+ ownership = result.owned;
252
+ return result.owned;
253
+ });
254
+ // A callback cannot accidentally consume mail by ignoring a rejected
255
+ // authorization result or by attempting more than one wake action.
256
+ return ownership === true && accepted !== false;
257
+ },
258
+ });
259
+ await transport.start();
260
+ heartbeat = setInterval(() => {
261
+ void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
262
+ if (!state) void stop();
263
+ });
264
+ }, heartbeatMs);
100
265
  };
266
+ return { ownerToken, start, stop, reconcile, get transport() { return transport; } };
267
+ }
101
268
 
269
+ /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
270
+ export default function (pi: ExtensionAPI) {
271
+ const root = process.env.ATDD_WORKFLOW_ROOT;
272
+ const seat = process.env.ATDD_WORKFLOW_SEAT;
273
+ if (!root || !seat) return;
274
+ let runtime: ReturnType<typeof createPiRuntime> | undefined;
102
275
  pi.on("session_start", async (_event, ctx) => {
103
- await discoverThreads();
104
- try { threadsWatcher = fs.watch(threads, () => { void discoverThreads(); }); }
105
- catch { /* The startup prompt still makes the missing Desk visible. */ }
106
- pi.sendMessage({
107
- customType: "atdd-flow-start",
108
- 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.`,
109
- display: true,
110
- details: { seat, root },
111
- }, { triggerTurn: true, deliverAs: "followUp" });
276
+ runtime = createPiRuntime({ root, seat, deliver: async (mail, thread, path, sendIfOwned) => {
277
+ if (!sendIfOwned) return false;
278
+ return sendIfOwned(() => {
279
+ pi.sendMessage({
280
+ customType: "atdd-flow-mail",
281
+ content: mailNotice(typeof thread.id === "string" ? thread.id : "unknown", { ...mail, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
282
+ display: true, details: { thread: thread.id, message: mail.id, path },
283
+ }, { triggerTurn: true, deliverAs: "followUp" });
284
+ });
285
+ } });
286
+ await runtime.start();
287
+ pi.sendMessage({ customType: "atdd-flow-start", 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.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
112
288
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
113
289
  });
114
-
115
- pi.on("session_shutdown", () => {
116
- threadsWatcher?.close();
117
- for (const watcher of threadWatchers.values()) watcher.close();
118
- threadWatchers.clear();
119
- });
290
+ pi.on("session_shutdown", () => { void runtime?.stop(); });
120
291
  }
@@ -0,0 +1,17 @@
1
+ schema: atdd-workflow/multiplexer/herdr/v1
2
+ projection:
3
+ primary:
4
+ worktree: primary
5
+ workspace_label: "{project}"
6
+ seats:
7
+ roles: [main, coordinator]
8
+ tab_label: "{seat.address}"
9
+ pane_label: "{seat.address}"
10
+ linked_worktree:
11
+ worktree: linked
12
+ seats:
13
+ coordinator: always
14
+ driver: active_or_assigned
15
+ workspace_label: "{seat.address}"
16
+ tab_label: "{seat.address}"
17
+ pane_label: "{seat.address}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,8 @@
17
17
  "extensions",
18
18
  "src",
19
19
  "README.md",
20
- "LICENSE"
20
+ "LICENSE",
21
+ "multiplexer"
21
22
  ],
22
23
  "keywords": [
23
24
  "atdd",
@@ -40,7 +41,7 @@
40
41
  "bun": ">=1.3.0"
41
42
  },
42
43
  "devDependencies": {
43
- "@afokapu/atdd-bun": "^0.10.44"
44
+ "@afokapu/atdd-bun": "^0.10.50"
44
45
  },
45
46
  "dependencies": {
46
47
  "@typesafe-ai/sdk": "^0.6.0",
package/src/adapters.ts CHANGED
@@ -17,13 +17,17 @@ export function discoverAddress(application: string, environment: Environment =
17
17
  const discoverers: Record<string, () => string> = {
18
18
  herdr: () => value(environment, "HERDR_PANE_ID", "Herdr"),
19
19
  tmux: () => value(environment, "TMUX_PANE", "tmux"),
20
- tuios: () => `${value(environment, "TUIOS_SESSION", "TUIOS")}/${value(environment, "TUIOS_WINDOW_ID", "TUIOS")}`,
21
20
  };
22
21
  const discover = discoverers[application];
23
22
  if (!discover) throw new Error(`No deterministic discovery adapter is installed for ${application}.`);
24
23
  return discover();
25
24
  }
26
25
 
26
+ /** Herdr pane ids are only unique inside a session, so new bindings record both. */
27
+ export function discoverHerdrLocator(environment: Environment = process.env) {
28
+ return { session: value(environment, "HERDR_SESSION", "Herdr"), pane: value(environment, "HERDR_PANE_ID", "Herdr") };
29
+ }
30
+
27
31
  export function notificationCommand(application: string, address: string, notice: string, herdrSession?: string): string[] | undefined {
28
32
  const commands: Record<string, () => string[] | undefined> = {
29
33
  tmux: () => ["tmux", "send-keys", "-t", address, notice, "Enter"],
@@ -32,11 +36,6 @@ export function notificationCommand(application: string, address: string, notice
32
36
  // pane text injection: a Desk message remains durable when an older host
33
37
  // cannot provide agent.prompt.
34
38
  herdr: () => ["herdr", ...(herdrSession ? ["--session", herdrSession] : []), "agent", "prompt", address, notice],
35
- tuios: () => {
36
- const separator = address.indexOf("/");
37
- if (separator < 1 || separator === address.length - 1) return undefined;
38
- return ["tuios", "queue", "-s", address.slice(0, separator), "-w", address.slice(separator + 1), notice];
39
- },
40
39
  };
41
40
  return commands[application]?.();
42
41
  }
@@ -53,33 +52,13 @@ export type LaunchRequest = {
53
52
  seat: string;
54
53
  };
55
54
 
56
- /** A launch placement names a host container, never the currently visible UI. */
57
- export function launchCommand(request: LaunchRequest): string[] {
58
- const environment = [
59
- "/usr/bin/env",
60
- `ATDD_WORKFLOW_ROOT=${request.root}`,
61
- `ATDD_WORKFLOW_SEAT=${request.seat}`,
62
- ...Object.entries(request.environment ?? {}).map(([key, value]) => `${key}=${value}`),
63
- request.agent,
64
- ...(request.args ?? []),
65
- ];
66
- if (request.application === "tuios") {
67
- return [
68
- "tuios", "new-window", request.name,
69
- "-s", request.placement,
70
- "--cwd", request.worktree,
71
- "--no-focus", "--print-id", "--",
72
- ...environment,
73
- ];
74
- }
75
- throw new Error(`No deterministic launch adapter is installed for ${request.application}.`);
55
+ /** Flow no longer owns host-pane creation; attach a host-created pane instead. */
56
+ export function launchCommand(_request: LaunchRequest): string[] {
57
+ throw new Error("No deterministic launch adapter is installed.");
76
58
  }
77
59
 
78
- export function launchedAddress(application: string, placement: string, output: string) {
79
- if (application !== "tuios") throw new Error(`No deterministic launch adapter is installed for ${application}.`);
80
- const window = output.split(/\s+/)[0];
81
- if (!window) throw new Error("TUIOS created a window without returning its id.");
82
- return `${placement}/${window}`;
60
+ export function launchedAddress(_application: string, _placement: string, _output: string): string {
61
+ throw new Error("No deterministic launch adapter is installed.");
83
62
  }
84
63
 
85
64
  export async function notify(application: string, address: string, notice: string, herdrSession?: string) {