@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.7

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 (96) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { resetPreludeDocsCache, warmPythonEnvironment } from "./python-executor";
5
+ import { createPythonTool, getPythonToolDescription } from "./tools/python";
6
+
7
+ const resolvePythonPath = (): string | null => {
8
+ const venvPath = process.env.VIRTUAL_ENV;
9
+ const candidates = [venvPath, join(process.cwd(), ".venv"), join(process.cwd(), "venv")].filter(Boolean) as string[];
10
+ for (const candidate of candidates) {
11
+ const binDir = process.platform === "win32" ? "Scripts" : "bin";
12
+ const exeName = process.platform === "win32" ? "python.exe" : "python";
13
+ const pythonCandidate = join(candidate, binDir, exeName);
14
+ if (existsSync(pythonCandidate)) {
15
+ return pythonCandidate;
16
+ }
17
+ }
18
+ return Bun.which("python") ?? Bun.which("python3");
19
+ };
20
+
21
+ const pythonPath = resolvePythonPath();
22
+ const hasKernelDeps = (() => {
23
+ if (!pythonPath) return false;
24
+ const result = Bun.spawnSync(
25
+ [
26
+ pythonPath,
27
+ "-c",
28
+ "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)",
29
+ ],
30
+ { stdin: "ignore", stdout: "pipe", stderr: "pipe" },
31
+ );
32
+ return result.exitCode === 0;
33
+ })();
34
+
35
+ const shouldRun = Boolean(pythonPath) && hasKernelDeps;
36
+
37
+ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
38
+ it("exposes prelude helpers via python tool", async () => {
39
+ const helpers = [
40
+ "pwd",
41
+ "cd",
42
+ "env",
43
+ "read",
44
+ "write",
45
+ "append",
46
+ "mkdir",
47
+ "rm",
48
+ "mv",
49
+ "cp",
50
+ "ls",
51
+ "cat",
52
+ "touch",
53
+ "find",
54
+ "grep",
55
+ "rgrep",
56
+ "head",
57
+ "tail",
58
+ "replace",
59
+ "sed",
60
+ "rsed",
61
+ "wc",
62
+ "sort_lines",
63
+ "uniq",
64
+ "cols",
65
+ "tree",
66
+ "stat",
67
+ "diff",
68
+ "glob_files",
69
+ "batch",
70
+ "lines",
71
+ "delete_lines",
72
+ "delete_matching",
73
+ "insert_at",
74
+ "git_status",
75
+ "git_diff",
76
+ "git_log",
77
+ "git_show",
78
+ "git_file_at",
79
+ "git_branch",
80
+ "git_has_changes",
81
+ "run",
82
+ "sh",
83
+ ];
84
+
85
+ const session = {
86
+ cwd: process.cwd(),
87
+ hasUI: false,
88
+ getSessionFile: () => null,
89
+ getSessionSpawns: () => null,
90
+ settings: {
91
+ getImageAutoResize: () => true,
92
+ getLspFormatOnWrite: () => false,
93
+ getLspDiagnosticsOnWrite: () => false,
94
+ getLspDiagnosticsOnEdit: () => false,
95
+ getEditFuzzyMatch: () => true,
96
+ getGitToolEnabled: () => true,
97
+ getBashInterceptorEnabled: () => true,
98
+ getBashInterceptorSimpleLsEnabled: () => true,
99
+ getBashInterceptorRules: () => [],
100
+ getPythonToolMode: () => "ipy-only" as const,
101
+ getPythonKernelMode: () => "per-call" as const,
102
+ },
103
+ };
104
+
105
+ const tool = createPythonTool(session);
106
+ const code = `
107
+ helpers = ${JSON.stringify(helpers)}
108
+ missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
109
+ docs = __omp_prelude_docs__()
110
+ doc_names = [d.get("name") for d in docs]
111
+ doc_categories = [d.get("category") for d in docs]
112
+ print("HELPERS_OK=" + ("1" if not missing else "0"))
113
+ print("DOCS_OK=" + ("1" if "pwd" in doc_names and "Navigation" in doc_categories else "0"))
114
+ if missing:
115
+ print("MISSING=" + ",".join(missing))
116
+ `;
117
+
118
+ const result = await tool.execute("tool-call-1", { code });
119
+ const output = result.content.find((item) => item.type === "text")?.text ?? "";
120
+ expect(output).toContain("HELPERS_OK=1");
121
+ expect(output).toContain("DOCS_OK=1");
122
+ });
123
+
124
+ it("exposes prelude docs via warmup", async () => {
125
+ resetPreludeDocsCache();
126
+ const result = await warmPythonEnvironment(process.cwd(), undefined, false);
127
+ expect(result.ok).toBe(true);
128
+ const names = result.docs.map((doc) => doc.name);
129
+ expect(names).toContain("pwd");
130
+ });
131
+
132
+ it("renders prelude docs in python tool description", async () => {
133
+ resetPreludeDocsCache();
134
+ const result = await warmPythonEnvironment(process.cwd(), undefined, false);
135
+ expect(result.ok).toBe(true);
136
+ const description = getPythonToolDescription();
137
+ expect(description).toContain("pwd");
138
+ expect(description).not.toContain("Documentation unavailable");
139
+ });
140
+ });
@@ -0,0 +1,3 @@
1
+ import pythonPrelude from "./python-prelude.py" with { type: "text" };
2
+
3
+ export const PYTHON_PRELUDE = pythonPrelude;
package/src/core/sdk.ts CHANGED
@@ -66,6 +66,7 @@ import { convertToLlm } from "./messages";
66
66
  import { ModelRegistry } from "./model-registry";
67
67
  import { formatModelString, parseModelString } from "./model-resolver";
68
68
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
69
+ import { disposeAllKernelSessions } from "./python-executor";
69
70
  import { SessionManager } from "./session-manager";
70
71
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
71
72
  import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills";
@@ -88,6 +89,7 @@ import {
88
89
  createGitTool,
89
90
  createGrepTool,
90
91
  createLsTool,
92
+ createPythonTool,
91
93
  createReadTool,
92
94
  createSshTool,
93
95
  createTools,
@@ -217,6 +219,7 @@ export {
217
219
  // Individual tool factories (for custom usage)
218
220
  createReadTool,
219
221
  createBashTool,
222
+ createPythonTool,
220
223
  createSshTool,
221
224
  createEditTool,
222
225
  createWriteTool,
@@ -441,6 +444,16 @@ function registerSshCleanup(): void {
441
444
  registerAsyncCleanup(() => cleanupSshResources());
442
445
  }
443
446
 
447
+ let pythonCleanupRegistered = false;
448
+
449
+ function registerPythonCleanup(): void {
450
+ if (pythonCleanupRegistered) return;
451
+ pythonCleanupRegistered = true;
452
+ registerAsyncCleanup(async () => {
453
+ await disposeAllKernelSessions();
454
+ });
455
+ }
456
+
444
457
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
445
458
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
446
459
  name: tool.name,
@@ -541,6 +554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
541
554
  const eventBus = options.eventBus ?? createEventBus();
542
555
 
543
556
  registerSshCleanup();
557
+ registerPythonCleanup();
544
558
 
545
559
  // Use provided or create AuthStorage and ModelRegistry
546
560
  const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
@@ -726,7 +740,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
726
740
 
727
741
  // Log MCP errors
728
742
  for (const { path, error } of mcpResult.errors) {
729
- console.error(`MCP "${path}": ${error}`);
743
+ logger.error("MCP tool load failed", { path, error });
730
744
  }
731
745
 
732
746
  if (mcpResult.tools.length > 0) {
@@ -784,7 +798,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
784
798
  );
785
799
  time("discoverAndLoadExtensions");
786
800
  for (const { path, error } of extensionsResult.errors) {
787
- console.error(`Failed to load extension "${path}": ${error}`);
801
+ logger.error("Failed to load extension", { path, error });
788
802
  }
789
803
  }
790
804
 
@@ -804,10 +818,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
804
818
  }
805
819
 
806
820
  // Discover custom commands (TypeScript slash commands)
807
- const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir });
808
- time("discoverCustomCommands");
809
- for (const { path, error } of customCommandsResult.errors) {
810
- console.error(`Failed to load custom command "${path}": ${error}`);
821
+ const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
822
+ ? { commands: [], errors: [] }
823
+ : await loadCustomCommandsInternal({ cwd, agentDir });
824
+ if (!options.disableExtensionDiscovery) {
825
+ time("discoverCustomCommands");
826
+ for (const { path, error } of customCommandsResult.errors) {
827
+ logger.error("Failed to load custom command", { path, error });
828
+ }
811
829
  }
812
830
 
813
831
  let extensionRunner: ExtensionRunner | undefined;
@@ -456,8 +456,8 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage =
456
456
 
457
457
  // Validate session header
458
458
  if (entries.length === 0) return entries;
459
- const header = entries[0];
460
- if (header.type !== "session" || typeof (header as any).id !== "string") {
459
+ const header = entries[0] as SessionHeader;
460
+ if (header.type !== "session" || typeof header.id !== "string") {
461
461
  return [];
462
462
  }
463
463
 
@@ -468,6 +468,14 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage =
468
468
  * Lightweight metadata for a session file, used in session picker UI.
469
469
  * Uses lazy getters to defer string formatting until actually displayed.
470
470
  */
471
+ function sanitizeSessionName(value: string | undefined): string | undefined {
472
+ if (!value) return undefined;
473
+ const firstLine = value.split(/\r?\n/)[0] ?? "";
474
+ const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
475
+ const trimmed = stripped.trim();
476
+ return trimmed.length > 0 ? trimmed : undefined;
477
+ }
478
+
471
479
  class RecentSessionInfo {
472
480
  readonly path: string;
473
481
  readonly mtime: number;
@@ -476,13 +484,16 @@ class RecentSessionInfo {
476
484
  #name: string | undefined;
477
485
  #timeAgo: string | undefined;
478
486
 
479
- constructor(path: string, mtime: number, header: Record<string, unknown>) {
487
+ constructor(path: string, mtime: number, header: Record<string, unknown>, firstPrompt?: string) {
480
488
  this.path = path;
481
489
  this.mtime = mtime;
482
490
 
483
- // Extract title from session header, falling back to id if title is missing
491
+ // Extract title from session header, falling back to first user prompt, then id
484
492
  const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
485
- this.#fullName = trystr(header.title) ?? trystr(header.id);
493
+ this.#fullName =
494
+ sanitizeSessionName(trystr(header.title)) ??
495
+ sanitizeSessionName(firstPrompt) ??
496
+ sanitizeSessionName(trystr(header.id));
486
497
  }
487
498
 
488
499
  /** Full session name from header, or filename without extension as fallback */
@@ -508,26 +519,58 @@ class RecentSessionInfo {
508
519
  }
509
520
  }
510
521
 
522
+ /**
523
+ * Extracts the text content from a user message entry.
524
+ * Returns undefined if the entry is not a user message or has no text.
525
+ */
526
+ function extractFirstUserPrompt(lines: string[]): string | undefined {
527
+ for (let i = 1; i < lines.length; i++) {
528
+ const line = lines[i];
529
+ if (!line?.trim()) continue;
530
+ try {
531
+ const entry = JSON.parse(line) as Record<string, unknown>;
532
+ if (entry.type !== "message") continue;
533
+ const message = entry.message as Record<string, unknown> | undefined;
534
+ if (message?.role !== "user") continue;
535
+ const content = message.content;
536
+ if (typeof content === "string") return content;
537
+ if (Array.isArray(content)) {
538
+ for (const block of content) {
539
+ if (typeof block === "object" && block !== null && "text" in block) {
540
+ const text = (block as { text: unknown }).text;
541
+ if (typeof text === "string") return text;
542
+ }
543
+ }
544
+ }
545
+ } catch {
546
+ // Invalid JSON, skip to next line
547
+ }
548
+ }
549
+ return undefined;
550
+ }
551
+
511
552
  /**
512
553
  * Reads all session files from the directory and returns them sorted by mtime (newest first).
513
- * Uses low-level file I/O to efficiently read only the first 512 bytes of each file
514
- * to extract the JSON header without loading entire session logs into memory.
554
+ * Uses low-level file I/O to efficiently read only the first 4KB of each file
555
+ * to extract the JSON header and first user message without loading entire session logs into memory.
515
556
  */
516
557
  function getSortedSessions(sessionDir: string, storage: SessionStorage): RecentSessionInfo[] {
517
558
  try {
518
- const buf = Buffer.alloc(512);
559
+ const buf = Buffer.alloc(4096);
519
560
  const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
520
561
  return files
521
562
  .map((path: string) => {
522
563
  try {
523
564
  const length = storage.readTextPrefixSync(path, buf);
524
565
  const content = buf.toString("utf-8", 0, length);
525
- const firstLine = content.split("\n")[0];
566
+ const lines = content.split("\n");
567
+ const firstLine = lines[0];
526
568
  if (!firstLine || !firstLine.trim()) return null;
527
569
  const header = JSON.parse(firstLine) as Record<string, unknown>;
528
570
  if (header.type !== "session" || typeof header.id !== "string") return null;
529
571
  const mtime = storage.statSync(path).mtimeMs;
530
- return new RecentSessionInfo(path, mtime, header);
572
+ const firstPrompt = header.title ? undefined : extractFirstUserPrompt(lines);
573
+ return new RecentSessionInfo(path, mtime, header, firstPrompt);
531
574
  } catch {
532
575
  return null;
533
576
  }
@@ -834,6 +877,85 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
834
877
  return usage as Usage;
835
878
  }
836
879
 
880
+ function extractTextFromContent(content: Message["content"]): string {
881
+ if (typeof content === "string") return content;
882
+ return content
883
+ .filter((block): block is TextContent => block.type === "text")
884
+ .map((block) => block.text)
885
+ .join(" ");
886
+ }
887
+
888
+ function collectSessionsFromFiles(files: string[], storage: SessionStorage): SessionInfo[] {
889
+ const sessions: SessionInfo[] = [];
890
+
891
+ for (const file of files) {
892
+ try {
893
+ const content = storage.readTextSync(file);
894
+ const lines = content.trim().split("\n");
895
+ if (lines.length === 0) continue;
896
+
897
+ // Check first line for valid session header
898
+ type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
899
+ let header: SessionHeaderShape | null = null;
900
+ try {
901
+ const first = JSON.parse(lines[0]) as SessionHeaderShape;
902
+ if (first.type === "session" && first.id) {
903
+ header = first;
904
+ }
905
+ } catch {
906
+ // Not valid JSON
907
+ }
908
+ if (!header) continue;
909
+
910
+ const stats = storage.statSync(file);
911
+ let messageCount = 0;
912
+ let firstMessage = "";
913
+ const allMessages: string[] = [];
914
+
915
+ for (let i = 1; i < lines.length; i++) {
916
+ try {
917
+ const entry = JSON.parse(lines[i]) as { type?: string; message?: Message };
918
+
919
+ if (entry.type === "message" && entry.message) {
920
+ messageCount++;
921
+
922
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
923
+ const textContent = extractTextFromContent(entry.message.content);
924
+
925
+ if (textContent) {
926
+ allMessages.push(textContent);
927
+
928
+ if (!firstMessage && entry.message.role === "user") {
929
+ firstMessage = textContent;
930
+ }
931
+ }
932
+ }
933
+ }
934
+ } catch {
935
+ // Skip malformed lines
936
+ }
937
+ }
938
+
939
+ sessions.push({
940
+ path: file,
941
+ id: header.id,
942
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
943
+ title: header.title,
944
+ created: new Date(header.timestamp),
945
+ modified: stats.mtime,
946
+ messageCount,
947
+ firstMessage: firstMessage || "(no messages)",
948
+ allMessagesText: allMessages.join(" "),
949
+ });
950
+ } catch {
951
+ // Skip files that can't be read
952
+ }
953
+ }
954
+
955
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
956
+ return sessions;
957
+ }
958
+
837
959
  export class SessionManager {
838
960
  private sessionId: string = "";
839
961
  private sessionTitle: string | undefined;
@@ -1643,6 +1765,32 @@ export class SessionManager {
1643
1765
  return manager;
1644
1766
  }
1645
1767
 
1768
+ /**
1769
+ * Fork a session into the current project directory.
1770
+ * Copies history from another session file while creating a new session file in the current sessionDir.
1771
+ */
1772
+ static async forkFrom(
1773
+ sourcePath: string,
1774
+ cwd: string,
1775
+ sessionDir?: string,
1776
+ storage: SessionStorage = new FileSessionStorage(),
1777
+ ): Promise<SessionManager> {
1778
+ const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1779
+ const manager = new SessionManager(cwd, dir, true, storage);
1780
+ const forkEntries = structuredClone(loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
1781
+ migrateToCurrentVersion(forkEntries);
1782
+ const sourceHeader = forkEntries.find((e) => e.type === "session") as SessionHeader | undefined;
1783
+ const historyEntries = forkEntries.filter((entry) => entry.type !== "session") as SessionEntry[];
1784
+ manager._newSessionSync({ parentSession: sourceHeader?.id });
1785
+ const newHeader = manager.fileEntries[0] as SessionHeader;
1786
+ newHeader.title = sourceHeader?.title;
1787
+ manager.fileEntries = [newHeader, ...historyEntries];
1788
+ manager.sessionTitle = newHeader.title;
1789
+ manager._buildIndex();
1790
+ await manager._rewriteFile();
1791
+ return manager;
1792
+ }
1793
+
1646
1794
  /**
1647
1795
  * Open a specific session file.
1648
1796
  * @param path Path to session file
@@ -1699,82 +1847,26 @@ export class SessionManager {
1699
1847
  */
1700
1848
  static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
1701
1849
  const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1702
- const sessions: SessionInfo[] = [];
1703
-
1704
1850
  try {
1705
1851
  const files = storage.listFilesSync(dir, "*.jsonl");
1706
-
1707
- for (const file of files) {
1708
- try {
1709
- const content = storage.readTextSync(file);
1710
- const lines = content.trim().split("\n");
1711
- if (lines.length === 0) continue;
1712
-
1713
- // Check first line for valid session header
1714
- let header: { type: string; id: string; cwd?: string; title?: string; timestamp: string } | null = null;
1715
- try {
1716
- const first = JSON.parse(lines[0]);
1717
- if (first.type === "session" && first.id) {
1718
- header = first;
1719
- }
1720
- } catch {
1721
- // Not valid JSON
1722
- }
1723
- if (!header) continue;
1724
-
1725
- const stats = storage.statSync(file);
1726
- let messageCount = 0;
1727
- let firstMessage = "";
1728
- const allMessages: string[] = [];
1729
-
1730
- for (let i = 1; i < lines.length; i++) {
1731
- try {
1732
- const entry = JSON.parse(lines[i]);
1733
-
1734
- if (entry.type === "message") {
1735
- messageCount++;
1736
-
1737
- if (entry.message.role === "user" || entry.message.role === "assistant") {
1738
- const textContent = entry.message.content
1739
- .filter((c: any) => c.type === "text")
1740
- .map((c: any) => c.text)
1741
- .join(" ");
1742
-
1743
- if (textContent) {
1744
- allMessages.push(textContent);
1745
-
1746
- if (!firstMessage && entry.message.role === "user") {
1747
- firstMessage = textContent;
1748
- }
1749
- }
1750
- }
1751
- }
1752
- } catch {
1753
- // Skip malformed lines
1754
- }
1755
- }
1756
-
1757
- sessions.push({
1758
- path: file,
1759
- id: header.id,
1760
- cwd: typeof header.cwd === "string" ? header.cwd : "",
1761
- title: header.title,
1762
- created: new Date(header.timestamp),
1763
- modified: stats.mtime,
1764
- messageCount,
1765
- firstMessage: firstMessage || "(no messages)",
1766
- allMessagesText: allMessages.join(" "),
1767
- });
1768
- } catch {
1769
- // Skip files that can't be read
1770
- }
1771
- }
1772
-
1773
- sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1852
+ return collectSessionsFromFiles(files, storage);
1774
1853
  } catch {
1775
- // Return empty list on error
1854
+ return [];
1776
1855
  }
1856
+ }
1777
1857
 
1778
- return sessions;
1858
+ /**
1859
+ * List all sessions across all project directories.
1860
+ */
1861
+ static listAll(storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
1862
+ const sessionsRoot = join(getDefaultAgentDir(), "sessions");
1863
+ try {
1864
+ const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map((name) =>
1865
+ join(sessionsRoot, name),
1866
+ );
1867
+ return collectSessionsFromFiles(files, storage);
1868
+ } catch {
1869
+ return [];
1870
+ }
1779
1871
  }
1780
1872
  }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SettingsManager } from "./settings-manager";
3
+
4
+ describe("SettingsManager python settings", () => {
5
+ it("defaults to both and session", () => {
6
+ const settings = SettingsManager.inMemory();
7
+
8
+ expect(settings.getPythonToolMode()).toBe("both");
9
+ expect(settings.getPythonKernelMode()).toBe("session");
10
+ });
11
+
12
+ it("persists python tool and kernel modes", async () => {
13
+ const settings = SettingsManager.inMemory();
14
+
15
+ await settings.setPythonToolMode("bash-only");
16
+ await settings.setPythonKernelMode("per-call");
17
+
18
+ expect(settings.getPythonToolMode()).toBe("bash-only");
19
+ expect(settings.getPythonKernelMode()).toBe("per-call");
20
+ expect(settings.serialize().python?.toolMode).toBe("bash-only");
21
+ expect(settings.serialize().python?.kernelMode).toBe("per-call");
22
+ });
23
+ });