@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.2
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 +79 -0
- package/package.json +8 -8
- package/src/async/job-manager.ts +43 -10
- package/src/commit/agentic/tools/analyze-file.ts +1 -2
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-equivalence.ts +1 -0
- package/src/config/model-registry.ts +63 -34
- package/src/config/model-resolver.ts +111 -15
- package/src/config/settings-schema.ts +4 -3
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +64 -23
- package/src/edit/index.ts +254 -89
- package/src/edit/modes/chunk.ts +336 -57
- package/src/edit/modes/hashline.ts +51 -26
- package/src/edit/modes/patch.ts +16 -10
- package/src/edit/modes/replace.ts +15 -7
- package/src/edit/renderer.ts +248 -94
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +6 -4
- package/src/extensibility/custom-tools/types.ts +0 -3
- package/src/extensibility/extensions/loader.ts +16 -0
- package/src/extensibility/extensions/runner.ts +2 -7
- package/src/extensibility/extensions/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/ipy/executor.ts +447 -52
- package/src/ipy/kernel.ts +39 -13
- package/src/lsp/client.ts +54 -0
- package/src/lsp/index.ts +8 -0
- package/src/lsp/types.ts +6 -0
- package/src/main.ts +0 -1
- package/src/modes/acp/acp-agent.ts +4 -1
- package/src/modes/components/bash-execution.ts +16 -4
- package/src/modes/components/status-line/presets.ts +17 -6
- package/src/modes/components/status-line/segments.ts +15 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +7 -1
- package/src/modes/components/tool-execution.ts +145 -75
- package/src/modes/controllers/command-controller.ts +24 -1
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +28 -5
- package/src/modes/controllers/input-controller.ts +9 -3
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +19 -3
- package/src/modes/print-mode.ts +13 -4
- package/src/modes/prompt-action-autocomplete.ts +3 -5
- package/src/modes/rpc/rpc-mode.ts +8 -2
- package/src/modes/shared.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/chunk-edit.md +191 -163
- package/src/prompts/tools/hashline.md +11 -11
- package/src/prompts/tools/patch.md +10 -5
- package/src/prompts/tools/{await.md → poll.md} +1 -1
- package/src/prompts/tools/read-chunk.md +3 -3
- package/src/prompts/tools/task.md +2 -2
- package/src/prompts/tools/vim.md +98 -0
- package/src/sdk.ts +754 -724
- package/src/session/agent-session.ts +164 -34
- package/src/session/session-manager.ts +50 -4
- package/src/slash-commands/builtin-registry.ts +17 -0
- package/src/task/executor.ts +4 -4
- package/src/task/index.ts +3 -5
- package/src/task/types.ts +2 -2
- package/src/tools/bash.ts +26 -8
- package/src/tools/find.ts +5 -2
- package/src/tools/grep.ts +77 -8
- package/src/tools/index.ts +48 -19
- package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
- package/src/tools/python.ts +293 -278
- package/src/tools/submit-result.ts +5 -2
- package/src/tools/todo-write.ts +8 -2
- package/src/tools/vim.ts +966 -0
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/session-color.ts +55 -0
- package/src/utils/title-generator.ts +15 -6
- package/src/vim/buffer.ts +309 -0
- package/src/vim/commands.ts +382 -0
- package/src/vim/engine.ts +2426 -0
- package/src/vim/parser.ts +151 -0
- package/src/vim/render.ts +252 -0
- package/src/vim/types.ts +197 -0
|
@@ -49,8 +49,16 @@ import {
|
|
|
49
49
|
modelsAreEqual,
|
|
50
50
|
parseRateLimitReason,
|
|
51
51
|
} from "@oh-my-pi/pi-ai";
|
|
52
|
-
import { killTree, MacOSPowerAssertion
|
|
53
|
-
import {
|
|
52
|
+
import { killTree, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
|
|
53
|
+
import {
|
|
54
|
+
abortableSleep,
|
|
55
|
+
getAgentDbPath,
|
|
56
|
+
isEnoent,
|
|
57
|
+
logger,
|
|
58
|
+
prompt,
|
|
59
|
+
Snowflake,
|
|
60
|
+
setNativeKillTree,
|
|
61
|
+
} from "@oh-my-pi/pi-utils";
|
|
54
62
|
import type { AsyncJob, AsyncJobManager } from "../async";
|
|
55
63
|
import type { Rule } from "../capability/rule";
|
|
56
64
|
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
|
|
@@ -95,7 +103,11 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
|
95
103
|
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
96
104
|
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
97
105
|
import { resolveLocalUrlToPath } from "../internal-urls";
|
|
98
|
-
import {
|
|
106
|
+
import {
|
|
107
|
+
disposeKernelSessionsByOwner,
|
|
108
|
+
executePython as executePythonCommand,
|
|
109
|
+
type PythonResult,
|
|
110
|
+
} from "../ipy/executor";
|
|
99
111
|
import {
|
|
100
112
|
buildDiscoverableMCPSearchIndex,
|
|
101
113
|
collectDiscoverableMCPTools,
|
|
@@ -126,6 +138,7 @@ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from ".
|
|
|
126
138
|
import { ToolError } from "../tools/tool-errors";
|
|
127
139
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
128
140
|
import { parseCommandArgs } from "../utils/command-args";
|
|
141
|
+
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
129
142
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
130
143
|
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
|
131
144
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
@@ -244,8 +257,8 @@ export interface AgentSessionConfig {
|
|
|
244
257
|
ttsrManager?: TtsrManager;
|
|
245
258
|
/** Secret obfuscator for deobfuscating streaming edit content */
|
|
246
259
|
obfuscator?: SecretObfuscator;
|
|
247
|
-
/**
|
|
248
|
-
|
|
260
|
+
/** Logical owner for retained Python kernels created by this session. */
|
|
261
|
+
pythonKernelOwnerId?: string;
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
/** Options for AgentSession.prompt() */
|
|
@@ -397,7 +410,6 @@ export class AgentSession {
|
|
|
397
410
|
readonly agent: Agent;
|
|
398
411
|
readonly sessionManager: SessionManager;
|
|
399
412
|
readonly settings: Settings;
|
|
400
|
-
readonly searchDb: SearchDb | undefined;
|
|
401
413
|
|
|
402
414
|
#powerAssertion: MacOSPowerAssertion | undefined;
|
|
403
415
|
|
|
@@ -452,9 +464,11 @@ export class AgentSession {
|
|
|
452
464
|
#pendingBashMessages: BashExecutionMessage[] = [];
|
|
453
465
|
|
|
454
466
|
// Python execution state
|
|
455
|
-
#
|
|
467
|
+
#pythonAbortControllers = new Set<AbortController>();
|
|
468
|
+
#pythonKernelOwnerId: string;
|
|
456
469
|
#pendingPythonMessages: PythonExecutionMessage[] = [];
|
|
457
|
-
|
|
470
|
+
#activePythonExecutions = new Set<Promise<unknown>>();
|
|
471
|
+
#pythonExecutionDisposing = false;
|
|
458
472
|
// Extension system
|
|
459
473
|
#extensionRunner: ExtensionRunner | undefined = undefined;
|
|
460
474
|
#turnIndex = 0;
|
|
@@ -544,9 +558,9 @@ export class AgentSession {
|
|
|
544
558
|
this.agent = config.agent;
|
|
545
559
|
this.sessionManager = config.sessionManager;
|
|
546
560
|
this.settings = config.settings;
|
|
547
|
-
this.searchDb = config.searchDb;
|
|
548
561
|
this.#startPowerAssertion();
|
|
549
562
|
this.#asyncJobManager = config.asyncJobManager;
|
|
563
|
+
this.#pythonKernelOwnerId = config.pythonKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
|
550
564
|
this.#scopedModels = config.scopedModels ?? [];
|
|
551
565
|
this.#thinkingLevel = config.thinkingLevel;
|
|
552
566
|
this.#promptTemplates = config.promptTemplates ?? [];
|
|
@@ -692,7 +706,23 @@ export class AgentSession {
|
|
|
692
706
|
}
|
|
693
707
|
}
|
|
694
708
|
|
|
709
|
+
#queuedExtensionEvents: Promise<void> = Promise.resolve();
|
|
710
|
+
|
|
711
|
+
#queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
|
|
712
|
+
const emit = async () => {
|
|
713
|
+
await this.#emitExtensionEvent(event);
|
|
714
|
+
};
|
|
715
|
+
const queued = this.#queuedExtensionEvents.then(emit, emit);
|
|
716
|
+
this.#queuedExtensionEvents = queued.catch(() => {});
|
|
717
|
+
return queued;
|
|
718
|
+
}
|
|
719
|
+
|
|
695
720
|
async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
|
|
721
|
+
if (event.type === "message_update") {
|
|
722
|
+
this.#emit(event);
|
|
723
|
+
void this.#queueExtensionEvent(event);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
696
726
|
await this.#emitExtensionEvent(event);
|
|
697
727
|
this.#emit(event);
|
|
698
728
|
}
|
|
@@ -1798,6 +1828,7 @@ export class AgentSession {
|
|
|
1798
1828
|
* Call this when completely done with the session.
|
|
1799
1829
|
*/
|
|
1800
1830
|
async dispose(): Promise<void> {
|
|
1831
|
+
this.#pythonExecutionDisposing = true;
|
|
1801
1832
|
try {
|
|
1802
1833
|
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
|
|
1803
1834
|
await this.#extensionRunner.emit({ type: "session_shutdown" });
|
|
@@ -1812,6 +1843,13 @@ export class AgentSession {
|
|
|
1812
1843
|
if (drained === false && deliveryState) {
|
|
1813
1844
|
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
|
|
1814
1845
|
}
|
|
1846
|
+
const pythonExecutionsSettled = await this.#preparePythonExecutionsForDispose();
|
|
1847
|
+
if (!pythonExecutionsSettled) {
|
|
1848
|
+
logger.warn(
|
|
1849
|
+
"Detaching retained Python kernel ownership during dispose while Python execution is still active",
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
await disposeKernelSessionsByOwner(this.#pythonKernelOwnerId);
|
|
1815
1853
|
this.#stopPowerAssertion();
|
|
1816
1854
|
await this.sessionManager.close();
|
|
1817
1855
|
this.#closeAllProviderSessions("dispose");
|
|
@@ -1970,6 +2008,24 @@ export class AgentSession {
|
|
|
1970
2008
|
return Array.from(this.#toolRegistry.keys());
|
|
1971
2009
|
}
|
|
1972
2010
|
|
|
2011
|
+
#getEditModeSession() {
|
|
2012
|
+
return {
|
|
2013
|
+
settings: this.settings,
|
|
2014
|
+
getActiveModelString: () => (this.model ? formatModelString(this.model) : undefined),
|
|
2015
|
+
} as const;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
#resolveActiveEditMode(): EditMode {
|
|
2019
|
+
return resolveEditMode(this.#getEditModeSession());
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
|
|
2023
|
+
const currentEditMode = this.#resolveActiveEditMode();
|
|
2024
|
+
if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
|
|
2025
|
+
await this.refreshBaseSystemPrompt();
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1973
2029
|
isMCPDiscoveryEnabled(): boolean {
|
|
1974
2030
|
return this.#mcpDiscoveryEnabled;
|
|
1975
2031
|
}
|
|
@@ -2017,6 +2073,7 @@ export class AgentSession {
|
|
|
2017
2073
|
toolNames: string[],
|
|
2018
2074
|
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
|
|
2019
2075
|
): Promise<void> {
|
|
2076
|
+
toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
|
|
2020
2077
|
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
|
|
2021
2078
|
const tools: AgentTool[] = [];
|
|
2022
2079
|
const validToolNames: string[] = [];
|
|
@@ -2108,7 +2165,6 @@ export class AgentSession {
|
|
|
2108
2165
|
sessionManager: this.sessionManager,
|
|
2109
2166
|
modelRegistry: this.#modelRegistry,
|
|
2110
2167
|
model: this.model,
|
|
2111
|
-
searchDb: this.searchDb,
|
|
2112
2168
|
isIdle: () => !this.isStreaming,
|
|
2113
2169
|
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
2114
2170
|
abort: () => {
|
|
@@ -3314,8 +3370,8 @@ export class AgentSession {
|
|
|
3314
3370
|
/**
|
|
3315
3371
|
* Set a display name for the current session.
|
|
3316
3372
|
*/
|
|
3317
|
-
setSessionName(name: string):
|
|
3318
|
-
this.sessionManager.setSessionName(name);
|
|
3373
|
+
setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
3374
|
+
return this.sessionManager.setSessionName(name, source);
|
|
3319
3375
|
}
|
|
3320
3376
|
|
|
3321
3377
|
/**
|
|
@@ -3396,6 +3452,7 @@ export class AgentSession {
|
|
|
3396
3452
|
role: string = "default",
|
|
3397
3453
|
options?: { selector?: string; thinkingLevel?: ThinkingLevel },
|
|
3398
3454
|
): Promise<void> {
|
|
3455
|
+
const previousEditMode = this.#resolveActiveEditMode();
|
|
3399
3456
|
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
3400
3457
|
if (!apiKey) {
|
|
3401
3458
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
@@ -3412,6 +3469,7 @@ export class AgentSession {
|
|
|
3412
3469
|
|
|
3413
3470
|
// Re-apply the current thinking level for the newly selected model
|
|
3414
3471
|
this.setThinkingLevel(this.thinkingLevel);
|
|
3472
|
+
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
3415
3473
|
}
|
|
3416
3474
|
|
|
3417
3475
|
/**
|
|
@@ -3420,6 +3478,7 @@ export class AgentSession {
|
|
|
3420
3478
|
* @throws Error if no API key available for the model
|
|
3421
3479
|
*/
|
|
3422
3480
|
async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
|
|
3481
|
+
const previousEditMode = this.#resolveActiveEditMode();
|
|
3423
3482
|
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
3424
3483
|
if (!apiKey) {
|
|
3425
3484
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
@@ -3432,6 +3491,7 @@ export class AgentSession {
|
|
|
3432
3491
|
|
|
3433
3492
|
// Apply explicit thinking level, or re-clamp current level to new model's capabilities
|
|
3434
3493
|
this.setThinkingLevel(thinkingLevel ?? this.thinkingLevel);
|
|
3494
|
+
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
3435
3495
|
}
|
|
3436
3496
|
|
|
3437
3497
|
/**
|
|
@@ -3539,6 +3599,7 @@ export class AgentSession {
|
|
|
3539
3599
|
}
|
|
3540
3600
|
|
|
3541
3601
|
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
3602
|
+
const previousEditMode = this.#resolveActiveEditMode();
|
|
3542
3603
|
const scopedModels = await this.#getScopedModelsWithApiKey();
|
|
3543
3604
|
if (scopedModels.length <= 1) return undefined;
|
|
3544
3605
|
|
|
@@ -3559,11 +3620,13 @@ export class AgentSession {
|
|
|
3559
3620
|
|
|
3560
3621
|
// Apply the scoped model's configured thinking level
|
|
3561
3622
|
this.setThinkingLevel(next.thinkingLevel);
|
|
3623
|
+
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
3562
3624
|
|
|
3563
3625
|
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
|
|
3564
3626
|
}
|
|
3565
3627
|
|
|
3566
3628
|
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
3629
|
+
const previousEditMode = this.#resolveActiveEditMode();
|
|
3567
3630
|
const availableModels = this.#modelRegistry.getAvailable();
|
|
3568
3631
|
if (availableModels.length <= 1) return undefined;
|
|
3569
3632
|
|
|
@@ -3587,6 +3650,7 @@ export class AgentSession {
|
|
|
3587
3650
|
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
|
|
3588
3651
|
// Re-apply the current thinking level for the newly selected model
|
|
3589
3652
|
this.setThinkingLevel(this.thinkingLevel);
|
|
3653
|
+
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
3590
3654
|
|
|
3591
3655
|
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
|
|
3592
3656
|
}
|
|
@@ -4245,6 +4309,14 @@ export class AgentSession {
|
|
|
4245
4309
|
return undefined;
|
|
4246
4310
|
}
|
|
4247
4311
|
|
|
4312
|
+
// Only inject on the first user message of the conversation. Subsequent user
|
|
4313
|
+
// turns must not receive the eager todo reminder — they often correct, clarify,
|
|
4314
|
+
// or redirect the prior task, and forcing a brand-new todo list there is wrong.
|
|
4315
|
+
const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
|
|
4316
|
+
if (hasPriorUserMessage) {
|
|
4317
|
+
return undefined;
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4248
4320
|
const trimmedPromptText = promptText.trimEnd();
|
|
4249
4321
|
if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
|
|
4250
4322
|
return undefined;
|
|
@@ -5661,43 +5733,67 @@ export class AgentSession {
|
|
|
5661
5733
|
): Promise<PythonResult> {
|
|
5662
5734
|
const excludeFromContext = options?.excludeFromContext === true;
|
|
5663
5735
|
const cwd = this.sessionManager.getCwd();
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5736
|
+
this.assertPythonExecutionAllowed();
|
|
5737
|
+
|
|
5738
|
+
const abortController = new AbortController();
|
|
5739
|
+
const execution = (async (): Promise<PythonResult> => {
|
|
5740
|
+
if (this.#extensionRunner?.hasHandlers("user_python")) {
|
|
5741
|
+
const hookResult = await this.#extensionRunner.emitUserPython({
|
|
5742
|
+
type: "user_python",
|
|
5743
|
+
code,
|
|
5744
|
+
excludeFromContext,
|
|
5745
|
+
cwd,
|
|
5746
|
+
});
|
|
5747
|
+
this.assertPythonExecutionAllowed();
|
|
5748
|
+
if (hookResult?.result) {
|
|
5749
|
+
this.recordPythonResult(code, hookResult.result, options);
|
|
5750
|
+
return hookResult.result;
|
|
5751
|
+
}
|
|
5675
5752
|
}
|
|
5676
|
-
}
|
|
5677
5753
|
|
|
5678
|
-
this.#pythonAbortController = new AbortController();
|
|
5679
|
-
|
|
5680
|
-
try {
|
|
5681
5754
|
// Use the same session ID as the Python tool for kernel sharing
|
|
5682
5755
|
const sessionFile = this.sessionManager.getSessionFile();
|
|
5683
5756
|
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
|
|
5684
|
-
|
|
5685
5757
|
const result = await executePythonCommand(code, {
|
|
5686
5758
|
cwd,
|
|
5687
5759
|
sessionId,
|
|
5760
|
+
kernelOwnerId: this.#pythonKernelOwnerId,
|
|
5688
5761
|
kernelMode: this.settings.get("python.kernelMode"),
|
|
5689
5762
|
useSharedGateway: this.settings.get("python.sharedGateway"),
|
|
5690
5763
|
onChunk,
|
|
5691
|
-
signal:
|
|
5764
|
+
signal: abortController.signal,
|
|
5692
5765
|
});
|
|
5693
|
-
|
|
5694
5766
|
this.recordPythonResult(code, result, options);
|
|
5695
5767
|
return result;
|
|
5696
|
-
}
|
|
5697
|
-
|
|
5768
|
+
})();
|
|
5769
|
+
return await this.trackPythonExecution(execution, abortController);
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
assertPythonExecutionAllowed(): void {
|
|
5773
|
+
if (this.#pythonExecutionDisposing) {
|
|
5774
|
+
throw new Error("Python execution is unavailable while session disposal is in progress");
|
|
5698
5775
|
}
|
|
5699
5776
|
}
|
|
5700
5777
|
|
|
5778
|
+
/**
|
|
5779
|
+
* Track Python work started outside AgentSession.executePython so dispose can await and abort it too.
|
|
5780
|
+
*/
|
|
5781
|
+
trackPythonExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
|
|
5782
|
+
this.#pythonAbortControllers.add(abortController);
|
|
5783
|
+
this.#activePythonExecutions.add(execution);
|
|
5784
|
+
void execution.then(
|
|
5785
|
+
() => {
|
|
5786
|
+
this.#pythonAbortControllers.delete(abortController);
|
|
5787
|
+
this.#activePythonExecutions.delete(execution);
|
|
5788
|
+
},
|
|
5789
|
+
() => {
|
|
5790
|
+
this.#pythonAbortControllers.delete(abortController);
|
|
5791
|
+
this.#activePythonExecutions.delete(execution);
|
|
5792
|
+
},
|
|
5793
|
+
);
|
|
5794
|
+
return execution;
|
|
5795
|
+
}
|
|
5796
|
+
|
|
5701
5797
|
/**
|
|
5702
5798
|
* Record a Python execution result in session history.
|
|
5703
5799
|
*/
|
|
@@ -5728,12 +5824,46 @@ export class AgentSession {
|
|
|
5728
5824
|
* Cancel running Python execution.
|
|
5729
5825
|
*/
|
|
5730
5826
|
abortPython(): void {
|
|
5731
|
-
this.#
|
|
5827
|
+
for (const abortController of this.#pythonAbortControllers) {
|
|
5828
|
+
abortController.abort();
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
|
|
5832
|
+
async #waitForPythonExecutionsToSettle(timeoutMs: number): Promise<boolean> {
|
|
5833
|
+
const deadline = Date.now() + timeoutMs;
|
|
5834
|
+
while (this.#activePythonExecutions.size > 0) {
|
|
5835
|
+
const remainingMs = deadline - Date.now();
|
|
5836
|
+
if (remainingMs <= 0) {
|
|
5837
|
+
return false;
|
|
5838
|
+
}
|
|
5839
|
+
const settled = await Promise.race([
|
|
5840
|
+
Promise.allSettled(Array.from(this.#activePythonExecutions)).then(() => true),
|
|
5841
|
+
Bun.sleep(remainingMs).then(() => false),
|
|
5842
|
+
]);
|
|
5843
|
+
if (!settled && this.#activePythonExecutions.size > 0) {
|
|
5844
|
+
return false;
|
|
5845
|
+
}
|
|
5846
|
+
}
|
|
5847
|
+
return true;
|
|
5848
|
+
}
|
|
5849
|
+
|
|
5850
|
+
async #preparePythonExecutionsForDispose(): Promise<boolean> {
|
|
5851
|
+
if (!(await this.#waitForPythonExecutionsToSettle(3_000))) {
|
|
5852
|
+
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
|
|
5853
|
+
this.abortPython();
|
|
5854
|
+
if (!(await this.#waitForPythonExecutionsToSettle(1_000))) {
|
|
5855
|
+
logger.warn(
|
|
5856
|
+
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
|
|
5857
|
+
);
|
|
5858
|
+
return false;
|
|
5859
|
+
}
|
|
5860
|
+
}
|
|
5861
|
+
return true;
|
|
5732
5862
|
}
|
|
5733
5863
|
|
|
5734
5864
|
/** Whether a Python execution is currently running */
|
|
5735
5865
|
get isPythonRunning(): boolean {
|
|
5736
|
-
return this.#
|
|
5866
|
+
return this.#pythonAbortControllers.size > 0;
|
|
5737
5867
|
}
|
|
5738
5868
|
|
|
5739
5869
|
/** Whether there are pending Python messages waiting to be flushed */
|
|
@@ -58,6 +58,7 @@ export interface SessionHeader {
|
|
|
58
58
|
version?: number; // v1 sessions don't have this
|
|
59
59
|
id: string;
|
|
60
60
|
title?: string; // Auto-generated title from first message
|
|
61
|
+
titleSource?: "auto" | "user";
|
|
61
62
|
timestamp: string;
|
|
62
63
|
cwd: string;
|
|
63
64
|
parentSession?: string;
|
|
@@ -268,6 +269,7 @@ export type ReadonlySessionManager = Pick<
|
|
|
268
269
|
| "getSessionDir"
|
|
269
270
|
| "getSessionId"
|
|
270
271
|
| "getSessionFile"
|
|
272
|
+
| "getSessionName"
|
|
271
273
|
| "getArtifactsDir"
|
|
272
274
|
| "allocateArtifactPath"
|
|
273
275
|
| "saveArtifact"
|
|
@@ -1270,7 +1272,14 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
|
|
|
1270
1272
|
if (entries.length === 0) return;
|
|
1271
1273
|
|
|
1272
1274
|
// Check first entry for valid session header
|
|
1273
|
-
type SessionHeaderShape = {
|
|
1275
|
+
type SessionHeaderShape = {
|
|
1276
|
+
type: string;
|
|
1277
|
+
id: string;
|
|
1278
|
+
cwd?: string;
|
|
1279
|
+
title?: string;
|
|
1280
|
+
titleSource?: "auto" | "user";
|
|
1281
|
+
timestamp: string;
|
|
1282
|
+
};
|
|
1274
1283
|
const header = entries[0] as SessionHeaderShape;
|
|
1275
1284
|
if (header.type !== "session" || !header.id) return;
|
|
1276
1285
|
|
|
@@ -1378,6 +1387,7 @@ export async function resolveResumableSession(
|
|
|
1378
1387
|
interface SessionManagerStateSnapshot {
|
|
1379
1388
|
sessionId: string;
|
|
1380
1389
|
sessionName: string | undefined;
|
|
1390
|
+
titleSource: "auto" | "user" | undefined;
|
|
1381
1391
|
sessionFile: string | undefined;
|
|
1382
1392
|
flushed: boolean;
|
|
1383
1393
|
needsFullRewriteOnNextPersist: boolean;
|
|
@@ -1387,6 +1397,7 @@ interface SessionManagerStateSnapshot {
|
|
|
1387
1397
|
export class SessionManager {
|
|
1388
1398
|
#sessionId: string = "";
|
|
1389
1399
|
#sessionName: string | undefined;
|
|
1400
|
+
#titleSource: "auto" | "user" | undefined;
|
|
1390
1401
|
#sessionFile: string | undefined;
|
|
1391
1402
|
#flushed: boolean = false;
|
|
1392
1403
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
@@ -1438,6 +1449,7 @@ export class SessionManager {
|
|
|
1438
1449
|
return {
|
|
1439
1450
|
sessionId: this.#sessionId,
|
|
1440
1451
|
sessionName: this.#sessionName,
|
|
1452
|
+
titleSource: this.#titleSource,
|
|
1441
1453
|
sessionFile: this.#sessionFile,
|
|
1442
1454
|
flushed: this.#flushed,
|
|
1443
1455
|
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
|
|
@@ -1450,6 +1462,7 @@ export class SessionManager {
|
|
|
1450
1462
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
1451
1463
|
this.#sessionId = snapshot.sessionId;
|
|
1452
1464
|
this.#sessionName = snapshot.sessionName;
|
|
1465
|
+
this.#titleSource = snapshot.titleSource;
|
|
1453
1466
|
this.#sessionFile = snapshot.sessionFile;
|
|
1454
1467
|
this.#flushed = snapshot.flushed;
|
|
1455
1468
|
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
@@ -1489,6 +1502,7 @@ export class SessionManager {
|
|
|
1489
1502
|
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1490
1503
|
this.#sessionId = header?.id ?? Snowflake.next();
|
|
1491
1504
|
this.#sessionName = header?.title;
|
|
1505
|
+
this.#titleSource = header?.titleSource;
|
|
1492
1506
|
|
|
1493
1507
|
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
1494
1508
|
|
|
@@ -1547,11 +1561,13 @@ export class SessionManager {
|
|
|
1547
1561
|
version: CURRENT_SESSION_VERSION,
|
|
1548
1562
|
id: this.#sessionId,
|
|
1549
1563
|
title: oldHeader?.title ?? this.#sessionName,
|
|
1564
|
+
titleSource: oldHeader?.titleSource ?? this.#titleSource,
|
|
1550
1565
|
timestamp,
|
|
1551
1566
|
cwd: this.cwd,
|
|
1552
1567
|
parentSession: oldSessionId,
|
|
1553
1568
|
};
|
|
1554
1569
|
this.#sessionName = newHeader.title;
|
|
1570
|
+
this.#titleSource = newHeader.titleSource;
|
|
1555
1571
|
|
|
1556
1572
|
// Replace the header in fileEntries
|
|
1557
1573
|
const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
@@ -1666,6 +1682,7 @@ export class SessionManager {
|
|
|
1666
1682
|
this.#persistErrorReported = false;
|
|
1667
1683
|
this.#sessionId = Snowflake.next();
|
|
1668
1684
|
this.#sessionName = undefined;
|
|
1685
|
+
this.#titleSource = undefined;
|
|
1669
1686
|
const timestamp = new Date().toISOString();
|
|
1670
1687
|
const header: SessionHeader = {
|
|
1671
1688
|
type: "session",
|
|
@@ -1953,17 +1970,43 @@ export class SessionManager {
|
|
|
1953
1970
|
return manager.getPath(id);
|
|
1954
1971
|
}
|
|
1955
1972
|
|
|
1973
|
+
/** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */
|
|
1974
|
+
get titleSource(): "auto" | "user" | undefined {
|
|
1975
|
+
return this.#titleSource;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1956
1978
|
getSessionName(): string | undefined {
|
|
1957
1979
|
return this.#sessionName;
|
|
1958
1980
|
}
|
|
1959
1981
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1982
|
+
/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
|
|
1983
|
+
static #sanitizeName(name: string): string {
|
|
1984
|
+
return name
|
|
1985
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
1986
|
+
.replace(/ +/g, " ")
|
|
1987
|
+
.trim();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Set the session display name.
|
|
1992
|
+
* @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
|
|
1993
|
+
* Auto-generated titles are silently ignored when the user has already set a name.
|
|
1994
|
+
*/
|
|
1995
|
+
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
1996
|
+
// User-set names take permanent precedence over auto-generated ones.
|
|
1997
|
+
if (this.#titleSource === "user" && source === "auto") return false;
|
|
1998
|
+
|
|
1999
|
+
const sanitized = SessionManager.#sanitizeName(name);
|
|
2000
|
+
if (!sanitized) return false;
|
|
2001
|
+
|
|
2002
|
+
this.#sessionName = sanitized;
|
|
2003
|
+
this.#titleSource = source;
|
|
1962
2004
|
|
|
1963
2005
|
// Update the in-memory header (so first flush includes title)
|
|
1964
2006
|
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1965
2007
|
if (header) {
|
|
1966
|
-
header.title =
|
|
2008
|
+
header.title = sanitized;
|
|
2009
|
+
header.titleSource = source;
|
|
1967
2010
|
}
|
|
1968
2011
|
|
|
1969
2012
|
// Update the session file header with the title (if already flushed)
|
|
@@ -1971,6 +2014,7 @@ export class SessionManager {
|
|
|
1971
2014
|
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
1972
2015
|
await this.#rewriteFile();
|
|
1973
2016
|
}
|
|
2017
|
+
return true;
|
|
1974
2018
|
}
|
|
1975
2019
|
|
|
1976
2020
|
_persist(entry: SessionEntry): void {
|
|
@@ -2630,8 +2674,10 @@ export class SessionManager {
|
|
|
2630
2674
|
manager.#newSessionSync({ parentSession: sourceHeader?.id });
|
|
2631
2675
|
const newHeader = manager.#fileEntries[0] as SessionHeader;
|
|
2632
2676
|
newHeader.title = sourceHeader?.title;
|
|
2677
|
+
newHeader.titleSource = sourceHeader?.titleSource;
|
|
2633
2678
|
manager.#fileEntries = [newHeader, ...historyEntries];
|
|
2634
2679
|
manager.#sessionName = newHeader.title;
|
|
2680
|
+
manager.#titleSource = newHeader.titleSource;
|
|
2635
2681
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
2636
2682
|
manager.#buildIndex();
|
|
2637
2683
|
await manager.#rewriteFile();
|
|
@@ -561,6 +561,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
|
|
561
561
|
await runtime.ctx.handleMemoryCommand(command.text);
|
|
562
562
|
},
|
|
563
563
|
},
|
|
564
|
+
{
|
|
565
|
+
name: "rename",
|
|
566
|
+
description: "Rename the current session",
|
|
567
|
+
inlineHint: "<title>",
|
|
568
|
+
allowArgs: true,
|
|
569
|
+
handle: async (command, runtime) => {
|
|
570
|
+
const title = command.args.trim();
|
|
571
|
+
if (!title) {
|
|
572
|
+
runtime.ctx.showError("Usage: /rename <title>");
|
|
573
|
+
runtime.ctx.editor.setText("");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
runtime.ctx.editor.setText("");
|
|
577
|
+
await runtime.ctx.handleRenameCommand(title);
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
|
|
564
581
|
{
|
|
565
582
|
name: "move",
|
|
566
583
|
description: "Move session to a different working directory",
|
package/src/task/executor.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
8
|
-
import type { SearchDb } from "@oh-my-pi/pi-natives";
|
|
9
8
|
import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
9
|
import type { TSchema } from "@sinclair/typebox";
|
|
11
10
|
import Ajv, { type ValidateFunction } from "ajv";
|
|
@@ -149,7 +148,6 @@ export interface ExecutorOptions {
|
|
|
149
148
|
mcpManager?: MCPManager;
|
|
150
149
|
authStorage?: AuthStorage;
|
|
151
150
|
modelRegistry?: ModelRegistry;
|
|
152
|
-
searchDb?: SearchDb;
|
|
153
151
|
settings?: Settings;
|
|
154
152
|
}
|
|
155
153
|
|
|
@@ -958,7 +956,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
958
956
|
cwd: worktree ?? cwd,
|
|
959
957
|
authStorage,
|
|
960
958
|
modelRegistry,
|
|
961
|
-
searchDb: options.searchDb,
|
|
962
959
|
settings: subagentSettings,
|
|
963
960
|
model,
|
|
964
961
|
thinkingLevel: effectiveThinkingLevel,
|
|
@@ -1061,10 +1058,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1061
1058
|
},
|
|
1062
1059
|
getThinkingLevel: () => session.thinkingLevel,
|
|
1063
1060
|
setThinkingLevel: level => session.setThinkingLevel(level),
|
|
1061
|
+
getSessionName: () => session.sessionManager.getSessionName(),
|
|
1062
|
+
setSessionName: async name => {
|
|
1063
|
+
await session.sessionManager.setSessionName(name, "user");
|
|
1064
|
+
},
|
|
1064
1065
|
},
|
|
1065
1066
|
{
|
|
1066
1067
|
getModel: () => session.model,
|
|
1067
|
-
getSearchDb: () => session.searchDb,
|
|
1068
1068
|
isIdle: () => !session.isStreaming,
|
|
1069
1069
|
abort: () => session.abort(),
|
|
1070
1070
|
hasPendingMessages: () => session.queuedMessageCount > 0,
|
package/src/task/index.ts
CHANGED
|
@@ -530,8 +530,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
530
530
|
});
|
|
531
531
|
const thinkingLevelOverride = effectiveAgent.thinkingLevel;
|
|
532
532
|
|
|
533
|
-
// Output schema priority:
|
|
534
|
-
const effectiveOutputSchema = effectiveAgent.output ??
|
|
533
|
+
// Output schema priority: caller params > agent frontmatter > inherited from parent session
|
|
534
|
+
const effectiveOutputSchema = outputSchema ?? effectiveAgent.output ?? this.session.outputSchema;
|
|
535
535
|
|
|
536
536
|
// Handle empty or missing tasks
|
|
537
537
|
if (!params.tasks || params.tasks.length === 0) {
|
|
@@ -787,7 +787,6 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
787
787
|
},
|
|
788
788
|
authStorage: this.session.authStorage,
|
|
789
789
|
modelRegistry: this.session.modelRegistry,
|
|
790
|
-
searchDb: this.session.searchDb,
|
|
791
790
|
settings: this.session.settings,
|
|
792
791
|
mcpManager: this.session.mcpManager,
|
|
793
792
|
contextFiles,
|
|
@@ -841,7 +840,6 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
841
840
|
},
|
|
842
841
|
authStorage: this.session.authStorage,
|
|
843
842
|
modelRegistry: this.session.modelRegistry,
|
|
844
|
-
searchDb: this.session.searchDb,
|
|
845
843
|
settings: this.session.settings,
|
|
846
844
|
mcpManager: this.session.mcpManager,
|
|
847
845
|
contextFiles,
|
|
@@ -1116,8 +1114,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
1116
1114
|
}
|
|
1117
1115
|
|
|
1118
1116
|
// Build final output - match plugin format
|
|
1119
|
-
const successCount = results.filter(r => r.exitCode === 0 && !r.error).length;
|
|
1120
1117
|
const cancelledCount = results.filter(r => r.aborted).length;
|
|
1118
|
+
const successCount = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted).length;
|
|
1121
1119
|
const totalDuration = Date.now() - startTime;
|
|
1122
1120
|
|
|
1123
1121
|
const summaries = results.map(r => {
|
package/src/task/types.ts
CHANGED
|
@@ -82,9 +82,9 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
|
|
|
82
82
|
}),
|
|
83
83
|
),
|
|
84
84
|
schema: Type.Optional(
|
|
85
|
-
Type.
|
|
85
|
+
Type.String({
|
|
86
86
|
description:
|
|
87
|
-
"JTD schema defining expected response structure.
|
|
87
|
+
"JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.",
|
|
88
88
|
}),
|
|
89
89
|
),
|
|
90
90
|
tasks: Type.Array(taskItemSchema, {
|