allye-pi 1.6.1 → 1.7.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.
@@ -81,8 +81,9 @@ adaptive toolkit in the current session. Use `/allye-capabilities` to inspect
81
81
  Allye/MCP, filesystem, subagent, and Herdr capabilities.
82
82
 
83
83
  When `HERDR_ENV=1`, the optional `allye_herdr` tool provides bounded `detect`,
84
- `spawn`, `dispatch`, `wait`, and Allye-backed `collect` operations. Without it,
85
- Pi continues locally. Herdr and subagents are capabilities, not prerequisites.
84
+ `workspace`, `tab`, `spawn`, `dispatch`, `status`, `mark_intervened`, `wait`,
85
+ `collect`, and ownership-guarded `cleanup` operations. Without it, Pi continues
86
+ locally. Herdr and subagents are capabilities, not prerequisites.
86
87
  Tasks are recommended for meaningful, delegated, multi-step, or review-heavy
87
88
  work, but an explicitly approved no-task path is supported.
88
89
 
@@ -138,6 +139,8 @@ Manual `herdr agent wait` commands started outside this Pi session (for example
138
139
  with `nohup` or Bash) do not wake Pi and do not generate this notification.
139
140
 
140
141
  A real Herdr dispatch still requires `HERDR_ENV=1`, a compatible Herdr session,
141
- and an existing isolated worktree. Pi records workspace/tab/pane/agent ownership
142
- for safe teardown; it never adopts foreign panes, stops Herdr, closes panes,
143
- merges, pushes, or publishes without explicit authorization.
142
+ and an existing isolated worktree. Pi records execution IDs plus
143
+ workspace/tab/pane/agent ownership for safe teardown; it never adopts foreign
144
+ panes, stops Herdr, closes panes, merges, pushes, or publishes without explicit
145
+ authorization. Cleanup is only allowed after a settled and collected execution;
146
+ blocked, unknown, timed-out, interrupted, or manually assumed resources remain open.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "allye-pi",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Pi package adapter for the canonical Allye workflow",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { randomUUID } from "node:crypto";
6
7
  import { Type } from "typebox";
7
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
 
@@ -26,9 +27,9 @@ type StartupContext = {
26
27
  allyeUnavailable: boolean;
27
28
  teams: Array<{ id: string; name: string; prefix?: string }>;
28
29
  };
29
- type WaitRegistrar = (name: string, timeoutMs: number) => Record<string, unknown>;
30
+ type WaitRegistrar = (name: string, timeoutMs: number, execution?: DelegationExecution) => Record<string, unknown>;
30
31
  type WaitCanceller = (name: string) => void;
31
- export type WaitOutcome = "completed" | "timeout" | "error" | "aborted";
32
+ export type WaitOutcome = "completed" | "blocked" | "unknown" | "timeout" | "error" | "aborted";
32
33
  export type WaitExecutionResult = {
33
34
  code: number;
34
35
  killed: boolean;
@@ -36,6 +37,8 @@ export type WaitExecutionResult = {
36
37
  stderr: string;
37
38
  };
38
39
  export type WaitEvent = {
40
+ kind?: "settled" | "intervened";
41
+ executionId?: string;
39
42
  name: string;
40
43
  timeoutMs: number;
41
44
  outcome: WaitOutcome;
@@ -66,10 +69,37 @@ export type OwnedPane = {
66
69
  tabId: string;
67
70
  paneId: string;
68
71
  agentName?: string;
72
+ resourceId?: string;
73
+ };
74
+ export type OwnedResource = {
75
+ resourceId: string;
76
+ level: "pane" | "tab" | "workspace";
77
+ workspaceId: string;
78
+ tabId?: string;
79
+ paneId?: string;
80
+ createdAt: string;
81
+ };
82
+ export type DelegationExecution = {
83
+ executionId: string;
84
+ callerPaneId?: string;
85
+ workspaceId: string;
86
+ tabId: string;
87
+ paneId: string;
88
+ resourceLevel: "pane" | "tab" | "workspace";
89
+ resourceId?: string;
90
+ worktree: string;
91
+ agentName?: string;
92
+ status: "created" | "working" | "settled" | "blocked" | "unknown" | "intervened" | "cleaned";
93
+ runtimeState?: "working" | "idle" | "done" | "blocked" | "unknown";
94
+ manualIntervention: boolean;
95
+ createdAt: string;
96
+ settledAt?: string;
69
97
  };
70
98
  export type DelegationState = {
71
99
  panes: OwnedPane[];
100
+ resources: Map<string, OwnedResource>;
72
101
  waits: Set<string>;
102
+ executions: Map<string, DelegationExecution>;
73
103
  };
74
104
  const WAIT_EVENT_TYPE = "allye-herdr-wait";
75
105
  const RUNTIME_TOOL_NAME = "allye_herdr";
@@ -87,10 +117,17 @@ function limitText(text: string, maxChars: number): string {
87
117
  return text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n[context truncated]`;
88
118
  }
89
119
 
90
- export function describeCapabilities(env: NodeJS.ProcessEnv = process.env): AllyeCapabilities {
120
+ type McpBridgeRuntime = typeof globalThis & {
121
+ __piMcpAdapterActiveToolCaller?: ActiveMcpToolCaller | null;
122
+ };
123
+
124
+ export function describeCapabilities(
125
+ env: NodeJS.ProcessEnv = process.env,
126
+ mcpAvailable = Boolean((globalThis as McpBridgeRuntime).__piMcpAdapterActiveToolCaller),
127
+ ): AllyeCapabilities {
91
128
  return {
92
129
  piSession: true,
93
- allyeMcp: env.ALLYE_PI_MCP !== "0",
130
+ allyeMcp: env.ALLYE_PI_MCP !== "0" && mcpAvailable,
94
131
  filesystem: true,
95
132
  subagents: env.ALLYE_PI_SUBAGENTS === "1",
96
133
  herdr: env.HERDR_ENV === "1",
@@ -133,9 +170,7 @@ async function configuredMcpCall(toolName: string, args: Record<string, unknown>
133
170
  // the in-process runtime. There is no public callMcpTool export in the
134
171
  // supported package version, so do not dynamically import its TypeScript
135
172
  // source from node_modules. A missing bridge is a hard bootstrap failure.
136
- const globalBridge = (globalThis as typeof globalThis & {
137
- __piMcpAdapterActiveToolCaller?: ActiveMcpToolCaller | null;
138
- }).__piMcpAdapterActiveToolCaller;
173
+ const globalBridge = (globalThis as McpBridgeRuntime).__piMcpAdapterActiveToolCaller;
139
174
  if (!globalBridge) throw new Error("pi-mcp-adapter is not initialized; start the configured MCP adapter before using Allye");
140
175
  return globalBridge(MCP_SERVER, toolName, args);
141
176
  }
@@ -182,6 +217,11 @@ export function classifyWaitResult(result?: WaitExecutionResult, timeoutMs = 0,
182
217
  const errorText = error instanceof Error ? error.message : String(error ?? "");
183
218
  if (/abort/i.test(errorText)) return "aborted";
184
219
  if (/timeout|timed out/i.test(errorText) || Boolean(result?.killed && timeoutMs > 0)) return "timeout";
220
+ if (/blocked/i.test(errorText)) return "blocked";
221
+ if (/unknown/i.test(errorText)) return "unknown";
222
+ const runtimeState = result ? extractAgentLifecycleState(parseJson(result.stdout)) ?? extractAgentLifecycleState(parseJson(result.stderr)) : null;
223
+ if (runtimeState === "blocked") return "blocked";
224
+ if (runtimeState === "unknown") return "unknown";
185
225
  if (!result || result.code !== 0) return "error";
186
226
  return "completed";
187
227
  }
@@ -195,7 +235,8 @@ export function shouldEmitWaitEvent(shuttingDown: boolean, settledWaits: Set<str
195
235
  export function formatWaitEvent(event: WaitEvent): string {
196
236
  const output = event.stdout?.trim() || "(no stdout)";
197
237
  const error = event.error || event.stderr?.trim() || "(none)";
198
- return `Herdr wait settled for agent ${event.name}. Outcome: ${event.outcome}. `
238
+ const prefix = event.kind === "intervened" ? "Herdr agent intervention detected" : "Herdr wait settled";
239
+ return `${prefix} for agent ${event.name}. Outcome: ${event.outcome}. `
199
240
  + `This is delegation evidence only, not a completion verdict. `
200
241
  + `Use allye_herdr collect when a managed work item exists: read work_children for the story and search Allye memories for Review and Implementation evidence before declaring completion.\n\n`
201
242
  + `timeout_ms=${event.timeoutMs} code=${event.code ?? "n/a"} killed=${event.killed ?? false} timestamp=${event.timestamp}\n`
@@ -203,7 +244,7 @@ export function formatWaitEvent(event: WaitEvent): string {
203
244
  }
204
245
 
205
246
  export function waitEventNotificationLevel(event: WaitEvent): "info" | "warning" | "error" {
206
- if (event.outcome === "error") return "error";
247
+ if (event.outcome === "error" || event.outcome === "blocked" || event.outcome === "unknown") return "error";
207
248
  if (event.outcome === "timeout") return "warning";
208
249
  return "info";
209
250
  }
@@ -255,8 +296,8 @@ export function deliverWaitEvent(
255
296
 
256
297
  export function invalidStartupContext(message: string): StartupContext {
257
298
  return {
258
- text: `## Allye bootstrap blocked\n${message}\nDo not call team-scoped work_items or intelligence operations. Resolve Allye connectivity and explicitly select a team with allye_team action team_switch before continuing.`,
259
- teamSelectionRequired: true,
299
+ text: `## Allye optional context unavailable\n${message}\nDo not call team-scoped work_items or intelligence operations until Allye connectivity and team context are available. Continue with local, non-team-scoped work when appropriate.`,
300
+ teamSelectionRequired: false,
260
301
  allyeUnavailable: true,
261
302
  teams: [],
262
303
  };
@@ -266,7 +307,8 @@ export function canUseOwnedPane(state: DelegationState, paneId: string): boolean
266
307
  return state.panes.some((pane) => pane.paneId === paneId);
267
308
  }
268
309
 
269
- async function loadStartupContext(): Promise<StartupContext> {
310
+ async function loadStartupContext(allyeMcp = true): Promise<StartupContext> {
311
+ if (!allyeMcp) return invalidStartupContext("Allye/MCP is disabled for this Pi session.");
270
312
  const sections: string[] = [];
271
313
  try {
272
314
  const initialization = await callAllye("allye_initialize", {
@@ -406,9 +448,30 @@ export function activeToolsForCapabilities(activeTools: string[], capabilities:
406
448
  return [...next];
407
449
  }
408
450
 
451
+ export type AgentLifecycleState = "working" | "idle" | "done" | "blocked" | "unknown";
452
+
453
+ export function extractAgentLifecycleState(value: unknown): AgentLifecycleState | null {
454
+ if (!value || typeof value !== "object") return null;
455
+ if (Array.isArray(value)) {
456
+ for (const item of value) {
457
+ const state = extractAgentLifecycleState(item);
458
+ if (state) return state;
459
+ }
460
+ return null;
461
+ }
462
+ const object = value as Record<string, unknown>;
463
+ const raw = object.agent_status;
464
+ if (raw === "working" || raw === "idle" || raw === "done" || raw === "blocked" || raw === "unknown") return raw;
465
+ for (const child of Object.values(object)) {
466
+ const state = extractAgentLifecycleState(child);
467
+ if (state) return state;
468
+ }
469
+ return null;
470
+ }
471
+
409
472
  export function dispatchStateIsAcceptable(stateText: string): boolean {
410
- if (/blocked|unknown/i.test(stateText)) return false;
411
- return /working|idle|done/i.test(stateText);
473
+ const state = extractAgentLifecycleState(parseJson(stateText));
474
+ return state === "working" || state === "idle" || state === "done";
412
475
  }
413
476
 
414
477
  function requireHerdr(capabilities: AllyeCapabilities): void {
@@ -422,6 +485,23 @@ function requireAbsolutePath(value: unknown, name: string): string {
422
485
  return value;
423
486
  }
424
487
 
488
+ async function cleanupExecution(execution: DelegationExecution, ownership: DelegationState): Promise<unknown> {
489
+ if (execution.status === "working" || execution.status === "blocked" || execution.status === "unknown" || execution.manualIntervention) {
490
+ throw new Error(`execution ${execution.executionId} is not safe to clean up while status=${execution.status}`);
491
+ }
492
+ const resourceId = execution.resourceId;
493
+ if (!resourceId) return { cleaned: false, reason: "execution has no owned resource" };
494
+ const resource = ownership.resources.get(resourceId);
495
+ if (!resource) return { cleaned: false, reason: "owned resource is already absent" };
496
+ if (resource.level === "pane" && resource.paneId) await runHerdr(["pane", "close", resource.paneId]);
497
+ else if (resource.level === "tab" && resource.tabId) await runHerdr(["tab", "close", resource.tabId]);
498
+ else if (resource.level === "workspace") await runHerdr(["workspace", "close", resource.workspaceId]);
499
+ execution.status = "cleaned";
500
+ ownership.resources.delete(resourceId);
501
+ ownership.panes = ownership.panes.filter((pane) => pane.resourceId !== resourceId);
502
+ return { cleaned: true, resource };
503
+ }
504
+
425
505
  async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<string, unknown>, registerWait: WaitRegistrar, cancelWait: WaitCanceller, ownership: DelegationState): Promise<unknown> {
426
506
  const operation = params.operation;
427
507
  if (operation === "detect") {
@@ -436,6 +516,45 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
436
516
 
437
517
  requireHerdr(capabilities);
438
518
 
519
+ if (operation === "workspace") {
520
+ const cwd = requireAbsolutePath(params.cwd, "cwd");
521
+ if (typeof params.worktree === "string" && params.worktree !== cwd) throw new Error("workspace cwd must match the requested worktree; use worktree management before creating a workspace");
522
+ const label = typeof params.label === "string" && params.label.trim() ? params.label.trim() : `allye-${randomUUID().slice(0, 8)}`;
523
+ const focus = params.focus === true ? "--focus" : "--no-focus";
524
+ const resource = herdrResult(await runHerdr(["workspace", "create", "--cwd", cwd, "--label", label, focus]));
525
+ const record = resource as Record<string, unknown>;
526
+ const workspace = record.workspace as Record<string, unknown> | undefined;
527
+ const tab = record.tab as Record<string, unknown> | undefined;
528
+ const rootPane = record.root_pane as Record<string, unknown> | undefined;
529
+ const workspaceId = typeof workspace?.workspace_id === "string" ? workspace.workspace_id : "";
530
+ const tabId = typeof tab?.tab_id === "string" ? tab.tab_id : "";
531
+ const paneId = typeof rootPane?.pane_id === "string" ? rootPane.pane_id : "";
532
+ if (!workspaceId || !tabId || !paneId) throw new Error("Herdr workspace response did not include workspace, tab, and root pane identifiers");
533
+ ownership.resources.set(workspaceId, { resourceId: workspaceId, level: "workspace", workspaceId, tabId, paneId, createdAt: new Date().toISOString() });
534
+ ownership.panes.push({ workspaceId, tabId, paneId, resourceId: workspaceId });
535
+ return { created: true, level: "workspace", resource };
536
+ }
537
+
538
+ if (operation === "tab") {
539
+ const workspace = typeof params.workspaceId === "string" ? params.workspaceId : "";
540
+ const cwd = requireAbsolutePath(params.cwd, "cwd");
541
+ const label = typeof params.label === "string" && params.label.trim() ? params.label.trim() : `task-${randomUUID().slice(0, 8)}`;
542
+ const focus = params.focus === true ? "--focus" : "--no-focus";
543
+ const args = ["tab", "create", "--workspace", workspace, "--cwd", cwd, "--label", label, focus];
544
+ if (!workspace) throw new Error("tab creation requires workspaceId");
545
+ const resource = herdrResult(await runHerdr(args));
546
+ const record = resource as Record<string, unknown>;
547
+ const tab = record.tab as Record<string, unknown> | undefined;
548
+ const rootPane = record.root_pane as Record<string, unknown> | undefined;
549
+ const tabId = typeof tab?.tab_id === "string" ? tab.tab_id : "";
550
+ const paneId = typeof rootPane?.pane_id === "string" ? rootPane.pane_id : "";
551
+ if (!tabId || !paneId) throw new Error("Herdr tab response did not include tab and root pane identifiers");
552
+ const resourceId = `${workspace}:${tabId}`;
553
+ ownership.resources.set(resourceId, { resourceId, level: "tab", workspaceId: workspace, tabId, paneId, createdAt: new Date().toISOString() });
554
+ ownership.panes.push({ workspaceId: workspace, tabId, paneId, resourceId });
555
+ return { created: true, level: "tab", resource };
556
+ }
557
+
439
558
  if (operation === "spawn") {
440
559
  const cwd = requireAbsolutePath(params.cwd, "cwd");
441
560
  const worktree = requireAbsolutePath(params.worktree, "worktree");
@@ -456,9 +575,11 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
456
575
  && candidate.workspaceId === String(layoutMetadata.workspace_id ?? candidate.workspaceId)
457
576
  && candidate.tabId === String(layoutMetadata.tab_id ?? candidate.tabId));
458
577
  if (!record) throw new Error("Herdr returned a pane without workspace/tab ownership metadata");
459
- ownership.panes.push(record);
578
+ const resourceId = `${record.workspaceId}:${record.tabId}:${record.paneId}`;
579
+ ownership.resources.set(resourceId, { resourceId, level: "pane", workspaceId: record.workspaceId, tabId: record.tabId, paneId: record.paneId, createdAt: new Date().toISOString() });
580
+ ownership.panes.push({ ...record, resourceId });
460
581
  await waitForInteractiveShell(newPane);
461
- return { pane: newPane, worktree, cwd, direction, ready: true, ownership: record };
582
+ return { pane: newPane, worktree, cwd, direction, ready: true, ownership: { ...record, resourceId } };
462
583
  }
463
584
 
464
585
  if (operation === "dispatch") {
@@ -490,7 +611,23 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
490
611
  // A fast agent can already be idle/done by the time it is inspected. The
491
612
  // server-owned wait is registered immediately so that completion is not
492
613
  // lost and a fast, successful turn is not falsely rejected.
493
- const wait = registerWait(name, 3_600_000);
614
+ const execution: DelegationExecution = {
615
+ executionId: randomUUID(),
616
+ callerPaneId: process.env.HERDR_PANE_ID,
617
+ workspaceId: ownedPane.workspaceId,
618
+ tabId: ownedPane.tabId,
619
+ paneId: ownedPane.paneId,
620
+ resourceLevel: "pane",
621
+ resourceId: ownedPane.resourceId,
622
+ worktree,
623
+ agentName: name,
624
+ status: "working",
625
+ runtimeState: "working",
626
+ manualIntervention: false,
627
+ createdAt: new Date().toISOString(),
628
+ };
629
+ ownership.executions.set(execution.executionId, execution);
630
+ const wait = registerWait(name, 3_600_000, execution);
494
631
  let agentState: unknown;
495
632
  try {
496
633
  agentState = herdrResult(await runHerdr(["agent", "get", name]));
@@ -508,10 +645,33 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
508
645
  started: herdrResult(start),
509
646
  state: agentState,
510
647
  wait,
648
+ execution,
511
649
  ownership: ownedPane,
512
650
  };
513
651
  }
514
652
 
653
+ if (operation === "status") {
654
+ const executionId = params.executionId;
655
+ if (typeof executionId !== "string") throw new Error("status requires executionId");
656
+ const execution = ownership.executions.get(executionId);
657
+ if (!execution) throw new Error(`execution ${executionId} is not owned by this Pi session`);
658
+ if (execution.agentName) {
659
+ const current = extractAgentLifecycleState(herdrResult(await runHerdr(["agent", "get", execution.agentName])));
660
+ if (current) execution.runtimeState = current;
661
+ }
662
+ return execution;
663
+ }
664
+
665
+ if (operation === "mark_intervened") {
666
+ const executionId = params.executionId;
667
+ if (typeof executionId !== "string") throw new Error("mark_intervened requires executionId");
668
+ const execution = ownership.executions.get(executionId);
669
+ if (!execution) throw new Error(`execution ${executionId} is not owned by this Pi session`);
670
+ execution.manualIntervention = true;
671
+ execution.status = "intervened";
672
+ return execution;
673
+ }
674
+
515
675
  if (operation === "wait") {
516
676
  const name = params.name;
517
677
  if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error("valid agent name is required");
@@ -520,6 +680,14 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
520
680
  return registerWait(name, timeout);
521
681
  }
522
682
 
683
+ if (operation === "cleanup") {
684
+ const executionId = params.executionId;
685
+ if (typeof executionId !== "string") throw new Error("cleanup requires executionId");
686
+ const execution = ownership.executions.get(executionId);
687
+ if (!execution) throw new Error(`execution ${executionId} is not owned by this Pi session`);
688
+ return cleanupExecution(execution, ownership);
689
+ }
690
+
523
691
  if (operation === "collect") {
524
692
  const storyId = params.storyId;
525
693
  if (typeof storyId !== "string" || storyId.length === 0) throw new Error("collect requires the story UUID as storyId");
@@ -528,7 +696,7 @@ async function runtimeOperation(capabilities: AllyeCapabilities, params: Record<
528
696
  const review = await callAllye("allye_intelligence", { action: "memory_search", query: reviewQuery, limit: 10, return_content: true });
529
697
  const implementationQuery = typeof params.taskKey === "string" ? `Implementation ${params.taskKey}` : `Implementation ${storyId}`;
530
698
  const implementation = await callAllye("allye_intelligence", { action: "memory_search", query: implementationQuery, limit: 10, return_content: true });
531
- return { storyChildren: story, review, implementation };
699
+ return { storyChildren: story, review, implementation, cleanupAvailable: typeof params.executionId === "string" };
532
700
  }
533
701
 
534
702
  throw new Error(`Unknown runtime operation: ${String(operation)}`);
@@ -540,15 +708,19 @@ function registerRuntimeTool(pi: ExtensionAPI, getCapabilities: () => AllyeCapab
540
708
  label: "Allye Runtime",
541
709
  description: "Use optional Herdr capabilities for delegation, waiting, and collecting results when available.",
542
710
  parameters: Type.Object({
543
- operation: Type.String({ description: "detect | spawn | dispatch | wait | collect" }),
711
+ operation: Type.String({ description: "detect | workspace | tab | spawn | dispatch | status | mark_intervened | wait | collect | cleanup" }),
544
712
  cwd: Type.Optional(Type.String({ description: "Absolute plugin-enabled repository root for spawn" })),
545
713
  worktree: Type.Optional(Type.String({ description: "Absolute isolated worktree path; required for spawn/dispatch" })),
714
+ workspaceId: Type.Optional(Type.String({ description: "Workspace id for tab creation" })),
715
+ label: Type.Optional(Type.String({ description: "Human-readable workspace/tab label" })),
716
+ focus: Type.Optional(Type.Boolean({ description: "Focus newly created workspace/tab" })),
546
717
  direction: Type.Optional(Type.String({ description: "right or down" })),
547
718
  pane: Type.Optional(Type.String({ description: "Pane id returned by Herdr" })),
548
719
  name: Type.Optional(Type.String({ description: "Stable Herdr agent name" })),
549
720
  kind: Type.Optional(Type.String({ description: "Herdr agent kind, normally pi" })),
550
721
  briefing: Type.Optional(Type.String({ description: "Complete story briefing, including the absolute worktree path" })),
551
722
  timeoutMs: Type.Optional(Type.Number({ description: "Bounded wait timeout in milliseconds" })),
723
+ executionId: Type.Optional(Type.String({ description: "Caller execution id for status/intervention/cleanup" })),
552
724
  storyId: Type.Optional(Type.String({ description: "Allye story UUID for collect" })),
553
725
  storyKey: Type.Optional(Type.String({ description: "Story key for review memory search" })),
554
726
  taskKey: Type.Optional(Type.String({ description: "Task key for implementation memory search" })),
@@ -574,9 +746,9 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
574
746
  let activeContext: ExtensionContext | undefined;
575
747
  const waits = new Map<string, AbortController>();
576
748
  const settledWaits = new Set<string>();
577
- const ownership: DelegationState = { panes: [], waits: new Set() };
749
+ const ownership: DelegationState = { panes: [], resources: new Map(), waits: new Set(), executions: new Map() };
578
750
 
579
- const registerWait: WaitRegistrar = (name, timeoutMs) => {
751
+ const registerWait: WaitRegistrar = (name, timeoutMs, execution) => {
580
752
  if (settledWaits.has(name)) return { registered: false, name, timeoutMs, duplicate: true, settled: true };
581
753
  const existing = waits.get(name);
582
754
  if (existing) return { registered: true, name, timeoutMs, duplicate: true };
@@ -587,6 +759,8 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
587
759
  signal: controller.signal,
588
760
  }).then((result) => {
589
761
  const event: WaitEvent = {
762
+ kind: execution?.manualIntervention ? "intervened" : "settled",
763
+ executionId: execution?.executionId,
590
764
  name,
591
765
  timeoutMs,
592
766
  outcome: classifyWaitResult(result, timeoutMs),
@@ -596,6 +770,11 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
596
770
  stderr: result.stderr,
597
771
  timestamp: new Date().toISOString(),
598
772
  };
773
+ if (execution) {
774
+ execution.status = execution.manualIntervention ? "intervened" : event.outcome === "blocked" ? "blocked" : event.outcome === "completed" ? "settled" : "unknown";
775
+ execution.runtimeState = event.outcome === "completed" ? "done" : event.outcome === "blocked" ? "blocked" : "unknown";
776
+ execution.settledAt = event.timestamp;
777
+ }
599
778
  deliverWaitEvent(event, shuttingDown, settledWaits, {
600
779
  appendEntry: (customType, value) => pi.appendEntry(customType, value),
601
780
  notify: activeContext?.hasUI ? (message, level) => activeContext?.ui.notify(message, level) : undefined,
@@ -603,12 +782,19 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
603
782
  });
604
783
  }).catch((error) => {
605
784
  const event: WaitEvent = {
785
+ kind: execution?.manualIntervention ? "intervened" : "settled",
786
+ executionId: execution?.executionId,
606
787
  name,
607
788
  timeoutMs,
608
789
  outcome: classifyWaitResult(undefined, timeoutMs, error),
609
790
  error: error instanceof Error ? error.message : String(error),
610
791
  timestamp: new Date().toISOString(),
611
792
  };
793
+ if (execution) {
794
+ execution.status = execution.manualIntervention ? "intervened" : event.outcome === "blocked" ? "blocked" : event.outcome === "completed" ? "settled" : "unknown";
795
+ execution.runtimeState = event.outcome === "completed" ? "done" : event.outcome === "blocked" ? "blocked" : "unknown";
796
+ execution.settledAt = event.timestamp;
797
+ }
612
798
  deliverWaitEvent(event, shuttingDown, settledWaits, {
613
799
  appendEntry: (customType, value) => pi.appendEntry(customType, value),
614
800
  notify: activeContext?.hasUI ? (message, level) => activeContext?.ui.notify(message, level) : undefined,
@@ -645,7 +831,7 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
645
831
  usingAllyeBootstrap = loadUsingAllyeSkill();
646
832
  syncCapabilityTools();
647
833
  if (ctx.hasUI) ctx.ui.setStatus("allye-pi", "loading Allye context…");
648
- if (process.env.ALLYE_PI_NATIVE_BOOTSTRAP !== "0") startupContext = await loadStartupContext();
834
+ if (process.env.ALLYE_PI_NATIVE_BOOTSTRAP !== "0") startupContext = await loadStartupContext(capabilities.allyeMcp);
649
835
  if (startupContext.teamSelectionRequired && ctx.hasUI) {
650
836
  ctx.ui.notify("Allye has multiple teams and no active team. Use /allye-team <name|prefix|id> before team-scoped work.", "warning");
651
837
  }
@@ -658,7 +844,9 @@ export default function allyePiAdapter(pi: ExtensionAPI): void {
658
844
  for (const controller of waits.values()) controller.abort();
659
845
  waits.clear();
660
846
  ownership.waits.clear();
661
- ownership.panes.length = 0;
847
+ // Owned Herdr resources intentionally remain open across a Pi shutdown.
848
+ // Cleanup requires an explicit, verified cleanup operation and never runs
849
+ // implicitly while the caller cannot inspect its final state.
662
850
  });
663
851
 
664
852
  pi.on("before_agent_start", async (event) => {
@@ -13,7 +13,13 @@ any runtime that satisfies the contract rather than one specific tool.
13
13
 
14
14
  Load this only when a compatible runtime is actually detected and the user benefits from delegation. Without one, continue locally or use another available subagent mechanism; never let the absence or degradation of a runtime block delivery.
15
15
 
16
- Concrete commands for the runtimes we implement live in `references/`. This file is the
16
+ Concrete commands for the runtimes we implement live in `references/`.
17
+
18
+ For Herdr, the Pi adapter exposes `workspace` and `tab` creation in addition to
19
+ `spawn`, and returns an execution ID from `dispatch`. The caller must retain this
20
+ ID, use `status`/`wait`, collect the result, and only then request ownership-guarded
21
+ `cleanup`. `mark_intervened` records human takeover and permanently blocks automatic
22
+ cleanup for that execution. This file is the
17
23
  contract; the reference is the implementation.
18
24
 
19
25
  <!-- adapted from ogulcancelik/herdr SKILL.md (Apache-2.0) — the primitive vocabulary and the safety rules -->
@@ -22,6 +22,18 @@ fails in a way that suggests the server went away.
22
22
  Caller context is injected into every managed pane: `$HERDR_PANE_ID`, `$HERDR_TAB_ID`,
23
23
  `$HERDR_WORKSPACE_ID`.
24
24
 
25
+ ## workspace and tab ownership
26
+
27
+ The Pi adapter can create a workspace or a tab without focusing it:
28
+
29
+ ```text
30
+ allye_herdr(operation="workspace", cwd="/absolute/repo", label="feature-runtime")
31
+ allye_herdr(operation="tab", workspaceId="w...", cwd="/absolute/repo", label="story-1")
32
+ ```
33
+
34
+ The returned IDs are registered as owned resources. Never infer IDs from layout
35
+ order and never use these operations to adopt an existing user resource.
36
+
25
37
  ## spawn
26
38
 
27
39
  ```bash
@@ -132,13 +144,31 @@ herdr agent send-keys {name} esc
132
144
  Then dispatch normally, including the submit confirmation — a freshly dismissed menu leaves
133
145
  the agent idle, which is exactly the state where a prompt lands unsubmitted.
134
146
 
135
- ## teardown
147
+ ## execution status, intervention, and teardown
148
+
149
+ Each dispatch returns an `executionId`. Retain it for the whole lifecycle:
150
+
151
+ ```text
152
+ allye_herdr(operation="status", executionId="...")
153
+ allye_herdr(operation="wait", name="story-agent", timeoutMs=3600000)
154
+ allye_herdr(operation="mark_intervened", executionId="...")
155
+ allye_herdr(operation="cleanup", executionId="...")
156
+ ```
157
+
158
+ Use `mark_intervened` when the user takes over or modifies the managed pane. A
159
+ manual intervention, `blocked`, `unknown`, timeout, or missing collection must
160
+ keep the resource open. Cleanup is valid only after wait settlement, Allye/result
161
+ collection, and verification.
162
+
163
+ The adapter rejects cleanup for resources it did not create and for executions
164
+ that are still working, blocked, unknown, or manually intervened.
136
165
 
137
166
  ```bash
138
167
  herdr pane close {pane_id}
139
168
  ```
140
169
 
141
- Only panes the plugin created, and only after §7.1's merge gates have passed.
170
+ Only panes/tabs/workspaces the plugin created, and only after §7.1's merge gates
171
+ have passed.
142
172
 
143
173
  ## Safety rules — never widened
144
174