pum-agent 0.2.35-beta.1 → 0.2.36-beta.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 CHANGED
@@ -23,8 +23,8 @@ pum
23
23
  PUM opens the login panel automatically on the first start. Press `?` on an
24
24
  empty prompt to see every control.
25
25
 
26
- Select a model in `Ctrl+P`. GPT-6 Astra is listed for OpenAI and Codex;
27
- using it requires access on your provider account. Press `r` in the model
26
+ Select a model in `Ctrl+P`. GPT-6 Astra, Luna and Sol are listed for OpenAI
27
+ and Codex. To use them, your provider account must have access. Press `r` in the model
28
28
  list to refresh providers that support catalog discovery.
29
29
 
30
30
  Use `/model <name or provider/model-id> [effort]` to change the main-agent model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.35-beta.1",
3
+ "version": "0.2.36-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,9 +50,8 @@
50
50
  "pack:check": "bun run scripts/validate-pack.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@earendil-works/pi-ai": "^0.85.0",
54
- "@earendil-works/pi-coding-agent": "^0.85.0",
55
- "@earendil-works/pi-server": "^0.85.0",
53
+ "@earendil-works/pi-ai": "^0.87.1",
54
+ "@earendil-works/pi-coding-agent": "^0.87.1",
56
55
  "@opentui/core": "^0.5.1",
57
56
  "@opentui/react": "^0.5.1",
58
57
  "react": "^19.2.8",
@@ -1,12 +1,13 @@
1
1
  import { Type } from "typebox";
2
2
  import { createHash } from "node:crypto";
3
3
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
+ import { getCurrentSystemMessage, getCurrentSystemPrompt, getCurrentTools, type Message, type Tool } from "@earendil-works/pi-ai";
4
5
  import {
5
- sessionEntryToContextMessages,
6
6
  type AgentSession,
7
7
  type ExtensionAPI,
8
8
  type InlineExtension,
9
9
  type SessionEntry,
10
+ type SessionProjection,
10
11
  type TurnEndEvent,
11
12
  } from "@earendil-works/pi-coding-agent";
12
13
  import { registerTranscriptHistoryTool } from "./transcript-history";
@@ -72,7 +73,7 @@ function boundaryData(value: unknown): BoundaryData {
72
73
  const { handoff } = handoffParams({ handoff: value.handoff });
73
74
  return { version: 1, ...(handoff === undefined ? {} : { handoff }) };
74
75
  }
75
- function header(id: string, handoff?: string, navigation?: { userId?: string; previousId?: string | null }, recovery = ""): AgentMessage {
76
+ function createHeader(id: string, handoff?: string, navigation?: { userId?: string; previousId?: string | null }, recovery = ""): AgentMessage {
76
77
  return {
77
78
  role: "custom", customType: CONTEXT_WINDOW_CUSTOM_TYPE, display: false, timestamp: 0,
78
79
  content: `Fresh PUM context window: ${id}. Earlier transcript entries remain retained in the full session transcript. The rollover generated no summary. Current system instructions still apply. ${recovery}`
@@ -83,6 +84,12 @@ function header(id: string, handoff?: string, navigation?: { userId?: string; pr
83
84
  function isBoundary(entry: SessionEntry): boolean {
84
85
  return entry.type === "custom" && entry.customType === CONTEXT_WINDOW_CUSTOM_TYPE;
85
86
  }
87
+ // System messages carry the prompt and tool declarations, which the meter
88
+ // fingerprints separately. Message lists and indexes here exclude them.
89
+ function conversation(messages: readonly AgentMessage[]): AgentMessage[] {
90
+ return messages.filter((message) => message.role !== "system");
91
+ }
92
+ const transcript = (messages: readonly AgentMessage[]) => messages as readonly Message[];
86
93
  const textResult = (details: Record<string, unknown>) => ({
87
94
  content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }], details,
88
95
  });
@@ -91,8 +98,8 @@ const textResult = (details: Record<string, unknown>) => ({
91
98
  export class ContextWindowController {
92
99
  private session?: AgentSession;
93
100
  private pending?: Pending;
94
- private refreshPending = false;
95
101
  private windowId: string | null = null;
102
+ private windowHeader?: { id: string; message: AgentMessage };
96
103
  private observedExtraTokens = 0;
97
104
  private modelKey?: string;
98
105
  private usageFloor = 0;
@@ -122,7 +129,7 @@ export class ContextWindowController {
122
129
  if (key !== this.modelKey) {
123
130
  this.modelKey = key;
124
131
  this.generation++;
125
- this.usageFloor = this.session?.agent.state.messages.length ?? 0;
132
+ this.usageFloor = this.session ? this.activeConversation().length : 0;
126
133
  }
127
134
  });
128
135
  pi.on("before_agent_start", (event) => {
@@ -209,22 +216,31 @@ export class ContextWindowController {
209
216
  this.disableAutomaticCompaction();
210
217
  this.restore();
211
218
  const transform = session.agent.transformContext;
212
- session.agent.transformContext = async (messages, signal) => {
213
- const originalTokens = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
219
+ session.agent.transformContext = async (input, signal) => {
220
+ // pi rebuilds request history from the session projection. The rollover
221
+ // window is applied here, before extension context handlers inject memory.
222
+ const messages = this.windowRequest(input);
223
+ const source = conversation(messages);
224
+ const originalTokens = source.reduce((sum, message) => sum + estimateTokens(message), 0);
214
225
  const stateSystemPrompt = fingerprint(session.agent.state.systemPrompt);
215
226
  const stateTools = this.toolSchemas();
216
- const prompt = this.preparedPrompt ?? { systemPrompt: stateSystemPrompt, tools: stateTools, modelKey: modelIdentity(session.model) };
217
227
  this.requestSnapshot = undefined;
218
228
  const generation = this.generation;
219
229
  const windowId = this.windowId;
220
- const sourceHash = messageHash(messages);
221
- const sourceCount = messages.length;
230
+ const sourceHash = messageHash(source);
231
+ const sourceCount = source.length;
222
232
  const transformed = transform ? await transform.call(session.agent, messages, signal) : messages;
223
233
  // Observe the complete extension chain, including memory injected after our
224
234
  // extension. Return it unchanged; memory and dynamic instructions stay active.
225
235
  if (generation === this.generation && !signal?.aborted) {
226
- this.observedExtraTokens = Math.max(0, transformed.reduce((sum, message) => sum + estimateTokens(message), 0)
227
- - originalTokens);
236
+ const prompt: PromptSnapshot = {
237
+ systemPrompt: fingerprint(getCurrentSystemPrompt(transcript(transformed))),
238
+ tools: this.toolSchemas(getCurrentTools(transcript(transformed))),
239
+ modelKey: modelIdentity(session.model),
240
+ };
241
+ this.preparedPrompt = prompt;
242
+ const transformedTokens = conversation(transformed).reduce((sum, message) => sum + estimateTokens(message), 0);
243
+ this.observedExtraTokens = Math.max(0, transformedTokens - originalTokens);
228
244
  const measured = this.latestUsageSnapshot;
229
245
  if (measured?.generation === generation && measured.modelKey === prompt.modelKey) {
230
246
  // A failed request may have observed growth followed by shrinkage
@@ -236,8 +252,7 @@ export class ContextWindowController {
236
252
  }
237
253
  this.requestSnapshot = { ...prompt, windowId, generation, stateSystemPrompt, stateTools,
238
254
  injectedTokens: this.observedExtraTokens, sourceHash, sourceCount,
239
- estimatedInputTokens: transformed.reduce((sum, message) => sum + estimateTokens(message), 0)
240
- + prompt.systemPrompt.tokens + prompt.tools.tokens };
255
+ estimatedInputTokens: transformedTokens + prompt.systemPrompt.tokens + prompt.tools.tokens };
241
256
  }
242
257
  return transformed;
243
258
  };
@@ -245,14 +260,7 @@ export class ContextWindowController {
245
260
  const legacy = session.agent.prepareNextTurn;
246
261
  session.agent.prepareNextTurnWithContext = async (turn, signal) => {
247
262
  this.disableAutomaticCompaction();
248
- const fresh = this.refreshPending;
249
- this.refreshPending = false;
250
- const input = fresh ? { ...turn, context: { ...turn.context, messages: session.agent.state.messages.slice() } } : turn;
251
- const update = previous ? await previous.call(session.agent, input, signal) : await legacy?.call(session.agent, signal);
252
- const context = update?.context ?? input.context;
253
- this.preparedPrompt = { systemPrompt: fingerprint(context.systemPrompt), tools: this.toolSchemas(context.tools),
254
- modelKey: modelIdentity(update?.model ?? session.model) };
255
- return fresh ? { ...update, context } : update;
263
+ return previous ? await previous.call(session.agent, turn, signal) : await legacy?.call(session.agent, signal);
256
264
  };
257
265
  }
258
266
 
@@ -272,6 +280,7 @@ export class ContextWindowController {
272
280
  const branch = session.sessionManager.getBranch();
273
281
  const index = branch.findLastIndex(isBoundary);
274
282
  this.windowId = index < 0 ? null : branch[index]!.id;
283
+ this.windowHeader = undefined;
275
284
  this.generation++;
276
285
  this.usageFloor = 0;
277
286
  this.modelKey = modelIdentity(session.model);
@@ -279,28 +288,57 @@ export class ContextWindowController {
279
288
  this.preparedPrompt = undefined;
280
289
  this.usageSnapshots = new WeakMap();
281
290
  this.latestUsageSnapshot = undefined;
282
- if (index < 0) { this.restoreUsageFloor(branch); return; }
291
+ // Validate now so tree and session events report a corrupt boundary. Every
292
+ // request validates again and fails closed.
293
+ if (index >= 0) this.activeWindow();
294
+ this.restoreUsageFloor(branch);
295
+ }
296
+ /**
297
+ * The request messages of the active window, or undefined without a boundary.
298
+ * The projection is authoritative: pi builds every request from it.
299
+ */
300
+ private activeWindow(projection?: SessionProjection): AgentMessage[] | undefined {
301
+ const manager = this.requireSession().sessionManager;
302
+ const branch = manager.getBranch();
303
+ const index = branch.findLastIndex(isBoundary);
304
+ if (index < 0) return undefined;
283
305
  const boundary = branch[index]!;
284
- if (boundary.type !== "custom") return;
285
- let data: BoundaryData;
286
- try { data = boundaryData(boundary.data); }
287
- catch (error) {
288
- // A tree-event handler can fail after pi has restored unfiltered messages.
289
- // Leave no old-window messages available even if its caller catches errors.
290
- session.agent.state.messages = [];
291
- this.refreshPending = true;
292
- throw error;
306
+ if (boundary.type !== "custom") return undefined;
307
+ let header = this.windowHeader?.id === boundary.id ? this.windowHeader.message : undefined;
308
+ if (!header) {
309
+ const data = boundaryData(boundary.data);
310
+ const latestUser = branch.slice(0, index).findLast((entry) => entry.type === "message" && entry.message.role === "user");
311
+ header = createHeader(boundary.id, data.handoff, { userId: latestUser?.id, previousId: boundary.parentId },
312
+ this.recoveryGuidance());
313
+ // Keep the header bytes stable for the whole window. Tool changes after
314
+ // rollover must not rewrite the start of every later request.
315
+ this.windowHeader = { id: boundary.id, message: header };
293
316
  }
294
317
  // Legacy sessions can contain a later compaction. Filter its kept entries
295
318
  // at the boundary, but never discard the boundary's literal handoff.
296
319
  const activeIds = new Set(branch.slice(index + 1).map((entry) => entry.id));
297
- const entries = session.sessionManager.buildContextEntries().filter((entry) => activeIds.has(entry.id));
298
- const latestUser = branch.slice(0, index).findLast((entry) => entry.type === "message" && entry.message.role === "user");
299
- session.agent.state.messages = [header(boundary.id, data.handoff, {
300
- userId: latestUser?.id, previousId: boundary.parentId,
301
- }, this.recoveryGuidance()), ...entries.flatMap(sessionEntryToContextMessages)];
302
- this.restoreUsageFloor(branch);
303
- this.refreshPending = true;
320
+ projection ??= manager.buildSessionProjection();
321
+ const archived = projection.entries.filter((entry) => !activeIds.has(entry.sourceEntry.id)).flatMap((entry) => entry.messages);
322
+ const active = projection.entries.filter((entry) => activeIds.has(entry.sourceEntry.id)).flatMap((entry) => entry.messages);
323
+ // Archived system messages still define the prompt and tools that apply.
324
+ const system = getCurrentSystemMessage(transcript(archived.filter((message) => message.role === "system")));
325
+ return [...(system ? [system as AgentMessage] : []), header, ...active];
326
+ }
327
+ private windowRequest(input: AgentMessage[]): AgentMessage[] {
328
+ const projection = this.requireSession().sessionManager.buildSessionProjection();
329
+ const active = this.activeWindow(projection);
330
+ if (!active) return input;
331
+ // Request input must be the persisted projection. Refuse an unexpected
332
+ // shape rather than send messages from an archived window.
333
+ if (conversation(input).length !== conversation(projection.messages).length) {
334
+ throw new Error("PUM context-window request does not match the persisted session. Refusing to send older context.");
335
+ }
336
+ return active;
337
+ }
338
+ /** Conversation messages that the next request of the active window contains. */
339
+ private activeConversation(): AgentMessage[] {
340
+ const projection = this.requireSession().sessionManager.buildSessionProjection();
341
+ return conversation(this.activeWindow(projection) ?? projection.messages);
304
342
  }
305
343
  private restoreUsageFloor(branch: SessionEntry[]): void {
306
344
  const change = branch.findLastIndex((entry) => entry.type === "model_change" || entry.type === "compaction");
@@ -309,7 +347,7 @@ export class ContextWindowController {
309
347
  // identity fallback for runtimes that copy their state on restoration.
310
348
  const older = new Set(branch.slice(0, change).filter((entry) => entry.type === "message")
311
349
  .map((entry) => JSON.stringify(entry.message)));
312
- const messages = this.requireSession().agent.state.messages;
350
+ const messages = this.activeConversation();
313
351
  for (let index = 0; index < messages.length; index++) {
314
352
  if (older.has(JSON.stringify(messages[index]))) this.usageFloor = index + 1;
315
353
  }
@@ -349,7 +387,7 @@ export class ContextWindowController {
349
387
  const capacity = model?.contextWindow;
350
388
  const branch = session.sessionManager.getBranch();
351
389
  const latestUser = branch.findLast((entry) => entry.type === "message" && entry.message.role === "user");
352
- const freshTokens = estimateTokens(header("pending", handoff, {
390
+ const freshTokens = estimateTokens(createHeader("pending", handoff, {
353
391
  userId: latestUser?.id, previousId: session.sessionManager.getLeafId(),
354
392
  }, this.recoveryGuidance())) + this.overheadTokens();
355
393
  const configuredReserve = this.reserveTokens();
@@ -377,7 +415,7 @@ export class ContextWindowController {
377
415
  const factor = typeof meter.calibrationFactor === "number" ? meter.calibrationFactor : 1;
378
416
  return Math.max(0, Math.floor(available / factor) - CONTEXT_MESSAGE_TOKENS);
379
417
  }
380
- private toolSchemas(tools = this.requireSession().agent.state.tools): EstimateFingerprint {
418
+ private toolSchemas(tools: readonly Tool[] = this.requireSession().agent.state.tools): EstimateFingerprint {
381
419
  const schema = fingerprint(JSON.stringify(tools.map((tool) => ({
382
420
  name: tool.name, description: tool.description, parameters: tool.parameters,
383
421
  }))));
@@ -408,7 +446,7 @@ export class ContextWindowController {
408
446
  private remaining(): Record<string, unknown> {
409
447
  const session = this.requireSession();
410
448
  const model = session.model;
411
- const messages = session.agent.state.messages;
449
+ const messages = this.activeConversation();
412
450
  const key = modelIdentity(model);
413
451
  if (key !== this.modelKey) { this.modelKey = key; this.generation++; this.usageFloor = messages.length; }
414
452
  let usageIndex = -1;
package/src/headless.ts CHANGED
@@ -12,7 +12,6 @@ import { conversationBranchState } from "./conversation-branch";
12
12
  import { isPlanModeActive, planModeExtension } from "./plan-mode";
13
13
  import { createFilesystemSandboxExtension } from "./filesystem-sandbox";
14
14
  import { bindFileCheckpointSession, createFileCheckpointExtension } from "./file-checkpoints";
15
- import { installModelCatalogFallbacks } from "./model-catalog";
16
15
  import { AGENT_DIR, AUTH_PATH, MODELS_PATH } from "./config";
17
16
  import { createMemoryExtension, hasMemoryContextExtension, MEMORY_EDIT_TOOL_NAME, MEMORY_READ_TOOL_NAME } from "./memory";
18
17
  import { ContextWindowController, CONTEXT_TOOL_NAMES } from "./context-window";
@@ -172,7 +171,6 @@ async function runPromptSession(
172
171
  authPath: AUTH_PATH,
173
172
  modelsPath: MODELS_PATH,
174
173
  });
175
- installModelCatalogFallbacks(modelRuntime);
176
174
  if ((await modelRuntime.getAvailable()).length === 0) {
177
175
  process.stderr.write("pum: no provider is available. Run 'pum login' first.\n");
178
176
  return 1;
package/src/main.tsx CHANGED
@@ -24,7 +24,6 @@ import { checkPathsForProject, loadSettings } from "./settings";
24
24
  import { setBashOutputSettingsIfPresent } from "./bash-output";
25
25
  import { bindSearchSession, installWebSearch, revokeSearchSession, webSearch } from "./web-search";
26
26
  import { bindRuntimeSettingsActivity, isRuntimeIdle, runtimeSettings } from "./runtime-settings";
27
- import { installModelCatalogFallbacks } from "./model-catalog";
28
27
  import { identityExtension } from "./identity";
29
28
  import { setWritingStyle, writingStyleExtension } from "./writing-style";
30
29
  import { checkModePromptExtension, setSandboxModeSource } from "./check-mode-prompt";
@@ -112,7 +111,6 @@ export async function start(
112
111
  authPath: AUTH_PATH,
113
112
  modelsPath: MODELS_PATH,
114
113
  });
115
- installModelCatalogFallbacks(modelRuntime);
116
114
  const loginRequired = options.login || (await modelRuntime.getAvailable()).length === 0;
117
115
 
118
116
  const settings = loadSettings();
@@ -129,7 +129,6 @@ export class ProjectValidationController {
129
129
  private generation = 0;
130
130
  private batchHasMutations = false;
131
131
  private latest?: ValidationEvidence;
132
- private refreshEvidence = false;
133
132
  private unbindHooks?: () => void;
134
133
  private readonly cwd: string;
135
134
  private readonly readonly: boolean;
@@ -187,30 +186,17 @@ export class ProjectValidationController {
187
186
  liveControllers.set(session.sessionId, this);
188
187
  const dispose = session.dispose.bind(session);
189
188
  session.dispose = () => { this.dispose(); dispose(); };
190
- // Agent-core owns a separate context snapshot. SDK custom-message persistence
191
- // does not refresh that snapshot unless compaction ran. Refresh only after our
192
- // turn-end evidence flushed, before delegating to existing context/compaction.
193
- const prepare = session.agent.prepareNextTurnWithContext;
194
- const legacy = session.agent.prepareNextTurn;
195
- const prepareWrapper: NonNullable<typeof session.agent.prepareNextTurnWithContext> = async (turn, signal) => {
196
- if (this.retired) return prepare ? await prepare.call(session.agent, turn, signal) : legacy ? await legacy.call(session.agent, signal) : undefined;
197
- signal?.throwIfAborted();
198
- const refresh = this.refreshEvidence;
199
- this.refreshEvidence = false;
200
- const input = refresh ? { ...turn, context: { ...turn.context, messages: session.agent.state.messages.slice() } } : turn;
201
- const update = prepare ? await prepare.call(session.agent, input, signal) : await legacy?.call(session.agent, signal);
202
- return refresh ? { ...update, context: update?.context ?? input.context } : update;
189
+ // pi dispatches turn_end, and so runs validation, inside its finishTurn hook.
190
+ // Delegate first, then end the run when that batch was cancelled.
191
+ const finish = session.agent.finishTurn;
192
+ const finishWrapper: NonNullable<typeof session.agent.finishTurn> = async (turn, signal) => {
193
+ const decision = await finish?.call(session.agent, turn, signal);
194
+ if (!this.retired && signal?.aborted) return { action: "end" };
195
+ return decision ?? undefined;
203
196
  };
204
- session.agent.prepareNextTurnWithContext = prepareWrapper;
205
- const stop = session.agent.shouldStopAfterTurn;
206
- const stopWrapper: NonNullable<typeof session.agent.shouldStopAfterTurn> = async (turn, signal) => {
207
- if (!this.retired && signal?.aborted) return true;
208
- return await stop?.call(session.agent, turn, signal) ?? false;
209
- };
210
- session.agent.shouldStopAfterTurn = stopWrapper;
197
+ session.agent.finishTurn = finishWrapper;
211
198
  this.unbindHooks = () => {
212
- if (session.agent.prepareNextTurnWithContext === prepareWrapper) session.agent.prepareNextTurnWithContext = prepare;
213
- if (session.agent.shouldStopAfterTurn === stopWrapper) session.agent.shouldStopAfterTurn = stop;
199
+ if (session.agent.finishTurn === finishWrapper) session.agent.finishTurn = finish;
214
200
  };
215
201
  }
216
202
  extension(): InlineExtension {
@@ -241,7 +227,6 @@ export class ProjectValidationController {
241
227
  this.latest = evidence;
242
228
  if (this.retired || !this.session) return;
243
229
  await this.session.sendCustomMessage({ customType: VALIDATION_CUSTOM_TYPE, content: validationEvidenceText(evidence), display: true, details: evidence }, { triggerTurn: false });
244
- this.refreshEvidence = true;
245
230
  }
246
231
  private stillApproved(proposal: Proposal, generation: number): boolean {
247
232
  if (this.retired || this.proposal !== proposal || this.generation !== generation) return false;
@@ -290,7 +275,7 @@ export class ProjectValidationController {
290
275
  const tool = session.agent.state.tools.find((entry) => entry.name === "bash");
291
276
  if (!tool || !session.agent.beforeToolCall) throw new Error("Checked Bash is unavailable.");
292
277
  const { id, args } = createSyntheticCheckCall({ command: command.command, timeout: command.timeoutSeconds, max_bytes: OUTPUT_BYTES });
293
- const context = { systemPrompt: session.agent.state.systemPrompt, messages: session.agent.state.messages, tools: session.agent.state.tools };
278
+ const context = { messages: session.agent.state.messages, tools: session.agent.state.tools };
294
279
  // Exactly the session's Check hooks and native-sandbox registered Bash.
295
280
  // Never executeBash(): that API deliberately bypasses model Check policy.
296
281
  const toolCall = { type: "toolCall" as const, id, name: "bash", arguments: args };
@@ -143,7 +143,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
143
143
  // an older run's extension hook. Async scope follows the SDK's entire
144
144
  // _runAgentPrompt (including retry/queued continuations and true settlement).
145
145
  // Never settle by session key: extensions run before ordinary subscribers.
146
- type Admission = { epoch: number; started: boolean; released: boolean; parent?: Admission; release: () => void };
146
+ type Admission = { epoch: number; started: boolean; released: boolean; parent?: Admission; outer?: Admission; release: () => void };
147
147
  let epoch = 0;
148
148
  let disposed = false;
149
149
  let frozen = false;
@@ -170,27 +170,36 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
170
170
  // compatibility lease separate; unscoped events cannot release scoped work.
171
171
  let unscoped: Admission | undefined = session.isStreaming || session.agent?.state?.isStreaming ? reserve() : undefined;
172
172
  if (unscoped) unscoped.started = true;
173
+ // The async scope in which the unscoped lease started. Only a settled event
174
+ // from that same scope ends it.
175
+ let unscopedScope: Admission | undefined;
173
176
  const unsubscribe = session.subscribe((event) => {
174
177
  const admission = scopes.getStore();
175
178
  // A cancelled preflight may unwind after a newer accepted prompt starts.
176
179
  // Its SDK finally still emits settled; that event does not own the new run.
177
180
  if (disposed) return;
181
+ // pi defers a run requested from agent_settled until those handlers finish.
182
+ // It starts in the settled admission's scope, after that admission released,
183
+ // and needs its own lease until its own settled event in that scope.
184
+ const leaseRun = (scope: Admission | undefined) => {
185
+ if (!unscoped) { unscoped = reserve(); unscopedScope = scope; }
186
+ unscoped.started = true;
187
+ };
178
188
  if (event.type === "agent_start") {
179
- if (admission) {
180
- if (admission.epoch === epoch && !admission.released) {
189
+ if (admission && !admission.released) {
190
+ if (admission.epoch === epoch) {
181
191
  admission.started = true;
182
192
  if (admission.parent && !admission.parent.released) admission.parent.started = true;
193
+ if (admission.outer) leaseRun(admission.outer);
183
194
  }
184
- } else {
185
- unscoped ??= reserve();
186
- unscoped.started = true;
187
- }
195
+ } else if (!admission || admission.epoch === epoch) leaseRun(admission);
188
196
  } else if (event.type === "agent_settled") {
189
- if (admission) admission.release();
190
- else {
197
+ if (admission && !admission.released) admission.release();
198
+ else if (unscoped && unscopedScope === admission) {
191
199
  const settled = unscoped;
192
200
  unscoped = undefined;
193
- settled?.release();
201
+ unscopedScope = undefined;
202
+ settled.release();
194
203
  }
195
204
  }
196
205
  });
@@ -222,6 +231,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
222
231
  // Its promise, not the session's shared idle flag, proves completion.
223
232
  direct.started = true;
224
233
  if (admission && !admission.released) direct.parent = admission;
234
+ else if (admission && admission.epoch === epoch) direct.outer = admission;
225
235
  try {
226
236
  if (disposed || frozen || direct.released || direct.epoch !== epoch) throw new Error("Runtime admission was cancelled");
227
237
  return await scopes.run(direct, run);
@@ -310,7 +320,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
310
320
  // SDK waitForIdle can return early after a stale preflight finally resets
311
321
  // isStreaming. Scoped execution still needs its own settled/promise proof.
312
322
  cancelledUnscoped?.release();
313
- if (unscoped?.released) unscoped = undefined;
323
+ if (unscoped?.released) { unscoped = undefined; unscopedScope = undefined; }
314
324
  } finally {
315
325
  for (const admission of cancelled) if (!admission.started) admission.release();
316
326
  }
@@ -323,6 +333,7 @@ export function bindRuntimeSettingsActivity(session: AgentSession, coordinator =
323
333
  finally {
324
334
  unsubscribe();
325
335
  unscoped = undefined;
336
+ unscopedScope = undefined;
326
337
  for (const admission of admissions) admission.release();
327
338
  admissions.clear();
328
339
  for (const release of shellReleases) release();
@@ -1,3 +1,5 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { getCurrentSystemMessage, type Message } from "@earendil-works/pi-ai";
1
3
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
2
4
  import { companionFileFor, readCompanion, writeCompanion } from "./session-companion";
3
5
  import { Type } from "typebox";
@@ -414,11 +416,33 @@ export class ToolGroupsController {
414
416
  });
415
417
  }
416
418
 
419
+ /** Sort tools into the canonical role-allowlist order. Unknown tools keep their order at the end. */
420
+ canonicalOrder<T extends { name: string }>(tools: readonly T[]): T[] {
421
+ const allowlist = this.audience === "main" ? mainAllowedToolNames(this.isReadonly) : childAllowedToolNames(this.isReadonly);
422
+ const rank = new Map(allowlist.map((name, index) => [name, index]));
423
+ return tools.map((tool, index) => ({ tool, index }))
424
+ .sort((a, b) => (rank.get(a.tool.name) ?? allowlist.length) - (rank.get(b.tool.name) ?? allowlist.length) || a.index - b.index)
425
+ .map(({ tool }) => tool);
426
+ }
427
+
417
428
  /** The inline extension that registers `enable_tools` for one session. */
418
429
  extension(): InlineExtension {
419
430
  return {
420
431
  name: `pum-tool-groups-${this.audience}`,
421
- factory: (pi) => this.registerTool(pi),
432
+ factory: (pi) => {
433
+ this.registerTool(pi);
434
+ // pi declares revealed tools in the transcript in reveal order. Send the
435
+ // canonical order, so that a request does not depend on that history.
436
+ pi.on("context_with_system", (event) => {
437
+ const head = getCurrentSystemMessage(event.messages as Message[]);
438
+ const tools = head?.toolsAdded;
439
+ if (!head || !tools) return undefined;
440
+ const ordered = this.canonicalOrder(tools);
441
+ if (ordered.every((tool, index) => tool === tools[index])) return undefined;
442
+ return { messages: [{ ...head, toolsAdded: ordered } as AgentMessage,
443
+ ...event.messages.filter((message) => message.role !== "system")] };
444
+ });
445
+ },
422
446
  };
423
447
  }
424
448
  }
@@ -1,47 +0,0 @@
1
- import type { Model, Provider } from "@earendil-works/pi-ai";
2
- import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
-
4
- const ASTRA_ID = "gpt-6-astra";
5
-
6
- /** Temporary catalog fallback until pi includes Astra. Never replace upstream metadata. */
7
- export function withAstraModel(base: Provider): Provider {
8
- if (base.id !== "openai" && base.id !== "openai-codex") return base;
9
- if (base.getModels().some((model) => model.id === ASTRA_ID)) return base;
10
- const codex = base.id === "openai-codex";
11
- // https://developers.openai.com/api/docs/models/gpt-6-astra
12
- const astra: Model<any> = {
13
- id: ASTRA_ID,
14
- name: "GPT-6 Astra",
15
- provider: base.id,
16
- api: codex ? "openai-codex-responses" : "openai-responses",
17
- baseUrl: base.baseUrl ?? (codex ? "https://chatgpt.com/backend-api" : "https://api.openai.com/v1"),
18
- reasoning: true,
19
- input: ["text", "image"],
20
- // Keep pi's conservative Codex context budget until that transport publishes one.
21
- contextWindow: codex ? 272000 : 1050000,
22
- maxTokens: 128000,
23
- cost: {
24
- input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5,
25
- tiers: [{ inputTokensAbove: 272000, input: 20, output: 75, cacheRead: 2, cacheWrite: 25 }],
26
- },
27
- thinkingLevelMap: {
28
- off: null, minimal: null, low: "low", medium: "medium",
29
- high: "high", xhigh: "xhigh", max: "max",
30
- },
31
- };
32
- const wrapped: Provider = Object.create(base);
33
- wrapped.getModels = () => {
34
- const models = base.getModels();
35
- return models.some((model) => model.id === ASTRA_ID) ? models : [...models, astra];
36
- };
37
- return wrapped;
38
- }
39
-
40
- export function installModelCatalogFallbacks(runtime: ModelRuntime): void {
41
- for (const id of ["openai", "openai-codex"]) {
42
- const base = runtime.getProvider(id);
43
- if (!base) continue;
44
- const provider = withAstraModel(base);
45
- if (provider !== base) runtime.registerNativeProvider(provider);
46
- }
47
- }