@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.
- package/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- 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
|
+
}
|