@afokapu/atdd-flow 0.3.14 → 0.3.15

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.
@@ -4,6 +4,7 @@ import { basename, dirname, join } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
+ import { paths, readYaml, type Seat } from "../../src/core";
7
8
 
8
9
  type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
9
10
  export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
@@ -40,12 +41,27 @@ export type PiRuntimeOptions = {
40
41
  staleAfterMs?: number;
41
42
  deliver: InboxOptions["deliver"];
42
43
  createTransport?: (options: InboxOptions) => FlowInboxTransport;
44
+ /** Flow-launched Pi instances register and heartbeat before their verified launch activates inbox delivery. */
45
+ deferActivation?: boolean;
46
+ };
47
+
48
+ export type LaunchActivationOptions = {
49
+ root: string;
50
+ seat: string;
51
+ piSession: string;
52
+ herdrSession: string;
53
+ pane: string;
54
+ activate: () => Promise<void>;
55
+ intervalMs?: number;
56
+ timeoutMs?: number;
43
57
  };
44
58
 
45
59
  const defaultBatchSize = 32;
46
60
  const defaultIntervalMs = 30_000;
47
61
  const defaultHeartbeatMs = 15_000;
48
62
  const defaultStaleAfterMs = 60_000;
63
+ const defaultActivationIntervalMs = 25;
64
+ const defaultActivationTimeoutMs = 60_000;
49
65
 
50
66
  function participants(thread: Thread) {
51
67
  return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
@@ -216,11 +232,12 @@ export const createInboxReconciler = createInboxTransport;
216
232
  */
217
233
  export function createPiRuntime({
218
234
  root, seat, pid = process.pid, model = process.env.PI_MODEL ?? "pi", cwd = process.cwd(),
219
- heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport,
235
+ heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport, deferActivation = false,
220
236
  }: PiRuntimeOptions) {
221
237
  const ownerToken = runtimeOwnerToken();
222
238
  let transport: FlowInboxTransport | undefined;
223
239
  let heartbeat: ReturnType<typeof setInterval> | undefined;
240
+ let activated = false;
224
241
 
225
242
  const owned = () => ownsRuntimeState(root, seat, ownerToken);
226
243
  const stop = async () => {
@@ -234,11 +251,9 @@ export function createPiRuntime({
234
251
  if (!transport || !await owned()) return;
235
252
  await transport.reconcile();
236
253
  };
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 });
254
+ const activate = async () => {
255
+ if (!await owned()) return false;
256
+ if (activated) return true;
242
257
  transport = createTransport({
243
258
  root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
244
259
  deliver: async (mail, thread, path) => {
@@ -257,13 +272,47 @@ export function createPiRuntime({
257
272
  },
258
273
  });
259
274
  await transport.start();
275
+ activated = true;
276
+ return true;
277
+ };
278
+ const start = async () => {
279
+ // This removes only an expired local observation. Registration itself is
280
+ // replacement-safe and does not alter the authoritative Desk seat.
281
+ await clearStaleRuntimeState(root, seat, staleAfterMs);
282
+ await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
260
283
  heartbeat = setInterval(() => {
261
284
  void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
262
285
  if (!state) void stop();
263
286
  });
264
287
  }, heartbeatMs);
288
+ if (!deferActivation) await activate();
265
289
  };
266
- return { ownerToken, start, stop, reconcile, get transport() { return transport; } };
290
+ return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; } };
291
+ }
292
+
293
+ type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
294
+
295
+ /** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
296
+ export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
297
+ const deadline = Date.now() + timeoutMs;
298
+ while (Date.now() <= deadline) {
299
+ try {
300
+ const record = await readYaml<Seat>(paths(root).seatFile(seat));
301
+ const runtime = record.runtime;
302
+ const locator = runtime?.addresses.herdr;
303
+ if (runtime?.application === "herdr" && typeof locator !== "string" && locator?.session === herdrSession && locator.pane === pane
304
+ && runtime.pi_session === piSession && runtime.pi_session_path && runtime.launch_receipt) {
305
+ const receipt = await readYaml<LaunchReceipt>(runtime.launch_receipt);
306
+ if (receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
307
+ && receipt.herdr_session === herdrSession && receipt.pane === pane) {
308
+ await activate();
309
+ return;
310
+ }
311
+ }
312
+ } catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
313
+ await pause(intervalMs);
314
+ }
315
+ throw new Error("Timed out waiting for verified Flow launch receipt and binding.");
267
316
  }
268
317
 
269
318
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
@@ -273,7 +322,7 @@ export default function (pi: ExtensionAPI) {
273
322
  if (!root || !seat) return;
274
323
  let runtime: ReturnType<typeof createPiRuntime> | undefined;
275
324
  pi.on("session_start", async (_event, ctx) => {
276
- runtime = createPiRuntime({ root, seat, deliver: async (mail, thread, path, sendIfOwned) => {
325
+ runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
277
326
  if (!sendIfOwned) return false;
278
327
  return sendIfOwned(() => {
279
328
  pi.sendMessage({
@@ -284,8 +333,19 @@ export default function (pi: ExtensionAPI) {
284
333
  });
285
334
  } });
286
335
  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" });
288
- if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
336
+ const piSession = process.env.ATDD_FLOW_PI_SESSION;
337
+ const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
338
+ const pane = process.env.ATDD_FLOW_HERDR_PANE;
339
+ if (!piSession || !herdrSession || !pane) return;
340
+ const current = runtime;
341
+ void awaitLaunchActivation({
342
+ root, seat, piSession, herdrSession, pane,
343
+ activate: async () => {
344
+ if (!await current.activate()) return;
345
+ 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" });
346
+ if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
347
+ },
348
+ }).catch(() => undefined);
289
349
  });
290
350
  pi.on("session_shutdown", () => { void runtime?.stop(); });
291
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -25,7 +25,7 @@ Usage:
25
25
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
26
26
  atdd-flow attach <address> [--application <application>] [--wake host|native]
27
27
  atdd-flow pi extension-path
28
- atdd-flow pi runtime launch <address> --pane <existing-herdr-pane> --herdr-session <session> [--resume] [--dry-run]
28
+ atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run]
29
29
  atdd-flow application use <address> <application>
30
30
  atdd-flow multiplexer status|apply herdr [--session <name>]
31
31
  atdd-flow describe <address> --purpose <one-line responsibility>
@@ -92,7 +92,7 @@ async function main() {
92
92
  else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
93
93
  return;
94
94
  }
95
- throw new Error("Use `pi extension-path` or `pi runtime launch <address> --pane <pane> --herdr-session <session> [--resume] [--dry-run]`.");
95
+ throw new Error("Use `pi extension-path` or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run]`.");
96
96
  },
97
97
  application: async () => {
98
98
  if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
@@ -28,6 +28,17 @@ type Workspace = { workspace_id: string; label?: string; worktree?: { checkout_p
28
28
  type Tab = { tab_id: string; workspace_id: string; label?: string };
29
29
  type Pane = { pane_id: string; tab_id: string; label?: string };
30
30
  type Target = { project: string; worktree: string; workspaceLabel: string; seat?: Seat };
31
+ type WorkspaceState = { id: string; label: string; worktree: string; rootTab?: string; rootPane?: string };
32
+ type HerdrCommand = (command: string[]) => Promise<string>;
33
+
34
+ export type HerdrSeatProjection = {
35
+ session: string;
36
+ workspace: string;
37
+ tab: string;
38
+ pane: string;
39
+ worktree: string;
40
+ label: string;
41
+ };
31
42
 
32
43
  const policyFile = () => join(import.meta.dir, "..", "multiplexer", "herdr.yaml");
33
44
  const selectedSession = (args: string[], environment: Record<string, string | undefined> = process.env) => {
@@ -87,11 +98,8 @@ async function targets(root: string): Promise<Target[]> {
87
98
  let config: Project;
88
99
  try { config = await project(root, name); }
89
100
  catch { continue; }
90
- // A repository is the Desk's explicit primary-checkout declaration. Do not infer one.
91
101
  if (!config.repository) continue;
92
102
  const primary = resolve(config.repository);
93
- // The primary checkout is explicitly declared by the project even if no
94
- // primary-worktree seat currently occupies it.
95
103
  result.push({ project: name, worktree: primary, workspaceLabel: name });
96
104
  for (const entry of await projectSeats(root, name)) {
97
105
  if ((entry.role === "main" || entry.role === "coordinator") && resolve(entry.worktree) === primary) {
@@ -111,68 +119,110 @@ async function targets(root: string): Promise<Target[]> {
111
119
  return result;
112
120
  }
113
121
 
114
- function command(session: string, args: string[]) {
115
- return runOutput(["herdr", "--session", session, ...args]);
116
- }
117
-
122
+ const herdr: HerdrCommand = (args) => runOutput(args);
118
123
  function json(output: string): any {
119
124
  try { return JSON.parse(output).result; }
120
125
  catch { throw new Error("Herdr returned invalid JSON; projection did not guess a target."); }
121
126
  }
122
-
123
127
  function workspaceId(value: any) {
124
128
  const found = value?.workspace?.workspace_id ?? value?.workspace_id;
125
129
  if (typeof found !== "string") throw new Error("Herdr did not return a workspace id.");
126
130
  return found;
127
131
  }
128
-
132
+ function tabId(value: any) {
133
+ const found = value?.root_tab?.tab_id ?? value?.tab?.tab_id;
134
+ return typeof found === "string" ? found : undefined;
135
+ }
129
136
  function paneId(value: any) {
130
137
  const found = value?.root_pane?.pane_id ?? value?.pane?.pane_id;
131
- if (typeof found !== "string") throw new Error("Herdr did not return a pane id.");
132
- return found;
138
+ return typeof found === "string" ? found : undefined;
133
139
  }
134
-
135
- async function liveWorkspaces(session: string) {
136
- const value = json(await command(session, ["workspace", "list"]));
140
+ async function liveWorkspaces(session: string, command: HerdrCommand) {
141
+ const value = json(await command(["herdr", "--session", session, "workspace", "list"]));
137
142
  if (!Array.isArray(value?.workspaces)) throw new Error("Herdr workspace list response is invalid.");
138
143
  return value.workspaces as Workspace[];
139
144
  }
140
145
 
141
- async function ensureWorkspace(session: string, target: Target, workspaces: Workspace[]) {
146
+ async function ensureWorkspace(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary?: WorkspaceState) {
142
147
  let current = workspaces.find((entry) => resolve(entry.worktree?.checkout_path ?? "") === target.worktree);
143
148
  if (!current) {
144
- const created = target.workspaceLabel === target.project
145
- ? json(await command(session, ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]))
146
- : json(await command(session, ["worktree", "open", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"]));
149
+ const args = target.workspaceLabel === target.project
150
+ ? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
151
+ : ["worktree", "open", "--workspace", primary?.id ?? "", "--cwd", primary?.worktree ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
152
+ if (args.includes("")) throw new Error(`Project primary workspace is required to open linked worktree ${target.worktree}.`);
153
+ const created = json(await command(["herdr", "--session", session, ...args]));
147
154
  current = { workspace_id: workspaceId(created), label: target.workspaceLabel, worktree: { checkout_path: target.worktree } };
148
155
  workspaces.push(current);
149
- } else if (current.label !== target.workspaceLabel) {
150
- await command(session, ["workspace", "rename", current.workspace_id, target.workspaceLabel]);
156
+ return { id: current.workspace_id, label: target.workspaceLabel, worktree: target.worktree, ...(tabId(created) ? { rootTab: tabId(created) } : {}), ...(paneId(created) ? { rootPane: paneId(created) } : {}) };
157
+ }
158
+ if (current.label !== target.workspaceLabel) {
159
+ await command(["herdr", "--session", session, "workspace", "rename", current.workspace_id, target.workspaceLabel]);
151
160
  current.label = target.workspaceLabel;
152
161
  }
153
- return current.workspace_id;
162
+ return { id: current.workspace_id, label: target.workspaceLabel, worktree: target.worktree };
154
163
  }
155
164
 
156
- async function ensureSeatTab(session: string, workspace: string, target: Target & { seat: Seat }) {
157
- const tabsResult = json(await command(session, ["tab", "list", "--workspace", workspace]));
158
- const tabs = tabsResult?.tabs as Tab[];
159
- if (!Array.isArray(tabs)) throw new Error("Herdr tab list response is invalid.");
160
- let tab = tabs.find((entry) => entry.label === target.seat.address);
165
+ async function tabs(session: string, workspace: string, command: HerdrCommand) {
166
+ const result = json(await command(["herdr", "--session", session, "tab", "list", "--workspace", workspace]));
167
+ if (!Array.isArray(result?.tabs)) throw new Error("Herdr tab list response is invalid.");
168
+ return result.tabs as Tab[];
169
+ }
170
+ async function panes(session: string, workspace: string, command: HerdrCommand) {
171
+ const result = json(await command(["herdr", "--session", session, "pane", "list", "--workspace", workspace]));
172
+ if (!Array.isArray(result?.panes)) throw new Error("Herdr pane list response is invalid.");
173
+ return result.panes as Pane[];
174
+ }
175
+
176
+ async function ensureSeatTab(session: string, workspace: WorkspaceState, target: Target & { seat: Seat }, command: HerdrCommand): Promise<HerdrSeatProjection> {
177
+ let entries = await tabs(session, workspace.id, command);
178
+ let tab = entries.find((entry) => entry.label === target.seat.address);
161
179
  let pane: string | undefined;
180
+ if (!tab && workspace.rootTab) {
181
+ tab = entries.find((entry) => entry.tab_id === workspace.rootTab) ?? { tab_id: workspace.rootTab, workspace_id: workspace.id };
182
+ await command(["herdr", "--session", session, "tab", "rename", tab.tab_id, target.seat.address]);
183
+ tab.label = target.seat.address;
184
+ }
162
185
  if (!tab) {
163
- const created = json(await command(session, ["tab", "create", "--workspace", workspace, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus"]));
164
- tab = { tab_id: created?.tab?.tab_id, workspace_id: workspace, label: target.seat.address };
186
+ const created = json(await command(["herdr", "--session", session, "tab", "create", "--workspace", workspace.id, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus"]));
187
+ const id = tabId(created);
188
+ if (!id) throw new Error("Herdr did not return a tab id.");
189
+ tab = { tab_id: id, workspace_id: workspace.id, label: target.seat.address };
165
190
  pane = paneId(created);
166
- if (!tab.tab_id) throw new Error("Herdr did not return a tab id.");
167
191
  }
168
- const panesResult = json(await command(session, ["pane", "list", "--workspace", workspace]));
169
- const panes = panesResult?.panes as Pane[];
170
- if (!Array.isArray(panes)) throw new Error("Herdr pane list response is invalid.");
171
- const labelled = panes.find((entry) => entry.tab_id === tab!.tab_id && entry.label === target.seat.address);
172
- if (labelled) return;
173
- const first = pane ?? panes.find((entry) => entry.tab_id === tab!.tab_id)?.pane_id;
192
+ const entriesPanes = await panes(session, workspace.id, command);
193
+ const labelled = entriesPanes.find((entry) => entry.tab_id === tab!.tab_id && entry.label === target.seat.address);
194
+ if (labelled) return { session, workspace: workspace.id, tab: tab.tab_id, pane: labelled.pane_id, worktree: target.worktree, label: target.seat.address };
195
+ const first = pane ?? workspace.rootPane ?? entriesPanes.find((entry) => entry.tab_id === tab!.tab_id)?.pane_id;
174
196
  if (!first) throw new Error(`Herdr tab ${tab.tab_id} has no pane to label.`);
175
- await command(session, ["pane", "rename", first, target.seat.address]);
197
+ await command(["herdr", "--session", session, "pane", "rename", first, target.seat.address]);
198
+ return { session, workspace: workspace.id, tab: tab.tab_id, pane: first, worktree: target.worktree, label: target.seat.address };
199
+ }
200
+
201
+ async function ensureTarget(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary: WorkspaceState) {
202
+ const workspace = target.workspaceLabel === target.project ? primary : await ensureWorkspace(session, target, workspaces, command, primary);
203
+ return target.seat ? ensureSeatTab(session, workspace, target as Target & { seat: Seat }, command) : undefined;
204
+ }
205
+
206
+ /** Reconciles exactly one Desk seat into a labeled Herdr workspace/tab/pane. */
207
+ export async function projectHerdrSeat(root: string, address: string, session: string, command: HerdrCommand = herdr): Promise<HerdrSeatProjection> {
208
+ await herdrPolicy();
209
+ const desired = await targets(root);
210
+ const target = desired.find((entry) => entry.seat?.address === address);
211
+ if (!target?.seat) throw new Error(`No active Desk projection target exists for ${address}.`);
212
+ const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
213
+ if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
214
+ const workspaces = await liveWorkspaces(session, command);
215
+ const primary = await ensureWorkspace(session, primaryTarget, workspaces, command);
216
+ return (await ensureTarget(session, target, workspaces, command, primary))!;
217
+ }
218
+
219
+ async function topologyCompliant(session: string, target: Target, workspace: Workspace, command: HerdrCommand) {
220
+ if (workspace.label !== target.workspaceLabel) return false;
221
+ if (!target.seat) return true;
222
+ const entries = await tabs(session, workspace.workspace_id, command);
223
+ const tab = entries.find((entry) => entry.label === target.seat!.address);
224
+ if (!tab) return false;
225
+ return (await panes(session, workspace.workspace_id, command)).some((entry) => entry.tab_id === tab.tab_id && entry.label === target.seat!.address);
176
226
  }
177
227
 
178
228
  export async function multiplexer(root: string, args: string[]) {
@@ -188,17 +238,27 @@ export async function multiplexer(root: string, args: string[]) {
188
238
  return;
189
239
  }
190
240
  const desired = await targets(root);
241
+ const command: HerdrCommand = herdr;
242
+ const workspaces = await liveWorkspaces(session, command);
191
243
  if (action === "status") {
192
- // Status deliberately has no reconciliation side effects.
193
- const workspaces = await liveWorkspaces(session);
194
244
  const present = desired.filter((target) => workspaces.some((entry) => resolve(entry.worktree?.checkout_path ?? "") === target.worktree));
195
- console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length }, null, 2));
245
+ const topology = await Promise.all(desired.map(async (target) => {
246
+ const workspace = workspaces.find((entry) => resolve(entry.worktree?.checkout_path ?? "") === target.worktree);
247
+ return workspace ? topologyCompliant(session, target, workspace, command) : false;
248
+ }));
249
+ console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length }, null, 2));
196
250
  return;
197
251
  }
198
- const workspaces = await liveWorkspaces(session);
252
+ const primary = new Map<string, WorkspaceState>();
199
253
  for (const target of desired) {
200
- const workspace = await ensureWorkspace(session, target, workspaces);
201
- if (target.seat) await ensureSeatTab(session, workspace, target);
254
+ let projectPrimary = primary.get(target.project);
255
+ if (!projectPrimary) {
256
+ const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
257
+ if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
258
+ projectPrimary = await ensureWorkspace(session, primaryTarget, workspaces, command);
259
+ primary.set(target.project, projectPrimary);
260
+ }
261
+ await ensureTarget(session, target, workspaces, command, projectPrimary);
202
262
  }
203
263
  console.log(`Projected ${desired.length} Desk seat worktree(s) into Herdr session ${session}.`);
204
264
  }
package/src/seats.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./core";
10
10
  import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
11
11
  import { seatTasks } from "./tasks";
12
+ import { projectHerdrSeat } from "./multiplexer";
12
13
 
13
14
  const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
14
15
  coordinator: {
@@ -271,17 +272,21 @@ export async function launchPiRuntime(root: string, address: string, args: strin
271
272
  const resolved = await canonicalAddress(root, address);
272
273
  const record = await seat(root, resolved);
273
274
  const config = await desk(root);
274
- const pane = required(words(args, "--pane"), "--pane");
275
+ const requestedPane = words(args, "--pane");
275
276
  const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
276
277
  const resume = args.includes("--resume");
277
278
  const dryRun = args.includes("--dry-run");
278
- if (args.some((argument) => !["--pane", pane, "--herdr-session", herdrSession, "--resume", "--dry-run"].includes(argument))) throw new Error("Use `pi runtime launch <seat> --pane <existing-pane> --herdr-session <session> [--resume] [--dry-run]`.");
279
+ const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : [])];
280
+ if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run]`.");
279
281
  const active = await launchEligibleTasks(root, record);
280
282
  if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
281
283
  const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
282
284
  const model = resolveModelCommand(config, selection.candidate);
283
285
  if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
284
286
  const command = dependencies.command ?? runOutput;
287
+ const projection = await projectHerdrSeat(root, resolved, herdrSession, command);
288
+ if (requestedPane && requestedPane !== projection.pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
289
+ const pane = projection.pane;
285
290
  const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
286
291
  const currentPane = paneReport(JSON.parse(paneRaw));
287
292
  const currentProcess = processReport(JSON.parse(processRaw));
@@ -301,7 +306,7 @@ export async function launchPiRuntime(root: string, address: string, args: strin
301
306
  const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume, args: [...model.args, "--extension", piExtensionPath()] };
302
307
  const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
303
308
  if (dryRun) return plan;
304
- await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)}`));
309
+ await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)} ATDD_FLOW_PI_SESSION=${shellQuote(piSession)} ATDD_FLOW_HERDR_SESSION=${shellQuote(herdrSession)} ATDD_FLOW_HERDR_PANE=${shellQuote(pane)}`));
305
310
  await command(startCommand(request));
306
311
  const [agentRaw, verifiedPaneRaw, verifiedProcessRaw] = await Promise.all([command(herdr(herdrSession, "agent", "get", agentName(resolved))), command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
307
312
  const agent = agentReport(JSON.parse(agentRaw));