@oh-my-pi/pi-coding-agent 14.0.5 → 14.1.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/package.json +8 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +43 -10
  5. package/src/async/support.ts +5 -0
  6. package/src/cli/list-models.ts +96 -57
  7. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  8. package/src/commit/model-selection.ts +16 -13
  9. package/src/config/mcp-schema.json +1 -1
  10. package/src/config/model-equivalence.ts +675 -0
  11. package/src/config/model-registry.ts +242 -45
  12. package/src/config/model-resolver.ts +282 -65
  13. package/src/config/settings-schema.ts +27 -3
  14. package/src/config/settings.ts +1 -1
  15. package/src/cursor.ts +64 -23
  16. package/src/edit/index.ts +254 -89
  17. package/src/edit/modes/chunk.ts +336 -57
  18. package/src/edit/modes/hashline.ts +51 -26
  19. package/src/edit/modes/patch.ts +16 -10
  20. package/src/edit/modes/replace.ts +15 -7
  21. package/src/edit/renderer.ts +248 -94
  22. package/src/export/html/template.css +82 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +614 -97
  25. package/src/extensibility/custom-tools/types.ts +0 -3
  26. package/src/extensibility/extensions/loader.ts +16 -0
  27. package/src/extensibility/extensions/runner.ts +2 -7
  28. package/src/extensibility/extensions/types.ts +8 -4
  29. package/src/internal-urls/docs-index.generated.ts +4 -4
  30. package/src/internal-urls/jobs-protocol.ts +2 -1
  31. package/src/ipy/executor.ts +447 -52
  32. package/src/ipy/kernel.ts +39 -13
  33. package/src/lsp/client.ts +55 -1
  34. package/src/lsp/index.ts +8 -0
  35. package/src/lsp/types.ts +6 -0
  36. package/src/main.ts +6 -2
  37. package/src/memories/index.ts +7 -6
  38. package/src/modes/acp/acp-agent.ts +4 -1
  39. package/src/modes/components/bash-execution.ts +16 -4
  40. package/src/modes/components/model-selector.ts +221 -64
  41. package/src/modes/components/status-line/presets.ts +17 -6
  42. package/src/modes/components/status-line/segments.ts +15 -0
  43. package/src/modes/components/status-line-segment-editor.ts +1 -0
  44. package/src/modes/components/status-line.ts +7 -1
  45. package/src/modes/components/tool-execution.ts +145 -75
  46. package/src/modes/controllers/command-controller.ts +42 -1
  47. package/src/modes/controllers/event-controller.ts +4 -1
  48. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  49. package/src/modes/controllers/input-controller.ts +9 -3
  50. package/src/modes/controllers/selector-controller.ts +17 -6
  51. package/src/modes/interactive-mode.ts +19 -3
  52. package/src/modes/print-mode.ts +13 -4
  53. package/src/modes/prompt-action-autocomplete.ts +3 -5
  54. package/src/modes/rpc/rpc-mode.ts +8 -2
  55. package/src/modes/shared.ts +2 -2
  56. package/src/modes/types.ts +1 -0
  57. package/src/modes/utils/ui-helpers.ts +1 -0
  58. package/src/prompts/system/system-prompt.md +5 -1
  59. package/src/prompts/tools/bash.md +16 -1
  60. package/src/prompts/tools/cancel-job.md +1 -1
  61. package/src/prompts/tools/chunk-edit.md +191 -163
  62. package/src/prompts/tools/hashline.md +11 -11
  63. package/src/prompts/tools/patch.md +10 -5
  64. package/src/prompts/tools/{await.md → poll.md} +1 -1
  65. package/src/prompts/tools/read-chunk.md +12 -3
  66. package/src/prompts/tools/read.md +9 -0
  67. package/src/prompts/tools/task.md +2 -2
  68. package/src/prompts/tools/vim.md +98 -0
  69. package/src/prompts/tools/write.md +1 -0
  70. package/src/sdk.ts +758 -725
  71. package/src/session/agent-session.ts +187 -40
  72. package/src/session/session-manager.ts +50 -4
  73. package/src/slash-commands/builtin-registry.ts +17 -0
  74. package/src/task/executor.ts +9 -5
  75. package/src/task/index.ts +3 -5
  76. package/src/task/types.ts +2 -2
  77. package/src/tools/bash.ts +240 -57
  78. package/src/tools/cancel-job.ts +2 -1
  79. package/src/tools/find.ts +5 -2
  80. package/src/tools/grep.ts +77 -8
  81. package/src/tools/index.ts +48 -19
  82. package/src/tools/inspect-image.ts +1 -1
  83. package/src/tools/{await-tool.ts → poll-tool.ts} +38 -31
  84. package/src/tools/python.ts +293 -278
  85. package/src/tools/read.ts +218 -1
  86. package/src/tools/sqlite-reader.ts +623 -0
  87. package/src/tools/submit-result.ts +5 -2
  88. package/src/tools/todo-write.ts +8 -2
  89. package/src/tools/vim.ts +966 -0
  90. package/src/tools/write.ts +187 -1
  91. package/src/utils/commit-message-generator.ts +1 -0
  92. package/src/utils/edit-mode.ts +2 -1
  93. package/src/utils/git.ts +24 -1
  94. package/src/utils/session-color.ts +55 -0
  95. package/src/utils/title-generator.ts +16 -7
  96. package/src/vim/buffer.ts +309 -0
  97. package/src/vim/commands.ts +382 -0
  98. package/src/vim/engine.ts +2426 -0
  99. package/src/vim/parser.ts +151 -0
  100. package/src/vim/render.ts +252 -0
  101. package/src/vim/types.ts +197 -0
@@ -49,13 +49,22 @@ import {
49
49
  modelsAreEqual,
50
50
  parseRateLimitReason,
51
51
  } from "@oh-my-pi/pi-ai";
52
- import { killTree, MacOSPowerAssertion, type SearchDb } from "@oh-my-pi/pi-natives";
53
- import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, setNativeKillTree } from "@oh-my-pi/pi-utils";
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";
57
65
  import {
58
66
  extractExplicitThinkingSelector,
67
+ formatModelSelectorValue,
59
68
  formatModelString,
60
69
  parseModelString,
61
70
  type ResolvedModelRoleValue,
@@ -94,7 +103,11 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
94
103
  import type { Skill, SkillWarning } from "../extensibility/skills";
95
104
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
96
105
  import { resolveLocalUrlToPath } from "../internal-urls";
97
- import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
106
+ import {
107
+ disposeKernelSessionsByOwner,
108
+ executePython as executePythonCommand,
109
+ type PythonResult,
110
+ } from "../ipy/executor";
98
111
  import {
99
112
  buildDiscoverableMCPSearchIndex,
100
113
  collectDiscoverableMCPTools,
@@ -125,6 +138,7 @@ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from ".
125
138
  import { ToolError } from "../tools/tool-errors";
126
139
  import { clampTimeout } from "../tools/tool-timeouts";
127
140
  import { parseCommandArgs } from "../utils/command-args";
141
+ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
128
142
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
129
143
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
130
144
  import { buildNamedToolChoice } from "../utils/tool-choice";
@@ -243,8 +257,8 @@ export interface AgentSessionConfig {
243
257
  ttsrManager?: TtsrManager;
244
258
  /** Secret obfuscator for deobfuscating streaming edit content */
245
259
  obfuscator?: SecretObfuscator;
246
- /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
247
- searchDb?: SearchDb;
260
+ /** Logical owner for retained Python kernels created by this session. */
261
+ pythonKernelOwnerId?: string;
248
262
  }
249
263
 
250
264
  /** Options for AgentSession.prompt() */
@@ -396,7 +410,6 @@ export class AgentSession {
396
410
  readonly agent: Agent;
397
411
  readonly sessionManager: SessionManager;
398
412
  readonly settings: Settings;
399
- readonly searchDb: SearchDb | undefined;
400
413
 
401
414
  #powerAssertion: MacOSPowerAssertion | undefined;
402
415
 
@@ -451,9 +464,11 @@ export class AgentSession {
451
464
  #pendingBashMessages: BashExecutionMessage[] = [];
452
465
 
453
466
  // Python execution state
454
- #pythonAbortController: AbortController | undefined = undefined;
467
+ #pythonAbortControllers = new Set<AbortController>();
468
+ #pythonKernelOwnerId: string;
455
469
  #pendingPythonMessages: PythonExecutionMessage[] = [];
456
-
470
+ #activePythonExecutions = new Set<Promise<unknown>>();
471
+ #pythonExecutionDisposing = false;
457
472
  // Extension system
458
473
  #extensionRunner: ExtensionRunner | undefined = undefined;
459
474
  #turnIndex = 0;
@@ -543,9 +558,9 @@ export class AgentSession {
543
558
  this.agent = config.agent;
544
559
  this.sessionManager = config.sessionManager;
545
560
  this.settings = config.settings;
546
- this.searchDb = config.searchDb;
547
561
  this.#startPowerAssertion();
548
562
  this.#asyncJobManager = config.asyncJobManager;
563
+ this.#pythonKernelOwnerId = config.pythonKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
549
564
  this.#scopedModels = config.scopedModels ?? [];
550
565
  this.#thinkingLevel = config.thinkingLevel;
551
566
  this.#promptTemplates = config.promptTemplates ?? [];
@@ -691,7 +706,23 @@ export class AgentSession {
691
706
  }
692
707
  }
693
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
+
694
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
+ }
695
726
  await this.#emitExtensionEvent(event);
696
727
  this.#emit(event);
697
728
  }
@@ -1797,6 +1828,7 @@ export class AgentSession {
1797
1828
  * Call this when completely done with the session.
1798
1829
  */
1799
1830
  async dispose(): Promise<void> {
1831
+ this.#pythonExecutionDisposing = true;
1800
1832
  try {
1801
1833
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
1802
1834
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -1811,6 +1843,13 @@ export class AgentSession {
1811
1843
  if (drained === false && deliveryState) {
1812
1844
  logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
1813
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);
1814
1853
  this.#stopPowerAssertion();
1815
1854
  await this.sessionManager.close();
1816
1855
  this.#closeAllProviderSessions("dispose");
@@ -1969,6 +2008,24 @@ export class AgentSession {
1969
2008
  return Array.from(this.#toolRegistry.keys());
1970
2009
  }
1971
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
+
1972
2029
  isMCPDiscoveryEnabled(): boolean {
1973
2030
  return this.#mcpDiscoveryEnabled;
1974
2031
  }
@@ -2016,6 +2073,7 @@ export class AgentSession {
2016
2073
  toolNames: string[],
2017
2074
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
2018
2075
  ): Promise<void> {
2076
+ toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
2019
2077
  const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
2020
2078
  const tools: AgentTool[] = [];
2021
2079
  const validToolNames: string[] = [];
@@ -2107,7 +2165,6 @@ export class AgentSession {
2107
2165
  sessionManager: this.sessionManager,
2108
2166
  modelRegistry: this.#modelRegistry,
2109
2167
  model: this.model,
2110
- searchDb: this.searchDb,
2111
2168
  isIdle: () => !this.isStreaming,
2112
2169
  hasQueuedMessages: () => this.queuedMessageCount > 0,
2113
2170
  abort: () => {
@@ -3313,8 +3370,8 @@ export class AgentSession {
3313
3370
  /**
3314
3371
  * Set a display name for the current session.
3315
3372
  */
3316
- setSessionName(name: string): void {
3317
- this.sessionManager.setSessionName(name);
3373
+ setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
3374
+ return this.sessionManager.setSessionName(name, source);
3318
3375
  }
3319
3376
 
3320
3377
  /**
@@ -3390,7 +3447,12 @@ export class AgentSession {
3390
3447
  * Validates API key, saves to session and settings.
3391
3448
  * @throws Error if no API key available for the model
3392
3449
  */
3393
- async setModel(model: Model, role: string = "default"): Promise<void> {
3450
+ async setModel(
3451
+ model: Model,
3452
+ role: string = "default",
3453
+ options?: { selector?: string; thinkingLevel?: ThinkingLevel },
3454
+ ): Promise<void> {
3455
+ const previousEditMode = this.#resolveActiveEditMode();
3394
3456
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3395
3457
  if (!apiKey) {
3396
3458
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3399,11 +3461,15 @@ export class AgentSession {
3399
3461
  this.#clearActiveRetryFallback();
3400
3462
  this.#setModelWithProviderSessionReset(model);
3401
3463
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
3402
- this.settings.setModelRole(role, this.#formatRoleModelValue(role, model));
3464
+ this.settings.setModelRole(
3465
+ role,
3466
+ this.#formatRoleModelValue(role, model, options?.selector, options?.thinkingLevel),
3467
+ );
3403
3468
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3404
3469
 
3405
3470
  // Re-apply the current thinking level for the newly selected model
3406
3471
  this.setThinkingLevel(this.thinkingLevel);
3472
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
3407
3473
  }
3408
3474
 
3409
3475
  /**
@@ -3412,6 +3478,7 @@ export class AgentSession {
3412
3478
  * @throws Error if no API key available for the model
3413
3479
  */
3414
3480
  async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
3481
+ const previousEditMode = this.#resolveActiveEditMode();
3415
3482
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3416
3483
  if (!apiKey) {
3417
3484
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3424,6 +3491,7 @@ export class AgentSession {
3424
3491
 
3425
3492
  // Apply explicit thinking level, or re-clamp current level to new model's capabilities
3426
3493
  this.setThinkingLevel(thinkingLevel ?? this.thinkingLevel);
3494
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
3427
3495
  }
3428
3496
 
3429
3497
  /**
@@ -3472,6 +3540,7 @@ export class AgentSession {
3472
3540
  const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
3473
3541
  settings: this.settings,
3474
3542
  matchPreferences,
3543
+ modelRegistry: this.#modelRegistry,
3475
3544
  });
3476
3545
  if (!resolved.model) continue;
3477
3546
 
@@ -3530,6 +3599,7 @@ export class AgentSession {
3530
3599
  }
3531
3600
 
3532
3601
  async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
3602
+ const previousEditMode = this.#resolveActiveEditMode();
3533
3603
  const scopedModels = await this.#getScopedModelsWithApiKey();
3534
3604
  if (scopedModels.length <= 1) return undefined;
3535
3605
 
@@ -3550,11 +3620,13 @@ export class AgentSession {
3550
3620
 
3551
3621
  // Apply the scoped model's configured thinking level
3552
3622
  this.setThinkingLevel(next.thinkingLevel);
3623
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
3553
3624
 
3554
3625
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
3555
3626
  }
3556
3627
 
3557
3628
  async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
3629
+ const previousEditMode = this.#resolveActiveEditMode();
3558
3630
  const availableModels = this.#modelRegistry.getAvailable();
3559
3631
  if (availableModels.length <= 1) return undefined;
3560
3632
 
@@ -3578,6 +3650,7 @@ export class AgentSession {
3578
3650
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
3579
3651
  // Re-apply the current thinking level for the newly selected model
3580
3652
  this.setThinkingLevel(this.thinkingLevel);
3653
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
3581
3654
 
3582
3655
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
3583
3656
  }
@@ -4236,6 +4309,14 @@ export class AgentSession {
4236
4309
  return undefined;
4237
4310
  }
4238
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
+
4239
4320
  const trimmedPromptText = promptText.trimEnd();
4240
4321
  if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
4241
4322
  return undefined;
@@ -4588,14 +4669,21 @@ export class AgentSession {
4588
4669
  return `${model.provider}/${model.id}`;
4589
4670
  }
4590
4671
 
4591
- #formatRoleModelValue(role: string, model: Model): string {
4592
- const modelKey = `${model.provider}/${model.id}`;
4672
+ #formatRoleModelValue(
4673
+ role: string,
4674
+ model: Model,
4675
+ selectorOverride?: string,
4676
+ thinkingLevelOverride?: ThinkingLevel,
4677
+ ): string {
4678
+ const modelKey = selectorOverride ?? `${model.provider}/${model.id}`;
4679
+ if (thinkingLevelOverride !== undefined) {
4680
+ return formatModelSelectorValue(modelKey, thinkingLevelOverride);
4681
+ }
4593
4682
  const existingRoleValue = this.settings.getModelRole(role);
4594
4683
  if (!existingRoleValue) return modelKey;
4595
4684
 
4596
4685
  const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
4597
- if (thinkingLevel === undefined) return modelKey;
4598
- return `${modelKey}:${thinkingLevel}`;
4686
+ return formatModelSelectorValue(modelKey, thinkingLevel);
4599
4687
  }
4600
4688
  #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
4601
4689
  const configuredTarget = currentModel.contextPromotionTarget?.trim();
@@ -4628,6 +4716,7 @@ export class AgentSession {
4628
4716
  return resolveModelRoleValue(roleModelStr, availableModels, {
4629
4717
  settings: this.settings,
4630
4718
  matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
4719
+ modelRegistry: this.#modelRegistry,
4631
4720
  });
4632
4721
  }
4633
4722
 
@@ -5644,43 +5733,67 @@ export class AgentSession {
5644
5733
  ): Promise<PythonResult> {
5645
5734
  const excludeFromContext = options?.excludeFromContext === true;
5646
5735
  const cwd = this.sessionManager.getCwd();
5647
-
5648
- if (this.#extensionRunner?.hasHandlers("user_python")) {
5649
- const hookResult = await this.#extensionRunner.emitUserPython({
5650
- type: "user_python",
5651
- code,
5652
- excludeFromContext,
5653
- cwd,
5654
- });
5655
- if (hookResult?.result) {
5656
- this.recordPythonResult(code, hookResult.result, options);
5657
- return hookResult.result;
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
+ }
5658
5752
  }
5659
- }
5660
5753
 
5661
- this.#pythonAbortController = new AbortController();
5662
-
5663
- try {
5664
5754
  // Use the same session ID as the Python tool for kernel sharing
5665
5755
  const sessionFile = this.sessionManager.getSessionFile();
5666
5756
  const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
5667
-
5668
5757
  const result = await executePythonCommand(code, {
5669
5758
  cwd,
5670
5759
  sessionId,
5760
+ kernelOwnerId: this.#pythonKernelOwnerId,
5671
5761
  kernelMode: this.settings.get("python.kernelMode"),
5672
5762
  useSharedGateway: this.settings.get("python.sharedGateway"),
5673
5763
  onChunk,
5674
- signal: this.#pythonAbortController.signal,
5764
+ signal: abortController.signal,
5675
5765
  });
5676
-
5677
5766
  this.recordPythonResult(code, result, options);
5678
5767
  return result;
5679
- } finally {
5680
- this.#pythonAbortController = undefined;
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");
5681
5775
  }
5682
5776
  }
5683
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
+
5684
5797
  /**
5685
5798
  * Record a Python execution result in session history.
5686
5799
  */
@@ -5711,12 +5824,46 @@ export class AgentSession {
5711
5824
  * Cancel running Python execution.
5712
5825
  */
5713
5826
  abortPython(): void {
5714
- this.#pythonAbortController?.abort();
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;
5715
5862
  }
5716
5863
 
5717
5864
  /** Whether a Python execution is currently running */
5718
5865
  get isPythonRunning(): boolean {
5719
- return this.#pythonAbortController !== undefined;
5866
+ return this.#pythonAbortControllers.size > 0;
5720
5867
  }
5721
5868
 
5722
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 = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
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
- async setSessionName(name: string): Promise<void> {
1961
- this.#sessionName = name;
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 = name;
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",
@@ -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
 
@@ -435,7 +433,11 @@ function createSubagentSettings(baseSettings: Settings): Settings {
435
433
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
436
434
  snapshot[key] = baseSettings.get(key);
437
435
  }
438
- return Settings.isolated({ ...snapshot, "async.enabled": false });
436
+ return Settings.isolated({
437
+ ...snapshot,
438
+ "async.enabled": false,
439
+ "bash.autoBackground.enabled": false,
440
+ });
439
441
  }
440
442
 
441
443
  /**
@@ -954,7 +956,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
954
956
  cwd: worktree ?? cwd,
955
957
  authStorage,
956
958
  modelRegistry,
957
- searchDb: options.searchDb,
958
959
  settings: subagentSettings,
959
960
  model,
960
961
  thinkingLevel: effectiveThinkingLevel,
@@ -1057,10 +1058,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1057
1058
  },
1058
1059
  getThinkingLevel: () => session.thinkingLevel,
1059
1060
  setThinkingLevel: level => session.setThinkingLevel(level),
1061
+ getSessionName: () => session.sessionManager.getSessionName(),
1062
+ setSessionName: async name => {
1063
+ await session.sessionManager.setSessionName(name, "user");
1064
+ },
1060
1065
  },
1061
1066
  {
1062
1067
  getModel: () => session.model,
1063
- getSearchDb: () => session.searchDb,
1064
1068
  isIdle: () => !session.isStreaming,
1065
1069
  abort: () => session.abort(),
1066
1070
  hasPendingMessages: () => session.queuedMessageCount > 0,