@oh-my-pi/pi-coding-agent 14.1.0 → 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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. 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, 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";
@@ -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 { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
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
- /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
248
- searchDb?: SearchDb;
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
- #pythonAbortController: AbortController | undefined = undefined;
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): void {
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
- if (this.#extensionRunner?.hasHandlers("user_python")) {
5666
- const hookResult = await this.#extensionRunner.emitUserPython({
5667
- type: "user_python",
5668
- code,
5669
- excludeFromContext,
5670
- cwd,
5671
- });
5672
- if (hookResult?.result) {
5673
- this.recordPythonResult(code, hookResult.result, options);
5674
- 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
+ }
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: this.#pythonAbortController.signal,
5764
+ signal: abortController.signal,
5692
5765
  });
5693
-
5694
5766
  this.recordPythonResult(code, result, options);
5695
5767
  return result;
5696
- } finally {
5697
- 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");
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.#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;
5732
5862
  }
5733
5863
 
5734
5864
  /** Whether a Python execution is currently running */
5735
5865
  get isPythonRunning(): boolean {
5736
- return this.#pythonAbortController !== undefined;
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 = { 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
 
@@ -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: agent frontmatter > params > inherited from parent session
534
- const effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema;
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.Record(Type.String(), Type.Unknown(), {
85
+ Type.String({
86
86
  description:
87
- "JTD schema defining expected response structure. Use typed properties. Output format belongs here — never in context or assignment.",
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, {