514 lines
24 KiB
TypeScript
514 lines
24 KiB
TypeScript
// pi /goal extension — operator-set goal with a forced check<->proceed loop.
|
|
//
|
|
// Source of truth for requirements: docs/PRD.md (mosaic-brain, issue #52).
|
|
// Design locked with Jason 2026-08-28 (Q1-Q8): verb set, agent-self-declared
|
|
// satisfaction via goal_report, abort pauses, seat-durable state, runaway cap.
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { fileURLToPath } from "node:url";
|
|
import { goalDisplay, goalDetails } from "./lib/display.ts";
|
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "typebox";
|
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
import {
|
|
clearGoal,
|
|
completeGoal,
|
|
blockGoal,
|
|
pauseGoal,
|
|
recordCheckInjected,
|
|
recordInProgressReport,
|
|
recordReport,
|
|
recordWorkEvent,
|
|
resumeGoal,
|
|
setGoal,
|
|
validateReport,
|
|
type GoalState,
|
|
type ProgressDetails,
|
|
} from "./lib/state.ts";
|
|
import { decideSettle, hasUnresolvedWait } from "./lib/settle.ts";
|
|
import { parseGoalCommand } from "./lib/parse.ts";
|
|
import { loadState, saveState, resolveStatePath } from "./lib/store.ts";
|
|
import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts";
|
|
import { incarnationIdentity } from "../mosaic-core/lib/incarnation.ts";
|
|
import { goalPolicyPublication, recordGoalPolicyDenial, registerGoalPolicyConsumer } from "../mosaic-core/lib/goal-policy.ts";
|
|
|
|
const WIDGET_ID = "goal";
|
|
|
|
export default function goalExtension(pi: ExtensionAPI) {
|
|
// Register before role-specific adapters and Mosaic Core publish the one
|
|
// trusted version-4 policy attestation for this process incarnation.
|
|
const incarnationId = incarnationIdentity();
|
|
registerGoalPolicyConsumer(incarnationId);
|
|
|
|
// NG-7 incarnation fencing (Mercer F1): state is scoped to THIS process
|
|
// launch incarnation — a sibling incarnation of the same seat (marcie-2)
|
|
// resolves its own fenced file and can never inherit, replace, pause, or
|
|
// settle this incarnation's active focus. Legacy unfenced state is
|
|
// quarantined without adoption (store.ts).
|
|
// NG development state belongs to this project copy, never the live fleet agent home.
|
|
const statePath = resolveStatePath(fileURLToPath(new URL("../../state/goal/", import.meta.url)));
|
|
let state: GoalState = loadState(statePath);
|
|
// Set on session_shutdown: after teardown, any ctx/runner use throws.
|
|
let dead = false;
|
|
let waitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let armedDeadline: string | undefined;
|
|
let deadlinePending = false;
|
|
// Process-local queue latch. Only observing this check's start releases it.
|
|
let pendingCheck: string | undefined;
|
|
|
|
function cancelWaitTimer(): void {
|
|
if (waitTimer !== undefined) clearTimeout(waitTimer);
|
|
waitTimer = undefined;
|
|
armedDeadline = undefined;
|
|
deadlinePending = false;
|
|
}
|
|
|
|
// A deadline is a single reconciliation wake, never an approval or a heartbeat.
|
|
function wakeDeadline(ctx: ExtensionContext): void {
|
|
if (dead || state.status !== "active" || !state.waitTimeoutSeconds ||
|
|
!state.activeWait || state.activeWait.wakeSent) return;
|
|
state = { ...state, waitWakeUsed: true,
|
|
activeWait: { ...state.activeWait, wakeSent: true, wakeRequestId: randomUUID(), wakeDispatchedAt: Date.now(), wakeObserved: false } };
|
|
if (!persist(ctx)) return;
|
|
beginCheck(ctx);
|
|
}
|
|
|
|
function syncWaitTimer(ctx: ExtensionContext): void {
|
|
const wait = state.activeWait;
|
|
if (dead || ctx.mode === "print" || state.status !== "active" || !state.waitTimeoutSeconds ||
|
|
!wait || !Number.isFinite(wait.deadlineAt) || (wait.wakeSent && wait.wakeObserved)) {
|
|
cancelWaitTimer();
|
|
return;
|
|
}
|
|
const deadline = wait.wakeSent ? wait.wakeDispatchedAt! + 30000 : wait.deadlineAt!;
|
|
const key = `${wait.wakeSent ? wait.wakeRequestId : "wait"}:${deadline}`;
|
|
if (armedDeadline === key) return;
|
|
cancelWaitTimer();
|
|
armedDeadline = key;
|
|
const tick = () => {
|
|
waitTimer = undefined;
|
|
if (dead || state.status !== "active" || armedDeadline !== key) return;
|
|
if (wait.wakeSent) {
|
|
pauseWith(ctx, "deadline wake was not observed within 30s", "wake delivery unconfirmed; paused — /goal resume after checking the session");
|
|
return;
|
|
}
|
|
if (!ctx.isIdle()) {
|
|
// Compaction can make a session busy without ever emitting agent_settled.
|
|
// Poll runtime readiness in code only, for at most 60s; never call the model.
|
|
if (Date.now() >= deadline + 60000) {
|
|
pauseWith(ctx, "session remained busy after wait deadline", "deadline could not start work; paused — /goal resume when ready");
|
|
return;
|
|
}
|
|
deadlinePending = true;
|
|
waitTimer = setTimeout(tick, 1000);
|
|
waitTimer.unref?.();
|
|
return;
|
|
}
|
|
wakeDeadline(ctx);
|
|
};
|
|
waitTimer = setTimeout(tick, Math.max(1, deadline - Date.now()));
|
|
waitTimer.unref?.();
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
|
|
try {
|
|
ctx.ui.notify(message, level);
|
|
} catch {
|
|
// stale ctx or no UI — never break a turn over a notification
|
|
}
|
|
}
|
|
|
|
function persist(ctx: ExtensionContext): boolean {
|
|
try {
|
|
saveState(state, statePath);
|
|
syncWaitTimer(ctx);
|
|
return true;
|
|
} catch (err) {
|
|
state = pauseGoal(state, "state save failed; continuation stopped");
|
|
cancelWaitTimer();
|
|
try {
|
|
safeNotify(ctx, `goal: state save failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
} catch {
|
|
// no UI — nothing else to do
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function renderWidget(ctx: ExtensionContext): void {
|
|
if (!ctx.hasUI) return;
|
|
try {
|
|
// Clear a stale widget after upgrading/reloading; never register widget content.
|
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
const display = goalDisplay(state);
|
|
const label = `Goal: ${display.label} (/goal, Alt+G)`;
|
|
ctx.ui.setStatus("goal", process.env.NO_COLOR !== undefined
|
|
? label : ctx.ui.theme.fg(display.color, label));
|
|
} catch {
|
|
// Rendering is best-effort; never break the session over it.
|
|
}
|
|
}
|
|
|
|
function notifyStatus(ctx: ExtensionContext): void {
|
|
safeNotify(ctx, goalDetails(state), "info");
|
|
}
|
|
|
|
function checkPrompt(): string {
|
|
const deadline = state.waitTimeoutSeconds && state.activeWait?.wakeSent
|
|
? " Wait deadline reached. Reconcile the dependency once. A deadline is not approval. If still unresolved and no independent work remains, report blocked; do not re-arm or poll."
|
|
: "";
|
|
const next = state.lastNextAction ? ` Execute the recorded next action first: ${state.lastNextAction}.` : "";
|
|
return (
|
|
`[goal check ${state.checks}/${state.maxChecks}; no-progress ${state.noProgressReports}/${state.maxNoProgressReports}]` +
|
|
`${state.activeWait?.wakeRequestId ? `[goal wake ${state.activeWait.wakeRequestId}] ` : ""}${deadline}${next} Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
|
`Report with goal_report exactly once. Use "satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step exists. For "in_progress", do not submit ` +
|
|
`status alone: provide structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) ` +
|
|
`with evidence plus progress.kind, nearest gate, owner, last live measurement, and next action. ` +
|
|
`Measurement/action/delegation requires a successful work tool event; non-tool work ` +
|
|
`requires an artifact; waiting requires an approved watch id or concrete next-check condition. ` +
|
|
`Empty, malformed, or duplicate reports increment the no-progress pause counter.`
|
|
);
|
|
}
|
|
|
|
/** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */
|
|
function beginCheck(ctx: ExtensionContext): void {
|
|
if (dead || state.status !== "active" || hasUnresolvedWait(state) || pendingCheck) return;
|
|
// The loop needs a persistent session. Print mode exits after the prompt
|
|
// pipeline completes; a forced turn there races teardown (measured). The
|
|
// goal directive (before_agent_start) still applies in print mode.
|
|
if (ctx.mode === "print") return;
|
|
state = recordCheckInjected(state);
|
|
if (!persist(ctx)) return;
|
|
renderWidget(ctx);
|
|
const msg = `${checkPrompt()} [goal dispatch ${randomUUID()}]`;
|
|
pendingCheck = msg;
|
|
if (ctx.isIdle()) {
|
|
try {
|
|
pi.sendUserMessage(msg);
|
|
return;
|
|
} catch {
|
|
// fall through to the queued form
|
|
}
|
|
}
|
|
try {
|
|
pi.sendUserMessage(msg, { deliverAs: "followUp" });
|
|
} catch (err) {
|
|
pauseWith(ctx, "check injection failed", "check injection failed — /goal resume after resolving delivery");
|
|
safeNotify(ctx, `goal: check injection failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
}
|
|
}
|
|
|
|
/** Stop reason of the most recent assistant message, or undefined when none. */
|
|
function lastAssistantStopReason(ctx: ExtensionContext): string | undefined {
|
|
try {
|
|
const branch = ctx.sessionManager.getBranch();
|
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
const entry = branch[i];
|
|
if (entry?.type === "message") {
|
|
const msg = entry.message;
|
|
if (msg?.role === "assistant") return msg.stopReason;
|
|
}
|
|
}
|
|
} catch {
|
|
// session introspection is best-effort
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void {
|
|
state = pauseGoal(state, reason);
|
|
pendingCheck = undefined;
|
|
persist(ctx);
|
|
renderWidget(ctx);
|
|
safeNotify(ctx, `goal: ${notice}`, "warning");
|
|
}
|
|
|
|
// ---------- /goal command ----------
|
|
|
|
pi.registerCommand("goal", {
|
|
description: "Set or manage the session goal: <text> | stop | clear | resume | --max N | --wait-timeout S",
|
|
handler: async (args, ctx) => {
|
|
const cmd = parseGoalCommand(args ?? "");
|
|
switch (cmd.kind) {
|
|
case "error":
|
|
safeNotify(ctx, `goal: ${cmd.message}`, "error");
|
|
return;
|
|
case "status":
|
|
notifyStatus(ctx);
|
|
return;
|
|
case "stop":
|
|
if (state.status !== "active") {
|
|
safeNotify(ctx, "goal: nothing active to stop", "warning");
|
|
return;
|
|
}
|
|
pauseWith(ctx, "stopped by operator", "paused — /goal resume to continue");
|
|
return;
|
|
case "clear":
|
|
if (state.status === "none" && !state.lastOutcome) {
|
|
safeNotify(ctx, "goal: no goal set", "warning");
|
|
return;
|
|
}
|
|
state = clearGoal(state);
|
|
pendingCheck = undefined;
|
|
if (!persist(ctx)) return;
|
|
renderWidget(ctx);
|
|
safeNotify(ctx, "goal: cleared — normal operation", "info");
|
|
return;
|
|
case "resume":
|
|
if (state.status !== "paused" && state.status !== "blocked" && !hasUnresolvedWait(state)) {
|
|
safeNotify(ctx, "goal: nothing paused or waiting to resume", "warning");
|
|
return;
|
|
}
|
|
state = resumeGoal(state);
|
|
pendingCheck = undefined;
|
|
if (!persist(ctx)) return;
|
|
renderWidget(ctx);
|
|
safeNotify(ctx, "goal: resumed", "info");
|
|
beginCheck(ctx);
|
|
return;
|
|
case "set": {
|
|
const replacing = state.status !== "none";
|
|
state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds);
|
|
pendingCheck = undefined;
|
|
if (!persist(ctx)) return;
|
|
renderWidget(ctx);
|
|
safeNotify(
|
|
ctx,
|
|
`${replacing ? "goal: replaced" : "goal: set"} — ${truncate(cmd.text, 60)} (cap ${state.maxChecks})`,
|
|
"info",
|
|
);
|
|
beginCheck(ctx);
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerShortcut("alt+g", {
|
|
description: "Show the full NG goal text and status",
|
|
handler: async (ctx) => { notifyStatus(ctx); },
|
|
});
|
|
|
|
// ---------- goal_report tool (the loop's only exit) ----------
|
|
|
|
pi.registerTool({
|
|
name: "goal_report",
|
|
label: "Goal Report",
|
|
description:
|
|
"Report progress on the active operator goal. Required at every goal check-in. " +
|
|
"Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. " +
|
|
'Use "satisfied" with evidence, "blocked" with the reason, or "in_progress" with ' +
|
|
"mechanically backed structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) or an explicit waiting state. Status-only and duplicate " +
|
|
"in_progress reports count toward an automatic no-progress pause.",
|
|
promptSnippet: "Report goal status with evidence and mechanically backed progress",
|
|
parameters: Type.Object({
|
|
status: StringEnum(["satisfied", "blocked", "in_progress"] as const),
|
|
evidence: Type.Optional(
|
|
Type.String({
|
|
description: "Completion evidence, blocker reason, or concise evidence for this work cycle",
|
|
}),
|
|
),
|
|
progress: Type.Optional(
|
|
Type.Object({
|
|
kind: StringEnum(["measurement", "action", "delegation", "wait", "non_tool"] as const),
|
|
gate: Type.String({ description: "Nearest incomplete gate" }),
|
|
owner: Type.String({ description: "Current gate or external-wait owner" }),
|
|
lastMeasurement: Type.String({ description: "Most recent live measurement, including what and when" }),
|
|
nextAction: Type.String({ description: "Next concrete action or check" }),
|
|
artifact: Type.Optional(Type.String({ description: "Required artifact locator for non_tool work" })),
|
|
watchId: Type.Optional(Type.String({ description: "Approved active watch identifier" })),
|
|
nextCheck: Type.Optional(Type.String({ description: "Concrete condition that ends or rechecks a wait" })),
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
if (state.status === "none") {
|
|
return {
|
|
content: [{ type: "text", text: "No goal is active. Nothing to report." }],
|
|
details: { goalStatus: "none" },
|
|
};
|
|
}
|
|
if (state.status !== "active") {
|
|
throw new Error("Goal is paused; only /goal resume or a new operator goal permits reports");
|
|
}
|
|
const reportingState = state;
|
|
// A v4 policy parses and resolves the untrimmed payload before any
|
|
// existing validation, state transition, persistence, or UI operation.
|
|
const policy = goalPolicyPublication(incarnationId);
|
|
if (policy) {
|
|
const attested = await validateAttestedGoalReport(params.evidence, policy, params.status);
|
|
if (!attested.ok) {
|
|
const denial = goalPolicyDenialCode(attested.reason);
|
|
recordGoalPolicyDenial(incarnationId, denial);
|
|
throw new Error(`goal_report rejected by attested policy: ${denial}`);
|
|
}
|
|
}
|
|
|
|
if (state !== reportingState || state.status !== "active") {
|
|
throw new Error("Goal changed while validating report; report was not applied");
|
|
}
|
|
const evidence = (params.evidence ?? "").trim();
|
|
const reportError = validateReport(params.status, evidence);
|
|
if (reportError) {
|
|
throw new Error(reportError);
|
|
}
|
|
|
|
if (params.status === "satisfied") {
|
|
state = completeGoal(state, evidence);
|
|
if (!persist(ctx)) {
|
|
state = pauseGoal(reportingState, "state save failed; completion not recorded");
|
|
throw new Error("Goal completion could not be saved; continuation stopped");
|
|
}
|
|
renderWidget(ctx);
|
|
safeNotify(ctx,
|
|
`goal: satisfied and cleared${evidence ? ` — ${truncate(evidence, 80)}` : ""}`,
|
|
"info",
|
|
);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Goal recorded as SATISFIED. The goal is cleared; normal operation resumes. Do not call goal_report again unless the operator sets a new goal.",
|
|
},
|
|
],
|
|
details: { goalStatus: "none" },
|
|
};
|
|
}
|
|
|
|
if (params.status === "blocked") {
|
|
const reason = evidence || "no reason given";
|
|
state = blockGoal(recordReport(state), truncate(reason, 120));
|
|
if (!persist(ctx)) throw new Error("Goal blocker could not be saved; continuation stopped");
|
|
renderWidget(ctx);
|
|
safeNotify(ctx, `goal: BLOCKED — ${truncate(reason, 100)} — /goal resume after resolving`, "warning");
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Goal recorded as BLOCKED. The loop is paused and the operator has been notified. Do not call goal_report again until the operator resumes or sets a new goal.",
|
|
},
|
|
],
|
|
details: { goalStatus: "blocked" },
|
|
};
|
|
}
|
|
|
|
const outcome = recordInProgressReport(state, {
|
|
evidence,
|
|
progress: params.progress as ProgressDetails | undefined,
|
|
});
|
|
state = outcome.state;
|
|
if (!persist(ctx)) throw new Error("Goal state could not be saved; continuation stopped");
|
|
renderWidget(ctx);
|
|
|
|
if (state.status === "paused") {
|
|
safeNotify(ctx, `${state.pausedReason} — /goal resume after resolving`, "warning");
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Goal paused: ${state.pausedReason} (${outcome.reason}). The operator has been notified. Do not continue this goal until the operator resumes it.`,
|
|
},
|
|
],
|
|
details: { goalStatus: "paused", classification: outcome.classification },
|
|
};
|
|
}
|
|
|
|
const text =
|
|
outcome.classification === "progress"
|
|
? `Recorded substantive progress (${outcome.reason}). Continue with the recorded next action.`
|
|
: outcome.classification === "waiting"
|
|
? (state.waitTimeoutSeconds && ctx.mode !== "print"
|
|
? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.`
|
|
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : "Waiting recorded. Automatic goal checks are suspended. No deadline timer is armed. Keep the existing message/watch delivery path; nextCheck is descriptive, not executable. Reconcile on relevant input or explicit /goal resume. Do not poll or report unchanged status. Checkpoint and yield now."))
|
|
: `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`;
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: { goalStatus: "active", classification: outcome.classification },
|
|
};
|
|
},
|
|
});
|
|
|
|
// ---------- loop wiring ----------
|
|
|
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
if (pendingCheck && event.prompt.includes(pendingCheck)) pendingCheck = undefined;
|
|
if (state.status !== "active") {
|
|
if (event.prompt.startsWith("[goal check ")) {
|
|
return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` };
|
|
}
|
|
return;
|
|
}
|
|
if (state.activeWait?.wakeRequestId && event.prompt.includes(`[goal wake ${state.activeWait.wakeRequestId}]`)) {
|
|
state = { ...state, activeWait: { ...state.activeWait, wakeObserved: true } };
|
|
if (!persist(ctx)) return { systemPrompt: `${event.systemPrompt}\nGoal state save failed. Stop this goal and report the recording failure.` };
|
|
}
|
|
const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print"
|
|
? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses."
|
|
: " Accepted waits suspend automatic goal checks without a deadline timer. Incoming messages do not clear the wait by themselves: on unrelated input preserve it; on relevant input verify the dependency before reporting progress. Do not poll, add timers, or report unchanged status. The operator can use /goal resume to reconcile once.";
|
|
const directive =
|
|
`[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` +
|
|
`Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
|
`At every check-in you MUST call goal_report exactly once. Use ` +
|
|
`"satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step ` +
|
|
`exists. An "in_progress" report MUST follow a concrete action and use structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked), or ` +
|
|
`declare an explicit approved wait/non-tool artifact. Status-only and duplicate reports are no progress. ` +
|
|
`Do not end a turn without reporting.${waitingDirective}`;
|
|
return { systemPrompt: `${event.systemPrompt}\n\n${directive}` };
|
|
});
|
|
|
|
pi.on("tool_result", async (event, ctx) => {
|
|
if (state.status !== "active" || event.isError || event.toolName === "goal_report") return;
|
|
state = recordWorkEvent(state, event.toolName);
|
|
persist(ctx);
|
|
renderWidget(ctx);
|
|
});
|
|
|
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
if (state.status !== "active") return;
|
|
|
|
// Abort/error pause (Q3i) and runaway cap (Q4) live in lib/settle.ts (pure, tested).
|
|
const decision = decideSettle(state, lastAssistantStopReason(ctx));
|
|
if (decision.action === "pause") {
|
|
pauseWith(ctx, decision.reason, `${decision.reason} — /goal resume to continue`);
|
|
return;
|
|
}
|
|
|
|
if (decision.action === "wait") {
|
|
if (deadlinePending) wakeDeadline(ctx);
|
|
else syncWaitTimer(ctx);
|
|
return;
|
|
}
|
|
beginCheck(ctx);
|
|
});
|
|
|
|
// Teardown guard: after session replacement/reload/quit, stop touching ctx.
|
|
pi.on("session_shutdown", async () => {
|
|
dead = true;
|
|
pendingCheck = undefined;
|
|
cancelWaitTimer();
|
|
});
|
|
|
|
// Seat-durable state (Q7b): reload from disk on every session start/reload/resume/new
|
|
// so a fresh session in the same seat picks up the active goal.
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
dead = false;
|
|
state = loadState(statePath);
|
|
syncWaitTimer(ctx);
|
|
renderWidget(ctx);
|
|
if (state.status === "active") {
|
|
safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info");
|
|
// Durability (Q7b): a fresh session with an active goal re-engages the
|
|
// loop immediately — no prior turn exists to emit agent_settled.
|
|
if (!hasUnresolvedWait(state)) beginCheck(ctx);
|
|
} else if (state.status === "paused" || state.status === "blocked") {
|
|
safeNotify(ctx, "goal: paused — /goal resume to continue", "info");
|
|
}
|
|
});
|
|
}
|
|
|
|
function truncate(s: string, n: number): string {
|
|
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
}
|