Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
138
extensions/commander-tracker.ts
Normal file
138
extensions/commander-tracker.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// ABOUTME: Extension that reconciles local tasks with Commander and retries failed sync ops.
|
||||
// ABOUTME: Activates when Commander becomes available; runs reconcile (15s) and heartbeat (30s) intervals.
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
createTrackerState,
|
||||
popRetries,
|
||||
computeReconcileActions,
|
||||
type TrackerState,
|
||||
} from "./lib/commander-tracker.ts";
|
||||
import {
|
||||
parseCommanderTaskId,
|
||||
addMapping,
|
||||
updateMappingStatus,
|
||||
type SyncState,
|
||||
} from "./lib/commander-sync.ts";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const g = globalThis as any;
|
||||
let reconcileTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let trackerState: TrackerState = createTrackerState();
|
||||
|
||||
// Publish tracker on globalThis so tasks.ts can push retries
|
||||
const tracker = {
|
||||
active: false,
|
||||
reconcileNow,
|
||||
_state: trackerState,
|
||||
};
|
||||
g.__piCommanderTracker = tracker;
|
||||
|
||||
function activate() {
|
||||
if (tracker.active) return;
|
||||
tracker.active = true;
|
||||
|
||||
// Reconcile every 15s — find unmapped tasks and retry failed ops
|
||||
reconcileTimer = setInterval(() => reconcileNow(), 15_000);
|
||||
|
||||
// Heartbeat every 30s — keep Commander aware agent is alive
|
||||
heartbeatTimer = setInterval(() => sendHeartbeat(), 30_000);
|
||||
|
||||
// Immediate reconcile to catch stale state on startup/reconnect
|
||||
reconcileNow();
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!tracker.active) return;
|
||||
tracker.active = false;
|
||||
if (reconcileTimer) { clearInterval(reconcileTimer); reconcileTimer = undefined; }
|
||||
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = undefined; }
|
||||
}
|
||||
|
||||
function reconcileNow() {
|
||||
const client = g.__piCommanderClient;
|
||||
if (!client) return;
|
||||
|
||||
// Drain retry queue
|
||||
const { entries, state: newState } = popRetries(tracker._state);
|
||||
tracker._state = newState;
|
||||
trackerState = newState;
|
||||
for (const entry of entries) {
|
||||
entry.fn(client).catch(() => {});
|
||||
}
|
||||
|
||||
// Find unmapped tasks and create them in Commander
|
||||
const taskList = g.__piTaskList;
|
||||
const syncState: SyncState | undefined = g.__piTaskList?.__syncState;
|
||||
if (!taskList?.tasks) return;
|
||||
|
||||
// Get current sync mappings from tasks extension's published state
|
||||
// (tasks.ts publishes syncState inside details, but we read the globalThis snapshot)
|
||||
const mappings = syncState?.mappings || [];
|
||||
const actions = computeReconcileActions(taskList.tasks, mappings);
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.type === "create") {
|
||||
const groupId = syncState?.groupId;
|
||||
client.callTool("commander_task", {
|
||||
operation: "create",
|
||||
description: action.text,
|
||||
working_directory: process.cwd(),
|
||||
...(groupId !== undefined ? { group_id: groupId } : {}),
|
||||
}).then((res: any) => {
|
||||
const cid = parseCommanderTaskId(res);
|
||||
if (cid !== undefined && syncState) {
|
||||
// Mutate sync state to add mapping — tasks.ts will pick it up
|
||||
syncState.mappings.push({ localId: action.localId, commanderId: cid });
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else if (action.type === "status-update") {
|
||||
client.callTool("commander_task", {
|
||||
operation: "update",
|
||||
task_id: action.commanderId,
|
||||
status: action.commanderStatus,
|
||||
}).then(() => {
|
||||
if (syncState) {
|
||||
// Mutate mapping's lastSyncedStatus so next reconcile sees it as synced
|
||||
const mapping = syncState.mappings.find(m => m.localId === action.localId);
|
||||
if (mapping) mapping.lastSyncedStatus = action.localStatus as any;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendHeartbeat() {
|
||||
const client = g.__piCommanderClient;
|
||||
const currentTask = g.__piCurrentTask;
|
||||
if (!client || !currentTask) return;
|
||||
|
||||
client.callTool("commander_orchestration", {
|
||||
operation: "agent:heartbeat",
|
||||
agent_name: process.env.PI_AGENT_NAME || "pi",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async () => {
|
||||
const gate = g.__piCommanderGate;
|
||||
if (!gate) return;
|
||||
|
||||
if (gate.state === "available") {
|
||||
activate();
|
||||
} else if (gate.state === "pending") {
|
||||
// Push callback to fire when Commander probe succeeds
|
||||
const callbacks: Array<() => void> = g.__piCommanderOnReady || [];
|
||||
g.__piCommanderOnReady = callbacks;
|
||||
callbacks.push(() => activate());
|
||||
}
|
||||
// If unavailable, stay dormant
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
deactivate();
|
||||
g.__piCommanderTracker = null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user