allye-pi 1.5.1
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 +363 -0
- package/docs/install-pi.md +174 -0
- package/package.json +50 -0
- package/packages/allye-pi/src/index.ts +911 -0
- package/packages/allye-pi/tsconfig.json +13 -0
- package/skills/agent-runtime/SKILL.md +124 -0
- package/skills/agent-runtime/references/herdr.md +154 -0
- package/skills/board-progression/SKILL.md +214 -0
- package/skills/branch-landing/SKILL.md +88 -0
- package/skills/delivery/SKILL.md +187 -0
- package/skills/execution/SKILL.md +390 -0
- package/skills/handover-protocol/SKILL.md +74 -0
- package/skills/handover-protocol/references/correction.md +32 -0
- package/skills/handover-protocol/references/discovery-to-planning.md +44 -0
- package/skills/handover-protocol/references/execution-report.md +42 -0
- package/skills/handover-protocol/references/planning-to-technical.md +42 -0
- package/skills/handover-protocol/references/story-execution.md +74 -0
- package/skills/handover-protocol/references/technical-to-orchestration.md +45 -0
- package/skills/memory-protocol/SKILL.md +323 -0
- package/skills/orchestrator/SKILL.md +247 -0
- package/skills/product-planning/SKILL.md +255 -0
- package/skills/review/SKILL.md +165 -0
- package/skills/sandbox/SKILL.md +62 -0
- package/skills/setup/SKILL.md +225 -0
- package/skills/tdd-workflow/SKILL.md +144 -0
- package/skills/technical-planning/SKILL.md +352 -0
- package/skills/tools-quickref/SKILL.md +303 -0
- package/skills/using-allye/SKILL.md +191 -0
- package/skills/verification-loop/SKILL.md +93 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const MCP_SERVER = "allye";
|
|
13
|
+
const MAX_CONTEXT_CHARS = 12_000;
|
|
14
|
+
const RUNTIME_TIMEOUT_MS = 30_000;
|
|
15
|
+
const AGENT_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
16
|
+
|
|
17
|
+
type PiMode = "orchestrator" | "executor";
|
|
18
|
+
type McpResult = { content?: Array<{ type?: string; text?: string }> };
|
|
19
|
+
type StartupContext = {
|
|
20
|
+
text: string;
|
|
21
|
+
teamSelectionRequired: boolean;
|
|
22
|
+
allyeUnavailable: boolean;
|
|
23
|
+
teams: Array<{ id: string; name: string; prefix?: string }>;
|
|
24
|
+
};
|
|
25
|
+
type WaitRegistrar = (name: string, timeoutMs: number) => Record<string, unknown>;
|
|
26
|
+
type WaitCanceller = (name: string) => void;
|
|
27
|
+
export type WaitOutcome = "completed" | "timeout" | "error" | "aborted";
|
|
28
|
+
export type WaitExecutionResult = {
|
|
29
|
+
code: number;
|
|
30
|
+
killed: boolean;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
};
|
|
34
|
+
export type WaitEvent = {
|
|
35
|
+
name: string;
|
|
36
|
+
timeoutMs: number;
|
|
37
|
+
outcome: WaitOutcome;
|
|
38
|
+
code?: number;
|
|
39
|
+
killed?: boolean;
|
|
40
|
+
stdout?: string;
|
|
41
|
+
stderr?: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
};
|
|
45
|
+
export type WaitEventDelivery = {
|
|
46
|
+
appendEntry: (customType: string, event: WaitEvent) => void;
|
|
47
|
+
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
48
|
+
sendMessage?: (
|
|
49
|
+
message: { customType: string; content: string; display: boolean; details: WaitEvent },
|
|
50
|
+
options: { triggerTurn: true; deliverAs: "followUp" },
|
|
51
|
+
) => void;
|
|
52
|
+
};
|
|
53
|
+
export type WaitEventDeliveryResult = {
|
|
54
|
+
delivered: boolean;
|
|
55
|
+
persisted: boolean;
|
|
56
|
+
notified: boolean;
|
|
57
|
+
sent: boolean;
|
|
58
|
+
errors: string[];
|
|
59
|
+
};
|
|
60
|
+
export type OrchestrationMode = "pi" | "hermes";
|
|
61
|
+
export type OrchestrationClaim = {
|
|
62
|
+
runId: string;
|
|
63
|
+
workItemKey: string;
|
|
64
|
+
master: OrchestrationMode;
|
|
65
|
+
pid: number;
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
ownerToken: string;
|
|
68
|
+
claimedAt: string;
|
|
69
|
+
leaseExpiresAt: string;
|
|
70
|
+
};
|
|
71
|
+
export type OwnedPane = {
|
|
72
|
+
workspaceId: string;
|
|
73
|
+
tabId: string;
|
|
74
|
+
paneId: string;
|
|
75
|
+
agentName?: string;
|
|
76
|
+
};
|
|
77
|
+
export type OwnershipState = {
|
|
78
|
+
claim?: OrchestrationClaim;
|
|
79
|
+
panes: OwnedPane[];
|
|
80
|
+
waits: Set<string>;
|
|
81
|
+
};
|
|
82
|
+
const RUNTIME_TOOL_NAME = "allye_runtime";
|
|
83
|
+
const DEFAULT_LOCK_DIR = ".allye/pi-orchestration-locks";
|
|
84
|
+
const DEFAULT_LEASE_MS = 30 * 60 * 1000;
|
|
85
|
+
const WAIT_EVENT_TYPE = "allye-herdr-wait";
|
|
86
|
+
|
|
87
|
+
function resultText(result: unknown): string {
|
|
88
|
+
const content = (result as McpResult | null)?.content;
|
|
89
|
+
if (!Array.isArray(content)) return String(result ?? "");
|
|
90
|
+
return content
|
|
91
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
92
|
+
.map((part) => part.text as string)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function limitText(text: string, maxChars: number): string {
|
|
97
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n[context truncated]`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function initialMode(): PiMode {
|
|
101
|
+
if (resolveOrchestrationMode(process.env) === "hermes") return "executor";
|
|
102
|
+
const explicit = process.env.ALLYE_PI_MODE?.toLowerCase();
|
|
103
|
+
if (explicit === "orchestrator"
|
|
104
|
+
&& resolveOrchestrationMode(process.env) === "pi"
|
|
105
|
+
&& process.env.ALLYE_ORCHESTRATION_RUN_ID
|
|
106
|
+
&& process.env.ALLYE_WORK_ITEM_KEY) return explicit;
|
|
107
|
+
if (explicit === "executor") return explicit;
|
|
108
|
+
return "executor";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function canonicalSkillsPath(): string {
|
|
112
|
+
// The package lives at packages/allye-pi; skills/ is the repository's one
|
|
113
|
+
// canonical source and is intentionally not copied into this adapter.
|
|
114
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../../../skills");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type ActiveMcpToolCaller = (
|
|
118
|
+
serverName: string,
|
|
119
|
+
toolName: string,
|
|
120
|
+
args?: Record<string, unknown>,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
) => Promise<unknown>;
|
|
123
|
+
|
|
124
|
+
async function configuredMcpCall(toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
|
125
|
+
// pi-mcp-adapter intentionally exposes its cooperating-extension bridge on
|
|
126
|
+
// the in-process runtime. There is no public callMcpTool export in the
|
|
127
|
+
// supported package version, so do not dynamically import its TypeScript
|
|
128
|
+
// source from node_modules. A missing bridge is a hard bootstrap failure.
|
|
129
|
+
const globalBridge = (globalThis as typeof globalThis & {
|
|
130
|
+
__piMcpAdapterActiveToolCaller?: ActiveMcpToolCaller | null;
|
|
131
|
+
}).__piMcpAdapterActiveToolCaller;
|
|
132
|
+
if (!globalBridge) throw new Error("pi-mcp-adapter is not initialized; start the configured MCP adapter before using Allye");
|
|
133
|
+
return globalBridge(MCP_SERVER, toolName, args);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function callAllye(toolName: string, args: Record<string, unknown>): Promise<string> {
|
|
137
|
+
return resultText(await configuredMcpCall(toolName, args));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseJsonBlock(text: string): Record<string, unknown> | null {
|
|
141
|
+
const block = text.match(/```json\s*([\s\S]*?)\s*```/)?.[1];
|
|
142
|
+
if (!block) return null;
|
|
143
|
+
try {
|
|
144
|
+
const parsed: unknown = JSON.parse(block);
|
|
145
|
+
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function inspectTeamSelection(initText: string): StartupContext {
|
|
152
|
+
const payload = parseJsonBlock(initText);
|
|
153
|
+
const profile = payload?.profile as Record<string, unknown> | undefined;
|
|
154
|
+
if (!payload || !profile || !Array.isArray(profile.teams)) {
|
|
155
|
+
return invalidStartupContext("Allye initialize returned a payload that the Pi adapter could not interpret.");
|
|
156
|
+
}
|
|
157
|
+
const rawTeams = Array.isArray(profile?.teams) ? profile.teams : [];
|
|
158
|
+
const teams = rawTeams.flatMap((team) => {
|
|
159
|
+
if (!team || typeof team !== "object") return [];
|
|
160
|
+
const value = team as Record<string, unknown>;
|
|
161
|
+
return typeof value.id === "string" && typeof value.name === "string"
|
|
162
|
+
? [{ id: value.id, name: value.name, ...(typeof value.prefix === "string" ? { prefix: value.prefix } : {}) }]
|
|
163
|
+
: [];
|
|
164
|
+
});
|
|
165
|
+
const activeTeam = profile?.team;
|
|
166
|
+
const hasActiveTeam = Boolean(activeTeam && typeof activeTeam === "object" && typeof (activeTeam as Record<string, unknown>).id === "string");
|
|
167
|
+
const teamSelectionRequired = teams.length > 1 && !hasActiveTeam;
|
|
168
|
+
const instruction = teamSelectionRequired
|
|
169
|
+
? `## Allye team selection required\nThis account has multiple teams and no active team. Do not call team-scoped work_items or intelligence operations yet. Ask the user to choose one of: ${teams.map((team) => `${team.name}${team.prefix ? ` [${team.prefix}]` : ""} (${team.id})`).join(", ")}. Then use allye_team action team_switch with the chosen team_query. Never choose a team silently.`
|
|
170
|
+
: "";
|
|
171
|
+
return { text: instruction, teamSelectionRequired, allyeUnavailable: false, teams };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function classifyWaitResult(result?: WaitExecutionResult, timeoutMs = 0, error?: unknown): WaitOutcome {
|
|
175
|
+
const errorText = error instanceof Error ? error.message : String(error ?? "");
|
|
176
|
+
if (/abort/i.test(errorText)) return "aborted";
|
|
177
|
+
if (/timeout|timed out/i.test(errorText) || Boolean(result?.killed && timeoutMs > 0)) return "timeout";
|
|
178
|
+
if (!result || result.code !== 0) return "error";
|
|
179
|
+
return "completed";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function shouldEmitWaitEvent(shuttingDown: boolean, settledWaits: Set<string>, name: string): boolean {
|
|
183
|
+
if (shuttingDown || settledWaits.has(name)) return false;
|
|
184
|
+
settledWaits.add(name);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function formatWaitEvent(event: WaitEvent): string {
|
|
189
|
+
const output = event.stdout?.trim() || "(no stdout)";
|
|
190
|
+
const error = event.error || event.stderr?.trim() || "(none)";
|
|
191
|
+
return `Herdr wait settled for agent ${event.name}. Outcome: ${event.outcome}. `
|
|
192
|
+
+ `This is runtime evidence only, not a completion verdict. `
|
|
193
|
+
+ `Use allye_runtime collect now: read work_children for the story and search Allye memories for Review and Implementation evidence before declaring completion.\n\n`
|
|
194
|
+
+ `timeout_ms=${event.timeoutMs} code=${event.code ?? "n/a"} killed=${event.killed ?? false} timestamp=${event.timestamp}\n`
|
|
195
|
+
+ `stdout:\n${output}\nerror/stderr:\n${error}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function waitEventNotificationLevel(event: WaitEvent): "info" | "warning" | "error" {
|
|
199
|
+
if (event.outcome === "error") return "error";
|
|
200
|
+
if (event.outcome === "timeout") return "warning";
|
|
201
|
+
return "info";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function deliverWaitEvent(
|
|
205
|
+
event: WaitEvent,
|
|
206
|
+
shuttingDown: boolean,
|
|
207
|
+
settledWaits: Set<string>,
|
|
208
|
+
delivery: WaitEventDelivery,
|
|
209
|
+
): WaitEventDeliveryResult {
|
|
210
|
+
const empty: WaitEventDeliveryResult = {
|
|
211
|
+
delivered: false,
|
|
212
|
+
persisted: false,
|
|
213
|
+
notified: false,
|
|
214
|
+
sent: false,
|
|
215
|
+
errors: [],
|
|
216
|
+
};
|
|
217
|
+
if (!shouldEmitWaitEvent(shuttingDown, settledWaits, event.name)) return empty;
|
|
218
|
+
|
|
219
|
+
const message = formatWaitEvent(event);
|
|
220
|
+
const result: WaitEventDeliveryResult = { ...empty, delivered: true };
|
|
221
|
+
try {
|
|
222
|
+
delivery.appendEntry("allye-runtime-wait", event);
|
|
223
|
+
result.persisted = true;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
result.errors.push(`appendEntry: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
}
|
|
227
|
+
if (delivery.notify) {
|
|
228
|
+
try {
|
|
229
|
+
delivery.notify(message, waitEventNotificationLevel(event));
|
|
230
|
+
result.notified = true;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
result.errors.push(`notify: ${error instanceof Error ? error.message : String(error)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (delivery.sendMessage) {
|
|
236
|
+
try {
|
|
237
|
+
delivery.sendMessage(
|
|
238
|
+
{ customType: WAIT_EVENT_TYPE, content: message, display: true, details: event },
|
|
239
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
240
|
+
);
|
|
241
|
+
result.sent = true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
result.errors.push(`sendMessage: ${error instanceof Error ? error.message : String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function invalidStartupContext(message: string): StartupContext {
|
|
250
|
+
return {
|
|
251
|
+
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.`,
|
|
252
|
+
teamSelectionRequired: true,
|
|
253
|
+
allyeUnavailable: true,
|
|
254
|
+
teams: [],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function resolveOrchestrationMode(env: NodeJS.ProcessEnv): OrchestrationMode {
|
|
259
|
+
if (env.ALLYE_ORCHESTRATOR?.toLowerCase() === "hermes") return "hermes";
|
|
260
|
+
return "pi";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function orchestrationLockPath(cwd: string, runId: string): string {
|
|
264
|
+
const safeRunId = runId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
265
|
+
return resolve(cwd, DEFAULT_LOCK_DIR, `${safeRunId}.json`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function canUseOwnedPane(state: OwnershipState, paneId: string): boolean {
|
|
269
|
+
return state.panes.some((pane) => pane.paneId === paneId);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function claimConflict(existing: OrchestrationClaim | undefined, requested: OrchestrationClaim): string | null {
|
|
273
|
+
if (!existing) return null;
|
|
274
|
+
if (existing.runId === requested.runId
|
|
275
|
+
&& existing.workItemKey === requested.workItemKey
|
|
276
|
+
&& existing.master === requested.master
|
|
277
|
+
&& existing.pid === requested.pid
|
|
278
|
+
&& existing.ownerToken === requested.ownerToken) return null;
|
|
279
|
+
return `orchestration ownership conflict: ${existing.master}/${existing.runId}/${existing.workItemKey} owns this lock`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function lockRoot(): string {
|
|
283
|
+
return process.env.ALLYE_PI_LOCK_DIR
|
|
284
|
+
?? process.env.PI_CODING_AGENT_DIR
|
|
285
|
+
?? resolve(homedir(), ".pi", "agent");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function processState(pid: number): "alive" | "dead" | "unknown" {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(pid, 0);
|
|
291
|
+
return "alive";
|
|
292
|
+
} catch (error) {
|
|
293
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
294
|
+
if (code === "ESRCH") return "dead";
|
|
295
|
+
return "unknown";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function claimFromEnvironment(sessionId?: string): OrchestrationClaim {
|
|
300
|
+
const runId = process.env.ALLYE_ORCHESTRATION_RUN_ID?.trim();
|
|
301
|
+
const workItemKey = process.env.ALLYE_WORK_ITEM_KEY?.trim();
|
|
302
|
+
if (!runId || !workItemKey) {
|
|
303
|
+
throw new Error("Pi orchestrator requires ALLYE_ORCHESTRATION_RUN_ID and ALLYE_WORK_ITEM_KEY");
|
|
304
|
+
}
|
|
305
|
+
if (resolveOrchestrationMode(process.env) === "hermes") {
|
|
306
|
+
throw new Error("Hermes owns orchestration for this process; Pi remains executor");
|
|
307
|
+
}
|
|
308
|
+
const claimedAt = new Date();
|
|
309
|
+
return {
|
|
310
|
+
runId,
|
|
311
|
+
workItemKey,
|
|
312
|
+
master: "pi",
|
|
313
|
+
pid: process.pid,
|
|
314
|
+
...(sessionId ? { sessionId } : {}),
|
|
315
|
+
ownerToken: randomUUID(),
|
|
316
|
+
claimedAt: claimedAt.toISOString(),
|
|
317
|
+
leaseExpiresAt: new Date(claimedAt.getTime() + DEFAULT_LEASE_MS).toISOString(),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readClaim(path: string): OrchestrationClaim {
|
|
322
|
+
try {
|
|
323
|
+
const claim = JSON.parse(readFileSync(path, "utf8")) as Partial<OrchestrationClaim>;
|
|
324
|
+
if (typeof claim.runId !== "string" || typeof claim.workItemKey !== "string"
|
|
325
|
+
|| (claim.master !== "pi" && claim.master !== "hermes")
|
|
326
|
+
|| typeof claim.pid !== "number" || typeof claim.ownerToken !== "string"
|
|
327
|
+
|| typeof claim.leaseExpiresAt !== "string") {
|
|
328
|
+
throw new Error("missing required owner, PID, or lease fields");
|
|
329
|
+
}
|
|
330
|
+
return claim as OrchestrationClaim;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new Error(`orchestration lock ${path} is unreadable or invalid (${error instanceof Error ? error.message : String(error)}); refusing to take ownership`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function acquireLocalClaim(_cwd: string, requested: OrchestrationClaim): void {
|
|
337
|
+
const path = orchestrationLockPath(lockRoot(), requested.workItemKey);
|
|
338
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
339
|
+
try {
|
|
340
|
+
writeFileSync(path, JSON.stringify(requested, null, 2), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
341
|
+
return;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const existing = readClaim(path);
|
|
347
|
+
const conflict = claimConflict(existing, requested);
|
|
348
|
+
if (!conflict) return;
|
|
349
|
+
|
|
350
|
+
const leaseExpiresAt = Date.parse(existing.leaseExpiresAt);
|
|
351
|
+
const staleRecoveryEnabled = process.env.ALLYE_PI_RECOVER_STALE_LOCK === "1";
|
|
352
|
+
const ownerPidState = processState(existing.pid);
|
|
353
|
+
if (!staleRecoveryEnabled || !Number.isFinite(leaseExpiresAt) || Date.now() <= leaseExpiresAt || ownerPidState !== "dead") {
|
|
354
|
+
const reason = ownerPidState === "alive"
|
|
355
|
+
? "owner process is active"
|
|
356
|
+
: ownerPidState === "unknown"
|
|
357
|
+
? "owner process state is inconclusive"
|
|
358
|
+
: "stale recovery is disabled, lease is active, or required lease data is invalid";
|
|
359
|
+
throw new Error(`${conflict}; refusing stale-lock recovery (${reason}). Set ALLYE_PI_RECOVER_STALE_LOCK=1 only after confirming the local PID is gone and the lease expired.`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const recoveryMarker = `${path}.recovering`;
|
|
363
|
+
try {
|
|
364
|
+
writeFileSync(recoveryMarker, JSON.stringify({ ownerToken: existing.ownerToken, pid: existing.pid }), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error(`orchestration lock ${path} is already being recovered; refusing concurrent stale-lock recovery`);
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const current = readClaim(path);
|
|
370
|
+
if (current.ownerToken !== existing.ownerToken) {
|
|
371
|
+
throw new Error(`orchestration lock ${path} changed owner during stale recovery; refusing takeover`);
|
|
372
|
+
}
|
|
373
|
+
const quarantine = `${path}.stale-${existing.ownerToken}-${randomUUID()}`;
|
|
374
|
+
renameSync(path, quarantine);
|
|
375
|
+
try {
|
|
376
|
+
writeFileSync(path, JSON.stringify(requested, null, 2), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
377
|
+
} catch (writeError) {
|
|
378
|
+
try { renameSync(quarantine, path); } catch { /* fail closed; quarantine remains for manual recovery */ }
|
|
379
|
+
throw writeError;
|
|
380
|
+
}
|
|
381
|
+
try { unlinkSync(quarantine); } catch { /* retained quarantine is safe for manual audit */ }
|
|
382
|
+
} finally {
|
|
383
|
+
try { unlinkSync(recoveryMarker); } catch { /* marker is advisory; stale recovery remains fail-closed */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function releaseLocalClaim(claim: OrchestrationClaim): boolean {
|
|
388
|
+
const path = orchestrationLockPath(lockRoot(), claim.workItemKey);
|
|
389
|
+
if (!existsSync(path)) return false;
|
|
390
|
+
const existing = readClaim(path);
|
|
391
|
+
if (claimConflict(existing, claim) !== null) return false;
|
|
392
|
+
try {
|
|
393
|
+
unlinkSync(path);
|
|
394
|
+
return true;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function shutdownOwnership(ownership: OwnershipState, waits: Map<string, AbortController>): boolean {
|
|
402
|
+
for (const controller of waits.values()) controller.abort();
|
|
403
|
+
waits.clear();
|
|
404
|
+
ownership.waits.clear();
|
|
405
|
+
if (!ownership.claim) return false;
|
|
406
|
+
const released = releaseLocalClaim(ownership.claim);
|
|
407
|
+
if (released) ownership.claim = undefined;
|
|
408
|
+
return released;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function loadStartupContext(): Promise<StartupContext> {
|
|
412
|
+
const sections: string[] = [];
|
|
413
|
+
try {
|
|
414
|
+
const initialization = await callAllye("allye_initialize", {
|
|
415
|
+
action: "init",
|
|
416
|
+
include_user_docs: true,
|
|
417
|
+
});
|
|
418
|
+
const teamState = inspectTeamSelection(initialization);
|
|
419
|
+
sections.push(initialization, teamState.text);
|
|
420
|
+
if (!teamState.allyeUnavailable) {
|
|
421
|
+
sections.push(await callAllye("allye_intelligence", {
|
|
422
|
+
action: "memory_preferences",
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
if (!teamState.teamSelectionRequired && !teamState.allyeUnavailable) {
|
|
426
|
+
sections.push(await callAllye("allye_intelligence", {
|
|
427
|
+
action: "memory_search",
|
|
428
|
+
query: "Pi Allye adapter workflow context",
|
|
429
|
+
limit: 5,
|
|
430
|
+
return_content: true,
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
text: sections.filter(Boolean).map((section) => limitText(section, MAX_CONTEXT_CHARS)).join("\n\n"),
|
|
435
|
+
teamSelectionRequired: teamState.teamSelectionRequired,
|
|
436
|
+
allyeUnavailable: false,
|
|
437
|
+
teams: teamState.teams,
|
|
438
|
+
};
|
|
439
|
+
} catch (error) {
|
|
440
|
+
return invalidStartupContext(`Allye is unavailable or initialize returned an unreadable payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function loadPromptContext(prompt: string): Promise<string> {
|
|
445
|
+
try {
|
|
446
|
+
return limitText(await callAllye("allye_intelligence", {
|
|
447
|
+
action: "memory_search",
|
|
448
|
+
query: prompt,
|
|
449
|
+
limit: 5,
|
|
450
|
+
return_content: true,
|
|
451
|
+
}), MAX_CONTEXT_CHARS);
|
|
452
|
+
} catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function modeInstructions(mode: PiMode): string {
|
|
458
|
+
if (mode === "orchestrator") {
|
|
459
|
+
return `## Allye Pi role: orchestrator
|
|
460
|
+
You are the explicitly selected Pi master for this work item.
|
|
461
|
+
- Allye is the source of truth for work items, skills, memories, decisions, and review/delivery state.
|
|
462
|
+
- Follow the canonical Allye workflow and gates; do not invent a second workflow.
|
|
463
|
+
- Do not implement a story yourself when coordinating it. Dispatch exactly one story at a time to an isolated worktree and executor pane.
|
|
464
|
+
- Use the allye_runtime tool only for the Herdr detect/spawn/dispatch/wait/collect contract. The pane cwd stays at the plugin-enabled repository root; absolute worktree paths belong in the briefing.
|
|
465
|
+
- Never close panes you did not create, stop Herdr, merge, push, or publish without explicit user approval.
|
|
466
|
+
- Hermes and Pi are alternative masters. Confirm that Hermes is not coordinating the same work item before taking control.`;
|
|
467
|
+
}
|
|
468
|
+
return `## Allye Pi role: executor
|
|
469
|
+
You are the executor for this session (the safe default).
|
|
470
|
+
- Read the canonical Allye skills and the exact story/tasks or handover before changing code.
|
|
471
|
+
- Implement only the assigned story/task scope, with the execution skill's verification and reporting rules.
|
|
472
|
+
- Follow the canonical execution skill's status protocol: the executor may use Allye to advance its assigned tasks to in_progress and review when the corresponding work and verification are actually complete.
|
|
473
|
+
- Do not create or dispatch panes, assume orchestration, or change the assigned scope. Hermes and Pi may both manage status; being allowed to advance assigned task status does not make this session a master.
|
|
474
|
+
- If Hermes is the master, follow Hermes' briefing and return a durable implementation/review trace through Allye; do not take control of the work item.
|
|
475
|
+
- Stop and report when the scope or a locked decision is unclear; do not guess.`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function sleep(ms: number): Promise<void> {
|
|
479
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function runHerdr(args: string[], timeout = RUNTIME_TIMEOUT_MS): Promise<string> {
|
|
483
|
+
const result = await execFileAsync("herdr", args, {
|
|
484
|
+
timeout,
|
|
485
|
+
maxBuffer: 512 * 1024,
|
|
486
|
+
env: process.env,
|
|
487
|
+
});
|
|
488
|
+
return result.stdout || result.stderr || "";
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function parseJson(text: string): Record<string, unknown> | null {
|
|
492
|
+
try {
|
|
493
|
+
const parsed: unknown = JSON.parse(text);
|
|
494
|
+
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function herdrResult(text: string): unknown {
|
|
501
|
+
const json = parseJson(text);
|
|
502
|
+
return json?.result ?? json ?? text.trim();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function collectValues(value: unknown, key: string, output: string[] = []): string[] {
|
|
506
|
+
if (!value || typeof value !== "object") return output;
|
|
507
|
+
if (Array.isArray(value)) {
|
|
508
|
+
for (const item of value) collectValues(item, key, output);
|
|
509
|
+
return output;
|
|
510
|
+
}
|
|
511
|
+
const object = value as Record<string, unknown>;
|
|
512
|
+
if (typeof object[key] === "string") output.push(object[key] as string);
|
|
513
|
+
for (const child of Object.values(object)) collectValues(child, key, output);
|
|
514
|
+
return output;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function collectPaneRecords(value: unknown, output: OwnedPane[] = []): OwnedPane[] {
|
|
518
|
+
if (!value || typeof value !== "object") return output;
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
for (const item of value) collectPaneRecords(item, output);
|
|
521
|
+
return output;
|
|
522
|
+
}
|
|
523
|
+
const object = value as Record<string, unknown>;
|
|
524
|
+
const layout = object.layout && typeof object.layout === "object" ? object.layout as Record<string, unknown> : object;
|
|
525
|
+
const workspaceId = typeof layout.workspace_id === "string" ? layout.workspace_id : undefined;
|
|
526
|
+
const tabId = typeof layout.tab_id === "string" ? layout.tab_id : undefined;
|
|
527
|
+
const panes = Array.isArray(layout.panes) ? layout.panes : [];
|
|
528
|
+
if (workspaceId && tabId) {
|
|
529
|
+
for (const pane of panes) {
|
|
530
|
+
if (pane && typeof pane === "object" && typeof (pane as Record<string, unknown>).pane_id === "string") {
|
|
531
|
+
output.push({ workspaceId, tabId, paneId: (pane as Record<string, unknown>).pane_id as string });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return output;
|
|
535
|
+
}
|
|
536
|
+
for (const child of Object.values(object)) collectPaneRecords(child, output);
|
|
537
|
+
return output;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function waitForInteractiveShell(pane: string): Promise<void> {
|
|
541
|
+
const shellNames = new Set(["bash", "zsh", "sh", "fish"]);
|
|
542
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
543
|
+
try {
|
|
544
|
+
const info = herdrResult(await runHerdr(["pane", "process-info", "--pane", pane], 5_000));
|
|
545
|
+
const foreground = collectValues(info, "name")[0];
|
|
546
|
+
if (foreground && shellNames.has(foreground)) return;
|
|
547
|
+
} catch {
|
|
548
|
+
// The pane may still be initializing; the bounded loop below is the gate.
|
|
549
|
+
}
|
|
550
|
+
if (attempt < 9) await sleep(3_000);
|
|
551
|
+
}
|
|
552
|
+
throw new Error(`Herdr pane ${pane} did not reach an interactive shell within 30 seconds`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function activeToolsForMode(activeTools: string[], mode: PiMode): string[] {
|
|
556
|
+
const next = new Set(activeTools);
|
|
557
|
+
if (mode === "orchestrator") next.add(RUNTIME_TOOL_NAME);
|
|
558
|
+
else next.delete(RUNTIME_TOOL_NAME);
|
|
559
|
+
return [...next];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function dispatchStateIsAcceptable(stateText: string): boolean {
|
|
563
|
+
if (/blocked|unknown/i.test(stateText)) return false;
|
|
564
|
+
return /working|idle|done/i.test(stateText);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function requireOrchestrator(mode: PiMode): void {
|
|
568
|
+
if (mode !== "orchestrator") {
|
|
569
|
+
throw new Error("Herdr orchestration is disabled in executor mode. Set ALLYE_PI_MODE=orchestrator explicitly for the Pi master.");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function requireAbsolutePath(value: unknown, name: string): string {
|
|
574
|
+
if (typeof value !== "string" || !value.startsWith("/")) {
|
|
575
|
+
throw new Error(`${name} must be an absolute path`);
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function runtimeOperation(mode: PiMode, params: Record<string, unknown>, registerWait: WaitRegistrar, cancelWait: WaitCanceller, ownership: OwnershipState): Promise<unknown> {
|
|
581
|
+
requireOrchestrator(mode);
|
|
582
|
+
if (!ownership.claim) throw new Error("Pi orchestrator has no explicit orchestration claim; provide ALLYE_ORCHESTRATION_RUN_ID and ALLYE_WORK_ITEM_KEY");
|
|
583
|
+
const operation = params.operation;
|
|
584
|
+
if (operation === "detect") {
|
|
585
|
+
if (process.env.HERDR_ENV !== "1") return { available: false, reason: "HERDR_ENV is not 1" };
|
|
586
|
+
try {
|
|
587
|
+
const output = await runHerdr(["status"]);
|
|
588
|
+
return { available: /compatible:\s*yes/i.test(output), output: output.trim() };
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return { available: false, reason: error instanceof Error ? error.message : String(error) };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (process.env.HERDR_ENV !== "1") {
|
|
595
|
+
throw new Error("Herdr runtime is not active in this Pi session (HERDR_ENV=1 is required).");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (operation === "spawn") {
|
|
599
|
+
const cwd = requireAbsolutePath(params.cwd, "cwd");
|
|
600
|
+
const worktree = requireAbsolutePath(params.worktree, "worktree");
|
|
601
|
+
if (!existsSync(worktree)) throw new Error(`worktree does not exist: ${worktree}`);
|
|
602
|
+
if (worktree === cwd) throw new Error("worktree must be isolated from the pane cwd");
|
|
603
|
+
const pane = process.env.HERDR_PANE_ID;
|
|
604
|
+
if (!pane) throw new Error("HERDR_PANE_ID is missing; cannot spawn safely");
|
|
605
|
+
const direction = params.direction === "down" ? "down" : "right";
|
|
606
|
+
const beforeLayout = herdrResult(await runHerdr(["pane", "layout", "--pane", pane]));
|
|
607
|
+
const beforePanes = new Set(collectValues(beforeLayout, "pane_id"));
|
|
608
|
+
await runHerdr(["pane", "split", "--pane", pane, "--direction", direction, "--cwd", cwd, "--no-focus"]);
|
|
609
|
+
const afterLayout = herdrResult(await runHerdr(["pane", "layout", "--pane", pane]));
|
|
610
|
+
const newPane = collectValues(afterLayout, "pane_id").find((id) => !beforePanes.has(id));
|
|
611
|
+
if (!newPane) throw new Error("Herdr split completed but no new pane id was returned");
|
|
612
|
+
const paneRecords = collectPaneRecords(afterLayout);
|
|
613
|
+
const record = paneRecords.find((candidate) => candidate.paneId === newPane && candidate.workspaceId === String((afterLayout as Record<string, unknown>).workspace_id ?? candidate.workspaceId) && candidate.tabId === String((afterLayout as Record<string, unknown>).tab_id ?? candidate.tabId));
|
|
614
|
+
if (!record) throw new Error("Herdr returned a pane without workspace/tab ownership metadata");
|
|
615
|
+
ownership.panes.push(record);
|
|
616
|
+
await waitForInteractiveShell(newPane);
|
|
617
|
+
return { pane: newPane, worktree, cwd, direction, ready: true, ownership: record };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (operation === "dispatch") {
|
|
621
|
+
const name = params.name;
|
|
622
|
+
if (typeof name !== "string" || !AGENT_NAME.test(name)) {
|
|
623
|
+
throw new Error("name must match [a-z][a-z0-9_-]{0,31}");
|
|
624
|
+
}
|
|
625
|
+
const kind = typeof params.kind === "string" ? params.kind : "pi";
|
|
626
|
+
const pane = typeof params.pane === "string" ? params.pane : "";
|
|
627
|
+
const briefing = params.briefing;
|
|
628
|
+
const worktree = requireAbsolutePath(params.worktree, "worktree");
|
|
629
|
+
if (!existsSync(worktree)) throw new Error(`worktree does not exist: ${worktree}`);
|
|
630
|
+
if (!pane || typeof briefing !== "string" || briefing.trim().length === 0) {
|
|
631
|
+
throw new Error("dispatch requires pane and a non-empty briefing");
|
|
632
|
+
}
|
|
633
|
+
const ownedPane = ownership.panes.find((candidate) => candidate.paneId === pane);
|
|
634
|
+
if (!ownedPane) throw new Error(`pane ${pane} is not owned by this Pi orchestration run`);
|
|
635
|
+
const currentLayout = herdrResult(await runHerdr(["pane", "layout", "--pane", pane]));
|
|
636
|
+
const currentRecord = collectPaneRecords(currentLayout).find((candidate) => candidate.paneId === pane);
|
|
637
|
+
if (!currentRecord || currentRecord.workspaceId !== ownedPane.workspaceId || currentRecord.tabId !== ownedPane.tabId) {
|
|
638
|
+
throw new Error(`pane ${pane} ownership metadata no longer matches this Pi orchestration run`);
|
|
639
|
+
}
|
|
640
|
+
await waitForInteractiveShell(pane);
|
|
641
|
+
if (!briefing.includes(worktree)) {
|
|
642
|
+
throw new Error("dispatch briefing must contain the absolute worktree path");
|
|
643
|
+
}
|
|
644
|
+
const start = await runHerdr(["agent", "start", name, "--kind", kind, "--pane", pane, "--timeout", "120000", "--"]);
|
|
645
|
+
await runHerdr(["agent", "prompt", name, briefing]);
|
|
646
|
+
// A fast agent can already be idle/done by the time it is inspected. The
|
|
647
|
+
// server-owned wait is registered immediately so that completion is not
|
|
648
|
+
// lost and a fast, successful turn is not falsely rejected.
|
|
649
|
+
const wait = registerWait(name, 3_600_000);
|
|
650
|
+
let agentState: unknown;
|
|
651
|
+
try {
|
|
652
|
+
agentState = herdrResult(await runHerdr(["agent", "get", name]));
|
|
653
|
+
} catch (error) {
|
|
654
|
+
cancelWait(name);
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
if (!dispatchStateIsAcceptable(JSON.stringify(agentState))) {
|
|
658
|
+
cancelWait(name);
|
|
659
|
+
throw new Error(`Herdr agent ${name} entered an unsupported state after prompt submission`);
|
|
660
|
+
}
|
|
661
|
+
ownedPane.agentName = name;
|
|
662
|
+
ownership.waits.add(name);
|
|
663
|
+
return {
|
|
664
|
+
started: herdrResult(start),
|
|
665
|
+
state: agentState,
|
|
666
|
+
wait,
|
|
667
|
+
ownership: ownedPane,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (operation === "wait") {
|
|
672
|
+
const name = params.name;
|
|
673
|
+
if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error("valid agent name is required");
|
|
674
|
+
const timeout = typeof params.timeoutMs === "number" ? params.timeoutMs : 3_600_000;
|
|
675
|
+
if (!ownership.waits.has(name)) throw new Error(`agent ${name} is not owned by this Pi orchestration run`);
|
|
676
|
+
return registerWait(name, timeout);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (operation === "collect") {
|
|
680
|
+
const storyId = params.storyId;
|
|
681
|
+
if (typeof storyId !== "string" || storyId.length === 0) throw new Error("collect requires the story UUID as storyId");
|
|
682
|
+
const story = await callAllye("allye_work_items", { action: "work_children", id: storyId });
|
|
683
|
+
const reviewQuery = typeof params.storyKey === "string" ? `Review ${params.storyKey}` : `Review ${storyId}`;
|
|
684
|
+
const review = await callAllye("allye_intelligence", { action: "memory_search", query: reviewQuery, limit: 10, return_content: true });
|
|
685
|
+
const implementationQuery = typeof params.taskKey === "string" ? `Implementation ${params.taskKey}` : `Implementation ${storyId}`;
|
|
686
|
+
const implementation = await callAllye("allye_intelligence", { action: "memory_search", query: implementationQuery, limit: 10, return_content: true });
|
|
687
|
+
return { storyChildren: story, review, implementation };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
throw new Error(`Unknown runtime operation: ${String(operation)}`);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function registerRuntimeTool(pi: ExtensionAPI, getMode: () => PiMode, registerWait: WaitRegistrar, cancelWait: WaitCanceller, state: OwnershipState): void {
|
|
694
|
+
pi.registerTool({
|
|
695
|
+
name: "allye_runtime",
|
|
696
|
+
label: "Allye Runtime",
|
|
697
|
+
description: "Drive Herdr from Pi orchestrator mode using detect, spawn, dispatch, wait, and Allye-backed collect.",
|
|
698
|
+
parameters: Type.Object({
|
|
699
|
+
operation: Type.String({ description: "detect | spawn | dispatch | wait | collect" }),
|
|
700
|
+
cwd: Type.Optional(Type.String({ description: "Absolute plugin-enabled repository root for spawn" })),
|
|
701
|
+
worktree: Type.Optional(Type.String({ description: "Absolute isolated worktree path; required for spawn/dispatch" })),
|
|
702
|
+
direction: Type.Optional(Type.String({ description: "right or down" })),
|
|
703
|
+
pane: Type.Optional(Type.String({ description: "Pane id returned by Herdr" })),
|
|
704
|
+
name: Type.Optional(Type.String({ description: "Stable Herdr agent name" })),
|
|
705
|
+
kind: Type.Optional(Type.String({ description: "Herdr agent kind, normally pi" })),
|
|
706
|
+
briefing: Type.Optional(Type.String({ description: "Complete story briefing, including the absolute worktree path" })),
|
|
707
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Bounded wait timeout in milliseconds" })),
|
|
708
|
+
storyId: Type.Optional(Type.String({ description: "Allye story UUID for collect" })),
|
|
709
|
+
storyKey: Type.Optional(Type.String({ description: "Story key for review memory search" })),
|
|
710
|
+
taskKey: Type.Optional(Type.String({ description: "Task key for implementation memory search" })),
|
|
711
|
+
}),
|
|
712
|
+
async execute(_toolCallId, params) {
|
|
713
|
+
try {
|
|
714
|
+
const result = await runtimeOperation(getMode(), params as Record<string, unknown>, registerWait, cancelWait, state);
|
|
715
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
|
|
716
|
+
} catch (error) {
|
|
717
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
718
|
+
return { content: [{ type: "text", text: `Allye runtime error: ${message}` }], isError: true, details: { error: message } };
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export default function allyePiAdapter(pi: ExtensionAPI): void {
|
|
725
|
+
let mode = initialMode();
|
|
726
|
+
let startupContext: StartupContext = { text: "", teamSelectionRequired: false, allyeUnavailable: true, teams: [] };
|
|
727
|
+
let firstPrompt = true;
|
|
728
|
+
let shuttingDown = false;
|
|
729
|
+
let activeContext: ExtensionContext | undefined;
|
|
730
|
+
const waits = new Map<string, AbortController>();
|
|
731
|
+
const settledWaits = new Set<string>();
|
|
732
|
+
const ownership: OwnershipState = { panes: [], waits: new Set() };
|
|
733
|
+
|
|
734
|
+
const registerWait: WaitRegistrar = (name, timeoutMs) => {
|
|
735
|
+
if (settledWaits.has(name)) return { registered: false, name, timeoutMs, duplicate: true, settled: true };
|
|
736
|
+
const existing = waits.get(name);
|
|
737
|
+
if (existing) return { registered: true, name, timeoutMs, duplicate: true };
|
|
738
|
+
const controller = new AbortController();
|
|
739
|
+
waits.set(name, controller);
|
|
740
|
+
void pi.exec("herdr", ["agent", "wait", name, "--timeout", String(timeoutMs)], {
|
|
741
|
+
timeout: timeoutMs + 5_000,
|
|
742
|
+
signal: controller.signal,
|
|
743
|
+
}).then((result) => {
|
|
744
|
+
const event: WaitEvent = {
|
|
745
|
+
name,
|
|
746
|
+
timeoutMs,
|
|
747
|
+
outcome: classifyWaitResult(result, timeoutMs),
|
|
748
|
+
code: result.code,
|
|
749
|
+
killed: result.killed,
|
|
750
|
+
stdout: result.stdout,
|
|
751
|
+
stderr: result.stderr,
|
|
752
|
+
timestamp: new Date().toISOString(),
|
|
753
|
+
};
|
|
754
|
+
deliverWaitEvent(event, shuttingDown, settledWaits, {
|
|
755
|
+
appendEntry: (customType, value) => pi.appendEntry(customType, value),
|
|
756
|
+
notify: activeContext?.hasUI ? (message, level) => activeContext?.ui.notify(message, level) : undefined,
|
|
757
|
+
sendMessage: (message, options) => pi.sendMessage(message, options),
|
|
758
|
+
});
|
|
759
|
+
}).catch((error) => {
|
|
760
|
+
const event: WaitEvent = {
|
|
761
|
+
name,
|
|
762
|
+
timeoutMs,
|
|
763
|
+
outcome: classifyWaitResult(undefined, timeoutMs, error),
|
|
764
|
+
error: error instanceof Error ? error.message : String(error),
|
|
765
|
+
timestamp: new Date().toISOString(),
|
|
766
|
+
};
|
|
767
|
+
deliverWaitEvent(event, shuttingDown, settledWaits, {
|
|
768
|
+
appendEntry: (customType, value) => pi.appendEntry(customType, value),
|
|
769
|
+
notify: activeContext?.hasUI ? (message, level) => activeContext?.ui.notify(message, level) : undefined,
|
|
770
|
+
sendMessage: (message, options) => pi.sendMessage(message, options),
|
|
771
|
+
});
|
|
772
|
+
}).finally(() => {
|
|
773
|
+
waits.delete(name);
|
|
774
|
+
});
|
|
775
|
+
return {
|
|
776
|
+
registered: true,
|
|
777
|
+
name,
|
|
778
|
+
timeoutMs,
|
|
779
|
+
diagnostic: "Wait runs through Pi's managed exec, is bounded, and records stdout/stderr in the session log.",
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const cancelWait: WaitCanceller = (name) => {
|
|
784
|
+
waits.get(name)?.abort();
|
|
785
|
+
waits.delete(name);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const syncModeTools = () => {
|
|
789
|
+
pi.setActiveTools(activeToolsForMode(pi.getActiveTools(), mode));
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
pi.registerMessageRenderer(WAIT_EVENT_TYPE, () => undefined);
|
|
793
|
+
|
|
794
|
+
pi.on("resources_discover", () => ({ skillPaths: [canonicalSkillsPath()] }));
|
|
795
|
+
|
|
796
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
797
|
+
shuttingDown = false;
|
|
798
|
+
activeContext = ctx;
|
|
799
|
+
mode = initialMode();
|
|
800
|
+
firstPrompt = true;
|
|
801
|
+
syncModeTools();
|
|
802
|
+
if (ctx.hasUI) ctx.ui.setStatus("allye-pi", `loading Allye context (${mode})…`);
|
|
803
|
+
if (process.env.ALLYE_PI_NATIVE_BOOTSTRAP !== "0") startupContext = await loadStartupContext();
|
|
804
|
+
if (mode === "orchestrator") {
|
|
805
|
+
try {
|
|
806
|
+
const claim = claimFromEnvironment(ctx.sessionManager.getSessionId());
|
|
807
|
+
acquireLocalClaim(ctx.cwd, claim);
|
|
808
|
+
ownership.claim = claim;
|
|
809
|
+
} catch (error) {
|
|
810
|
+
mode = "executor";
|
|
811
|
+
syncModeTools();
|
|
812
|
+
startupContext = {
|
|
813
|
+
...startupContext,
|
|
814
|
+
text: `${startupContext.text}\n## Pi orchestration blocked\n${error instanceof Error ? error.message : String(error)}\nPi remains executor; status management for assigned tasks remains allowed.`,
|
|
815
|
+
};
|
|
816
|
+
if (ctx.hasUI) ctx.ui.notify("Pi orchestration blocked; no compatible ownership claim was acquired. Executor mode remains active.", "warning");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (startupContext.teamSelectionRequired && ctx.hasUI) {
|
|
820
|
+
ctx.ui.notify("Allye has multiple teams and no active team. Use /allye-team <name|prefix|id> before team-scoped work.", "warning");
|
|
821
|
+
}
|
|
822
|
+
if (ctx.hasUI) ctx.ui.setStatus("allye-pi", `Allye ${mode} ready`);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
pi.on("session_shutdown", () => {
|
|
826
|
+
shuttingDown = true;
|
|
827
|
+
activeContext = undefined;
|
|
828
|
+
try {
|
|
829
|
+
shutdownOwnership(ownership, waits);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.error(`Allye Pi: could not release orchestration lock safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
pi.on("before_agent_start", async (event) => {
|
|
836
|
+
const sections = [
|
|
837
|
+
`<allye-pi-adapter>\n${modeInstructions(mode)}\n\nCanonical Allye skills are loaded from ${canonicalSkillsPath()} — do not create copies.\n${startupContext.teamSelectionRequired ? "\n" + startupContext.text : ""}\n</allye-pi-adapter>`,
|
|
838
|
+
];
|
|
839
|
+
if (firstPrompt && process.env.ALLYE_PI_NATIVE_BOOTSTRAP !== "0") {
|
|
840
|
+
firstPrompt = false;
|
|
841
|
+
if (startupContext.text) sections.push(`<allye-pi-startup-context>\n${startupContext.text}\n</allye-pi-startup-context>`);
|
|
842
|
+
if (!startupContext.teamSelectionRequired && !startupContext.allyeUnavailable) {
|
|
843
|
+
const relevant = await loadPromptContext(event.prompt);
|
|
844
|
+
if (relevant) sections.push(`<allye-pi-relevant-memory>\n${relevant}\n</allye-pi-relevant-memory>`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${sections.join("\n\n")}` };
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
pi.registerCommand("allye-mode", {
|
|
851
|
+
description: "Select executor or orchestrator mode for this Pi session",
|
|
852
|
+
getArgumentCompletions: (prefix) => ["executor", "orchestrator"]
|
|
853
|
+
.filter((value) => value.startsWith(prefix.trim()))
|
|
854
|
+
.map((value) => ({ value, label: value })),
|
|
855
|
+
handler: async (args, ctx) => {
|
|
856
|
+
const selected = args?.trim().toLowerCase();
|
|
857
|
+
if (selected !== "executor" && selected !== "orchestrator") {
|
|
858
|
+
ctx.ui.notify("Usage: /allye-mode executor|orchestrator", "error");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (selected === "orchestrator" && resolveOrchestrationMode(process.env) === "hermes") {
|
|
862
|
+
ctx.ui.notify("Hermes owns orchestration for this process; Pi remains executor.", "warning");
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (selected === mode) {
|
|
866
|
+
ctx.ui.notify(`Allye mode is already ${mode} for this session`, "info");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (selected === "executor") {
|
|
870
|
+
// Downgrading must release the orchestration claim and abort waits so
|
|
871
|
+
// this session cannot keep coordinating after becoming an executor.
|
|
872
|
+
shutdownOwnership(ownership, waits);
|
|
873
|
+
ownership.panes.length = 0;
|
|
874
|
+
}
|
|
875
|
+
if (selected === "orchestrator" && !ownership.claim) {
|
|
876
|
+
try {
|
|
877
|
+
const claim = claimFromEnvironment(ctx.sessionManager.getSessionId());
|
|
878
|
+
acquireLocalClaim(ctx.cwd, claim);
|
|
879
|
+
ownership.claim = claim;
|
|
880
|
+
} catch (error) {
|
|
881
|
+
ctx.ui.notify(`Pi orchestration blocked: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
mode = selected;
|
|
886
|
+
syncModeTools();
|
|
887
|
+
ctx.ui.setStatus("allye-pi", `Allye ${mode}`);
|
|
888
|
+
ctx.ui.notify(`Allye mode set to ${mode} for this session`, "info");
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
pi.registerCommand("allye-team", {
|
|
893
|
+
description: "Select the active Allye team without choosing one silently",
|
|
894
|
+
handler: async (args, ctx) => {
|
|
895
|
+
const teamQuery = args?.trim();
|
|
896
|
+
if (!teamQuery) {
|
|
897
|
+
ctx.ui.notify("Usage: /allye-team <name|prefix|id>", "error");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
const result = await callAllye("allye_team", { action: "team_switch", team_query: teamQuery });
|
|
902
|
+
startupContext = await loadStartupContext();
|
|
903
|
+
ctx.ui.notify(`Allye team selection: ${result}`, "info");
|
|
904
|
+
} catch (error) {
|
|
905
|
+
ctx.ui.notify(`Allye team selection failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
registerRuntimeTool(pi, () => mode, registerWait, cancelWait, ownership);
|
|
911
|
+
}
|