Files
hapi/cli/src/pi/loop.ts
T
SSU-WEI HUANGandGitHub a6feb6e8ba feat: unified agent configuration descriptors (session config consolidation) (#1469)
* feat(config): add agent config descriptor protocol and advertise via runner capability

Introduce shared agent configuration descriptors covering model, effort,
permission, and secondary settings per agent flavor, plus the canonical
HAPI YOLO -> native permission mode mapping. Runners advertise the
builtin descriptors through the runner-state capability so hubs and web
can render configuration without hardcoded flavor branches.

Migrate the OpenCode create-session model picker from a bespoke radio
list to the shared SelectControl combobox.

* feat(web): render create-session permission from agent config descriptor

Replace the flavor-branched Grok/Codex-family/YOLO permission block with
a descriptor-driven PermissionField. Pi now reports permission as managed
instead of silently ignoring the YOLO toggle, and YOLO-only flavors show
the native permission mode the preference maps to.

Removes the superseded GrokPermissionModeSelector and
CodexFamilyPermissionModeSelector components.

* ci: retry flaky claudeRemote 5s-timeout failure

* fix(web): persist explicit OpenCode Default selection instead of restoring a concrete model

The parent initialization effect treated every null selected model as
'uninitialized' and auto-picked a concrete advertised model, clobbering
the user's explicit Default choice (and a restored Default preference).
null now means explicit Default and is preserved; only undefined (no
choice made yet) triggers probe-based initialization. Add parent-level
regression tests for Default persistence and remembered-model restore.

* fix(web): accept undefined selected model in OpencodeModelSelector props

* feat(web+cli+hub): unify create-session model/effort fields and add Pi model/effort support

Pi's agent config descriptor now advertises model (machine) and effort
(static thinking levels) for create AND session availability:
- cli: ListPiModelsForMachine RPC runs 'pi --list-models' (cached, inflight
  deduped) and parses the provider/model table; startup model match accepts
  provider-qualified ids
- hub: GET /api/machines/:id/pi-models route + rpcGateway/syncEngine passthrough
- web: NewSession renders Pi models grouped by provider through the generic
  ModelSelector and a new descriptor-driven EffortField (replaces the
  per-flavor LaunchEffortSelector/ReasoningEffortSelector pair); launch payload
  forwards Pi model + thinking-level effort (runner already supported --model/
  --effort for pi)

* fix(web): render Pi provider groups in ModelSelector and scope Grok availability warning

- ModelSelector now renders grouped options as <optgroup> (Pi models are
  provider-grouped; identical modelIds from different providers stay distinct)
- PermissionField only receives autoPermissionModeSupported for Grok — a
  cached Grok probe result no longer leaks the Grok warning onto other agents

Addresses HAPI Bot Minor findings on #1469.

* fix(web): drop Object.groupBy from ModelSelector; revalidate restored Pi models against the catalog

- ModelSelector buckets options with a reduce instead of Object.groupBy
  (Safari < 17.4 has no polyfill — New Session would throw on those clients)
- Pi restored model/effort are cleared when the value is absent from the live
  machine catalog, and Create waits for the catalog while a non-default Pi
  choice is being validated (mirrors Codex/Grok/Copilot handling)

Addresses HAPI Bot findings on #1469.

* fix(pi+web): serialize startup model before thinking level; hide Pi launch controls during history import

- PiSession gains startupModelSettled; the startup set_thinking_level waits for
  the requested model's set_model attempt to settle first, so a level the
  default model rejects is not lost before the requested model is confirmed
  (set_model and set_thinking_level were already serialized by the runtime
  mutation lock; this pins the model-first ordering)
- Create Session hides Pi model/effort controls while a Pi history import is
  selected — the import reopens the native session as-is and would silently
  ignore launch-only model/effort values

Addresses HAPI Bot findings on #1469.

* fix(pi): settle startup-model gate when model discovery fails or returns no models

A failed or empty get_available_models response would leave the
startupModelSettled gate unresolved, stranding a requested startup effort
indefinitely. Resolve the gate on the error path and the empty-models path;
adds regression tests for both.
2026-08-16 22:43:39 +08:00

909 lines
42 KiB
TypeScript

import { logger } from '@/ui/logger';
import { convertAgentMessage } from '@/agent/messageConverter';
import { createNativeSessionTitleMetadataSync } from '@/agent/nativeSessionTitle';
import { PiTransport } from './piTransport';
import { convertPiEvent, convertPiTurnUsage } from './piEventConverter';
import { PiMessageAccumulator } from './piMessageAccumulator';
import { PiExtensionUiHandler } from './extensionUiHandler';
import { parsePiModels, parsePiCommands, parsePiContextUsage, PiAgentEndEventSchema, PiAgentSettledEventSchema, PiExtensionUiRequestSchema, PiLifecycleEventSchema, PiResponseEventSchema, PiSessionInfoChangedEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas';
import type { PiContextUsage, PiResponseEvent, PiRpcCommand, PiThinkingLevel, PiTurnEndEvent } from './types';
import type { PiSession } from './session';
import type { PiConversationHistory } from './conversationHistory';
// --- Response parsers: re-exported from schemas.ts ---
export { parsePiModels, parsePiCommands, parsePiContextUsage } from './schemas';
// --- Pending RPC resolver ---
// Instance-scoped: created once by wireTransportEvents, stored on PiSession.
export class PiRpcTimeoutError extends Error {
readonly command: string;
readonly requestId: number;
readonly timeoutMs: number;
constructor(command: string, requestId: number, timeoutMs: number) {
super(`Pi RPC ${command} (id=${requestId}) timed out after ${timeoutMs}ms`);
this.name = 'PiRpcTimeoutError';
this.command = command;
this.requestId = requestId;
this.timeoutMs = timeoutMs;
}
}
export class PiRpcResolver {
private idCounter = 0;
private terminalError: Error | null = null;
private readonly pending = new Map<number, {
resolve: (data: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
sendAndWait(transport: PiTransport, command: Record<string, unknown>, timeoutMs = 10_000): Promise<unknown> {
if (this.terminalError) return Promise.reject(this.terminalError);
const id = ++this.idCounter;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new PiRpcTimeoutError(String(command.type), id, timeoutMs));
}, timeoutMs);
this.pending.set(id, {
timer,
resolve: (data) => { clearTimeout(timer); this.pending.delete(id); resolve(data); },
reject: (error) => { clearTimeout(timer); this.pending.delete(id); reject(error); },
});
transport.send({ ...command, id: String(id) } as unknown as PiRpcCommand);
});
}
rejectAll(error: Error): void {
this.terminalError ??= error;
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
this.pending.delete(id);
pending.reject(error);
}
}
resolveResponse(raw: unknown): void {
const parsed = PiResponseEventSchema.safeParse(raw);
if (!parsed.success) return;
const response = parsed.data;
const rawId = response.id;
if (rawId !== undefined) {
const numericId = Number(rawId);
if (!Number.isNaN(numericId)) {
const resolver = this.pending.get(numericId);
if (resolver) {
if (response.success) {
resolver.resolve(response.data);
} else {
resolver.reject(new Error(response.error ?? 'Unknown error'));
}
}
}
}
}
}
export function sendPiRpcAndWait(session: PiSession, transport: PiTransport, command: Record<string, unknown>, timeoutMs = 10_000): Promise<unknown> {
if (!session.rpcResolver) throw new Error('Pi RPC resolver not initialized');
return session.rpcResolver.sendAndWait(transport, command, timeoutMs);
}
function resolvePendingRpc(resolver: PiRpcResolver, response: PiResponseEvent): void {
resolver.resolveResponse(response);
}
// Mirror the web picker's provider-qualified selection into metadata so the hub
// and web can disambiguate duplicate modelId values across providers. The web
// /sessions/:id/model path already writes piSelectedModel via persistPiSelectedModel;
// these runtime paths (get_state, startup set_model, successful set_model response)
// previously only keepAlive'd the bare modelId, so a Pi session on Pi's default model
// or started with --model could render/filter against the wrong provider.
function persistSelectedPiModel(session: PiSession): void {
const modelId = session.currentModel;
const provider = session.currentProvider;
if (!modelId || !provider) return;
session.updateMetadata((meta) => ({
...meta,
piSelectedModel: { provider, modelId },
}));
}
// --- Response handler ---
function applyGetState(
data: {
model?: { id?: string; modelId?: string; provider?: string };
sessionId?: string;
sessionName?: string;
thinkingLevel?: string;
steeringMode?: 'all' | 'one-at-a-time';
isStreaming?: boolean;
},
session: PiSession,
syncNativeTitle: (title: unknown) => void,
applyStreamingState = true,
): void {
if (data.model) {
// Pi returns model.id (not modelId). Fallback to modelId for forward compat.
const newModel = data.model.id ?? data.model.modelId ?? session.currentModel;
if (data.model.provider && data.model.provider.length > 0) {
session.currentProvider = data.model.provider;
}
// Do NOT overwrite currentModel with the unconfirmed startup model here.
// The requested startup model is applied (and committed) only after
// get_available_models confirms it exists and Pi accepts set_model;
// reporting Pi's actual current model until then keeps the hub in sync
// if the requested model is unavailable or rejected.
session.currentModel = newModel ?? session.currentModel;
if (session.initialModel) {
logger.debug(`[pi] Startup model requested: ${session.initialModel} (will apply once available models arrive); Pi default model: ${newModel ?? 'unknown'}`);
} else if (newModel) {
logger.debug(`[pi] Initial model: ${newModel} (provider=${session.currentProvider ?? 'unknown'})`);
}
// Pi reported its actual model+provider; persist the provider-qualified
// selection so the web can disambiguate (a startup --model overrides this
// once get_available_models confirms and applies it below).
persistSelectedPiModel(session);
}
if (data.sessionId) {
session.updateMetadata((meta) => ({ ...meta, piSessionId: data.sessionId }));
logger.debug(`[pi] Session ID persisted to metadata: ${data.sessionId}`);
}
syncNativeTitle(data.sessionName);
if (data.thinkingLevel) {
session.currentThinkingLevel = data.thinkingLevel as PiThinkingLevel;
logger.debug(`[pi] Initial thinking level: ${data.thinkingLevel}`);
}
if (data.steeringMode) {
session.currentSteeringMode = data.steeringMode;
}
if (data.isStreaming !== undefined && applyStreamingState) {
// get_state is Pi's authoritative current-session snapshot. Synchronize
// only its explicit boolean, never infer state from unknown event types.
session.updateThinkingState(data.isStreaming);
}
}
function handleResponse(
response: PiResponseEvent,
session: PiSession,
pendingLocalIds: string[],
transport?: PiTransport,
onStartupFailure?: (error: Error) => void,
conversationHistory?: PiConversationHistory,
onReady?: () => void,
shouldApplyGetStateStreaming?: (isStreaming: boolean) => boolean,
syncNativeTitle?: (title: unknown) => void,
): { rejectedPromptLocalId?: string } {
const { command, success } = response;
const resolver = session.rpcResolver!;
if (!success) {
const error = response.error ?? 'Unknown Pi error';
logger.debug(`[pi] RPC error for ${command}: ${error}`);
resolvePendingRpc(resolver, response);
// get_session_stats is a best-effort compatibility probe. Older Pi
// versions may reject it, so fall back silently instead of surfacing an
// error event to the user on every completed turn. compact/set_model
// are owned by the awaited slash-command/config handlers, which report
// their own formatted failure message.
if (!['get_session_stats', 'steer', 'compact', 'set_model'].includes(command)) {
session.sendSessionEvent({ type: 'message', message: error });
}
if (command === 'prompt' && pendingLocalIds.length > 0) {
const oldestLocalId = pendingLocalIds.shift()!;
session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true });
conversationHistory?.rejectPendingEntry(oldestLocalId);
return { rejectedPromptLocalId: oldestLocalId };
}
// A failed initial get_state means Pi did not load its native session.
// Do not leave the HAPI wrapper alive until the hub's ready timeout: the
// caller tears down the process so the archived row can be restored.
// A fresh Pi session keeps the historic non-fatal fallback behavior;
// only a requested native resume must fail closed.
if (command === 'get_state' && session.expectedNativeSessionId && !session.isNativeReady) {
onStartupFailure?.(new Error(`Pi get_state failed: ${error}`));
}
// A failed model discovery must not strand a startup effort that waits
// on the startup-model gate (see runPi startup effort).
if (command === 'get_available_models') {
session.resolveStartupModelSettled?.();
}
return {};
}
switch (command) {
case 'get_state': {
const parsed = PiStateDataSchema.safeParse(response.data);
// Pi has finished startup init (this is the response that persists
// metadata.piSessionId). It is also the only native-ready signal
// that the hub trusts for Pi resume; session-alive only proves the
// HAPI wrapper connected. Validate a requested native session before
// mutating model/metadata state: an invalid resume must not publish a
// colliding piSessionId that auto-dedup could merge.
if (!parsed.success) {
resolvePendingRpc(resolver, response);
if (session.expectedNativeSessionId) {
onStartupFailure?.(new Error('Pi get_state returned malformed state data'));
}
break;
}
const state = parsed.data;
if (!session.isHistoryTransactionActive && !session.matchesExpectedNativeSessionId(state.sessionId)) {
const actual = state.sessionId ? state.sessionId : '(missing)';
const error = `Pi loaded unexpected native session ${actual} instead of ${session.expectedNativeSessionId}`;
logger.debug(`[pi] ${error}`);
session.sendSessionEvent({ type: 'message', message: error });
onStartupFailure?.(new Error(error));
break;
}
// Emit ready before publishing Pi metadata. On native resume, this
// ensures the hub can never merge based on a piSessionId before the
// get_state identity check has completed.
// History transactions deliberately switch Pi through temporary
// clone/fork identities. Resolve their awaited get_state request,
// but never publish the temporary identity/model to the source row.
if (!session.isHistoryTransactionActive) {
session.markNativeReady();
const applyStreamingState = state.isStreaming === undefined
|| shouldApplyGetStateStreaming?.(state.isStreaming) !== false;
if (!applyStreamingState) {
logger.debug('[pi] Ignoring get_state isStreaming=false during an active prompt lifecycle');
}
applyGetState(state, session, syncNativeTitle ?? (() => {}), applyStreamingState);
onReady?.();
}
resolvePendingRpc(resolver, response);
break;
}
case 'set_model': {
const parsed = PiSetModelDataSchema.safeParse(response.data);
if (parsed.success) {
const data = parsed.data;
const modelId = data.id ?? data.modelId;
if (modelId) {
session.currentModel = modelId;
}
if (data.provider && data.provider.length > 0) {
session.currentProvider = data.provider;
}
persistSelectedPiModel(session);
logger.debug(`[pi] Model changed to: ${modelId ?? session.currentModel}`);
}
// set_model is awaited by SetSessionConfig (Fix #9); without this
// the awaited RPC would time out and /sessions/:id/model return 409.
resolvePendingRpc(resolver, response);
break;
}
case 'set_thinking_level': {
// Awaited by SetSessionConfig (Fix #9 symmetry with set_model).
// currentThinkingLevel is maintained by the SetSessionConfig
// handler, so this branch only resolves the pending RPC — without
// it the awaited call times out and /sessions/:id/effort returns 409.
resolvePendingRpc(resolver, response);
break;
}
case 'get_available_models': {
const models = parsePiModels(response.data);
if (models.length > 0) {
session.cachedPiModels = models;
logger.debug(`[pi] Available models: ${models.map((m) => m.modelId).join(', ')}`);
session.updateMetadata((meta) => ({
...meta,
piAvailableModels: models,
}));
// Apply the requested startup model only after confirming it exists
// in Pi's available models and Pi accepts set_model. Commit
// currentModel/currentProvider only on success so the hub does not
// persist a model Pi rejected or never had. Fire-and-forget the
// await so resolving the get_available_models RPC itself is not
// blocked (it may be awaited by ListPiModels).
if (session.initialModel && transport) {
const match = models.find((m) => m.modelId === session.initialModel)
?? models.find((m) => `${m.provider}/${m.modelId}` === session.initialModel);
if (match) {
void (async () => {
try {
await session.runRuntimeMutation(async () => {
await sendPiRpcAndWait(session, transport, {
type: 'set_model',
provider: match.provider,
modelId: match.modelId,
});
session.currentModel = match.modelId;
session.currentProvider = match.provider;
persistSelectedPiModel(session);
}, { poisonOnError: (error) => error instanceof PiRpcTimeoutError });
logger.debug(`[pi] Startup model applied: ${match.provider}/${match.modelId}`);
} catch (error) {
if (error instanceof PiRpcTimeoutError) {
onStartupFailure?.(new Error(`Pi startup model outcome is indeterminate: ${error.message}`));
session.resolveStartupModelSettled?.();
return;
}
const detail = error instanceof Error ? error.message : String(error);
logger.debug(`[pi] Startup model set_model rejected, keeping Pi default: ${detail}`);
session.sendSessionEvent({
type: 'message',
message: `⚠️ Startup model switch failed: ${detail}`,
});
}
session.resolveStartupModelSettled?.();
})();
} else {
logger.debug(`[pi] Startup model not found in available models: ${session.initialModel}`);
session.resolveStartupModelSettled?.();
}
} else {
session.resolveStartupModelSettled?.();
}
} else {
// Empty discovery — settle the startup-model gate so a waiting
// startup effort does not strand (nothing to match against).
session.resolveStartupModelSettled?.();
}
resolvePendingRpc(resolver, response);
break;
}
case 'get_commands': {
const commands = parsePiCommands(response.data);
if (commands.length > 0) {
session.cachedPiCommands = commands;
logger.debug(`[pi] Available commands: ${commands.map((c) => c.name).join(', ')}`);
}
resolvePendingRpc(resolver, response);
break;
}
case 'new_session':
logger.debug('[pi] Pi session initialized');
break;
case 'abort':
logger.debug('[pi] Abort confirmed');
resolvePendingRpc(resolver, response);
break;
case 'prompt':
logger.debug('[pi] Prompt accepted');
break;
case 'steer':
logger.debug('[pi] Steer accepted');
resolvePendingRpc(resolver, response);
break;
default:
logger.debug(`[pi] Response for ${command}`);
resolvePendingRpc(resolver, response);
break;
}
return {};
}
const PI_CONTEXT_USAGE_RPC_TIMEOUT_MS = 1_000;
async function publishPiTurnUsage(
event: PiTurnEndEvent,
transport: PiTransport,
session: PiSession,
isLatestRequest: () => boolean,
): Promise<void> {
let contextUsage: PiContextUsage | null | undefined;
try {
const stats = await sendPiRpcAndWait(
session,
transport,
{ type: 'get_session_stats' },
PI_CONTEXT_USAGE_RPC_TIMEOUT_MS,
);
contextUsage = parsePiContextUsage(stats);
} catch (error) {
// Unsupported/failed stats RPC: convertPiTurnUsage falls back to the
// positive per-turn totalTokens value. The fallback is intentionally
// local to Pi so providers with different usage semantics are untouched.
logger.debug(`[pi] get_session_stats unavailable, using turn usage fallback: ${error instanceof Error ? error.message : String(error)}`);
contextUsage = undefined;
}
// RPC responses can arrive after a newer turn has already completed.
// Publishing only the newest request prevents stale context values from
// overwriting a later turn's usage state.
if (!isLatestRequest()) return;
const usageMessage = convertPiTurnUsage(event, contextUsage);
if (!usageMessage) return;
const converted = convertAgentMessage(usageMessage, session.currentModel);
if (converted) session.sendAgentMessage(converted);
}
// --- Wire transport events to session ---
export type PiTransportEventController = {
flush: () => void;
cancelPendingExtensionUi: (reason: string, options?: { sendResponse?: boolean }) => void;
terminatePendingRpc: (error: Error) => void;
beginPromptLifecycle: (promptId: string) => void;
abortPromptLifecycle: () => void;
};
type PiTransportEventOptions = {
onStartupFailure?: (error: Error) => void;
onReady?: () => void;
/** Observes each Pi agent_start/turn_start without releasing the prompt queue. */
onAgentLifecycleStarted?: () => void;
onAgentSettled?: () => void;
onPromptRejected?: (localId?: string) => void;
/** Return false to keep this prompt generation open for a late lifecycle. */
onPromptLifecycleMissing?: (localId?: string) => void | boolean;
conversationHistory?: PiConversationHistory;
};
const PI_LEGACY_SETTLE_GRACE_MS = 500;
const PI_PROMPT_LIFECYCLE_GRACE_MS = 1_000;
const PI_COMPACTION_RETRY_START_GRACE_MS = 1_000;
class PiLifecycleTimeline {
private compacting = false;
private activeRetryKey: string | null = null;
private summaryRetryActive = false;
emit(event: unknown, session: PiSession): void {
const parsed = PiLifecycleEventSchema.safeParse(event);
if (!parsed.success) return;
const lifecycle = parsed.data;
let message: string | null = null;
switch (lifecycle.type) {
case 'compaction_start':
if (this.compacting) return;
this.compacting = true;
message = '📦 Compaction started';
break;
case 'compaction_end':
if (!this.compacting) return;
this.compacting = false;
message = lifecycle.aborted
? '📦 Compaction canceled'
: lifecycle.errorMessage
? `📦 Compaction failed: ${lifecycle.errorMessage}`
: lifecycle.willRetry
? '📦 Compaction will retry'
: '📦 Compaction completed';
break;
case 'auto_retry_start': {
const key = `${lifecycle.attempt}:${lifecycle.errorMessage}`;
if (this.activeRetryKey === key) return;
this.activeRetryKey = key;
message = `↻ Retrying after error (attempt ${lifecycle.attempt}/${lifecycle.maxAttempts}): ${lifecycle.errorMessage}`;
break;
}
case 'auto_retry_end':
if (this.activeRetryKey === null) return;
this.activeRetryKey = null;
message = lifecycle.success
? `↻ Retry succeeded (attempt ${lifecycle.attempt})`
: `↻ Retry failed (attempt ${lifecycle.attempt})${lifecycle.finalError ? `: ${lifecycle.finalError}` : ''}`;
break;
case 'summarization_retry_scheduled':
if (this.summaryRetryActive) return;
this.summaryRetryActive = true;
message = `📝 Summary retry scheduled (attempt ${lifecycle.attempt}/${lifecycle.maxAttempts}): ${lifecycle.errorMessage}`;
break;
case 'summarization_retry_attempt_start':
if (!this.summaryRetryActive) return;
message = `📝 Summary retry started (${lifecycle.source})`;
break;
case 'summarization_retry_finished':
if (!this.summaryRetryActive) return;
this.summaryRetryActive = false;
message = '📝 Summary retry completed';
break;
}
if (message) session.sendSessionEvent({ type: 'message', message });
}
}
export function wireTransportEvents(
transport: PiTransport,
session: PiSession,
pendingLocalIds: string[],
options: PiTransportEventOptions = {},
): PiTransportEventController {
session.rpcResolver = new PiRpcResolver();
const assistantMessageAccumulator = new PiMessageAccumulator();
const extensionUi = new PiExtensionUiHandler({
session: session.client,
sendResponse: (response) => transport.send(response),
});
// Shared dedup state for native Pi titles (get_state startup/resume and
// live session_info_changed renames) so repeated identical names do not
// trigger redundant updateMetadata calls.
const syncNativeTitle = createNativeSessionTitleMetadataSync(session.client);
const lifecycleTimeline = new PiLifecycleTimeline();
let latestContextUsageRequest = 0;
let deliveredSettlement = false;
let legacySettleTimer: ReturnType<typeof setTimeout> | null = null;
let promptLifecycleTimer: ReturnType<typeof setTimeout> | null = null;
let compactionRetryTimer: ReturnType<typeof setTimeout> | null = null;
const maintenanceActive = new Set<'compaction' | 'compactionRetry' | 'autoRetry' | 'summary'>();
let agentEndObserved = false;
let agentLifecycleSeen = false;
let lifecycleGeneration = 0;
let activePromptId: string | null = null;
let activePromptResponseAccepted = false;
let activeAgentSettledSeen = false;
let promptLifecycleAborted = false;
// turn_start consumes the FIFO entry before the prompt response is known.
// Retain that exact local ID so a later matching response failure can reject
// the history registration even after pendingLocalIds has been drained.
let activePromptLocalId: string | undefined;
const clearLegacySettleFallback = (): void => {
if (legacySettleTimer) clearTimeout(legacySettleTimer);
legacySettleTimer = null;
};
const clearPromptLifecycleFallback = (): void => {
if (promptLifecycleTimer) clearTimeout(promptLifecycleTimer);
promptLifecycleTimer = null;
};
const clearCompactionRetryPending = (): void => {
maintenanceActive.delete('compactionRetry');
if (compactionRetryTimer) clearTimeout(compactionRetryTimer);
compactionRetryTimer = null;
};
const beginPromptLifecycle = (promptId: string): void => {
lifecycleGeneration += 1;
activePromptId = promptId;
promptLifecycleAborted = false;
activePromptResponseAccepted = false;
activeAgentSettledSeen = false;
activePromptLocalId = undefined;
deliveredSettlement = false;
agentEndObserved = false;
agentLifecycleSeen = false;
maintenanceActive.clear();
clearCompactionRetryPending();
clearLegacySettleFallback();
clearPromptLifecycleFallback();
};
const abortPromptLifecycle = (): void => {
lifecycleGeneration += 1;
activePromptId = null;
promptLifecycleAborted = true;
activePromptResponseAccepted = false;
activeAgentSettledSeen = false;
activePromptLocalId = undefined;
deliveredSettlement = false;
agentEndObserved = false;
agentLifecycleSeen = false;
maintenanceActive.clear();
clearCompactionRetryPending();
clearLegacySettleFallback();
clearPromptLifecycleFallback();
};
const rejectPromptLifecycle = (): void => {
// A rejected prompt is terminal for this generation. Invalidate all
// delayed settlement work before notifying runPi so it can immediately
// resume FIFO pumping without a stale grace timer changing its state.
lifecycleGeneration += 1;
activePromptId = null;
promptLifecycleAborted = true;
activePromptResponseAccepted = false;
activeAgentSettledSeen = false;
activePromptLocalId = undefined;
deliveredSettlement = true;
agentEndObserved = false;
agentLifecycleSeen = false;
maintenanceActive.clear();
clearCompactionRetryPending();
latestContextUsageRequest += 1;
clearLegacySettleFallback();
clearPromptLifecycleFallback();
flushAccumulator();
session.updateThinkingState(false);
};
const deliverSettlement = (): void => {
if (deliveredSettlement || (activePromptId !== null && !activePromptResponseAccepted)) return;
deliveredSettlement = true;
clearCompactionRetryPending();
clearLegacySettleFallback();
clearPromptLifecycleFallback();
session.updateThinkingState(false);
if (options.conversationHistory) {
// The settled notification fires after an async history sync. A new
// lifecycle (e.g. an autonomous wake-up) can begin in the meantime;
// generation-scope the callback so a stale settlement cannot mark
// that newer lifecycle's abort boundary as settled.
const settlementGeneration = lifecycleGeneration;
void options.conversationHistory.syncEntries()
.catch(() => {})
.finally(() => {
if (settlementGeneration === lifecycleGeneration) {
options.onAgentSettled?.();
}
});
} else {
options.onAgentSettled?.();
}
};
const scheduleLegacySettleFallback = (): void => {
if ((activePromptId !== null && !activePromptResponseAccepted) || !agentEndObserved || maintenanceActive.size > 0 || deliveredSettlement || legacySettleTimer) return;
legacySettleTimer = setTimeout(() => {
legacySettleTimer = null;
deliverSettlement();
}, PI_LEGACY_SETTLE_GRACE_MS);
legacySettleTimer.unref?.();
};
const schedulePromptLifecycleFallback = (): void => {
const generation = lifecycleGeneration;
clearPromptLifecycleFallback();
promptLifecycleTimer = setTimeout(() => {
promptLifecycleTimer = null;
void (async () => {
if (generation !== lifecycleGeneration || deliveredSettlement || agentLifecycleSeen) return;
// Some Pi integrations omit entry_appended even for successful
// command-only prompts. Read the append log before runPi retires
// the pending localId, otherwise the next user entry can inherit
// this prompt's native history association.
if (options.conversationHistory) {
try {
await options.conversationHistory.syncEntries();
} catch (error) {
if (generation !== lifecycleGeneration || deliveredSettlement || agentLifecycleSeen) return;
const detail = error instanceof Error ? error.message : String(error);
// Continuing would discard the only FIFO association for
// an unread native user entry. Fail the wrapper closed
// instead of allowing all later fork/rewind points to
// shift onto the wrong HAPI messages.
options.onStartupFailure?.(new Error(`Pi command-only history sync failed: ${detail}`));
return;
}
}
if (generation !== lifecycleGeneration || deliveredSettlement || agentLifecycleSeen) return;
const handled = options.onPromptLifecycleMissing?.(pendingLocalIds[0]);
// The callback may synchronously pump the next queued prompt,
// whose beginPromptLifecycle() advances the generation and
// resets state. Never stamp the old timer's settlement onto it.
if (handled === false || generation !== lifecycleGeneration) return;
deliveredSettlement = true;
session.updateThinkingState(false);
})();
}, PI_PROMPT_LIFECYCLE_GRACE_MS);
promptLifecycleTimer.unref?.();
};
const sendMessages = (messages: ReturnType<PiMessageAccumulator['handleEvent']>): void => {
for (const message of messages) {
const converted = convertAgentMessage(message, session.currentModel);
if (converted) session.sendAgentMessage(converted);
}
};
const flushAccumulator = (): void => sendMessages(assistantMessageAccumulator.flush());
transport.onEvent((event) => {
// Legacy Pi emitted auto_compaction_*; normalize it before every
// lifecycle consumer so both the maintenance gate and timeline see the
// same current event names. PiTransport performs this for subprocess
// traffic too; retaining it here keeps direct/test transports aligned.
const parsedLifecycle = PiLifecycleEventSchema.safeParse(event);
if (parsedLifecycle.success) {
event = parsedLifecycle.data;
}
if (event.type !== 'keep_alive') {
logger.debug(`[pi][event] ${event.type}`);
}
if (event.type === 'response') {
const parsed = PiResponseEventSchema.safeParse(event);
if (parsed.success) {
const isCurrentPrompt = parsed.data.command === 'prompt'
&& !deliveredSettlement
&& !activePromptResponseAccepted
&& (activePromptId === null ? !promptLifecycleAborted : parsed.data.id === activePromptId);
if (parsed.data.command === 'prompt' && !isCurrentPrompt) {
logger.debug(`[pi] Ignoring stale prompt response id=${parsed.data.id ?? 'missing'}`);
return;
}
const responseOutcome = handleResponse(
parsed.data,
session,
pendingLocalIds,
transport,
options.onStartupFailure,
options.conversationHistory,
options.onReady,
(isStreaming) => {
if (isStreaming) return true;
const promptLifecycleActive = !deliveredSettlement
&& !promptLifecycleAborted
&& (activePromptId !== null || agentLifecycleSeen);
return !promptLifecycleActive;
},
syncNativeTitle,
);
if (isCurrentPrompt && !parsed.data.success) {
const rejectedLocalId = responseOutcome.rejectedPromptLocalId ?? activePromptLocalId;
// A Pi 0.83 turn_start can consume the HAPI FIFO before Pi
// replies that prompt failed. handleResponse only has the
// still-pending list, so finish the exact consumed history
// entry here when the FIFO was already shifted.
if (responseOutcome.rejectedPromptLocalId === undefined && rejectedLocalId) {
options.conversationHistory?.rejectPendingEntry(rejectedLocalId);
}
rejectPromptLifecycle();
options.onPromptRejected?.(rejectedLocalId);
return;
}
if (isCurrentPrompt && parsed.data.success) {
activePromptResponseAccepted = true;
if (activeAgentSettledSeen) {
deliverSettlement();
} else if (agentLifecycleSeen) {
scheduleLegacySettleFallback();
} else {
schedulePromptLifecycleFallback();
}
}
} else {
logger.debug('[pi] Ignoring malformed RPC response');
}
return;
}
if (event.type === 'entry_appended') {
options.conversationHistory?.observeEntry((event as { entry?: unknown }).entry);
}
if (event.type === 'extension_ui_request') {
const parsed = PiExtensionUiRequestSchema.safeParse(event);
if (parsed.success) {
extensionUi.handle(parsed.data);
} else {
logger.debug('[pi] Ignoring malformed extension_ui_request');
}
return;
}
if (event.type === 'session_info_changed') {
// Pi emits this for `/name` and the set_session_name RPC. Mirror the
// native rename into HAPI metadata so the web title stays in sync
// without any HAPI-injected change_title flow (issue #1440).
const parsed = PiSessionInfoChangedEventSchema.safeParse(event);
if (parsed.success) {
syncNativeTitle(parsed.data.name);
} else {
logger.debug('[pi] Ignoring malformed session_info_changed');
}
return;
}
if (event.type === 'agent_start' || event.type === 'turn_start') {
if (deliveredSettlement) {
// Pi can start an agent lifecycle on its own — subagent
// completion wake-ups, scheduled work — with no HAPI prompt in
// flight. The previous prompt lifecycle already delivered its
// settlement, so every settlement path below is gated shut and
// this turn's agent_settled/agent_end would be swallowed,
// leaving thinking=true (and the FIFO pump blocked) forever.
// Open a fresh settlement cycle for the autonomous lifecycle.
// Prompt-driven lifecycles are unaffected: beginPromptLifecycle
// has already reset deliveredSettlement to false by the time
// their agent_start arrives.
// Advance the generation so a previous settlement's async
// history-sync callback (still in flight) turns stale and
// cannot mark this new lifecycle's abort boundary as settled.
lifecycleGeneration += 1;
deliveredSettlement = false;
agentEndObserved = false;
activeAgentSettledSeen = false;
}
clearCompactionRetryPending();
agentLifecycleSeen = true;
clearLegacySettleFallback();
clearPromptLifecycleFallback();
options.onAgentLifecycleStarted?.();
}
if (event.type === 'compaction_start') {
clearCompactionRetryPending();
maintenanceActive.add('compaction');
clearLegacySettleFallback();
} else if (event.type === 'auto_retry_start') {
maintenanceActive.add('autoRetry');
clearLegacySettleFallback();
} else if (event.type === 'summarization_retry_scheduled') {
maintenanceActive.add('summary');
clearLegacySettleFallback();
} else if (event.type === 'compaction_end') {
maintenanceActive.delete('compaction');
if ('willRetry' in event && event.willRetry === true) {
maintenanceActive.add('compactionRetry');
clearLegacySettleFallback();
if (compactionRetryTimer) clearTimeout(compactionRetryTimer);
compactionRetryTimer = setTimeout(() => {
compactionRetryTimer = null;
if (!maintenanceActive.delete('compactionRetry')) return;
scheduleLegacySettleFallback();
}, PI_COMPACTION_RETRY_START_GRACE_MS);
compactionRetryTimer.unref?.();
}
} else if (event.type === 'auto_retry_end') {
maintenanceActive.delete('autoRetry');
} else if (event.type === 'summarization_retry_finished') {
maintenanceActive.delete('summary');
}
lifecycleTimeline.emit(event, session);
sendMessages(assistantMessageAccumulator.handleEvent(event));
if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') {
const messages = convertPiEvent(event);
for (const message of messages) {
const converted = convertAgentMessage(message, session.currentModel);
if (converted) session.sendAgentMessage(converted);
}
}
if (event.type === 'agent_start') {
session.updateThinkingState(true);
} else if (event.type === 'turn_start') {
session.updateThinkingState(true);
if (pendingLocalIds.length > 0) {
const oldestLocalId = pendingLocalIds.shift()!;
activePromptLocalId = oldestLocalId;
session.emitMessagesConsumed([oldestLocalId]);
}
// Some Pi integrations omit entry_appended forwarding. Incremental
// get_entries is the durable fallback and still pairs only FIFO.
if (options.conversationHistory) {
void options.conversationHistory.syncEntries().catch(() => {});
}
} else if (event.type === 'turn_end') {
// Pi emits turn_end for each LLM/tool-loop iteration. The enclosing
// user prompt remains active until agent_end, so keep both streaming
// state and the local FIFO blocked here.
const requestVersion = ++latestContextUsageRequest;
void publishPiTurnUsage(
event as PiTurnEndEvent,
transport,
session,
() => requestVersion === latestContextUsageRequest,
);
} else if (event.type === 'agent_end') {
const parsed = PiAgentEndEventSchema.safeParse(event);
// Pi can end one attempt before its built-in auto-retry starts. That
// is not a user-prompt settlement, so keep the HAPI FIFO blocked.
if (parsed.success && parsed.data.willRetry === true) return;
agentEndObserved = true;
scheduleLegacySettleFallback();
} else if (event.type === 'agent_settled') {
// A command-only generation has no Pi agent lifecycle; a delayed
// settled event from an earlier prompt must not settle this one.
if (agentLifecycleSeen && PiAgentSettledEventSchema.safeParse(event).success) {
activeAgentSettledSeen = true;
deliverSettlement();
}
}
if (agentEndObserved && maintenanceActive.size === 0 && (event.type === 'compaction_end' || event.type === 'auto_retry_end' || event.type === 'summarization_retry_finished')) {
scheduleLegacySettleFallback();
}
});
return {
flush: flushAccumulator,
cancelPendingExtensionUi: (reason, options) => extensionUi.cancelAll(reason, options),
terminatePendingRpc: (error) => session.rpcResolver?.rejectAll(error),
beginPromptLifecycle,
abortPromptLifecycle,
};
}