@oh-my-pi/pi-coding-agent 17.2.2 → 17.2.4
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/CHANGELOG.md +55 -0
- package/dist/{CHANGELOG-xfpkakrn.md → CHANGELOG-bpmhv26t.md} +55 -0
- package/dist/cli.js +3294 -3294
- package/dist/types/advisor/advise-tool.d.ts +6 -0
- package/dist/types/advisor/runtime.d.ts +4 -4
- package/dist/types/capability/mcp.d.ts +9 -0
- package/dist/types/cli/models-cli.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/discovery/helpers.d.ts +6 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
- package/dist/types/extensibility/extensions/loader.d.ts +6 -2
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
- package/dist/types/launch/paths.d.ts +2 -1
- package/dist/types/mcp/request-id.d.ts +19 -0
- package/dist/types/mcp/types.d.ts +14 -0
- package/dist/types/memory-backend/index.d.ts +1 -0
- package/dist/types/memory-backend/messages.d.ts +15 -0
- package/dist/types/modes/components/ask-dialog.d.ts +0 -1
- package/dist/types/modes/components/model-browser.d.ts +7 -5
- package/dist/types/modes/components/model-picker.d.ts +9 -3
- package/dist/types/modes/components/read-tool-group.d.ts +9 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/registry/agent-lifecycle.d.ts +14 -5
- package/dist/types/registry/agent-registry.d.ts +4 -3
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/secrets/index.d.ts +6 -8
- package/dist/types/session/agent-session.d.ts +6 -1
- package/dist/types/session/model-controls.d.ts +0 -1
- package/dist/types/session/session-advisors.d.ts +7 -0
- package/dist/types/session/session-maintenance.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +11 -5
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tools/browser/launch.d.ts +30 -0
- package/dist/types/tools/browser/registry.d.ts +6 -1
- package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
- package/dist/types/tools/default-renderer.d.ts +3 -0
- package/package.json +12 -12
- package/scripts/legacy-pi-virtual-module.ts +12 -2
- package/src/advisor/advise-tool.ts +18 -0
- package/src/advisor/runtime.ts +6 -6
- package/src/capability/mcp.ts +7 -0
- package/src/cli/models-cli.ts +13 -16
- package/src/collab/host.ts +1 -1
- package/src/config/keybindings.ts +14 -9
- package/src/config/mcp-schema.json +5 -0
- package/src/config/model-registry.ts +7 -1
- package/src/config/settings-schema.ts +1 -1
- package/src/discovery/builtin.ts +10 -0
- package/src/discovery/helpers.ts +10 -0
- package/src/discovery/mcp-json.ts +11 -1
- package/src/discovery/omp-extension-roots.ts +79 -20
- package/src/discovery/omp-plugins.ts +10 -1
- package/src/extensibility/extensions/loader.ts +66 -28
- package/src/extensibility/plugins/marketplace/registry.ts +4 -8
- package/src/launch/broker.ts +59 -41
- package/src/launch/paths.ts +2 -5
- package/src/main.ts +24 -22
- package/src/mcp/config.ts +1 -0
- package/src/mcp/request-id.ts +24 -0
- package/src/mcp/transports/http.ts +4 -2
- package/src/mcp/transports/sse.ts +4 -2
- package/src/mcp/transports/stdio.ts +4 -2
- package/src/mcp/types.ts +15 -0
- package/src/memory-backend/index.ts +1 -0
- package/src/memory-backend/messages.ts +19 -0
- package/src/modes/components/agent-hub.ts +1 -1
- package/src/modes/components/ask-dialog.ts +65 -23
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/model-browser.ts +25 -17
- package/src/modes/components/model-picker.ts +10 -6
- package/src/modes/components/read-tool-group.ts +26 -0
- package/src/modes/components/tool-execution.ts +77 -20
- package/src/modes/controllers/command-controller.ts +2 -2
- package/src/modes/controllers/event-controller.ts +182 -10
- package/src/modes/controllers/selector-controller.ts +27 -6
- package/src/modes/types.ts +5 -1
- package/src/prompts/tools/bash.md +1 -1
- package/src/registry/agent-lifecycle.ts +36 -11
- package/src/registry/agent-registry.ts +12 -5
- package/src/sdk.ts +68 -29
- package/src/secrets/index.ts +15 -17
- package/src/session/agent-session.ts +11 -2
- package/src/session/agent-storage.ts +6 -4
- package/src/session/history-storage.ts +4 -2
- package/src/session/messages.ts +31 -7
- package/src/session/model-controls.ts +0 -1
- package/src/session/session-advisors.ts +19 -1
- package/src/session/session-maintenance.ts +5 -2
- package/src/session/session-manager.ts +128 -76
- package/src/session/session-tools.ts +25 -23
- package/src/slash-commands/builtin-registry.ts +2 -2
- package/src/system-prompt.ts +6 -1
- package/src/task/executor.ts +8 -1
- package/src/tiny/text.ts +12 -0
- package/src/tools/bash.ts +11 -5
- package/src/tools/browser/launch.ts +54 -9
- package/src/tools/browser/registry.ts +123 -30
- package/src/tools/browser/shared-daemon.ts +190 -0
- package/src/tools/browser.ts +1 -1
- package/src/tools/default-renderer.ts +19 -4
- package/src/tools/index.ts +10 -6
- package/src/web/search/providers/anthropic.ts +3 -1
- package/src/web/search/providers/codex.ts +31 -6
|
@@ -91,6 +91,30 @@ export class EventController {
|
|
|
91
91
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
92
92
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
93
93
|
#toolTimelineComponents = new Map<string, Component>();
|
|
94
|
+
// Stable identity for a streamed tool call while its assistant message is
|
|
95
|
+
// live: maps the tool-call block's position in the streaming message to the
|
|
96
|
+
// id last seen at that position. A streamed id can CHANGE across cumulative
|
|
97
|
+
// `message_update`s — some providers (GitHub Copilot's `call_id|id`
|
|
98
|
+
// transport, any stream that delivers name/args before the id) emit the
|
|
99
|
+
// block with an empty or partial id first and rewrite it in a later delta
|
|
100
|
+
// (openai-completions sets `id: toolCall.id || ""` then overwrites on the
|
|
101
|
+
// next chunk). Keyed only by id, the changed id reads as a brand-new call
|
|
102
|
+
// and a second card is created — the old-id card orphans as a pending
|
|
103
|
+
// preview while the new-id card takes the result (#6879). Reset per assistant
|
|
104
|
+
// message (indices are per-message).
|
|
105
|
+
#streamedToolCallIdByIndex = new Map<number, string>();
|
|
106
|
+
// A TTSR rewind retracts its uncommitted never-run cards at message_end;
|
|
107
|
+
// retain their ids until agent-loop's synthetic tool_execution_start/end for
|
|
108
|
+
// those calls arrive so the normal no-pending path cannot recreate the
|
|
109
|
+
// retracted card below the rewind's fresh blocks (#6879).
|
|
110
|
+
#retractedToolCallIds = new Set<string>();
|
|
111
|
+
// Cards settled by a synthetic aborted/error `tool_execution_end` (agent-loop
|
|
112
|
+
// emits one per never-run call on a terminal error/abort). They stay visible
|
|
113
|
+
// for a genuinely terminal failure, but if an auto-retry then supersedes the
|
|
114
|
+
// turn (`auto_retry_start`, decided AFTER message_end) they must be removed so
|
|
115
|
+
// the retry's fresh cards do not render the same call twice (Codex review on
|
|
116
|
+
// #6881). Keyed by call id; reset per turn.
|
|
117
|
+
#syntheticFailureCards = new Map<string, ToolExecutionHandle>();
|
|
94
118
|
// Completions that arrived before any component existed for their call id.
|
|
95
119
|
// Cursor's server-resolved tools (todo) emit `tool_execution_end` through a
|
|
96
120
|
// synchronous callback fired mid-parse, while the `toolcall_start` for the
|
|
@@ -274,6 +298,71 @@ export class EventController {
|
|
|
274
298
|
this.#readToolCallAssistantComponents.delete(toolCallId);
|
|
275
299
|
}
|
|
276
300
|
|
|
301
|
+
#retractToolCardEntry(toolCallId: string, component: ToolExecutionHandle): void {
|
|
302
|
+
component.seal();
|
|
303
|
+
let removeComponent = true;
|
|
304
|
+
if (component instanceof ReadToolGroupComponent) {
|
|
305
|
+
removeComponent = component.removeEntry(toolCallId);
|
|
306
|
+
if (component === this.#lastReadGroup) this.#resetReadGroup();
|
|
307
|
+
}
|
|
308
|
+
if (removeComponent) this.ctx.chatContainer.removeChild(component);
|
|
309
|
+
this.ctx.pendingTools.delete(toolCallId);
|
|
310
|
+
this.#toolTimelineComponents.delete(toolCallId);
|
|
311
|
+
this.#clearReadToolCall(toolCallId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Re-key a live streamed tool card whose id changed mid-stream (see
|
|
316
|
+
* {@link #streamedToolCallIdByIndex}). Moves every id-keyed tracker from the
|
|
317
|
+
* old id to the new one so the next cumulative `message_update` reuses the
|
|
318
|
+
* existing card instead of creating a duplicate (#6879). The card component
|
|
319
|
+
* itself is id-agnostic (routing is via `pendingTools`), so only the maps and
|
|
320
|
+
* the shared read group's entry need re-keying.
|
|
321
|
+
*/
|
|
322
|
+
#migrateStreamedToolCallId(oldId: string, newId: string): void {
|
|
323
|
+
// `oldId` may be "" (the block streamed before its id): that empty key still
|
|
324
|
+
// owns a live card and must migrate. Skip only a no-op or an empty target.
|
|
325
|
+
if (oldId === newId || !newId) return;
|
|
326
|
+
const pending = this.ctx.pendingTools.get(oldId);
|
|
327
|
+
if (pending && !this.ctx.pendingTools.has(newId)) {
|
|
328
|
+
this.ctx.pendingTools.delete(oldId);
|
|
329
|
+
this.ctx.pendingTools.set(newId, pending);
|
|
330
|
+
}
|
|
331
|
+
const timeline = this.#toolTimelineComponents.get(oldId);
|
|
332
|
+
if (timeline && !this.#toolTimelineComponents.has(newId)) {
|
|
333
|
+
this.#toolTimelineComponents.delete(oldId);
|
|
334
|
+
this.#toolTimelineComponents.set(newId, timeline);
|
|
335
|
+
}
|
|
336
|
+
// The reveal controller is id-keyed; drop the stale target so the loop's
|
|
337
|
+
// setTarget/bind under the new id owns the paced reveal.
|
|
338
|
+
this.#toolArgsReveal.finish(oldId);
|
|
339
|
+
const readArgs = this.#readToolCallArgs.get(oldId);
|
|
340
|
+
if (readArgs !== undefined) {
|
|
341
|
+
this.#readToolCallArgs.delete(oldId);
|
|
342
|
+
this.#readToolCallArgs.set(newId, readArgs);
|
|
343
|
+
}
|
|
344
|
+
const readAssistant = this.#readToolCallAssistantComponents.get(oldId);
|
|
345
|
+
if (readAssistant !== undefined) {
|
|
346
|
+
this.#readToolCallAssistantComponents.delete(oldId);
|
|
347
|
+
this.#readToolCallAssistantComponents.set(newId, readAssistant);
|
|
348
|
+
}
|
|
349
|
+
// A collapsed read renders into a shared group keyed by id; rename its
|
|
350
|
+
// entry so the row isn't duplicated under the new id.
|
|
351
|
+
if (pending instanceof ReadToolGroupComponent) pending.renameEntry(oldId, newId);
|
|
352
|
+
// A server-resolved completion (Cursor/todo) can land under `newId` while
|
|
353
|
+
// the card was still keyed by `oldId`, so it was parked in
|
|
354
|
+
// `#orphanedToolCompletions` instead of settling. Now that the card owns
|
|
355
|
+
// `newId`, apply the held result — the normal creation path that consumes
|
|
356
|
+
// held completions is skipped on a re-key (Codex review on #6881).
|
|
357
|
+
if (pending) {
|
|
358
|
+
const orphan = this.#orphanedToolCompletions.get(newId);
|
|
359
|
+
if (orphan) {
|
|
360
|
+
this.#orphanedToolCompletions.delete(newId);
|
|
361
|
+
this.#settleHeldCompletion(pending, orphan);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
277
366
|
#inlineReadToolImages(
|
|
278
367
|
toolCallId: string,
|
|
279
368
|
result: { content: Array<{ type: string; data?: string; mimeType?: string }> },
|
|
@@ -351,6 +440,9 @@ export class EventController {
|
|
|
351
440
|
this.#renderedCustomMessages.clear();
|
|
352
441
|
this.#lastIntent = undefined;
|
|
353
442
|
this.#toolTimelineComponents.clear();
|
|
443
|
+
this.#streamedToolCallIdByIndex.clear();
|
|
444
|
+
this.#retractedToolCallIds.clear();
|
|
445
|
+
this.#syntheticFailureCards.clear();
|
|
354
446
|
this.#orphanedToolCompletions.clear();
|
|
355
447
|
this.#postToolAssistantComponents.clear();
|
|
356
448
|
this.#backgroundTaskCallIds.clear();
|
|
@@ -440,6 +532,9 @@ export class EventController {
|
|
|
440
532
|
|
|
441
533
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
442
534
|
this.#toolTimelineComponents.clear();
|
|
535
|
+
this.#streamedToolCallIdByIndex.clear();
|
|
536
|
+
this.#retractedToolCallIds.clear();
|
|
537
|
+
this.#syntheticFailureCards.clear();
|
|
443
538
|
this.#orphanedToolCompletions.clear();
|
|
444
539
|
this.#postToolAssistantComponents.clear();
|
|
445
540
|
this.#lastIntent = undefined;
|
|
@@ -539,6 +634,7 @@ export class EventController {
|
|
|
539
634
|
this.ctx.ui.requestRender();
|
|
540
635
|
} else if (event.message.role === "assistant") {
|
|
541
636
|
this.#lastVisibleBlockCount = 0;
|
|
637
|
+
this.#streamedToolCallIdByIndex.clear();
|
|
542
638
|
this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
|
|
543
639
|
this.ctx.streamingMessage = event.message;
|
|
544
640
|
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|
|
@@ -750,8 +846,17 @@ export class EventController {
|
|
|
750
846
|
if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) {
|
|
751
847
|
this.ctx.streamingComponent.markTranscriptBlockFinalized();
|
|
752
848
|
}
|
|
753
|
-
for (
|
|
849
|
+
for (let contentIndex = 0; contentIndex < this.ctx.streamingMessage.content.length; contentIndex++) {
|
|
850
|
+
const content = this.ctx.streamingMessage.content[contentIndex]!;
|
|
754
851
|
if (content.type !== "toolCall") continue;
|
|
852
|
+
// Re-key the live card when a provider rewrites this block's id
|
|
853
|
+
// across deltas, so the changed id reuses the existing card
|
|
854
|
+
// instead of spawning a duplicate (#6879).
|
|
855
|
+
const priorId = this.#streamedToolCallIdByIndex.get(contentIndex);
|
|
856
|
+
if (priorId !== undefined && priorId !== content.id) {
|
|
857
|
+
this.#migrateStreamedToolCallId(priorId, content.id);
|
|
858
|
+
}
|
|
859
|
+
this.#streamedToolCallIdByIndex.set(contentIndex, content.id);
|
|
755
860
|
if (content.name === "read") {
|
|
756
861
|
if (!readArgsHaveTarget(content.arguments)) {
|
|
757
862
|
// Args still streaming — defer until path is parseable so we can route to the
|
|
@@ -929,17 +1034,40 @@ export class EventController {
|
|
|
929
1034
|
component.setArgsComplete(toolCallId);
|
|
930
1035
|
}
|
|
931
1036
|
} else {
|
|
932
|
-
// The turn ended without running these calls
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
// streams fresh
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1037
|
+
// The turn ended without running these calls. What happens next
|
|
1038
|
+
// decides whether their cards should vanish or stay:
|
|
1039
|
+
// • TTSR rewind — known NOW via `isTtsrAbortPending` — re-runs the
|
|
1040
|
+
// turn and re-streams fresh cards, so retract the uncommitted
|
|
1041
|
+
// never-run cards here and swallow the synthetic completions
|
|
1042
|
+
// agent-loop emits for them, else the call renders twice (#6879).
|
|
1043
|
+
// • A plain terminal error/abort, or an auto-retry (whose
|
|
1044
|
+
// supersession is only known later at `auto_retry_start`), must
|
|
1045
|
+
// NOT be retracted here: agent-loop emits a synthetic
|
|
1046
|
+
// `tool_execution_end` right after this that settles each card
|
|
1047
|
+
// into a visible aborted/error result. Leave them so the terminal
|
|
1048
|
+
// failure stays visible; `#handleAutoRetryStart` removes them only
|
|
1049
|
+
// if a retry actually supersedes them (Codex review on #6881).
|
|
1050
|
+
const supersededByRewind =
|
|
1051
|
+
this.ctx.streamingMessage.stopReason === "aborted" && this.ctx.viewSession.isTtsrAbortPending;
|
|
1052
|
+
if (supersededByRewind) {
|
|
1053
|
+
for (const [toolCallId, component] of Array.from(this.ctx.pendingTools.entries())) {
|
|
1054
|
+
if (this.#backgroundTaskCallIds.has(toolCallId)) continue;
|
|
1055
|
+
if (
|
|
1056
|
+
!(component instanceof ToolExecutionComponent) &&
|
|
1057
|
+
!(component instanceof ReadToolGroupComponent)
|
|
1058
|
+
) {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (this.ctx.chatContainer.isBlockUncommitted(component)) {
|
|
1062
|
+
this.#retractToolCardEntry(toolCallId, component);
|
|
1063
|
+
this.#retractedToolCallIds.add(toolCallId);
|
|
1064
|
+
} else {
|
|
1065
|
+
component.seal();
|
|
1066
|
+
}
|
|
939
1067
|
}
|
|
940
1068
|
}
|
|
941
|
-
// These calls will never
|
|
942
|
-
//
|
|
1069
|
+
// These calls will never run this attempt, so the tracked waiting
|
|
1070
|
+
// poll cannot be displaced anymore — freeze it in place.
|
|
943
1071
|
this.#resolveDisplaceablePoll();
|
|
944
1072
|
}
|
|
945
1073
|
// Surface a prompt-cache invalidation: if the previous turn cached a
|
|
@@ -1005,6 +1133,7 @@ export class EventController {
|
|
|
1005
1133
|
}
|
|
1006
1134
|
|
|
1007
1135
|
async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
|
|
1136
|
+
if (this.#retractedToolCallIds.has(event.toolCallId)) return;
|
|
1008
1137
|
this.#ensureWorkingLoaderWhileStreaming();
|
|
1009
1138
|
this.#updateWorkingMessageFromIntent(event.intent);
|
|
1010
1139
|
if (event.toolName === "ask" || this.#toolWillPromptForApproval(event.toolName, event.args)) {
|
|
@@ -1157,6 +1286,23 @@ export class EventController {
|
|
|
1157
1286
|
}
|
|
1158
1287
|
|
|
1159
1288
|
async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
|
|
1289
|
+
// `createAbortedToolResult` emits start/end after an error/aborted
|
|
1290
|
+
// assistant message. The matching card was deliberately retracted at
|
|
1291
|
+
// message_end; consume the completion instead of recreating/updating UI.
|
|
1292
|
+
if (this.#retractedToolCallIds.delete(event.toolCallId)) return;
|
|
1293
|
+
// A synthetic aborted/error completion (agent-loop's placeholder for a
|
|
1294
|
+
// never-run call on a terminal error/abort) settles the card in place so a
|
|
1295
|
+
// terminal failure stays visible. Remember it so `#handleAutoRetryStart`
|
|
1296
|
+
// can remove it if an auto-retry then supersedes the turn (Codex review on
|
|
1297
|
+
// #6881). Capture the live component now — the settle path below drops it
|
|
1298
|
+
// from `pendingTools` but leaves it in the transcript.
|
|
1299
|
+
const syntheticFailureDetails = event.result.details as { __synthetic?: boolean; source?: string } | undefined;
|
|
1300
|
+
const syntheticFailureCard =
|
|
1301
|
+
syntheticFailureDetails?.__synthetic === true &&
|
|
1302
|
+
(syntheticFailureDetails.source === "assistant_stop_error" ||
|
|
1303
|
+
syntheticFailureDetails.source === "assistant_stop_aborted")
|
|
1304
|
+
? this.ctx.pendingTools.get(event.toolCallId)
|
|
1305
|
+
: undefined;
|
|
1160
1306
|
// A transient overlay (auto-compaction / auto-retry / handoff) that ran
|
|
1161
1307
|
// between this tool's start and end could have detached the working
|
|
1162
1308
|
// loader. `tool_execution_update` already reconciles this so the spinner
|
|
@@ -1187,6 +1333,17 @@ export class EventController {
|
|
|
1187
1333
|
} else {
|
|
1188
1334
|
let component = this.ctx.pendingTools.get(event.toolCallId);
|
|
1189
1335
|
if (!component) {
|
|
1336
|
+
// A persisted result can win a mid-stream transcript rebuild
|
|
1337
|
+
// before this live completion handler runs. Rebuild removes the
|
|
1338
|
+
// pending handle and replay owns the completed card, but the
|
|
1339
|
+
// original timeline entry remains as proof that a card already
|
|
1340
|
+
// existed. Do not create a fallback read group beside replay
|
|
1341
|
+
// (#6879); the fallback is only for a completion that genuinely
|
|
1342
|
+
// outran every streamed card.
|
|
1343
|
+
if (this.#toolTimelineComponents.has(event.toolCallId)) {
|
|
1344
|
+
this.#clearReadToolCall(event.toolCallId);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1190
1347
|
const group = this.#getReadGroup();
|
|
1191
1348
|
const args = this.#readToolCallArgs.get(event.toolCallId);
|
|
1192
1349
|
if (args) {
|
|
@@ -1243,6 +1400,7 @@ export class EventController {
|
|
|
1243
1400
|
this.#orphanedToolCompletions.set(event.toolCallId, event);
|
|
1244
1401
|
}
|
|
1245
1402
|
}
|
|
1403
|
+
if (syntheticFailureCard) this.#syntheticFailureCards.set(event.toolCallId, syntheticFailureCard);
|
|
1246
1404
|
// Update todo display when todo tool completes
|
|
1247
1405
|
if (event.toolName === "todo" && !event.isError) {
|
|
1248
1406
|
const details = event.result.details as { phases?: TodoPhase[] } | undefined;
|
|
@@ -1349,6 +1507,9 @@ export class EventController {
|
|
|
1349
1507
|
this.#readToolCallArgs.clear();
|
|
1350
1508
|
this.#readToolCallAssistantComponents.clear();
|
|
1351
1509
|
this.#toolTimelineComponents.clear();
|
|
1510
|
+
this.#streamedToolCallIdByIndex.clear();
|
|
1511
|
+
this.#retractedToolCallIds.clear();
|
|
1512
|
+
this.#syntheticFailureCards.clear();
|
|
1352
1513
|
this.#orphanedToolCompletions.clear();
|
|
1353
1514
|
this.#postToolAssistantComponents.clear();
|
|
1354
1515
|
this.#resetReadGroup();
|
|
@@ -1522,6 +1683,17 @@ export class EventController {
|
|
|
1522
1683
|
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
|
|
1523
1684
|
this.#retryPending = true;
|
|
1524
1685
|
this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
|
|
1686
|
+
// A retry supersedes the just-failed turn: its assistant + tool calls are
|
|
1687
|
+
// pruned from context and re-streamed. Remove the cards that a synthetic
|
|
1688
|
+
// aborted/error completion settled in place at message_end so the retry's
|
|
1689
|
+
// fresh cards don't render the same call twice (#6879). Only uncommitted
|
|
1690
|
+
// cards are removable; one already on the scrollback tape stays as history.
|
|
1691
|
+
for (const [toolCallId, component] of this.#syntheticFailureCards) {
|
|
1692
|
+
if (this.ctx.chatContainer.isBlockUncommitted(component)) {
|
|
1693
|
+
this.#retractToolCardEntry(toolCallId, component);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
this.#syntheticFailureCards.clear();
|
|
1525
1697
|
this.#stopWorkingLoader();
|
|
1526
1698
|
this.ctx.statusContainer.disposeChildren();
|
|
1527
1699
|
if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
|
|
2
3
|
import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
|
|
3
4
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
4
5
|
import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
|
|
@@ -696,15 +697,38 @@ export class SelectorController {
|
|
|
696
697
|
this.ctx.session.modelRegistry,
|
|
697
698
|
this.ctx.session.scopedModels,
|
|
698
699
|
{
|
|
699
|
-
onPick: async (model, selector) => {
|
|
700
|
-
|
|
701
|
-
|
|
700
|
+
onPick: async (model, selector, { overContext }) => {
|
|
701
|
+
// Session-only: update agent state but don't persist the model to settings.
|
|
702
|
+
const applySessionModel = async () => {
|
|
702
703
|
const roleThinkingLevel = this.ctx.session.resolveTemporaryModelThinkingLevel(model);
|
|
703
704
|
await this.ctx.session.setModelTemporary(model, roleThinkingLevel);
|
|
704
705
|
this.ctx.statusLine.invalidate();
|
|
705
706
|
this.ctx.updateEditorBorderColor();
|
|
706
707
|
const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
|
|
707
708
|
this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
|
|
709
|
+
};
|
|
710
|
+
try {
|
|
711
|
+
if (overContext) {
|
|
712
|
+
// Over-context pick: close the picker so the compaction loader is
|
|
713
|
+
// visible, compact with the current model, then switch. The switch
|
|
714
|
+
// runs in the before-flush hook so any prompt queued during
|
|
715
|
+
// compaction executes on the target model, not the old one; the
|
|
716
|
+
// early "nothing to compact" return skips the hook, so the
|
|
717
|
+
// idempotent post-return call covers it. A cancelled or failed
|
|
718
|
+
// compaction keeps the current model — the target still cannot
|
|
719
|
+
// fit the transcript.
|
|
720
|
+
done();
|
|
721
|
+
let switched = false;
|
|
722
|
+
const switchAfterCompaction = async (outcome: CompactionOutcome) => {
|
|
723
|
+
if (switched || outcome !== "ok") return;
|
|
724
|
+
switched = true;
|
|
725
|
+
await applySessionModel();
|
|
726
|
+
};
|
|
727
|
+
const outcome = await this.ctx.handleCompactCommand(undefined, undefined, switchAfterCompaction);
|
|
728
|
+
await switchAfterCompaction(outcome);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await applySessionModel();
|
|
708
732
|
done();
|
|
709
733
|
} catch (error) {
|
|
710
734
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
@@ -753,7 +777,6 @@ export class SelectorController {
|
|
|
753
777
|
* entry — used when reopening the hub after a /login round-trip.
|
|
754
778
|
*/
|
|
755
779
|
#showModelHub(hubOptions: { initialProviderId?: string }): void {
|
|
756
|
-
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
|
757
780
|
let overlayHandle: OverlayHandle | undefined;
|
|
758
781
|
let hub: ModelHubComponent | undefined;
|
|
759
782
|
let closed = false;
|
|
@@ -821,7 +844,6 @@ export class SelectorController {
|
|
|
821
844
|
selector,
|
|
822
845
|
thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking,
|
|
823
846
|
persist: targetScope === "global",
|
|
824
|
-
currentContextTokens,
|
|
825
847
|
});
|
|
826
848
|
if (!switched) return;
|
|
827
849
|
if (targetScope === "project") {
|
|
@@ -922,7 +944,6 @@ export class SelectorController {
|
|
|
922
944
|
thinkingLevel: effectiveIsAuto
|
|
923
945
|
? ThinkingLevel.Inherit
|
|
924
946
|
: (concreteThinking ?? ThinkingLevel.Inherit),
|
|
925
|
-
currentContextTokens,
|
|
926
947
|
});
|
|
927
948
|
if (!switched) return;
|
|
928
949
|
if (effectiveIsAuto) {
|
package/src/modes/types.ts
CHANGED
|
@@ -355,7 +355,11 @@ export interface InteractiveModeContext {
|
|
|
355
355
|
handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
|
|
356
356
|
handleMCPCommand(text: string): Promise<void>;
|
|
357
357
|
handleSSHCommand(text: string): Promise<void>;
|
|
358
|
-
handleCompactCommand(
|
|
358
|
+
handleCompactCommand(
|
|
359
|
+
customInstructions?: string,
|
|
360
|
+
mode?: CompactMode,
|
|
361
|
+
beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
|
|
362
|
+
): Promise<CompactionOutcome>;
|
|
359
363
|
handleHandoffCommand(customInstructions?: string): Promise<void>;
|
|
360
364
|
handleShakeCommand(mode: ShakeMode): Promise<void>;
|
|
361
365
|
handleMoveCommand(targetPath?: string): Promise<void>;
|
|
@@ -8,7 +8,7 @@ Use ONLY for one binary or a short pipeline that computes a fact (`wc -l`, `sort
|
|
|
8
8
|
- `pty: true` only for terminal interaction (`sudo`, `ssh`).
|
|
9
9
|
- Order-dependent commands use `&&` in one call; independent calls may run concurrently.
|
|
10
10
|
- Internal URIs (`skill://`, `agent://`, …) auto-resolve to paths.
|
|
11
|
-
{{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}}
|
|
11
|
+
{{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, pgrep, pkill, pidwait, top, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}}
|
|
12
12
|
{{#if asyncEnabled}}- `async: true` defers a finite command's result; it does not extend `timeout`.{{/if}}
|
|
13
13
|
</instruction>
|
|
14
14
|
|
|
@@ -345,12 +345,19 @@ export class AgentLifecycleManager {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
348
|
+
* Dispose if live and drop timers. When `expected` is given, only a ref
|
|
349
|
+
* matching it is released; a stale release can never take down a newer
|
|
350
|
+
* same-id ref. Returns true when a matching ref was released.
|
|
351
|
+
*
|
|
352
|
+
* By default the ref is unregistered (teardown / one-shot removal). Pass
|
|
353
|
+
* `tombstone: true` for an explicit kill: the ref is kept registered as a
|
|
354
|
+
* terminal `aborted` row (session detached) instead of being removed, so a
|
|
355
|
+
* later persisted-subagent scan (e.g. Agent Hub reopen) skips it via its
|
|
356
|
+
* `if (!registry.get(id))` guard rather than re-adopting the surviving
|
|
357
|
+
* on-disk transcript as a fresh `parked` row. Mirrors
|
|
358
|
+
* `finalizeSubagentLifecycle`'s genuine-kill path.
|
|
352
359
|
*/
|
|
353
|
-
async release(id: string, expected?: AgentRefExpectation): Promise<boolean> {
|
|
360
|
+
async release(id: string, expected?: AgentRefExpectation, options?: { tombstone?: boolean }): Promise<boolean> {
|
|
354
361
|
const adopted = this.#adopted.get(id);
|
|
355
362
|
const current = this.#registry.get(id);
|
|
356
363
|
const currentMatches =
|
|
@@ -371,14 +378,26 @@ export class AgentLifecycleManager {
|
|
|
371
378
|
await park.promise;
|
|
372
379
|
}
|
|
373
380
|
|
|
374
|
-
if (
|
|
381
|
+
if (options?.tombstone) {
|
|
382
|
+
// Explicit kill: mark the ref terminal `aborted` and detach the session
|
|
383
|
+
// BEFORE disposing. aborted refs must satisfy the AgentRef invariant
|
|
384
|
+
// (session === null) so ensureLive / hub focus can never route into a
|
|
385
|
+
// disposed session; setting the terminal status first also makes
|
|
386
|
+
// createAgentSession's dispose wrapper (unregisterUnlessParked) preserve
|
|
387
|
+
// the ref instead of removing it, so a later persisted-subagent rescan
|
|
388
|
+
// skips it. The transcript file is left intact (history://<id>).
|
|
389
|
+
this.#registry.setStatus(id, "aborted", ref);
|
|
390
|
+
}
|
|
391
|
+
const live = this.#registry.get(id) === ref ? ref.session : null;
|
|
392
|
+
if (options?.tombstone) this.#registry.detachSession(id, ref);
|
|
393
|
+
if (live) {
|
|
375
394
|
try {
|
|
376
|
-
await
|
|
395
|
+
await live.dispose();
|
|
377
396
|
} catch (error) {
|
|
378
397
|
logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
|
|
379
398
|
}
|
|
380
399
|
}
|
|
381
|
-
this.#registry.unregister(id, ref);
|
|
400
|
+
if (!options?.tombstone) this.#registry.unregister(id, ref);
|
|
382
401
|
return true;
|
|
383
402
|
}
|
|
384
403
|
|
|
@@ -396,21 +415,27 @@ export class AgentLifecycleManager {
|
|
|
396
415
|
async #revive(id: string, revive: AgentReviver, ref: AgentRef, adopted: AdoptedAgent): Promise<AgentSession> {
|
|
397
416
|
const session = await revive(ref);
|
|
398
417
|
let liveRef = this.#registry.get(id);
|
|
399
|
-
if (liveRef === ref) {
|
|
418
|
+
if (liveRef === ref && ref.status === "parked" && !ref.session) {
|
|
419
|
+
// A simple reviver returned a session without claiming the parked ref;
|
|
420
|
+
// attach it here while the exact ref is still revivable.
|
|
400
421
|
if (!this.#registry.attachSession(id, session, ref.sessionFile, ref)) {
|
|
401
422
|
await session.dispose();
|
|
402
423
|
throw new Error(`Agent "${id}" changed before its persisted session could attach.`);
|
|
403
424
|
}
|
|
404
425
|
liveRef = ref;
|
|
405
426
|
} else if (
|
|
406
|
-
|
|
427
|
+
liveRef !== ref ||
|
|
428
|
+
liveRef.status !== "running" ||
|
|
407
429
|
liveRef.session !== session ||
|
|
408
430
|
liveRef.kind !== ref.kind ||
|
|
409
431
|
liveRef.parentId !== ref.parentId ||
|
|
410
432
|
liveRef.sessionFile !== ref.sessionFile
|
|
411
433
|
) {
|
|
434
|
+
// createAgentSession may have already claimed this exact parked ref and
|
|
435
|
+
// attached the returned session. Any other state — especially an
|
|
436
|
+
// `aborted` tombstone set while revive() was in flight — is stale.
|
|
412
437
|
await session.dispose();
|
|
413
|
-
throw new Error(`Agent "${id}" was replaced while its persisted session was reviving.`);
|
|
438
|
+
throw new Error(`Agent "${id}" was replaced or became terminal while its persisted session was reviving.`);
|
|
414
439
|
}
|
|
415
440
|
adopted.ref = liveRef;
|
|
416
441
|
// Emits status_changed → "idle", which re-arms the TTL timer below.
|
|
@@ -105,19 +105,23 @@ export class AgentRegistry {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Register a new id only when it is absent, or reuse the exact
|
|
109
|
-
* revival was authorized to revive. A missing
|
|
110
|
-
*
|
|
108
|
+
* Register a new id only when it is absent, or reuse the exact detached
|
|
109
|
+
* `parked` ref a revival was authorized to revive. A missing, replaced, or
|
|
110
|
+
* terminal expected ref is a failed CAS: delayed revivers must never claim an
|
|
111
|
+
* id after its prior generation disappeared or was hard-killed.
|
|
111
112
|
*/
|
|
112
113
|
registerIfAvailable(input: RegisterInput, expected: AgentRef | null): AgentRef | undefined {
|
|
113
114
|
const current = this.#refs.get(input.id);
|
|
114
115
|
if (expected === null) return current ? undefined : this.register(input);
|
|
115
|
-
return current === expected ? current : undefined;
|
|
116
|
+
return current === expected && current.status === "parked" && !current.session ? current : undefined;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean {
|
|
119
120
|
const ref = this.#refs.get(id);
|
|
120
121
|
if (!ref || !this.#matchesExpected(ref, expected)) return false;
|
|
122
|
+
// `aborted` is terminal: delayed progress/revival work from the killed
|
|
123
|
+
// generation must never transition the tombstone back to a live status.
|
|
124
|
+
if (ref.status === "aborted") return status === "aborted";
|
|
121
125
|
if (ref.status === status) return true;
|
|
122
126
|
ref.status = status;
|
|
123
127
|
// Activity describes current work; it is meaningless once the agent
|
|
@@ -159,7 +163,10 @@ export class AgentRegistry {
|
|
|
159
163
|
expected?: AgentRefExpectation,
|
|
160
164
|
): boolean {
|
|
161
165
|
const ref = this.#refs.get(id);
|
|
162
|
-
|
|
166
|
+
// Never attach a late-created session to a hard-killed tombstone. This
|
|
167
|
+
// closes the race between a parked reviver claiming the ref and finishing
|
|
168
|
+
// createAgentSession after an explicit kill.
|
|
169
|
+
if (!ref || ref.status === "aborted" || !this.#matchesExpected(ref, expected)) return false;
|
|
163
170
|
ref.session = session;
|
|
164
171
|
if (sessionFile !== undefined) ref.sessionFile = sessionFile;
|
|
165
172
|
ref.lastActivity = Date.now();
|