Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export class AgentSession {
private _retryPromise: Promise<void> | undefined = undefined;
private _retryResolve: (() => void) | undefined = undefined;
private _userAbortPromise: Promise<void> | undefined = undefined;
private _agentAbortSource: "user" | "system" | undefined = undefined;
private _suppressQueuedContinuationAfterUserAbort = false;
private _extensionEventSignal: AbortSignal | undefined = undefined;

Expand Down Expand Up @@ -1629,7 +1630,19 @@ export class AgentSession {
this._turnIndex = 0;
await this._extensionRunner.emit({ type: "agent_start" });
} else if (event.type === "agent_end") {
await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
const abortSource = this._agentAbortSource;
const aborted =
abortSource !== undefined || this._findLastAssistantInMessages(event.messages)?.stopReason === "aborted";
try {
await this._extensionRunner.emit({
type: "agent_end",
messages: event.messages,
...(aborted ? { aborted: true } : {}),
...(abortSource === undefined ? {} : { abortSource }),
});
} finally {
this._agentAbortSource = undefined;
}
} else if (event.type === "turn_start") {
const extensionEvent: TurnStartEvent = {
type: "turn_start",
Expand Down Expand Up @@ -2932,7 +2945,7 @@ export class AgentSession {
*/
async abort(): Promise<void> {
this.abortCompaction();
await this._abortActiveAgentAndRetry();
await this._abortActiveAgentAndRetry("user");
}

async waitForIdle(): Promise<void> {
Expand Down Expand Up @@ -3373,7 +3386,7 @@ export class AgentSession {
admission.finishSessionWork();
}

private async _abortActiveAgentAndRetry(): Promise<void> {
private async _abortActiveAgentAndRetry(source: "user" | "system"): Promise<void> {
this.abortRetry();
this.abortBranchSummary();
if (this._userAbortPromise) {
Expand All @@ -3383,6 +3396,7 @@ export class AgentSession {
}
if (this.isStreaming) {
this._suppressQueuedContinuationAfterUserAbort = true;
this._agentAbortSource = source;
}

const abortPromise = (async () => {
Expand Down Expand Up @@ -3414,7 +3428,7 @@ export class AgentSession {
// Keep the session subscriber attached until the aborted run emits
// agent_end. That event clears the active-run and retry state that
// waitForIdle() depends on.
await this._abortActiveAgentAndRetry();
await this._abortActiveAgentAndRetry("system");
this._disconnectFromAgent();
disconnected = true;
this._emit({ type: "compaction_start", reason: "manual" });
Expand Down Expand Up @@ -4722,7 +4736,7 @@ export class AgentSession {
let disconnected = false;

try {
await this._abortActiveAgentAndRetry();
await this._abortActiveAgentAndRetry("system");
this._disconnectFromAgent();
disconnected = true;
this._emit({ type: "compaction_start", reason: "extension" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ function parsePositiveInt(value: string | undefined): number | undefined {
}

export function resolveBashTimeoutDefaults(env: EnvLike): BashTimeoutDefaults {
const defaultSeconds = parsePositiveInt(env.PI_BASH_DEFAULT_TIMEOUT_SECONDS) ?? BASH_DEFAULT_TIMEOUT_SECONDS;
const rawMax = parsePositiveInt(env.PI_BASH_MAX_TIMEOUT_SECONDS) ?? BASH_MAX_TIMEOUT_SECONDS;
const { PI_BASH_DEFAULT_TIMEOUT_SECONDS: defaultTimeout, PI_BASH_MAX_TIMEOUT_SECONDS: maxTimeout } = env;
const defaultSeconds = parsePositiveInt(defaultTimeout) ?? BASH_DEFAULT_TIMEOUT_SECONDS;
const rawMax = parsePositiveInt(maxTimeout) ?? BASH_MAX_TIMEOUT_SECONDS;
const maxSeconds = Math.max(rawMax, defaultSeconds);
return { defaultSeconds, maxSeconds };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
},
"todowrite": {
"packageName": "pi-todotools",
"version": "0.1.1",
"version": "0.2.0",
"source": "../pi-extensions/pi-todotools"
},
"goal": {
"packageName": "pi-goal",
"version": "0.2.1",
"version": "0.3.0",
"source": "../pi-extensions/pi-goal"
},
"websearch": {
Expand Down
35 changes: 35 additions & 0 deletions packages/coding-agent/src/core/extensions/builtin/goal/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,38 @@ codex-aligned tool naming, and budget-driven behavior removed. An optional
- LOW: `builtin/index.ts` import block + `builtinExtensions` array if upstream
reorders or adds builtins.
- NONE for `extensions/types.ts` (untouched).

## Sync from pi-goal 0.3.0 (2026-07-26)

### Source

- Canonical source: `code-yeongyu/pi-goal` 0.3.0, merged by
[pi-goal PR #1](https://github.com/code-yeongyu/pi-goal/pull/1).
- Version metadata was regenerated through `sync-builtin-extensions.mjs`; this
builtin remains a manual-merge package because of senpi-only structure.

### What changed

- Imported the blocked lifecycle (`blockedReason`/`blockedAt`), model-only
blocked/complete transitions, and blocked continuation suppression.
- `create_goal` now replaces a completed goal after JSONL archival; oversized
objectives are marker-budget truncated and preserve their full text in a
per-thread spill file.
- Aligned tool schemas and guidance with the 4,000-character, complete-replace,
and blocked-audit contract while retaining budget-free behavior.

### Senpi conflict zone: abort detection

- Standalone pi-goal 0.3.0 captures `ctx.signal` and treats any aborted signal
as a user interruption. Senpi deliberately does not retain that heuristic:
todo 11 supplies an internal agent-end aborted flag so only a real user abort
blocks an active goal.
- Follow up upstream: add an aborted flag and source to the published extension
API so standalone pi-goal can remove its `ctx.signal` heuristic too.

### Expected merge conflict zones on the next sync

- HIGH: `store.ts`/`persistence.ts` retain senpi's atomic writes and stale-brace
recovery while upstream owns the lifecycle persistence semantics.
- MEDIUM: `index.ts`, `tool-registration.ts`, and `ui.ts` retain senpi's split
registration, elapsed ticker, and core abort-event integration.
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function registerGoalCommand(pi: ExtensionAPI, deps: GoalCommandRegistrat
if (command.status === "paused") {
await deps.accountCurrentAgentTurn(ctx, "active");
}
const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status });
const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status }, "user");
if (goal.status === "active") {
deps.beginAgentGoalAccounting(goal);
} else {
Expand Down Expand Up @@ -89,7 +89,7 @@ async function setGoalObjective(
if (current?.status === "active") {
await deps.accountCurrentAgentTurn(ctx, "active");
}
const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective });
const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }, "user");
if (goal.status === "active") deps.beginAgentGoalAccounting(goal);
deps.refreshGoalUi(ctx, goal);
ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info");
Expand Down
10 changes: 8 additions & 2 deletions packages/coding-agent/src/core/extensions/builtin/goal/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function goalStatusLabel(status: GoalStatus): string {
return "active";
case "paused":
return "paused";
case "blocked":
return "blocked";
case "complete":
return "complete";
}
Expand All @@ -45,6 +47,7 @@ export function formatGoalForTool(goal: Goal | null): string {
`Time used: ${formatGoalElapsedSeconds(goal.timeUsedSeconds)}`,
`Tokens used: ${formatTokensCompact(goal.tokensUsed)}`,
];
if (goal.blockedReason) lines.push(`Blocked reason: ${goal.blockedReason}`);
if (goal.completedAt) lines.push(`Completed at: ${new Date(goal.completedAt * 1000).toISOString()}`);
return lines.join("\n");
}
Expand All @@ -53,8 +56,9 @@ export function goalToolResponse(goal: Goal | null): GoalToolResponse {
return { goal: goal === null ? null : goalToolSnapshot(goal) };
}

export function formatGoalToolResponse(goal: Goal | null): string {
return JSON.stringify(goalToolResponse(goal), null, 2);
export function formatGoalToolResponse(goal: Goal | null, notice?: string): string {
const response = JSON.stringify(goalToolResponse(goal), null, 2);
return notice === undefined ? response : `${response}\n${notice}`;
}

function goalToolSnapshot(goal: Goal): GoalToolSnapshot {
Expand All @@ -66,6 +70,8 @@ function goalToolSnapshot(goal: Goal): GoalToolSnapshot {
timeUsedSeconds: goal.timeUsedSeconds,
createdAt: goal.createdAt,
updatedAt: goal.updatedAt,
...(goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }),
...(goal.blockedAt === undefined ? {} : { blockedAt: goal.blockedAt }),
};
}

Expand Down
75 changes: 42 additions & 33 deletions packages/coding-agent/src/core/extensions/builtin/goal/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ExtensionAPI, ExtensionContext } from "../../types.ts";
import { registerGoalCommand } from "./command-registration.ts";
import { shouldQueueGoalContinuationAfterAgentEnd, shouldQueueGoalContinuationWhenIdle } from "./continuation.ts";
import { shouldQueueGoalContinuationAfterAgentEnd } from "./continuation.ts";
import { GoalElapsedTicker } from "./elapsed-ticker.ts";
import { formatGoalForTool, goalStatusLabel } from "./format.ts";
import { isResumeOfPausedGoal, queueGoalContinuation, queueHiddenGoalPrompt } from "./lifecycle-helpers.ts";
import { buildContinuationPrompt } from "./prompt.ts";
import { accountGoalUsage, readGoal, updateGoal } from "./store.ts";
import { goalStoreRef as buildGoalStoreRef } from "./store-ref.ts";
Expand All @@ -11,7 +12,6 @@ import { TurnUsageTracker } from "./turn-usage.ts";
import type { Goal, GoalAccountingMode, GoalStoreRef } from "./types.ts";
import { updateGoalUi } from "./ui.ts";

const GOAL_CONTINUATION_MESSAGE_TYPE = "goal-continuation";
const RESUME_GOAL_CHOICE = "Resume goal";
const LEAVE_GOAL_PAUSED_CHOICE = "Leave paused";
const STALE_EXTENSION_CONTEXT_ERROR_PREFIX = "This extension ctx is stale after session replacement or reload.";
Expand All @@ -24,7 +24,9 @@ type AgentGoalAccounting = {
export default function goalExtension(pi: ExtensionAPI): void {
let agentTurnInProgress = false;
let agentGoalAccounting: AgentGoalAccounting | null = null;
let blockedThisTurnGoalId: string | null = null;
let completedThisTurnGoalId: string | null = null;
let nextAgentStartWasUserTriggered = false;
const turnUsage = new TurnUsageTracker();

const goalTicker = new GoalElapsedTicker({
Expand All @@ -42,6 +44,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
goalStoreRef: (ctx) => buildGoalStoreRef(ctx.sessionManager, ctx.cwd),
accountCurrentAgentTurn,
beginAgentGoalAccounting,
markGoalBlockedThisTurn,
markGoalCompletedThisTurn,
refreshGoalUi,
});
Expand All @@ -66,16 +69,24 @@ export default function goalExtension(pi: ExtensionAPI): void {
if (await maybePromptResumePausedGoal(pi, ctx, event.reason, goal)) {
return;
}
if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) {
queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal));
}
if (goal) queueGoalContinuation(pi, ctx, goal);
});

pi.on("before_agent_start", async () => {
nextAgentStartWasUserTriggered = true;
});

pi.on("agent_start", async (_event, ctx) => {
const userTriggered = nextAgentStartWasUserTriggered;
nextAgentStartWasUserTriggered = false;
agentTurnInProgress = true;
blockedThisTurnGoalId = null;
completedThisTurnGoalId = null;
turnUsage.reset();
const goal = await readGoal(goalStoreRef(ctx));
let goal = await readGoal(goalStoreRef(ctx));
if (userTriggered && goal?.status === "blocked") {
goal = await updateGoal(goalStoreRef(ctx), { status: "active" }, "user");
}
if (goal?.status === "active") {
beginAgentGoalAccounting(goal);
} else {
Expand All @@ -88,10 +99,23 @@ export default function goalExtension(pi: ExtensionAPI): void {
});

pi.on("agent_end", async (event, ctx) => {
const mode: GoalAccountingMode = completedThisTurnGoalId === null ? "active" : "activeOrComplete";
const goal = await accountCurrentAgentTurn(ctx, mode, event.messages);
const mode: GoalAccountingMode =
blockedThisTurnGoalId !== null
? "activeOrBlocked"
: completedThisTurnGoalId === null
? "active"
: "activeOrComplete";
let goal = await accountCurrentAgentTurn(ctx, mode, event.messages);
agentTurnInProgress = false;
blockedThisTurnGoalId = null;
completedThisTurnGoalId = null;
if (event.aborted === true && event.abortSource === "user" && goal?.status === "active") {
goal = await updateGoal(
goalStoreRef(ctx),
{ status: "blocked", reason: "user interrupted the turn" },
"model",
);
}
if (goal?.status === "active") {
beginAgentGoalAccounting(goal);
} else {
Expand All @@ -100,7 +124,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
refreshGoalUiBestEffort(ctx, goal);
if (
goal?.status === "active" &&
!ctx.signal?.aborted &&
!event.aborted &&
shouldQueueGoalContinuationAfterAgentEnd(goal, ctx.hasPendingMessages(), event.messages)
) {
queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal));
Expand Down Expand Up @@ -131,7 +155,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
]);
if (choice !== RESUME_GOAL_CHOICE) return true;

const resumed = await updateGoal(goalStoreRef(ctx), { status: "active" });
const resumed = await updateGoal(goalStoreRef(ctx), { status: "active" }, "user");
beginAgentGoalAccounting(resumed);
refreshGoalUi(ctx, resumed);
ctx.ui.notify(`Goal ${goalStatusLabel(resumed.status)}\n${formatGoalForTool(resumed)}`, "info");
Expand All @@ -146,6 +170,10 @@ export default function goalExtension(pi: ExtensionAPI): void {
agentGoalAccounting = { goalId: goal.id, measuredFromMilliseconds: Date.now() };
}

function markGoalBlockedThisTurn(goal: Goal): void {
if (agentTurnInProgress) blockedThisTurnGoalId = goal.id;
}

function markGoalCompletedThisTurn(goal: Goal): void {
if (!agentTurnInProgress) return;
completedThisTurnGoalId = goal.id;
Expand All @@ -156,13 +184,17 @@ export default function goalExtension(pi: ExtensionAPI): void {
if (agentGoalAccounting?.goalId === goalId) {
agentGoalAccounting = null;
}
if (blockedThisTurnGoalId === goalId) {
blockedThisTurnGoalId = null;
}
if (completedThisTurnGoalId === goalId) {
completedThisTurnGoalId = null;
}
}

function clearAgentGoalAccounting(): void {
agentGoalAccounting = null;
blockedThisTurnGoalId = null;
completedThisTurnGoalId = null;
}

Expand Down Expand Up @@ -210,29 +242,6 @@ export default function goalExtension(pi: ExtensionAPI): void {
}
}

function isResumeOfPausedGoal(ctx: ExtensionContext, sessionStartReason: string, goal: Goal | null): goal is Goal {
return (
sessionStartReason === "resume" &&
goal?.status === "paused" &&
ctx.hasUI &&
ctx.isIdle() &&
!ctx.hasPendingMessages()
);
}

function queueGoalContinuation(pi: ExtensionAPI, ctx: ExtensionContext, goal: Goal): void {
if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) {
queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal));
}
}

function queueHiddenGoalPrompt(pi: ExtensionAPI, content: string): void {
pi.sendMessage(
{ customType: GOAL_CONTINUATION_MESSAGE_TYPE, content, display: false },
{ triggerTurn: true, deliverAs: "followUp" },
);
}

function goalStoreRef(ctx: ExtensionContext): GoalStoreRef {
return buildGoalStoreRef(ctx.sessionManager, ctx.cwd);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ExtensionAPI, ExtensionContext } from "../../types.ts";
import { shouldQueueGoalContinuationWhenIdle } from "./continuation.ts";
import { buildContinuationPrompt } from "./prompt.ts";
import type { Goal } from "./types.ts";

const GOAL_CONTINUATION_MESSAGE_TYPE = "goal-continuation";

export function isResumeOfPausedGoal(
ctx: ExtensionContext,
sessionStartReason: string,
goal: Goal | null,
): goal is Goal {
return (
sessionStartReason === "resume" &&
goal?.status === "paused" &&
ctx.hasUI &&
ctx.isIdle() &&
!ctx.hasPendingMessages()
);
}

export function queueGoalContinuation(pi: ExtensionAPI, ctx: ExtensionContext, goal: Goal): void {
if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) {
queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal));
}
}

export function queueHiddenGoalPrompt(pi: ExtensionAPI, content: string): void {
pi.sendMessage(
{ customType: GOAL_CONTINUATION_MESSAGE_TYPE, content, display: false },
{ triggerTurn: true, deliverAs: "followUp" },
);
}
Loading