@meowlynxsea/koi 0.1.0

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 (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Koi Session Store
3
+ *
4
+ * Manages multiple AgentSessions: listing, creating, loading, switching, and
5
+ * persisting Koi-specific per-session UI state (UIMessage[], collapsed states).
6
+ * Builds on top of Pi's SessionManager for underlying JSONL persistence.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ import { createAgentSession, SessionManager, defineTool, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
13
+ import type { AgentSession, CreateAgentSessionResult, SessionInfo, ToolDefinition, Skill, ResourceDiagnostic } from "@mariozechner/pi-coding-agent";
14
+ import { Type } from "typebox";
15
+ import type { UIMessage } from "../tui/components/chat-panel.js";
16
+ import type { ModelRef } from "../config/settings.js";
17
+ import {
18
+ getPiAuthStorage,
19
+ getPiModelRegistry,
20
+ getPiSettingsManager,
21
+ getCurrentPiModel,
22
+ } from "../config/settings.js";
23
+ import { createCodingToolDefinitions } from "../tools/index.js";
24
+ import type { SessionTaskManager } from "./session-tasks.js";
25
+ import { forkManager } from "./session-fork.js";
26
+ import {
27
+ initializeMcpConnections,
28
+ disconnectAllMcpServers,
29
+ getAllMcpTools,
30
+ getMcpConnection,
31
+ type McpProgressCallback,
32
+ } from "../services/mcp/index.js";
33
+ import { getActiveToolNamesForMode } from "./mode.js";
34
+ import {
35
+ loadAllSkills,
36
+ initBundledSkills,
37
+ type SkillCommand,
38
+ } from "../skills/index.js";
39
+
40
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "koi");
41
+ const KOI_SESSIONS_DIR = path.join(CONFIG_DIR, "sessions");
42
+ const PI_AGENT_DIR = path.join(CONFIG_DIR, "pi");
43
+
44
+ export interface SessionMeta {
45
+ id: string;
46
+ title: string;
47
+ filePath: string;
48
+ cwd: string;
49
+ createdAt: Date;
50
+ updatedAt: Date;
51
+ messageCount: number;
52
+ /** Fork source session ID (null for original sessions) */
53
+ forkedFrom: string | null;
54
+ /** Depth in the fork tree (0 for original, incremented for each fork level) */
55
+ forkDepth: number;
56
+ /** List of session IDs that were forked from this session */
57
+ childForks: string[];
58
+ }
59
+
60
+ export interface KoiSessionState {
61
+ sessionId: string;
62
+ title: string;
63
+ currentModel: ModelRef | null;
64
+ auxiliaryModel: ModelRef | null;
65
+ messages: UIMessage[];
66
+ createdAt: number;
67
+ updatedAt: number;
68
+
69
+ // === Fork-related state ===
70
+ /** Fork source session ID (null for original sessions) */
71
+ forkedFrom: string | null;
72
+ /** Branch ID at the fork point */
73
+ forkBranchId: string | null;
74
+ /** Timestamp when this session was forked */
75
+ forkedAt: number | null;
76
+
77
+ // === Agent mode state ===
78
+ /** Current agent mode (build/ask/plan) */
79
+ agentMode: "build" | "ask" | "plan";
80
+ /** Active tool names for current mode */
81
+ activeTools: string[];
82
+
83
+ // === UI state ===
84
+ /** IDs of expanded messages (thinking blocks) */
85
+ expandedMessages: string[];
86
+ /** IDs of collapsed messages (tool results) */
87
+ collapsedMessages: string[];
88
+ }
89
+
90
+ /**
91
+ * File System Helpers
92
+ *
93
+ * All fs operations are wrapped in safe* variants that swallow errors gracefully.
94
+ * This avoids crashing the agent loop when ~/.config is read-only or a session file is corrupted.
95
+ */
96
+
97
+ function ensureDir(dir: string): void {
98
+ if (!fs.existsSync(dir)) {
99
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
100
+ }
101
+ }
102
+
103
+ function getKoiSessionDir(sessionId: string): string {
104
+ return path.join(KOI_SESSIONS_DIR, sessionId);
105
+ }
106
+
107
+ function getKoiStatePath(sessionId: string): string {
108
+ return path.join(getKoiSessionDir(sessionId), "koi-state.json");
109
+ }
110
+
111
+ function safeReadFile<T>(path: string, parser: (raw: string) => T): T | null {
112
+ try {
113
+ if (!fs.existsSync(path)) return null;
114
+ const raw = fs.readFileSync(path, "utf-8");
115
+ return parser(raw);
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function safeWriteFile(filePath: string, data: string): void {
122
+ try {
123
+ ensureDir(path.dirname(filePath));
124
+ fs.writeFileSync(filePath, data, { mode: 0o600 });
125
+ } catch {
126
+ // Silently ignore write errors
127
+ }
128
+ }
129
+
130
+ function safeDeleteFile(filePath: string): void {
131
+ try {
132
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
133
+ } catch {
134
+ // ignore
135
+ }
136
+ }
137
+
138
+ function safeDeleteDir(dir: string): void {
139
+ try {
140
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
141
+ } catch {
142
+ // ignore
143
+ }
144
+ }
145
+
146
+ /**
147
+ * MCP Tool Definition Helpers
148
+ *
149
+ * These functions convert MCP tools to Pi ToolDefinition format,
150
+ * allowing MCP tools to be registered with the agent session.
151
+ */
152
+
153
+ import type { TSchema } from "typebox";
154
+
155
+ // TypeBox schema builder that properly handles dynamic schemas
156
+ function convertJsonSchemaToTypeBox(schema: unknown): TSchema {
157
+ if (!schema || typeof schema !== "object") {
158
+ return Type.String();
159
+ }
160
+
161
+ const s = schema as Record<string, unknown>;
162
+ const type = s["type"] as string | undefined;
163
+
164
+ if (type === "string") return Type.String() as TSchema;
165
+ if (type === "number" || type === "integer") return Type.Number() as TSchema;
166
+ if (type === "boolean") return Type.Boolean() as TSchema;
167
+
168
+ if (type === "array") {
169
+ const itemsSchema = s["items"] ? convertJsonSchemaToTypeBox(s["items"]) : Type.String();
170
+ return Type.Array([itemsSchema]) as TSchema;
171
+ }
172
+
173
+ if (type === "object") {
174
+ const properties: Record<string, TSchema> = {};
175
+ const props = s["properties"] as Record<string, unknown> | undefined;
176
+ if (props && typeof props === "object") {
177
+ for (const [key, value] of Object.entries(props)) {
178
+ properties[key] = convertJsonSchemaToTypeBox(value);
179
+ }
180
+ }
181
+ return Type.Object(properties) as TSchema;
182
+ }
183
+
184
+ return Type.String() as TSchema;
185
+ }
186
+
187
+ function createMcpToolDefinitions(): ToolDefinition[] {
188
+ const mcpTools = getAllMcpTools();
189
+ return mcpTools.map((tool) => {
190
+ const serverName = tool.serverName ?? "unknown";
191
+ const originalToolName = tool.originalToolName ?? tool.name;
192
+
193
+ // Create a TypeBox schema from the input schema
194
+ const inputSchema = tool.inputSchema || {};
195
+ const properties: Record<string, TSchema> = {};
196
+
197
+ if (typeof inputSchema === "object" && inputSchema !== null) {
198
+ const schema = inputSchema as Record<string, unknown>;
199
+
200
+ const schemaProps = schema["properties"] as Record<string, unknown> | undefined;
201
+ if (schemaProps && typeof schemaProps === "object") {
202
+ for (const [key, value] of Object.entries(schemaProps)) {
203
+ properties[key] = convertJsonSchemaToTypeBox(value);
204
+ }
205
+ }
206
+ }
207
+
208
+ const typeboxSchema = Type.Object(properties, {
209
+ additionalProperties: true,
210
+ });
211
+
212
+ return defineTool({
213
+ name: tool.name,
214
+ label: `${serverName}: ${originalToolName}`,
215
+ description: tool.description || `MCP tool from ${serverName}`,
216
+ parameters: typeboxSchema,
217
+ // @ts-expect-error - execute signature compatibility with dynamic tools
218
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
219
+ try {
220
+ const connection = getMcpConnection(serverName);
221
+ if (!connection || connection.status !== "connected") {
222
+ return {
223
+ content: [{ type: "text" as const, text: `MCP server '${serverName}' is not connected` }],
224
+ isError: true,
225
+ };
226
+ }
227
+
228
+ const result = await connection.client.callTool({
229
+ name: originalToolName ?? "",
230
+ arguments: params as Record<string, unknown>,
231
+ });
232
+
233
+ return {
234
+ content: result.content as Array<{ type: string; text?: string; [key: string]: unknown }>,
235
+ };
236
+ } catch (error) {
237
+ const errorMessage = error instanceof Error ? error.message : String(error);
238
+ return {
239
+ content: [{ type: "text" as const, text: errorMessage }],
240
+ isError: true,
241
+ };
242
+ }
243
+ },
244
+ });
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Session Helpers
250
+ *
251
+ * SessionConfig collects the cross-cutting dependencies (auth, registry, settings, tools)
252
+ * needed by every createAgentSession call so create/load/continue can share one code path.
253
+ */
254
+
255
+ function sessionInfoToMeta(info: SessionInfo): SessionMeta {
256
+ const forkMeta = forkManager.getForkMetadata(info.id);
257
+
258
+ // Ensure valid dates with fallbacks to prevent rendering issues
259
+ const createdAt =
260
+ info.created instanceof Date && !isNaN(info.created.getTime())
261
+ ? info.created
262
+ : new Date();
263
+ const updatedAt =
264
+ info.modified instanceof Date && !isNaN(info.modified.getTime())
265
+ ? info.modified
266
+ : new Date();
267
+
268
+ // Ensure messageCount is a valid number
269
+ const messageCount =
270
+ typeof info.messageCount === "number" && info.messageCount >= 0
271
+ ? info.messageCount
272
+ : 0;
273
+
274
+ return {
275
+ id: info.id,
276
+ title: info.name || info.firstMessage || "Untitled Session",
277
+ filePath: info.path,
278
+ cwd: info.cwd ?? "",
279
+ createdAt,
280
+ updatedAt,
281
+ messageCount,
282
+ // Fork-related fields
283
+ forkedFrom: forkMeta?.sourceSessionId ?? null,
284
+ forkDepth: forkMeta ? forkManager.getForkDepth(info.id) : 0,
285
+ childForks: forkManager.getChildForks(info.id),
286
+ };
287
+ }
288
+
289
+ interface SessionConfig {
290
+ authStorage: ReturnType<typeof getPiAuthStorage>;
291
+ modelRegistry: ReturnType<typeof getPiModelRegistry>;
292
+ settingsManager: ReturnType<typeof getPiSettingsManager>;
293
+ currentModel: ReturnType<typeof getCurrentPiModel>;
294
+ customTools: ToolDefinition[];
295
+ skills: Skill[];
296
+ }
297
+
298
+ /**
299
+ * Convert Koi's SkillCommand to Pi's Skill format for injection into the session.
300
+ */
301
+ function convertKoiSkillsToPiSkills(skillCommands: SkillCommand[]): Skill[] {
302
+ return skillCommands
303
+ .filter((cmd) => !cmd.disableModelInvocation)
304
+ .map((cmd) => {
305
+ const filePath = cmd.skillRoot
306
+ ? path.join(cmd.skillRoot, "SKILL.md")
307
+ : `koi://bundled-skills/${cmd.name}`;
308
+
309
+ const baseDir = cmd.skillRoot || "";
310
+
311
+ return {
312
+ name: cmd.name,
313
+ description: cmd.description,
314
+ filePath,
315
+ baseDir,
316
+ sourceInfo: {
317
+ path: filePath,
318
+ source: cmd.loadedFrom === "bundled" ? "koi-bundled" : "koi",
319
+ scope: cmd.source === "projectSettings" ? "project" : "user",
320
+ origin: "top-level",
321
+ baseDir,
322
+ },
323
+ disableModelInvocation: cmd.disableModelInvocation,
324
+ };
325
+ });
326
+ }
327
+
328
+ async function buildSessionConfig(taskManager: SessionTaskManager, onMcpProgress?: McpProgressCallback): Promise<SessionConfig> {
329
+ // Initialize MCP connections to get tool definitions (with progress callback)
330
+ await disconnectAllMcpServers();
331
+ await initializeMcpConnections({ onProgressUpdate: onMcpProgress });
332
+
333
+ // Create MCP tool definitions
334
+ const mcpToolDefs = createMcpToolDefinitions();
335
+
336
+ // Combine coding tools with MCP tools
337
+ const codingTools = createCodingToolDefinitions(process.cwd(), taskManager);
338
+
339
+ // Initialize bundled skills and load all skills
340
+ initBundledSkills();
341
+ const koiSkillCommands = await loadAllSkills(process.cwd());
342
+ const piSkills = convertKoiSkillsToPiSkills(koiSkillCommands);
343
+
344
+ // Debug log
345
+ const logPath = "/tmp/koi-session-debug.log";
346
+ const logLine = `[${new Date().toISOString()}] buildSessionConfig: loaded ${koiSkillCommands.length} skills, ${piSkills.length} after filter\n`;
347
+ try { fs.appendFileSync(logPath, logLine); } catch {}
348
+
349
+ return {
350
+ authStorage: getPiAuthStorage(),
351
+ modelRegistry: getPiModelRegistry(),
352
+ settingsManager: getPiSettingsManager(),
353
+ currentModel: getCurrentPiModel(),
354
+ customTools: [...codingTools, ...mcpToolDefs],
355
+ skills: piSkills,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Tool Abort Support
361
+ *
362
+ * Wraps all tool definitions with abort signal support.
363
+ * When Ctrl+C is pressed, the abort signal is set, and any wrapped tool
364
+ * will immediately return "User interrupted tool use." instead of continuing.
365
+ *
366
+ * This uses Promise.race to ensure tools can be interrupted at any point,
367
+ * even if they don't explicitly check the signal.
368
+ */
369
+
370
+ /** Sentinel error used to cancel tool execution via Promise.race */
371
+ class ToolAbortError extends Error {
372
+ constructor() {
373
+ super("Tool execution aborted");
374
+ this.name = "ToolAbortError";
375
+ }
376
+ }
377
+
378
+ /** Wraps a tool definition to support abort signal checking. */
379
+ function wrapToolWithAbortSupport<TParams, TDetails>(
380
+ tool: ToolDefinition<TParams, TDetails>
381
+ ): ToolDefinition<TParams, TDetails> {
382
+ return {
383
+ ...tool,
384
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
385
+ // Immediately check if signal is already aborted
386
+ if (signal?.aborted) {
387
+ return {
388
+ content: [{ type: "text", text: "User interrupted tool use." }],
389
+ details: {} as TDetails,
390
+ isError: true,
391
+ };
392
+ }
393
+
394
+ // Create a promise that resolves when abort is signaled
395
+ const abortPromise = new Promise<never>((_, reject) => {
396
+ if (signal) {
397
+ const abortHandler = () => {
398
+ reject(new ToolAbortError());
399
+ };
400
+ signal.addEventListener("abort", abortHandler, { once: true });
401
+ }
402
+ });
403
+
404
+ // Race the tool execution against the abort signal
405
+ try {
406
+ return await Promise.race([
407
+ tool.execute(toolCallId, params, signal, onUpdate, ctx),
408
+ abortPromise,
409
+ ]);
410
+ } catch (error) {
411
+ if (error instanceof ToolAbortError) {
412
+ return {
413
+ content: [{ type: "text", text: "User interrupted tool use." }],
414
+ details: {} as TDetails,
415
+ isError: true,
416
+ };
417
+ }
418
+ throw error;
419
+ }
420
+ },
421
+ };
422
+ }
423
+
424
+ /** Wraps all tools in an array with abort support. */
425
+ function wrapAllToolsWithAbortSupport(tools: ToolDefinition[]): ToolDefinition[] {
426
+ return tools.map((tool) => wrapToolWithAbortSupport(tool));
427
+ }
428
+
429
+ async function createAgentSessionWithConfig(
430
+ sessionManager: ReturnType<typeof SessionManager.create>,
431
+ config: SessionConfig
432
+ ): Promise<CreateAgentSessionResult> {
433
+ const skillDiagnostics: ResourceDiagnostic[] = [];
434
+
435
+ // Create resource loader with Koi skills injected
436
+ const resourceLoader = new DefaultResourceLoader({
437
+ cwd: process.cwd(),
438
+ agentDir: PI_AGENT_DIR,
439
+ settingsManager: config.settingsManager,
440
+ noSkills: true,
441
+ skillsOverride: () => ({
442
+ skills: config.skills,
443
+ diagnostics: skillDiagnostics,
444
+ }),
445
+ });
446
+ await resourceLoader.reload();
447
+
448
+ // Debug log
449
+ const logPath = "/tmp/koi-session-debug.log";
450
+ const logLine = `[${new Date().toISOString()}] createAgentSessionWithConfig: injecting ${config.skills.length} skills into session\n`;
451
+ try { fs.appendFileSync(logPath, logLine); } catch {}
452
+
453
+ return createAgentSession({
454
+ cwd: process.cwd(),
455
+ agentDir: PI_AGENT_DIR,
456
+ authStorage: config.authStorage,
457
+ modelRegistry: config.modelRegistry,
458
+ settingsManager: config.settingsManager,
459
+ model: config.currentModel,
460
+ noTools: "builtin",
461
+ customTools: wrapAllToolsWithAbortSupport(config.customTools),
462
+ sessionManager,
463
+ resourceLoader,
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Public API
469
+ *
470
+ * createNewSession / loadSession / continueRecentSession all share the same boot sequence:
471
+ * buildSessionConfig → createAgentSessionWithConfig → (optionally save initial state)
472
+ * listSessions converts Pi SessionInfo objects into Koi's SessionMeta shape.
473
+ */
474
+
475
+ export async function listSessions(): Promise<SessionMeta[]> {
476
+ try {
477
+ // Clear fork manager cache to ensure fresh data
478
+ forkManager.clearCache();
479
+ const infos = await SessionManager.listAll();
480
+ return infos.map(sessionInfoToMeta);
481
+ } catch {
482
+ return [];
483
+ }
484
+ }
485
+
486
+ export async function createNewSession(
487
+ taskManager: SessionTaskManager,
488
+ onMcpProgress?: McpProgressCallback
489
+ ): Promise<CreateAgentSessionResult> {
490
+ ensureDir(KOI_SESSIONS_DIR);
491
+ const config = await buildSessionConfig(taskManager, onMcpProgress);
492
+ const sessionManager = SessionManager.create(process.cwd());
493
+ const result = await createAgentSessionWithConfig(sessionManager, config);
494
+
495
+ const now = Date.now();
496
+ // Get active tools for build mode, which includes MCP tools
497
+ const activeTools = getActiveToolNamesForMode("build");
498
+
499
+ const state: KoiSessionState = {
500
+ sessionId: result.session.sessionId,
501
+ title: "New Session",
502
+ currentModel: config.currentModel ? { provider: config.currentModel.provider, modelId: config.currentModel.id } : null,
503
+ auxiliaryModel: null,
504
+ messages: [],
505
+ createdAt: now,
506
+ updatedAt: now,
507
+ // Fork-related state (null for new sessions)
508
+ forkedFrom: null,
509
+ forkBranchId: null,
510
+ forkedAt: null,
511
+ // Agent mode state (defaults for new sessions)
512
+ agentMode: "build",
513
+ activeTools,
514
+ // UI state
515
+ expandedMessages: [],
516
+ collapsedMessages: [],
517
+ };
518
+ saveKoiState(result.session.sessionId, state);
519
+ return result;
520
+ }
521
+
522
+ export async function loadSession(
523
+ filePath: string,
524
+ taskManager: SessionTaskManager,
525
+ onMcpProgress?: McpProgressCallback
526
+ ): Promise<CreateAgentSessionResult> {
527
+ ensureDir(KOI_SESSIONS_DIR);
528
+ const config = await buildSessionConfig(taskManager, onMcpProgress);
529
+ const sessionManager = SessionManager.open(filePath, undefined, process.cwd());
530
+ return createAgentSessionWithConfig(sessionManager, config);
531
+ }
532
+
533
+ export async function continueRecentSession(
534
+ taskManager: SessionTaskManager
535
+ ): Promise<CreateAgentSessionResult> {
536
+ ensureDir(KOI_SESSIONS_DIR);
537
+ const config = await buildSessionConfig(taskManager);
538
+ const sessionManager = SessionManager.continueRecent(process.cwd());
539
+ return createAgentSessionWithConfig(sessionManager, config);
540
+ }
541
+
542
+ export function saveKoiState(sessionId: string, state: KoiSessionState): void {
543
+ safeWriteFile(getKoiStatePath(sessionId), JSON.stringify(state, null, 2) + "\n");
544
+ }
545
+
546
+ export function loadKoiState(sessionId: string): KoiSessionState | null {
547
+ return safeReadFile(getKoiStatePath(sessionId), (raw) => JSON.parse(raw) as KoiSessionState);
548
+ }
549
+
550
+ export function deleteKoiSessionData(sessionId: string): void {
551
+ safeDeleteDir(getKoiSessionDir(sessionId));
552
+ }
553
+
554
+ export async function deleteSession(meta: SessionMeta): Promise<void> {
555
+ safeDeleteFile(meta.filePath);
556
+ deleteKoiSessionData(meta.id);
557
+ }
558
+
559
+ /**
560
+ * Message Builders
561
+ *
562
+ * extractUserContent / extractAssistantContent normalize Pi's message content unions
563
+ * (string | TextBlock[] | ThinkingBlock[]) into plain strings for the TUI fallback path.
564
+ *
565
+ * buildUIMessagesFromAgentSession is a best-effort reconstruction used when koi-state.json
566
+ * is missing (e.g. the user deleted it or opened the session on a different machine).
567
+ */
568
+
569
+ function extractUserContent(content: unknown): string {
570
+ if (typeof content === "string") return content;
571
+ if (Array.isArray(content)) {
572
+ return content
573
+ .filter((c): c is { type: "text"; text: string } =>
574
+ typeof c === "object" && c !== null && "type" in c && (c as Record<string, unknown>)["type"] === "text"
575
+ )
576
+ .map((c) => c.text)
577
+ .join("");
578
+ }
579
+ return "";
580
+ }
581
+
582
+ function extractAssistantContent(msg: { content: unknown[] }): { text: string; thinking: string } {
583
+ let text = "";
584
+ let thinking = "";
585
+ for (const block of msg.content) {
586
+ if (typeof block !== "object" || block === null) continue;
587
+ const type = (block as Record<string, unknown>)["type"];
588
+ if (type === "text") {
589
+ text += String((block as Record<string, unknown>)["text"] ?? "");
590
+ } else if (type === "thinking" && "thinking" in block) {
591
+ thinking += String((block as Record<string, unknown>)["thinking"] ?? "");
592
+ }
593
+ }
594
+ return { text, thinking };
595
+ }
596
+
597
+ /**
598
+ * Build UIMessage array from AgentSession.messages as a fallback when
599
+ * koi-state.json is missing. This is a best-effort reconstruction.
600
+ */
601
+ export function buildUIMessagesFromAgentSession(session: AgentSession): UIMessage[] {
602
+ const uiMessages: UIMessage[] = [];
603
+
604
+ for (const msg of session.messages) {
605
+ if (msg.role === "user") {
606
+ uiMessages.push({
607
+ id: `user-${msg.timestamp}`,
608
+ type: "user",
609
+ content: extractUserContent(msg.content),
610
+ });
611
+ } else if (msg.role === "assistant") {
612
+ const { text, thinking } = extractAssistantContent(msg as { content: unknown[] });
613
+ uiMessages.push({
614
+ id: `agent-${msg.timestamp}`,
615
+ type: "agent",
616
+ content: text,
617
+ thinking: thinking || undefined,
618
+ thinkingCollapsed: true,
619
+ });
620
+ } else if (msg.role === "custom" && (msg as unknown as Record<string, unknown>)["customType"] === "plan") {
621
+ const rawContent = (msg as unknown as Record<string, unknown>)["content"];
622
+ const content = typeof rawContent === "string"
623
+ ? rawContent
624
+ : extractUserContent(rawContent);
625
+ uiMessages.push({
626
+ id: `plan-${msg.timestamp}`,
627
+ type: "plan",
628
+ content,
629
+ });
630
+ }
631
+ // tool_result messages are skipped in fallback reconstruction
632
+ }
633
+
634
+ // Ensure only the latest plan message is kept (old plans are replaced by new ones).
635
+ const planIndices: number[] = [];
636
+ for (let i = 0; i < uiMessages.length; i++) {
637
+ if (uiMessages[i]!.type === "plan") {
638
+ planIndices.push(i);
639
+ }
640
+ }
641
+ if (planIndices.length > 1) {
642
+ // Remove all but the last plan message.
643
+ for (let i = planIndices.length - 2; i >= 0; i--) {
644
+ uiMessages.splice(planIndices[i]!, 1);
645
+ }
646
+ }
647
+
648
+ return uiMessages;
649
+ }