@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,1560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Lifecycle Hooks
|
|
3
|
+
*
|
|
4
|
+
* React hooks that bridge Pi AgentSession events to the TUI state layer.
|
|
5
|
+
* Supports multi-session: create, load, switch, fork.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
9
|
+
import type {
|
|
10
|
+
AgentSession,
|
|
11
|
+
AgentSessionEvent,
|
|
12
|
+
} from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
type SessionManagerType = AgentSession["sessionManager"];
|
|
15
|
+
type SessionTreeNode = ReturnType<SessionManagerType["getTree"]>[number];
|
|
16
|
+
import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
|
|
17
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
18
|
+
import type { UIMessage } from "../tui/components/chat-panel.js";
|
|
19
|
+
import { isToolExpandable, isToolForceExpanded, getToolDefaultCollapsed } from "../tui/components/chat-panel.js";
|
|
20
|
+
import type { ModelRef } from "../config/settings.js";
|
|
21
|
+
import { setSessionTitle, getSessionTitle, getCurrentModel, getAuxiliaryModel, callAuxiliaryModel } from "../config/settings.js";
|
|
22
|
+
import {
|
|
23
|
+
listSessions,
|
|
24
|
+
createNewSession,
|
|
25
|
+
loadSession,
|
|
26
|
+
saveKoiState,
|
|
27
|
+
loadKoiState,
|
|
28
|
+
buildUIMessagesFromAgentSession,
|
|
29
|
+
deleteSession as deleteSessionStore,
|
|
30
|
+
type SessionMeta,
|
|
31
|
+
type KoiSessionState,
|
|
32
|
+
} from "./session-store.js";
|
|
33
|
+
import type { McpConnectionProgress } from "../services/mcp/index.js";
|
|
34
|
+
import { globalTaskManager } from "./session-tasks.js";
|
|
35
|
+
import fs from "fs";
|
|
36
|
+
import {
|
|
37
|
+
getAgentMode,
|
|
38
|
+
setAgentMode,
|
|
39
|
+
getActiveToolNamesForMode,
|
|
40
|
+
injectModeIntoSystemPrompt,
|
|
41
|
+
} from "./mode.js";
|
|
42
|
+
import { getCurrentPlanText } from "./plan-ui.js";
|
|
43
|
+
import { forkManager } from "./session-fork.js";
|
|
44
|
+
import {
|
|
45
|
+
saveSnapshotIfChanged,
|
|
46
|
+
restoreSnapshot,
|
|
47
|
+
} from "./session-snapshots.js";
|
|
48
|
+
|
|
49
|
+
/** Global ref to the active AgentSession, usable by tools outside React hooks. */
|
|
50
|
+
export const activeSessionRef = { current: null as AgentSession | null };
|
|
51
|
+
|
|
52
|
+
/* ───────── Session Naming ───────── */
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Anti-injection system prompt for session naming.
|
|
56
|
+
* Uses XML tags to clearly delimit the expected output format.
|
|
57
|
+
*/
|
|
58
|
+
const NAMING_SYSTEM_PROMPT = `You are a session naming assistant. Your ONLY task is to output a session name.
|
|
59
|
+
|
|
60
|
+
IMPORTANT RULES:
|
|
61
|
+
1. Output ONLY the session name in the exact format below
|
|
62
|
+
2. Do NOT include any explanation, prefix, suffix, or markdown formatting
|
|
63
|
+
3. The name must be 5-20 characters long
|
|
64
|
+
4. Use Chinese or English (mix allowed)
|
|
65
|
+
5. Start with Chinese if user messages contain Chinese
|
|
66
|
+
6. If the content is inappropriate or you cannot determine a good name, output: Chat
|
|
67
|
+
|
|
68
|
+
RESPONSE FORMAT (MUST follow exactly):
|
|
69
|
+
<name>your_session_name_here</name>
|
|
70
|
+
|
|
71
|
+
If you output extra text outside the tags, the session will be named "Chat".`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse the generated session name from the model response.
|
|
75
|
+
* Returns the extracted name or null if parsing fails.
|
|
76
|
+
*/
|
|
77
|
+
function parseSessionName(response: string): string | null {
|
|
78
|
+
// Try to extract content from <name>...</name> tags
|
|
79
|
+
const tagMatch = response.match(/<name>(.*?)<\/name>/s);
|
|
80
|
+
if (tagMatch && tagMatch[1]) {
|
|
81
|
+
return tagMatch[1].trim();
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate a session name using the auxiliary model based on user messages.
|
|
88
|
+
* Returns the generated name or null if generation fails.
|
|
89
|
+
*/
|
|
90
|
+
async function generateSessionNameFromMessages(
|
|
91
|
+
userMessages: string[]
|
|
92
|
+
): Promise<string | null> {
|
|
93
|
+
if (userMessages.length === 0) {
|
|
94
|
+
fs.appendFileSync("/tmp/koi-debug.log", "[generateSessionNameFromMessages] No user messages\n");
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Combine user messages into a single context
|
|
99
|
+
const userContext = userMessages
|
|
100
|
+
.map((msg, i) => `[Message ${i + 1}]\n${msg}`)
|
|
101
|
+
.join("\n\n");
|
|
102
|
+
|
|
103
|
+
fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Calling auxiliary model with context: ${userContext.slice(0, 200)}\n`);
|
|
104
|
+
|
|
105
|
+
const result = await callAuxiliaryModel(
|
|
106
|
+
NAMING_SYSTEM_PROMPT,
|
|
107
|
+
[{ role: "user", content: userContext, timestamp: Date.now() }]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Result: ${result}\n`);
|
|
111
|
+
|
|
112
|
+
if (!result) {
|
|
113
|
+
fs.appendFileSync("/tmp/koi-debug.log", "[generateSessionNameFromMessages] No result from auxiliary model\n");
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parsed = parseSessionName(result);
|
|
118
|
+
fs.appendFileSync("/tmp/koi-debug.log", `[generateSessionNameFromMessages] Parsed name: ${parsed}\n`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface KoiAgentState {
|
|
123
|
+
session: AgentSession | null;
|
|
124
|
+
messages: UIMessage[];
|
|
125
|
+
isStreaming: boolean;
|
|
126
|
+
isReady: boolean;
|
|
127
|
+
error: string | null;
|
|
128
|
+
sessionTitle: string;
|
|
129
|
+
steeringMessages: readonly string[];
|
|
130
|
+
followUpMessages: readonly string[];
|
|
131
|
+
// MCP connection progress state
|
|
132
|
+
isConnectingMcp: boolean;
|
|
133
|
+
mcpConnectionProgress: McpConnectionProgress | null;
|
|
134
|
+
prompt: (text: string) => Promise<void>;
|
|
135
|
+
steer: (text: string) => Promise<void>;
|
|
136
|
+
followUp: (text: string) => Promise<void>;
|
|
137
|
+
abort: () => Promise<void>;
|
|
138
|
+
toggleCollapse: (id: string) => void;
|
|
139
|
+
expandAll: () => void;
|
|
140
|
+
collapseAll: () => void;
|
|
141
|
+
clearMessages: () => void;
|
|
142
|
+
removePendingMessage: (type: "sheer" | "queued", index: number) => string | null;
|
|
143
|
+
retractMessage: (id: string) => string | null;
|
|
144
|
+
switchSession: (sessionFile: string) => Promise<void>;
|
|
145
|
+
newSession: () => Promise<void>;
|
|
146
|
+
forkSession: (entryId: string) => Promise<void>;
|
|
147
|
+
setSessionTitle: (title: string) => void;
|
|
148
|
+
sessionList: SessionMeta[];
|
|
149
|
+
refreshSessionList: () => Promise<void>;
|
|
150
|
+
currentSessionId: string | null;
|
|
151
|
+
saveCurrentState: () => void;
|
|
152
|
+
deleteSession: (sessionId: string) => Promise<void>;
|
|
153
|
+
addPlanMessage: (content: string) => Promise<void>;
|
|
154
|
+
/** Sync agent mode changes to session state (called when mode changes externally) */
|
|
155
|
+
syncAgentMode: (mode: "build" | "ask" | "plan") => void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* ID & Type Guards
|
|
160
|
+
*
|
|
161
|
+
* generateId: collision-resistant enough for UI message keys within a single session.
|
|
162
|
+
* isAssistantMessage / isThinkingBlock: narrow union types from the generic AgentMessage content blocks.
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
function generateId(prefix: string): string {
|
|
166
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isAssistantMessage(msg: unknown): msg is AssistantMessage {
|
|
170
|
+
return (
|
|
171
|
+
typeof msg === "object" &&
|
|
172
|
+
msg !== null &&
|
|
173
|
+
"role" in msg &&
|
|
174
|
+
(msg as Record<string, unknown>)["role"] === "assistant"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isUserMessage(msg: unknown): msg is UserMessage {
|
|
179
|
+
return (
|
|
180
|
+
typeof msg === "object" &&
|
|
181
|
+
msg !== null &&
|
|
182
|
+
"role" in msg &&
|
|
183
|
+
(msg as Record<string, unknown>)["role"] === "user"
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getUserMessageContent(msg: UserMessage): string {
|
|
188
|
+
if (typeof msg.content === "string") {
|
|
189
|
+
return msg.content;
|
|
190
|
+
}
|
|
191
|
+
return msg.content
|
|
192
|
+
.filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
|
|
193
|
+
.map((b) => b.text)
|
|
194
|
+
.join("");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface ThinkingBlock {
|
|
198
|
+
type: "thinking";
|
|
199
|
+
thinking: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isThinkingBlock(block: { type: string }): block is ThinkingBlock {
|
|
203
|
+
return block.type === "thinking" && "thinking" in block;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isCustomPlanMessage(msg: unknown): msg is { role: "custom"; customType: "plan"; content: string | unknown[]; display: boolean; timestamp: number } {
|
|
207
|
+
return (
|
|
208
|
+
typeof msg === "object" &&
|
|
209
|
+
msg !== null &&
|
|
210
|
+
"role" in msg &&
|
|
211
|
+
(msg as unknown as Record<string, unknown>)["role"] === "custom" &&
|
|
212
|
+
"customType" in msg &&
|
|
213
|
+
(msg as unknown as Record<string, unknown>)["customType"] === "plan"
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function extractCustomPlanContent(msg: { content: string | unknown[] }): string {
|
|
218
|
+
if (typeof msg.content === "string") return msg.content;
|
|
219
|
+
if (Array.isArray(msg.content)) {
|
|
220
|
+
return msg.content
|
|
221
|
+
.filter((c): c is { type: "text"; text: string } =>
|
|
222
|
+
typeof c === "object" && c !== null && "type" in c && (c as unknown as Record<string, unknown>)["type"] === "text"
|
|
223
|
+
)
|
|
224
|
+
.map((c) => c.text)
|
|
225
|
+
.join("");
|
|
226
|
+
}
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractTextAndThinking(msg: AssistantMessage): {
|
|
231
|
+
text: string;
|
|
232
|
+
thinking: string;
|
|
233
|
+
} {
|
|
234
|
+
let text = "";
|
|
235
|
+
let thinking = "";
|
|
236
|
+
for (const block of msg.content) {
|
|
237
|
+
if (block.type === "text") {
|
|
238
|
+
text += block.text;
|
|
239
|
+
} else if (isThinkingBlock(block)) {
|
|
240
|
+
thinking += block.thinking || "";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { text, thinking };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Event Handlers
|
|
248
|
+
*
|
|
249
|
+
* Each Pi AgentSession event is mapped to a dedicated handler below.
|
|
250
|
+
* Handlers receive an EventHandlerContext (setters + refs) so they stay pure-ish and testable.
|
|
251
|
+
* The handleEvent() switch at the bottom of this section dispatches by event type.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
interface EventHandlerContext {
|
|
255
|
+
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
|
256
|
+
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;
|
|
257
|
+
streamingMsgIdRef: React.MutableRefObject<string | null>;
|
|
258
|
+
pendingToolsRef: React.MutableRefObject<Map<string, string>>;
|
|
259
|
+
setSessionTitleState: React.Dispatch<React.SetStateAction<string>>;
|
|
260
|
+
setSessionTitle: (title: string) => void;
|
|
261
|
+
allExpandedRef: React.MutableRefObject<boolean>;
|
|
262
|
+
setSteeringMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
|
|
263
|
+
setFollowUpMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
|
|
264
|
+
localSteerQueueRef: React.MutableRefObject<string[]>;
|
|
265
|
+
localFollowUpQueueRef: React.MutableRefObject<string[]>;
|
|
266
|
+
hasToolCallsRef: React.MutableRefObject<boolean>;
|
|
267
|
+
sessionRef: React.MutableRefObject<AgentSession | null>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Computes the next agent message state during a streaming message_update event.
|
|
272
|
+
* Tracks thinking start/end timestamps so the UI can show a "Thinking..." spinner
|
|
273
|
+
* and collapse/expand the reasoning block after generation finishes.
|
|
274
|
+
*/
|
|
275
|
+
function buildAgentMessageUpdate(
|
|
276
|
+
prevMsg: UIMessage & { type: "agent" },
|
|
277
|
+
text: string,
|
|
278
|
+
thinking: string,
|
|
279
|
+
assistantEvent?: { type: string }
|
|
280
|
+
): UIMessage {
|
|
281
|
+
const thinkingStarted = thinking.length > 0 && !prevMsg.thinkingStartTime;
|
|
282
|
+
const thinkingJustEnded =
|
|
283
|
+
prevMsg.thinkingStartTime &&
|
|
284
|
+
!prevMsg.thinkingEndTime &&
|
|
285
|
+
(assistantEvent?.type === "thinking_end" ||
|
|
286
|
+
assistantEvent?.type === "text_start" ||
|
|
287
|
+
assistantEvent?.type === "text_delta" ||
|
|
288
|
+
assistantEvent?.type === "toolcall_start" ||
|
|
289
|
+
assistantEvent?.type === "toolcall_delta");
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...prevMsg,
|
|
293
|
+
content: text,
|
|
294
|
+
thinking: thinking.length > 0 ? thinking : undefined,
|
|
295
|
+
thinkingStartTime: thinkingStarted ? Date.now() : prevMsg.thinkingStartTime,
|
|
296
|
+
thinkingEndTime: thinkingJustEnded ? Date.now() : prevMsg.thinkingEndTime,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function updateAgentMessage(
|
|
301
|
+
messages: UIMessage[],
|
|
302
|
+
msgId: string,
|
|
303
|
+
updater: (msg: UIMessage & { type: "agent" }) => UIMessage
|
|
304
|
+
): UIMessage[] {
|
|
305
|
+
const next = [...messages];
|
|
306
|
+
const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
|
|
307
|
+
if (idx >= 0) {
|
|
308
|
+
next[idx] = updater(next[idx] as UIMessage & { type: "agent" });
|
|
309
|
+
}
|
|
310
|
+
return next;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function removeAgentMessageIfEmpty(
|
|
314
|
+
messages: UIMessage[],
|
|
315
|
+
msgId: string,
|
|
316
|
+
text: string,
|
|
317
|
+
thinking: string
|
|
318
|
+
): UIMessage[] {
|
|
319
|
+
const next = [...messages];
|
|
320
|
+
const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
|
|
321
|
+
if (idx >= 0) {
|
|
322
|
+
if (text.length === 0 && thinking.length === 0) {
|
|
323
|
+
next.splice(idx, 1);
|
|
324
|
+
} else {
|
|
325
|
+
const prevMsg = next[idx] as UIMessage & { type: "agent" };
|
|
326
|
+
next[idx] = {
|
|
327
|
+
...prevMsg,
|
|
328
|
+
content: text,
|
|
329
|
+
thinking: thinking.length > 0 ? thinking : undefined,
|
|
330
|
+
thinkingEndTime:
|
|
331
|
+
thinking.length > 0 && !prevMsg.thinkingEndTime
|
|
332
|
+
? Date.now()
|
|
333
|
+
: prevMsg.thinkingEndTime,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return next;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Rebuilds the UI message list from Pi's session history (`event.messages`),
|
|
342
|
+
* preserving existing UI state (thinkingCollapsed, expanded, etc.) for matched messages.
|
|
343
|
+
* Unmatched messages from the current UI (e.g. tool_call, tool_result) are appended at the end.
|
|
344
|
+
*/
|
|
345
|
+
export function isInternalNotification(text: string): boolean {
|
|
346
|
+
const t = text.trimStart();
|
|
347
|
+
return t.startsWith("<task-notification>") || t.startsWith("<monitor-notification>");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function rebuildMessagesFromHistory(
|
|
351
|
+
currentMessages: UIMessage[],
|
|
352
|
+
historyMessages: AgentMessage[],
|
|
353
|
+
pendingMsgId?: string | null
|
|
354
|
+
): UIMessage[] {
|
|
355
|
+
const reordered: UIMessage[] = [];
|
|
356
|
+
const usedIndices = new Set<number>();
|
|
357
|
+
|
|
358
|
+
for (const histMsg of historyMessages) {
|
|
359
|
+
if (isUserMessage(histMsg)) {
|
|
360
|
+
const content = getUserMessageContent(histMsg);
|
|
361
|
+
const idx = currentMessages.findIndex(
|
|
362
|
+
(m, i) => !usedIndices.has(i) && m.type === "user" && m.content === content
|
|
363
|
+
);
|
|
364
|
+
if (idx >= 0) {
|
|
365
|
+
usedIndices.add(idx);
|
|
366
|
+
reordered.push(currentMessages[idx]!);
|
|
367
|
+
} else {
|
|
368
|
+
reordered.push({ id: generateId("user"), type: "user", content });
|
|
369
|
+
}
|
|
370
|
+
} else if (isAssistantMessage(histMsg)) {
|
|
371
|
+
const { text, thinking } = extractTextAndThinking(histMsg);
|
|
372
|
+
|
|
373
|
+
// If the pending streaming agent message ended up empty, skip it
|
|
374
|
+
// so we don't resurrect a removed placeholder.
|
|
375
|
+
const pendingIdx = currentMessages.findIndex(
|
|
376
|
+
(m, i) => !usedIndices.has(i) && m.type === "agent" && m.id === pendingMsgId
|
|
377
|
+
);
|
|
378
|
+
const isEmptyPending = pendingIdx >= 0 && text.length === 0 && thinking.length === 0;
|
|
379
|
+
if (isEmptyPending) {
|
|
380
|
+
usedIndices.add(pendingIdx);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const idx = currentMessages.findIndex(
|
|
385
|
+
(m, i) => !usedIndices.has(i) && m.type === "agent" && m.content === text
|
|
386
|
+
);
|
|
387
|
+
if (idx >= 0) {
|
|
388
|
+
usedIndices.add(idx);
|
|
389
|
+
reordered.push(currentMessages[idx]!);
|
|
390
|
+
|
|
391
|
+
// Pull any trailing tool_call / tool_result messages that immediately
|
|
392
|
+
// followed this agent message in the old UI order so they stay together.
|
|
393
|
+
for (let i = idx + 1; i < currentMessages.length; i++) {
|
|
394
|
+
if (usedIndices.has(i)) break;
|
|
395
|
+
const m = currentMessages[i];
|
|
396
|
+
if (!m) break;
|
|
397
|
+
if (m.type === "tool_call") {
|
|
398
|
+
usedIndices.add(i);
|
|
399
|
+
reordered.push(m);
|
|
400
|
+
} else {
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
reordered.push({
|
|
406
|
+
id: generateId("agent"),
|
|
407
|
+
type: "agent",
|
|
408
|
+
content: text,
|
|
409
|
+
thinking: thinking || undefined,
|
|
410
|
+
thinkingCollapsed: true,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
} else if (isCustomPlanMessage(histMsg)) {
|
|
414
|
+
const content = extractCustomPlanContent(histMsg);
|
|
415
|
+
const idx = currentMessages.findIndex(
|
|
416
|
+
(m, i) => !usedIndices.has(i) && m.type === "plan"
|
|
417
|
+
);
|
|
418
|
+
if (idx >= 0) {
|
|
419
|
+
usedIndices.add(idx);
|
|
420
|
+
reordered.push(currentMessages[idx]!);
|
|
421
|
+
} else {
|
|
422
|
+
reordered.push({
|
|
423
|
+
id: generateId("plan"),
|
|
424
|
+
type: "plan",
|
|
425
|
+
content,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Append any unmatched current messages (tool_call, etc.)
|
|
432
|
+
for (let i = 0; i < currentMessages.length; i++) {
|
|
433
|
+
if (!usedIndices.has(i)) {
|
|
434
|
+
reordered.push(currentMessages[i]!);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Deduplicate plan messages: only the latest plan is kept.
|
|
439
|
+
const planIndices: number[] = [];
|
|
440
|
+
for (let i = 0; i < reordered.length; i++) {
|
|
441
|
+
if (reordered[i]!.type === "plan") {
|
|
442
|
+
planIndices.push(i);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (planIndices.length > 1) {
|
|
446
|
+
for (let i = planIndices.length - 2; i >= 0; i--) {
|
|
447
|
+
reordered.splice(planIndices[i]!, 1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return reordered;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Fired when the LLM begins generating a response. */
|
|
455
|
+
function handleAgentStart(ctx: EventHandlerContext) {
|
|
456
|
+
ctx.setIsStreaming(true);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Fired when the LLM finishes a full turn.
|
|
461
|
+
* Replaces the streaming placeholder with the final assistant text (or removes it if empty).
|
|
462
|
+
* Also inserts any pending followUp messages and remaining steer messages at turn end.
|
|
463
|
+
*/
|
|
464
|
+
function handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>, ctx: EventHandlerContext) {
|
|
465
|
+
ctx.setIsStreaming(false);
|
|
466
|
+
|
|
467
|
+
// Deliver any remaining steer messages (turn had no tool calls) and all followUp messages
|
|
468
|
+
const steerToInsert = ctx.localSteerQueueRef.current;
|
|
469
|
+
ctx.localSteerQueueRef.current = [];
|
|
470
|
+
const followUpToInsert = ctx.localFollowUpQueueRef.current;
|
|
471
|
+
ctx.localFollowUpQueueRef.current = [];
|
|
472
|
+
|
|
473
|
+
const pendingMsgId = ctx.streamingMsgIdRef.current;
|
|
474
|
+
|
|
475
|
+
ctx.setMessages((prev) => {
|
|
476
|
+
let next: UIMessage[] = prev.filter((m) => m.type !== "status");
|
|
477
|
+
|
|
478
|
+
// AgentSessionEvent.agent_end.messages only contains messages from the
|
|
479
|
+
// CURRENT run (newMessages), not the full session history. We must use
|
|
480
|
+
// sessionRef.current.messages for any history-reconstruction logic.
|
|
481
|
+
const fullHistory = ctx.sessionRef.current?.messages ?? event.messages;
|
|
482
|
+
|
|
483
|
+
// Check whether Pi's session history already contains user messages
|
|
484
|
+
// that are not yet in our UI (e.g. queued/followUp messages delivered
|
|
485
|
+
// by Pi before agent_end fired). If so, rebuild from full history
|
|
486
|
+
// to get the correct order instead of blindly appending to the end.
|
|
487
|
+
const historyUserTexts = fullHistory
|
|
488
|
+
.filter(isUserMessage)
|
|
489
|
+
.map(getUserMessageContent);
|
|
490
|
+
const uiUserTexts = new Set(next.filter((m) => m.type === "user").map((m) => m.content));
|
|
491
|
+
const hasNewUserMessages = historyUserTexts.some((text) => !uiUserTexts.has(text));
|
|
492
|
+
|
|
493
|
+
if (hasNewUserMessages && fullHistory.length > 0) {
|
|
494
|
+
// Finalise the pending streaming placeholder first
|
|
495
|
+
if (pendingMsgId) {
|
|
496
|
+
const lastAssistant = [...fullHistory].reverse().find(isAssistantMessage);
|
|
497
|
+
if (lastAssistant) {
|
|
498
|
+
const { text, thinking } = extractTextAndThinking(lastAssistant);
|
|
499
|
+
next = removeAgentMessageIfEmpty(next, pendingMsgId, text, thinking);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return rebuildMessagesFromHistory(next, fullHistory, pendingMsgId);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Fallback: old append logic for cases where Pi hasn't yet added
|
|
506
|
+
// the queued messages to its history snapshot.
|
|
507
|
+
if (pendingMsgId && fullHistory.length > 0) {
|
|
508
|
+
const lastAssistant = [...fullHistory].reverse().find(isAssistantMessage);
|
|
509
|
+
if (lastAssistant) {
|
|
510
|
+
const { text, thinking } = extractTextAndThinking(lastAssistant);
|
|
511
|
+
next = removeAgentMessageIfEmpty(next, pendingMsgId, text, thinking);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const inserts = [
|
|
516
|
+
...steerToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text })),
|
|
517
|
+
...followUpToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text })),
|
|
518
|
+
];
|
|
519
|
+
if (inserts.length > 0) {
|
|
520
|
+
return next.concat(inserts);
|
|
521
|
+
}
|
|
522
|
+
return next;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
ctx.streamingMsgIdRef.current = null;
|
|
526
|
+
ctx.pendingToolsRef.current.clear();
|
|
527
|
+
ctx.hasToolCallsRef.current = false;
|
|
528
|
+
|
|
529
|
+
// Save snapshot after each completed turn so forks can restore exact state.
|
|
530
|
+
const currentSession = ctx.sessionRef.current;
|
|
531
|
+
if (currentSession) {
|
|
532
|
+
saveSnapshotIfChanged(currentSession, {
|
|
533
|
+
tasks: globalTaskManager.listTasks(),
|
|
534
|
+
planText: getCurrentPlanText(),
|
|
535
|
+
agentMode: getAgentMode(),
|
|
536
|
+
activeTools: getActiveToolNamesForMode(getAgentMode()),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Creates a blank streaming placeholder for the incoming assistant message.
|
|
542
|
+
* If there were tool calls in this turn, any pending steer messages are delivered
|
|
543
|
+
* right before the new assistant message (after tools finish, before next LLM call).
|
|
544
|
+
*/
|
|
545
|
+
function handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>, ctx: EventHandlerContext) {
|
|
546
|
+
if (!isAssistantMessage(event.message)) return;
|
|
547
|
+
const steerToInsert = ctx.hasToolCallsRef.current ? ctx.localSteerQueueRef.current : [];
|
|
548
|
+
if (ctx.hasToolCallsRef.current) {
|
|
549
|
+
ctx.localSteerQueueRef.current = [];
|
|
550
|
+
}
|
|
551
|
+
const msgId = generateId("agent");
|
|
552
|
+
ctx.streamingMsgIdRef.current = msgId;
|
|
553
|
+
ctx.setMessages((prev) => [
|
|
554
|
+
...prev.filter((m) => m.type !== "status"),
|
|
555
|
+
...(steerToInsert.length > 0
|
|
556
|
+
? steerToInsert.map((text) => ({ id: generateId("user"), type: "user" as const, content: text }))
|
|
557
|
+
: []),
|
|
558
|
+
{ id: msgId, type: "agent", content: "", thinkingCollapsed: true },
|
|
559
|
+
]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Fired on every token / block delta during streaming.
|
|
564
|
+
* Updates content, thinking text, and thinking start/end timestamps in a single immutable swap.
|
|
565
|
+
*/
|
|
566
|
+
function handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>, ctx: EventHandlerContext) {
|
|
567
|
+
if (!isAssistantMessage(event.message)) return;
|
|
568
|
+
const msgId = ctx.streamingMsgIdRef.current;
|
|
569
|
+
if (!msgId) return;
|
|
570
|
+
const { text, thinking } = extractTextAndThinking(event.message);
|
|
571
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
572
|
+
ctx.setMessages((prev) =>
|
|
573
|
+
updateAgentMessage(prev, msgId, (prevMsg) =>
|
|
574
|
+
buildAgentMessageUpdate(prevMsg, text, thinking, assistantEvent)
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Finalizes the streaming message. Unlike agent_end, this fires per-message
|
|
581
|
+
* (a turn may contain multiple messages when tools are involved).
|
|
582
|
+
*/
|
|
583
|
+
function handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>, ctx: EventHandlerContext) {
|
|
584
|
+
if (!isAssistantMessage(event.message)) return;
|
|
585
|
+
const msgId = ctx.streamingMsgIdRef.current;
|
|
586
|
+
if (msgId) {
|
|
587
|
+
const { text, thinking } = extractTextAndThinking(event.message);
|
|
588
|
+
ctx.setMessages((prev) => removeAgentMessageIfEmpty(prev, msgId, text, thinking));
|
|
589
|
+
}
|
|
590
|
+
ctx.streamingMsgIdRef.current = null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Adds a pending tool_call message to the UI so the user sees live execution. */
|
|
594
|
+
function handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>, ctx: EventHandlerContext) {
|
|
595
|
+
ctx.hasToolCallsRef.current = true;
|
|
596
|
+
const toolMsgId = generateId("tool");
|
|
597
|
+
ctx.pendingToolsRef.current.set(event.toolCallId, toolMsgId);
|
|
598
|
+
ctx.setMessages((prev) =>
|
|
599
|
+
prev.concat({
|
|
600
|
+
id: toolMsgId,
|
|
601
|
+
type: "tool_call",
|
|
602
|
+
toolCallId: event.toolCallId,
|
|
603
|
+
toolName: event.toolName,
|
|
604
|
+
args: event.args as Record<string, unknown>,
|
|
605
|
+
collapsed: getToolDefaultCollapsed(event.toolName, ctx.allExpandedRef.current),
|
|
606
|
+
})
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Streams partial tool results (e.g. long-running bash output chunks). */
|
|
611
|
+
function handleToolExecutionUpdate(event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>, ctx: EventHandlerContext) {
|
|
612
|
+
const toolMsgId = ctx.pendingToolsRef.current.get(event.toolCallId);
|
|
613
|
+
if (!toolMsgId) return;
|
|
614
|
+
ctx.setMessages((prev) =>
|
|
615
|
+
prev.map((m) =>
|
|
616
|
+
m.id === toolMsgId && m.type === "tool_call"
|
|
617
|
+
? { ...m, result: event.partialResult }
|
|
618
|
+
: m
|
|
619
|
+
)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Marks the tool call as complete and stores the final result (or error). */
|
|
624
|
+
function handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>, ctx: EventHandlerContext) {
|
|
625
|
+
const toolMsgId = ctx.pendingToolsRef.current.get(event.toolCallId);
|
|
626
|
+
if (!toolMsgId) return;
|
|
627
|
+
ctx.setMessages((prev) =>
|
|
628
|
+
prev.map((m) =>
|
|
629
|
+
m.id === toolMsgId && m.type === "tool_call"
|
|
630
|
+
? { ...m, result: event.result, isError: event.isError }
|
|
631
|
+
: m
|
|
632
|
+
)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Notifies the user that the session is being compacted to reduce context usage. */
|
|
637
|
+
function handleCompactionStart(event: Extract<AgentSessionEvent, { type: "compaction_start" }>, ctx: EventHandlerContext) {
|
|
638
|
+
ctx.setMessages((prev) =>
|
|
639
|
+
prev.concat({
|
|
640
|
+
id: generateId("compact"),
|
|
641
|
+
type: "compaction",
|
|
642
|
+
content: `Compacting session (${event.reason})...`,
|
|
643
|
+
})
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function handleCompactionEnd(event: Extract<AgentSessionEvent, { type: "compaction_end" }>, ctx: EventHandlerContext) {
|
|
648
|
+
ctx.setMessages((prev) =>
|
|
649
|
+
prev.map((m) =>
|
|
650
|
+
m.type === "compaction" && m.content.includes("Compacting")
|
|
651
|
+
? {
|
|
652
|
+
...m,
|
|
653
|
+
content: event.aborted ? "Compaction aborted." : "Session compacted.",
|
|
654
|
+
}
|
|
655
|
+
: m
|
|
656
|
+
)
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
// Re-apply mode-specific tool restrictions and system prompt after compaction,
|
|
660
|
+
// in case the compaction process reset any session state.
|
|
661
|
+
const session = ctx.sessionRef.current;
|
|
662
|
+
if (session && !event.aborted) {
|
|
663
|
+
const mode = getAgentMode();
|
|
664
|
+
session.setActiveToolsByName(getActiveToolNamesForMode(mode));
|
|
665
|
+
injectModeIntoSystemPrompt(session, mode);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Shows a retry banner when the agent encounters a transient error and retries automatically. */
|
|
670
|
+
function handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>, ctx: EventHandlerContext) {
|
|
671
|
+
ctx.setMessages((prev) =>
|
|
672
|
+
prev
|
|
673
|
+
.filter((m) => m.type !== "status")
|
|
674
|
+
.concat({
|
|
675
|
+
id: generateId("retry"),
|
|
676
|
+
type: "retry",
|
|
677
|
+
attempt: event.attempt,
|
|
678
|
+
maxAttempts: event.maxAttempts,
|
|
679
|
+
content: `Retrying... (${event.attempt}/${event.maxAttempts}): ${event.errorMessage}`,
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Clears the retry banner once the retry cycle finishes (success or final failure). */
|
|
685
|
+
function handleAutoRetryEnd(_event: Extract<AgentSessionEvent, { type: "auto_retry_end" }>, ctx: EventHandlerContext) {
|
|
686
|
+
ctx.setMessages((prev) => prev.filter((m) => m.type !== "retry"));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Syncs the session name when the agent or user renames it. */
|
|
690
|
+
function handleSessionInfoChanged(event: Extract<AgentSessionEvent, { type: "session_info_changed" }>, ctx: EventHandlerContext) {
|
|
691
|
+
if (event.name) {
|
|
692
|
+
ctx.setSessionTitleState(event.name);
|
|
693
|
+
ctx.setSessionTitle(event.name);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Syncs the pending steer/followUp queues from the agent session to React state.
|
|
698
|
+
* Delivery detection is handled manually via local queues and event boundaries
|
|
699
|
+
* (steer after tool calls, followUp at agent_end), so this only updates the UI state.
|
|
700
|
+
*/
|
|
701
|
+
function handleQueueUpdate(event: Extract<AgentSessionEvent, { type: "queue_update" }>, ctx: EventHandlerContext) {
|
|
702
|
+
ctx.setSteeringMessages(event.steering);
|
|
703
|
+
ctx.setFollowUpMessages(event.followUp);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Central dispatcher for all AgentSession events.
|
|
708
|
+
* Uses a switch so TypeScript can narrow the event type for each handler.
|
|
709
|
+
*/
|
|
710
|
+
function handleEvent(event: AgentSessionEvent, ctx: EventHandlerContext) {
|
|
711
|
+
switch (event.type) {
|
|
712
|
+
case "agent_start": handleAgentStart(ctx); break;
|
|
713
|
+
case "agent_end": handleAgentEnd(event, ctx); break;
|
|
714
|
+
case "message_start": handleMessageStart(event, ctx); break;
|
|
715
|
+
case "message_update": handleMessageUpdate(event, ctx); break;
|
|
716
|
+
case "message_end": handleMessageEnd(event, ctx); break;
|
|
717
|
+
case "tool_execution_start": handleToolExecutionStart(event, ctx); break;
|
|
718
|
+
case "tool_execution_update": handleToolExecutionUpdate(event, ctx); break;
|
|
719
|
+
case "tool_execution_end": handleToolExecutionEnd(event, ctx); break;
|
|
720
|
+
case "compaction_start": handleCompactionStart(event, ctx); break;
|
|
721
|
+
case "compaction_end": handleCompactionEnd(event, ctx); break;
|
|
722
|
+
case "auto_retry_start": handleAutoRetryStart(event, ctx); break;
|
|
723
|
+
case "auto_retry_end": handleAutoRetryEnd(event, ctx); break;
|
|
724
|
+
case "session_info_changed": handleSessionInfoChanged(event, ctx); break;
|
|
725
|
+
case "queue_update": handleQueueUpdate(event, ctx); break;
|
|
726
|
+
default: break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Tree Navigation
|
|
732
|
+
*
|
|
733
|
+
* Session entries form a tree because of forking / branching.
|
|
734
|
+
* findNodeInTree walks the entire tree to locate an entry by its id.
|
|
735
|
+
*/
|
|
736
|
+
|
|
737
|
+
function findNodeInTree(
|
|
738
|
+
nodes: SessionTreeNode[],
|
|
739
|
+
id: string
|
|
740
|
+
): SessionTreeNode | null {
|
|
741
|
+
for (const node of nodes) {
|
|
742
|
+
if (node.entry.id === id) return node;
|
|
743
|
+
const found = findNodeInTree(node.children, id);
|
|
744
|
+
if (found) return found;
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* useKoiAgent — Core React hook for the Koi TUI.
|
|
751
|
+
*
|
|
752
|
+
* Bridges Pi's AgentSession lifecycle to React state:
|
|
753
|
+
* • Event subscription & message streaming
|
|
754
|
+
* • Session CRUD (create, switch, fork, delete)
|
|
755
|
+
* • Auto-save of UI state to ~/.config/koi/sessions/<id>/koi-state.json
|
|
756
|
+
* • Collapse / expand helpers for tool_calls and thinking blocks
|
|
757
|
+
*
|
|
758
|
+
* Refs are kept in sync with state so cleanup handlers (unmount, switch, delete)
|
|
759
|
+
* always see the latest values without adding them to dependency arrays.
|
|
760
|
+
*/
|
|
761
|
+
|
|
762
|
+
export function useKoiAgent(): KoiAgentState {
|
|
763
|
+
const [session, setSession] = useState<AgentSession | null>(null);
|
|
764
|
+
const [messages, setMessages] = useState<UIMessage[]>([]);
|
|
765
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
766
|
+
const [isReady, setIsReady] = useState(false);
|
|
767
|
+
const [error, setError] = useState<string | null>(null);
|
|
768
|
+
const [sessionList, setSessionList] = useState<SessionMeta[]>([]);
|
|
769
|
+
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
|
770
|
+
const [sessionTitle, setSessionTitleState] = useState<string>(getSessionTitle());
|
|
771
|
+
const [steeringMessages, setSteeringMessages] = useState<readonly string[]>([]);
|
|
772
|
+
const [followUpMessages, setFollowUpMessages] = useState<readonly string[]>([]);
|
|
773
|
+
// MCP connection progress state
|
|
774
|
+
const [isConnectingMcp, setIsConnectingMcp] = useState(false);
|
|
775
|
+
const [mcpConnectionProgress, setMcpConnectionProgress] = useState<McpConnectionProgress | null>(null);
|
|
776
|
+
|
|
777
|
+
const streamingMsgIdRef = useRef<string | null>(null);
|
|
778
|
+
const pendingToolsRef = useRef<Map<string, string>>(new Map());
|
|
779
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
780
|
+
const currentModelRef = useRef<ModelRef | null>(getCurrentModel());
|
|
781
|
+
const auxiliaryModelRef = useRef<ModelRef | null>(getAuxiliaryModel());
|
|
782
|
+
const sessionRef = useRef<AgentSession | null>(null);
|
|
783
|
+
const messagesRef = useRef<UIMessage[]>([]);
|
|
784
|
+
const currentSessionIdRef = useRef<string | null>(null);
|
|
785
|
+
const allExpandedRef = useRef<boolean>(false);
|
|
786
|
+
const localSteerQueueRef = useRef<string[]>([]);
|
|
787
|
+
const localFollowUpQueueRef = useRef<string[]>([]);
|
|
788
|
+
const hasToolCallsRef = useRef(false);
|
|
789
|
+
// Track whether this session has been named by the auxiliary model
|
|
790
|
+
const sessionNamedRef = useRef(false);
|
|
791
|
+
|
|
792
|
+
// Refs for session state that needs to be persisted with KoiSessionState
|
|
793
|
+
const sessionStateRef = useRef<{
|
|
794
|
+
forkedFrom: string | null;
|
|
795
|
+
forkBranchId: string | null;
|
|
796
|
+
forkedAt: number | null;
|
|
797
|
+
agentMode: "build" | "ask" | "plan";
|
|
798
|
+
activeTools: string[];
|
|
799
|
+
}>({
|
|
800
|
+
forkedFrom: null,
|
|
801
|
+
forkBranchId: null,
|
|
802
|
+
forkedAt: null,
|
|
803
|
+
agentMode: "build",
|
|
804
|
+
activeTools: getActiveToolNamesForMode("build"),
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Keep refs in sync with latest state for cleanup handlers (unmount, switch, delete).
|
|
808
|
+
// These refs avoid stale closures without adding every state to dependency arrays.
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
sessionRef.current = session;
|
|
811
|
+
activeSessionRef.current = session;
|
|
812
|
+
}, [session]);
|
|
813
|
+
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
|
814
|
+
useEffect(() => { currentSessionIdRef.current = currentSessionId; }, [currentSessionId]);
|
|
815
|
+
|
|
816
|
+
// Debounce writes to disk: avoids hammering the filesystem on every token during streaming.
|
|
817
|
+
// Also batches rapid message updates into a single save.
|
|
818
|
+
const scheduleSave = useCallback(
|
|
819
|
+
(sessionId: string, msgs: UIMessage[], title: string) => {
|
|
820
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
821
|
+
saveTimerRef.current = setTimeout(() => {
|
|
822
|
+
const state: KoiSessionState = {
|
|
823
|
+
sessionId,
|
|
824
|
+
title,
|
|
825
|
+
currentModel: currentModelRef.current,
|
|
826
|
+
auxiliaryModel: auxiliaryModelRef.current,
|
|
827
|
+
messages: msgs,
|
|
828
|
+
createdAt: Date.now(),
|
|
829
|
+
updatedAt: Date.now(),
|
|
830
|
+
// Fork and agent mode state
|
|
831
|
+
forkedFrom: sessionStateRef.current.forkedFrom,
|
|
832
|
+
forkBranchId: sessionStateRef.current.forkBranchId,
|
|
833
|
+
forkedAt: sessionStateRef.current.forkedAt,
|
|
834
|
+
agentMode: sessionStateRef.current.agentMode,
|
|
835
|
+
activeTools: sessionStateRef.current.activeTools,
|
|
836
|
+
// UI state (empty, will be populated by actual UI interactions)
|
|
837
|
+
expandedMessages: [],
|
|
838
|
+
collapsedMessages: [],
|
|
839
|
+
};
|
|
840
|
+
saveKoiState(sessionId, state);
|
|
841
|
+
globalTaskManager.save(sessionId);
|
|
842
|
+
}, 500);
|
|
843
|
+
},
|
|
844
|
+
[]
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
if (currentSessionId && session) {
|
|
849
|
+
scheduleSave(currentSessionId, messages, session.sessionName || getSessionTitle());
|
|
850
|
+
}
|
|
851
|
+
}, [messages, currentSessionId, session, scheduleSave]);
|
|
852
|
+
|
|
853
|
+
// Wire Pi AgentSession events into React setters via the central handleEvent dispatcher.
|
|
854
|
+
const subscribeToSession = useCallback((s: AgentSession) => {
|
|
855
|
+
// Set refs immediately so event handlers (which may fire before the next
|
|
856
|
+
// React render cycle / useEffect) see the correct session.
|
|
857
|
+
sessionRef.current = s;
|
|
858
|
+
activeSessionRef.current = s;
|
|
859
|
+
const ctx: EventHandlerContext = {
|
|
860
|
+
setMessages,
|
|
861
|
+
setIsStreaming,
|
|
862
|
+
streamingMsgIdRef,
|
|
863
|
+
pendingToolsRef,
|
|
864
|
+
setSessionTitleState,
|
|
865
|
+
setSessionTitle,
|
|
866
|
+
allExpandedRef,
|
|
867
|
+
setSteeringMessages,
|
|
868
|
+
setFollowUpMessages,
|
|
869
|
+
localSteerQueueRef,
|
|
870
|
+
localFollowUpQueueRef,
|
|
871
|
+
hasToolCallsRef,
|
|
872
|
+
sessionRef,
|
|
873
|
+
};
|
|
874
|
+
return s.subscribe((event: AgentSessionEvent) => handleEvent(event, ctx));
|
|
875
|
+
}, []);
|
|
876
|
+
|
|
877
|
+
// On session load: prefer persisted koi-state.json; fall back to rebuilding from AgentSession.messages.
|
|
878
|
+
const restoreSessionState = useCallback((s: AgentSession) => {
|
|
879
|
+
const koiState = loadKoiState(s.sessionId);
|
|
880
|
+
let restoredMessages = koiState?.messages.length ? koiState.messages : buildUIMessagesFromAgentSession(s);
|
|
881
|
+
|
|
882
|
+
// Strip internal subagent notifications from restored messages — they are
|
|
883
|
+
// meant for the LLM context only and should not clutter the UI.
|
|
884
|
+
restoredMessages = restoredMessages.filter(
|
|
885
|
+
(m) => !(m.type === "user" && isInternalNotification(m.content))
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
// Deduplicate plan messages: only the latest plan is kept.
|
|
889
|
+
const planIndices: number[] = [];
|
|
890
|
+
for (let i = 0; i < restoredMessages.length; i++) {
|
|
891
|
+
if (restoredMessages[i]!.type === "plan") {
|
|
892
|
+
planIndices.push(i);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (planIndices.length > 1) {
|
|
896
|
+
const filtered = restoredMessages.filter((_, i) => !planIndices.slice(0, -1).includes(i));
|
|
897
|
+
restoredMessages = filtered;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
setMessages(restoredMessages);
|
|
901
|
+
|
|
902
|
+
const title = koiState?.title ?? s.sessionName;
|
|
903
|
+
if (title) {
|
|
904
|
+
setSessionTitleState(title);
|
|
905
|
+
setSessionTitle(title);
|
|
906
|
+
}
|
|
907
|
+
if (koiState?.currentModel) currentModelRef.current = koiState.currentModel;
|
|
908
|
+
if (koiState?.auxiliaryModel) auxiliaryModelRef.current = koiState.auxiliaryModel;
|
|
909
|
+
|
|
910
|
+
// Restore fork-related and agent mode state
|
|
911
|
+
if (koiState) {
|
|
912
|
+
sessionStateRef.current = {
|
|
913
|
+
forkedFrom: koiState.forkedFrom ?? null,
|
|
914
|
+
forkBranchId: koiState.forkBranchId ?? null,
|
|
915
|
+
forkedAt: koiState.forkedAt ?? null,
|
|
916
|
+
agentMode: koiState.agentMode ?? "build",
|
|
917
|
+
activeTools: koiState.activeTools ?? getActiveToolNamesForMode(koiState.agentMode ?? "build"),
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Restore agent mode for the session
|
|
921
|
+
setAgentMode(sessionStateRef.current.agentMode);
|
|
922
|
+
s.setActiveToolsByName(sessionStateRef.current.activeTools);
|
|
923
|
+
injectModeIntoSystemPrompt(s, sessionStateRef.current.agentMode);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Restore snapshot (tasks + plan + mode) for current leaf, overriding koiState if present.
|
|
927
|
+
const leafId = s.sessionManager.getLeafId();
|
|
928
|
+
if (leafId) {
|
|
929
|
+
const snapshotData = restoreSnapshot(s, leafId, globalTaskManager);
|
|
930
|
+
if (snapshotData) {
|
|
931
|
+
sessionStateRef.current = {
|
|
932
|
+
...sessionStateRef.current,
|
|
933
|
+
agentMode: snapshotData.agentMode,
|
|
934
|
+
activeTools: snapshotData.activeTools,
|
|
935
|
+
};
|
|
936
|
+
setAgentMode(snapshotData.agentMode);
|
|
937
|
+
s.setActiveToolsByName(snapshotData.activeTools);
|
|
938
|
+
injectModeIntoSystemPrompt(s, snapshotData.agentMode);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}, []);
|
|
942
|
+
|
|
943
|
+
// Orchestrates the full session boot sequence (subscribe → restore state → refresh list).
|
|
944
|
+
const setupSession = useCallback(
|
|
945
|
+
async (result: { session: AgentSession }) => {
|
|
946
|
+
const s = result.session;
|
|
947
|
+
setSession(s);
|
|
948
|
+
setCurrentSessionId(s.sessionId);
|
|
949
|
+
globalTaskManager.setActiveSession(s.sessionId);
|
|
950
|
+
subscribeToSession(s);
|
|
951
|
+
restoreSessionState(s);
|
|
952
|
+
setIsReady(true);
|
|
953
|
+
setSessionList(await listSessions());
|
|
954
|
+
},
|
|
955
|
+
[subscribeToSession, restoreSessionState]
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
// Shared state shape used by saveCurrentState, scheduleSave, and the unmount cleanup effect.
|
|
959
|
+
const buildKoiState = useCallback(
|
|
960
|
+
(sid: string, msgs: UIMessage[], title: string): KoiSessionState => ({
|
|
961
|
+
sessionId: sid,
|
|
962
|
+
title,
|
|
963
|
+
currentModel: currentModelRef.current,
|
|
964
|
+
auxiliaryModel: auxiliaryModelRef.current,
|
|
965
|
+
messages: msgs,
|
|
966
|
+
createdAt: Date.now(),
|
|
967
|
+
updatedAt: Date.now(),
|
|
968
|
+
// Fork and agent mode state
|
|
969
|
+
forkedFrom: sessionStateRef.current.forkedFrom,
|
|
970
|
+
forkBranchId: sessionStateRef.current.forkBranchId,
|
|
971
|
+
forkedAt: sessionStateRef.current.forkedAt,
|
|
972
|
+
agentMode: sessionStateRef.current.agentMode,
|
|
973
|
+
activeTools: sessionStateRef.current.activeTools,
|
|
974
|
+
// UI state
|
|
975
|
+
expandedMessages: [],
|
|
976
|
+
collapsedMessages: [],
|
|
977
|
+
}),
|
|
978
|
+
[]
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
// On mount: create a new session instead of continuing the most recent one.
|
|
982
|
+
useEffect(() => {
|
|
983
|
+
let mounted = true;
|
|
984
|
+
void createNewSession(globalTaskManager)
|
|
985
|
+
.then((result) => {
|
|
986
|
+
if (!mounted) {
|
|
987
|
+
result.session.dispose();
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
void setupSession(result);
|
|
991
|
+
})
|
|
992
|
+
.catch((err: unknown) => {
|
|
993
|
+
if (!mounted) return;
|
|
994
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
995
|
+
setIsReady(true);
|
|
996
|
+
});
|
|
997
|
+
return () => { mounted = false; };
|
|
998
|
+
}, [setupSession]);
|
|
999
|
+
|
|
1000
|
+
// On unmount: persist final state before disposing the AgentSession to prevent data loss.
|
|
1001
|
+
useEffect(() => {
|
|
1002
|
+
return () => {
|
|
1003
|
+
const s = sessionRef.current;
|
|
1004
|
+
const sid = currentSessionIdRef.current;
|
|
1005
|
+
const msgs = messagesRef.current;
|
|
1006
|
+
if (s) {
|
|
1007
|
+
if (sid) {
|
|
1008
|
+
saveKoiState(sid, buildKoiState(sid, msgs, s.sessionName || getSessionTitle()));
|
|
1009
|
+
globalTaskManager.save(sid);
|
|
1010
|
+
}
|
|
1011
|
+
s.dispose();
|
|
1012
|
+
}
|
|
1013
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
1014
|
+
};
|
|
1015
|
+
}, [buildKoiState]);
|
|
1016
|
+
|
|
1017
|
+
const saveCurrentState = useCallback(() => {
|
|
1018
|
+
if (currentSessionId && session) {
|
|
1019
|
+
saveKoiState(currentSessionId, buildKoiState(currentSessionId, messages, session.sessionName || getSessionTitle()));
|
|
1020
|
+
globalTaskManager.save(currentSessionId);
|
|
1021
|
+
}
|
|
1022
|
+
}, [currentSessionId, session, messages, buildKoiState]);
|
|
1023
|
+
|
|
1024
|
+
// Clears streaming artifacts (msg id, pending tools) when switching or creating a new session.
|
|
1025
|
+
const resetSessionUI = useCallback(() => {
|
|
1026
|
+
setError(null);
|
|
1027
|
+
streamingMsgIdRef.current = null;
|
|
1028
|
+
pendingToolsRef.current.clear();
|
|
1029
|
+
setSteeringMessages([]);
|
|
1030
|
+
setFollowUpMessages([]);
|
|
1031
|
+
localSteerQueueRef.current = [];
|
|
1032
|
+
localFollowUpQueueRef.current = [];
|
|
1033
|
+
hasToolCallsRef.current = false;
|
|
1034
|
+
sessionNamedRef.current = false;
|
|
1035
|
+
}, []);
|
|
1036
|
+
|
|
1037
|
+
// -- Session Actions --
|
|
1038
|
+
const switchSession = useCallback(
|
|
1039
|
+
async (sessionFile: string) => {
|
|
1040
|
+
if (!session) return;
|
|
1041
|
+
setIsReady(false);
|
|
1042
|
+
saveCurrentState();
|
|
1043
|
+
await session.abort();
|
|
1044
|
+
session.dispose();
|
|
1045
|
+
|
|
1046
|
+
// Start MCP connection progress tracking
|
|
1047
|
+
setIsConnectingMcp(true);
|
|
1048
|
+
setMcpConnectionProgress({
|
|
1049
|
+
total: 0,
|
|
1050
|
+
completed: 0,
|
|
1051
|
+
currentServer: "Initializing...",
|
|
1052
|
+
status: "connecting",
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
const result = await loadSession(sessionFile, globalTaskManager, (progress) => {
|
|
1057
|
+
setMcpConnectionProgress(progress);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// Clear MCP connection progress
|
|
1061
|
+
setIsConnectingMcp(false);
|
|
1062
|
+
setMcpConnectionProgress(null);
|
|
1063
|
+
|
|
1064
|
+
resetSessionUI();
|
|
1065
|
+
await setupSession(result);
|
|
1066
|
+
} catch (err: unknown) {
|
|
1067
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1068
|
+
setIsReady(true);
|
|
1069
|
+
setIsConnectingMcp(false);
|
|
1070
|
+
setMcpConnectionProgress(null);
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
[session, saveCurrentState, setupSession, resetSessionUI]
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
const newSession = useCallback(async () => {
|
|
1077
|
+
if (!session) return;
|
|
1078
|
+
setIsReady(false);
|
|
1079
|
+
saveCurrentState();
|
|
1080
|
+
await session.abort();
|
|
1081
|
+
session.dispose();
|
|
1082
|
+
|
|
1083
|
+
// Start MCP connection progress tracking
|
|
1084
|
+
setIsConnectingMcp(true);
|
|
1085
|
+
setMcpConnectionProgress({
|
|
1086
|
+
total: 0,
|
|
1087
|
+
completed: 0,
|
|
1088
|
+
currentServer: "Initializing...",
|
|
1089
|
+
status: "connecting",
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
const result = await createNewSession(globalTaskManager, (progress) => {
|
|
1094
|
+
setMcpConnectionProgress(progress);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Clear MCP connection progress
|
|
1098
|
+
setIsConnectingMcp(false);
|
|
1099
|
+
setMcpConnectionProgress(null);
|
|
1100
|
+
|
|
1101
|
+
resetSessionUI();
|
|
1102
|
+
setMessages([]);
|
|
1103
|
+
setSessionTitleState("New Session");
|
|
1104
|
+
setSessionTitle("New Session");
|
|
1105
|
+
currentModelRef.current = getCurrentModel();
|
|
1106
|
+
auxiliaryModelRef.current = getAuxiliaryModel();
|
|
1107
|
+
await setupSession(result);
|
|
1108
|
+
} catch (err: unknown) {
|
|
1109
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1110
|
+
setIsReady(true);
|
|
1111
|
+
setIsConnectingMcp(false);
|
|
1112
|
+
setMcpConnectionProgress(null);
|
|
1113
|
+
}
|
|
1114
|
+
}, [session, saveCurrentState, setupSession, resetSessionUI]);
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Fork Logic
|
|
1118
|
+
*
|
|
1119
|
+
* Forking creates a new branch in the conversation tree.
|
|
1120
|
+
* computeForwardPath builds the path from the selected entry to the leaf.
|
|
1121
|
+
* findBranchPoint walks forward to locate the next user message; we branch
|
|
1122
|
+
* from the entry *before* it so the entire assistant/tool turn is preserved.
|
|
1123
|
+
*/
|
|
1124
|
+
const computeForwardPath = useCallback(
|
|
1125
|
+
(session: AgentSession, entryId: string) => {
|
|
1126
|
+
const branchPath = session.sessionManager.getBranch();
|
|
1127
|
+
const selectedIndex = branchPath.findIndex((e) => e.id === entryId);
|
|
1128
|
+
|
|
1129
|
+
if (selectedIndex >= 0) {
|
|
1130
|
+
return branchPath.slice(selectedIndex);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const tree = session.sessionManager.getTree();
|
|
1134
|
+
const selectedNode = findNodeInTree(tree, entryId);
|
|
1135
|
+
if (!selectedNode) return [];
|
|
1136
|
+
|
|
1137
|
+
const path = [selectedNode.entry];
|
|
1138
|
+
let current = selectedNode;
|
|
1139
|
+
while (current.children.length > 0) {
|
|
1140
|
+
const next = current.children[current.children.length - 1];
|
|
1141
|
+
if (!next) break;
|
|
1142
|
+
current = next;
|
|
1143
|
+
path.push(current.entry);
|
|
1144
|
+
}
|
|
1145
|
+
return path;
|
|
1146
|
+
},
|
|
1147
|
+
[]
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
const findBranchPoint = useCallback((forwardPath: ReturnType<SessionManagerType["getBranch"]>, entryId: string) => {
|
|
1151
|
+
if (forwardPath.length === 0) return entryId;
|
|
1152
|
+
|
|
1153
|
+
let nextUserIndex = -1;
|
|
1154
|
+
for (let i = 1; i < forwardPath.length; i++) {
|
|
1155
|
+
const entry = forwardPath[i];
|
|
1156
|
+
if (entry?.type === "message" && entry.message.role === "user") {
|
|
1157
|
+
nextUserIndex = i;
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Walk backward from the candidate to skip custom entries (snapshots, plans)
|
|
1163
|
+
// so we never branch from a synthetic node.
|
|
1164
|
+
const findLastNonCustom = (startIndex: number): string | undefined => {
|
|
1165
|
+
for (let i = startIndex; i >= 0; i--) {
|
|
1166
|
+
const entry = forwardPath[i];
|
|
1167
|
+
if (entry && entry.type !== "custom") {
|
|
1168
|
+
return entry.id;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return undefined;
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
if (nextUserIndex >= 1) {
|
|
1175
|
+
return findLastNonCustom(nextUserIndex - 1) ?? entryId;
|
|
1176
|
+
}
|
|
1177
|
+
if (nextUserIndex === -1) {
|
|
1178
|
+
return findLastNonCustom(forwardPath.length - 1) ?? entryId;
|
|
1179
|
+
}
|
|
1180
|
+
return entryId;
|
|
1181
|
+
}, []);
|
|
1182
|
+
|
|
1183
|
+
const forkSession = useCallback(
|
|
1184
|
+
async (entryId: string) => {
|
|
1185
|
+
if (!session) return;
|
|
1186
|
+
|
|
1187
|
+
// 1. Calculate branch point
|
|
1188
|
+
const forwardPath = computeForwardPath(session, entryId);
|
|
1189
|
+
const branchFromId = findBranchPoint(forwardPath, entryId);
|
|
1190
|
+
const branchPath = session.sessionManager.getBranch();
|
|
1191
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[fork] entryId=${entryId} branchFromId=${branchFromId} forwardPath=${forwardPath.length} branchPath=${branchPath.length}\n`);
|
|
1192
|
+
|
|
1193
|
+
// 2. Execute session branching
|
|
1194
|
+
session.sessionManager.branch(branchFromId);
|
|
1195
|
+
const context = session.sessionManager.buildSessionContext();
|
|
1196
|
+
session.state.messages = context.messages;
|
|
1197
|
+
|
|
1198
|
+
// 3. Restore snapshot at the fork point (tasks + plan)
|
|
1199
|
+
const snapshotData = restoreSnapshot(session, entryId, globalTaskManager);
|
|
1200
|
+
|
|
1201
|
+
// 4. Determine restored or fallback state for metadata
|
|
1202
|
+
const restoredAgentMode = snapshotData?.agentMode ?? getAgentMode();
|
|
1203
|
+
const restoredActiveTools = snapshotData?.activeTools ?? getActiveToolNamesForMode(restoredAgentMode);
|
|
1204
|
+
const restoredPlan = snapshotData?.planText ?? getCurrentPlanText();
|
|
1205
|
+
const restoredTasks = globalTaskManager.listTasks();
|
|
1206
|
+
const currentKoiState = loadKoiState(session.sessionId);
|
|
1207
|
+
|
|
1208
|
+
// 5. Create and save fork metadata
|
|
1209
|
+
const restoredTaskStatuses = restoredTasks.map(t => `${t.id}:${t.status}`).join(", ");
|
|
1210
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[fork] restoredTasks=[${restoredTaskStatuses}] plan=${restoredPlan?.slice(0, 20) ?? "null"} mode=${restoredAgentMode}\n`);
|
|
1211
|
+
const forkMetadata = {
|
|
1212
|
+
forkId: session.sessionId,
|
|
1213
|
+
sourceSessionId: session.sessionId,
|
|
1214
|
+
sourceBranchId: branchPath.find(e => e.id === branchFromId)?.id ?? '',
|
|
1215
|
+
forkPoint: branchFromId,
|
|
1216
|
+
forkedAt: Date.now(),
|
|
1217
|
+
tasksSnapshot: restoredTasks,
|
|
1218
|
+
agentMode: restoredAgentMode,
|
|
1219
|
+
activeTools: restoredActiveTools,
|
|
1220
|
+
pendingPlanText: restoredPlan,
|
|
1221
|
+
};
|
|
1222
|
+
forkManager.saveForkMetadata(session.sessionId, forkMetadata);
|
|
1223
|
+
|
|
1224
|
+
// 6. Update KoiSessionState with fork-related info
|
|
1225
|
+
const now = Date.now();
|
|
1226
|
+
const forkedState: KoiSessionState = {
|
|
1227
|
+
...(currentKoiState ?? {
|
|
1228
|
+
sessionId: session.sessionId,
|
|
1229
|
+
title: session.sessionName || "Forked Session",
|
|
1230
|
+
currentModel: getCurrentModel(),
|
|
1231
|
+
auxiliaryModel: getAuxiliaryModel(),
|
|
1232
|
+
messages: [],
|
|
1233
|
+
createdAt: now,
|
|
1234
|
+
updatedAt: now,
|
|
1235
|
+
}),
|
|
1236
|
+
forkedFrom: session.sessionId,
|
|
1237
|
+
forkBranchId: branchFromId,
|
|
1238
|
+
forkedAt: now,
|
|
1239
|
+
agentMode: restoredAgentMode,
|
|
1240
|
+
activeTools: restoredActiveTools,
|
|
1241
|
+
expandedMessages: [],
|
|
1242
|
+
collapsedMessages: [],
|
|
1243
|
+
};
|
|
1244
|
+
saveKoiState(session.sessionId, forkedState);
|
|
1245
|
+
|
|
1246
|
+
// Update sessionStateRef for future saves
|
|
1247
|
+
sessionStateRef.current = {
|
|
1248
|
+
forkedFrom: session.sessionId,
|
|
1249
|
+
forkBranchId: branchFromId,
|
|
1250
|
+
forkedAt: Date.now(),
|
|
1251
|
+
agentMode: restoredAgentMode,
|
|
1252
|
+
activeTools: restoredActiveTools,
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
// 7. Rebuild UI messages from the new branch context
|
|
1256
|
+
setMessages(buildUIMessagesFromAgentSession(session));
|
|
1257
|
+
|
|
1258
|
+
// 8. Restore agent mode state for the new branch
|
|
1259
|
+
setAgentMode(restoredAgentMode);
|
|
1260
|
+
session.setActiveToolsByName(restoredActiveTools);
|
|
1261
|
+
injectModeIntoSystemPrompt(session, restoredAgentMode);
|
|
1262
|
+
|
|
1263
|
+
// 9. Clear streaming state
|
|
1264
|
+
streamingMsgIdRef.current = null;
|
|
1265
|
+
pendingToolsRef.current.clear();
|
|
1266
|
+
|
|
1267
|
+
// 10. Save all state
|
|
1268
|
+
saveCurrentState();
|
|
1269
|
+
globalTaskManager.saveActive();
|
|
1270
|
+
},
|
|
1271
|
+
[session, computeForwardPath, findBranchPoint, saveCurrentState]
|
|
1272
|
+
);
|
|
1273
|
+
|
|
1274
|
+
// Persist the title to both React state and the Pi AgentSession so the JSONL file reflects the change.
|
|
1275
|
+
// Also save to koiState immediately so the title persists across sessions.
|
|
1276
|
+
const setSessionTitleWrapper = useCallback(
|
|
1277
|
+
(title: string) => {
|
|
1278
|
+
setSessionTitleState(title);
|
|
1279
|
+
setSessionTitle(title);
|
|
1280
|
+
session?.setSessionName(title);
|
|
1281
|
+
// Immediately persist the title change to koiState
|
|
1282
|
+
const sid = currentSessionIdRef.current;
|
|
1283
|
+
if (sid) {
|
|
1284
|
+
const koiState = loadKoiState(sid);
|
|
1285
|
+
if (koiState) {
|
|
1286
|
+
saveKoiState(sid, {
|
|
1287
|
+
...koiState,
|
|
1288
|
+
title,
|
|
1289
|
+
updatedAt: Date.now(),
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
[session]
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
const refreshSessionList = useCallback(async () => {
|
|
1298
|
+
setSessionList(await listSessions());
|
|
1299
|
+
}, []);
|
|
1300
|
+
|
|
1301
|
+
// Deleting the active session disposes it and immediately creates a new blank session
|
|
1302
|
+
// so the UI never enters a "dead" state with no session available.
|
|
1303
|
+
const deleteSession = useCallback(
|
|
1304
|
+
async (sessionId: string) => {
|
|
1305
|
+
const isCurrent = sessionId === currentSessionId;
|
|
1306
|
+
const meta = sessionList.find((s) => s.id === sessionId);
|
|
1307
|
+
if (!meta) return;
|
|
1308
|
+
|
|
1309
|
+
if (isCurrent && session) {
|
|
1310
|
+
saveCurrentState();
|
|
1311
|
+
await session.abort();
|
|
1312
|
+
session.dispose();
|
|
1313
|
+
await deleteSessionStore(meta);
|
|
1314
|
+
try {
|
|
1315
|
+
const result = await createNewSession(globalTaskManager);
|
|
1316
|
+
resetSessionUI();
|
|
1317
|
+
setMessages([]);
|
|
1318
|
+
setSessionTitleState("New Session");
|
|
1319
|
+
setSessionTitle("New Session");
|
|
1320
|
+
currentModelRef.current = getCurrentModel();
|
|
1321
|
+
auxiliaryModelRef.current = getAuxiliaryModel();
|
|
1322
|
+
await setupSession(result);
|
|
1323
|
+
} catch (err: unknown) {
|
|
1324
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1325
|
+
setIsReady(true);
|
|
1326
|
+
}
|
|
1327
|
+
} else {
|
|
1328
|
+
await deleteSessionStore(meta);
|
|
1329
|
+
setSessionList((prev) => prev.filter((s) => s.id !== sessionId));
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
[session, currentSessionId, sessionList, saveCurrentState, setupSession, resetSessionUI]
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
// Internal function to trigger session naming (called after user prompt)
|
|
1336
|
+
const triggerSessionNaming = useCallback(
|
|
1337
|
+
async (allMessages: UIMessage[]) => {
|
|
1338
|
+
// Only name if:
|
|
1339
|
+
// 1. Session hasn't been named yet
|
|
1340
|
+
// 2. Current title is the default "New Session"
|
|
1341
|
+
// 3. There are user messages to base the name on
|
|
1342
|
+
fs.appendFileSync("/tmp/koi-debug.log", `[triggerSessionNaming] sessionNamedRef: ${sessionNamedRef.current}, sessionTitle: "${sessionTitle}", equals: ${sessionTitle === "New Session"}\n`);
|
|
1343
|
+
if (sessionNamedRef.current) return;
|
|
1344
|
+
if (sessionTitle !== "New Session") return;
|
|
1345
|
+
|
|
1346
|
+
const userMessages = allMessages
|
|
1347
|
+
.filter((m) => m.type === "user")
|
|
1348
|
+
.map((m) => m.content);
|
|
1349
|
+
|
|
1350
|
+
if (userMessages.length === 0) return;
|
|
1351
|
+
|
|
1352
|
+
const name = await generateSessionNameFromMessages(userMessages);
|
|
1353
|
+
if (name) {
|
|
1354
|
+
sessionNamedRef.current = true;
|
|
1355
|
+
// Update all: Pi AgentSession, React state, and settings file
|
|
1356
|
+
// Use setSessionTitleWrapper to also persist the title to koiState
|
|
1357
|
+
setSessionTitleWrapper(name);
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
[sessionTitle, session, setSessionTitleWrapper] // intentional: omit sessionTitle to avoid re-running
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
const prompt = useCallback(
|
|
1364
|
+
async (text: string) => {
|
|
1365
|
+
if (!session) return;
|
|
1366
|
+
setMessages((prev) => {
|
|
1367
|
+
const updated = prev.concat({ id: generateId("user"), type: "user", content: text });
|
|
1368
|
+
// Trigger naming asynchronously after state update
|
|
1369
|
+
void triggerSessionNaming(updated);
|
|
1370
|
+
return updated;
|
|
1371
|
+
});
|
|
1372
|
+
await session.prompt(text);
|
|
1373
|
+
},
|
|
1374
|
+
[session, triggerSessionNaming]
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
const steer = useCallback(
|
|
1378
|
+
async (text: string) => {
|
|
1379
|
+
if (!session) return;
|
|
1380
|
+
localSteerQueueRef.current.push(text);
|
|
1381
|
+
await session.steer(text);
|
|
1382
|
+
},
|
|
1383
|
+
[session]
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
const followUp = useCallback(
|
|
1387
|
+
async (text: string) => {
|
|
1388
|
+
if (!session) return;
|
|
1389
|
+
localFollowUpQueueRef.current.push(text);
|
|
1390
|
+
await session.followUp(text);
|
|
1391
|
+
},
|
|
1392
|
+
[session]
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
const abort = useCallback(async () => {
|
|
1396
|
+
await session?.abort();
|
|
1397
|
+
}, [session]);
|
|
1398
|
+
|
|
1399
|
+
// Per-message collapse toggle: tool_calls collapse their full output;
|
|
1400
|
+
// agent messages collapse their thinking block (if present).
|
|
1401
|
+
const toggleCollapse = useCallback((id: string) => {
|
|
1402
|
+
setMessages((prev) =>
|
|
1403
|
+
prev.map((m) => {
|
|
1404
|
+
if (m.id === id && m.type === "tool_call") {
|
|
1405
|
+
if (!isToolExpandable(m.toolName)) return m;
|
|
1406
|
+
return { ...m, collapsed: !m.collapsed };
|
|
1407
|
+
}
|
|
1408
|
+
if (m.id === id && m.type === "agent" && m.thinking) return { ...m, thinkingCollapsed: !m.thinkingCollapsed };
|
|
1409
|
+
return m;
|
|
1410
|
+
})
|
|
1411
|
+
);
|
|
1412
|
+
}, []);
|
|
1413
|
+
|
|
1414
|
+
// Global expand/collapse: updates every collapsible message at once.
|
|
1415
|
+
// Also sets allExpandedRef so *new* tool calls inherit the current preference.
|
|
1416
|
+
const updateAllCollapsed = useCallback((collapsed: boolean) => {
|
|
1417
|
+
allExpandedRef.current = !collapsed;
|
|
1418
|
+
setMessages((prev) =>
|
|
1419
|
+
prev.map((m) => {
|
|
1420
|
+
if (m.type === "tool_call") {
|
|
1421
|
+
if (!isToolExpandable(m.toolName) || isToolForceExpanded(m.toolName)) return m;
|
|
1422
|
+
return { ...m, collapsed };
|
|
1423
|
+
}
|
|
1424
|
+
if (m.type === "agent" && m.thinking) return { ...m, thinkingCollapsed: collapsed };
|
|
1425
|
+
return m;
|
|
1426
|
+
})
|
|
1427
|
+
);
|
|
1428
|
+
}, []);
|
|
1429
|
+
|
|
1430
|
+
const expandAll = useCallback(() => updateAllCollapsed(false), [updateAllCollapsed]);
|
|
1431
|
+
const collapseAll = useCallback(() => updateAllCollapsed(true), [updateAllCollapsed]);
|
|
1432
|
+
|
|
1433
|
+
const clearMessages = useCallback(() => {
|
|
1434
|
+
setMessages([]);
|
|
1435
|
+
streamingMsgIdRef.current = null;
|
|
1436
|
+
pendingToolsRef.current.clear();
|
|
1437
|
+
}, []);
|
|
1438
|
+
|
|
1439
|
+
const removePendingMessage = useCallback(
|
|
1440
|
+
(type: "sheer" | "queued", index: number) => {
|
|
1441
|
+
if (!session) return null;
|
|
1442
|
+
const cleared = session.clearQueue();
|
|
1443
|
+
const newSteering = [...cleared.steering];
|
|
1444
|
+
const newFollowUp = [...cleared.followUp];
|
|
1445
|
+
|
|
1446
|
+
let removedText: string | null = null;
|
|
1447
|
+
if (type === "sheer") {
|
|
1448
|
+
removedText = newSteering.splice(index, 1)[0] ?? null;
|
|
1449
|
+
localSteerQueueRef.current.splice(index, 1);
|
|
1450
|
+
} else {
|
|
1451
|
+
removedText = newFollowUp.splice(index, 1)[0] ?? null;
|
|
1452
|
+
localFollowUpQueueRef.current.splice(index, 1);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Re-add remaining messages to Pi's queue
|
|
1456
|
+
for (const text of newSteering) {
|
|
1457
|
+
void session.steer(text);
|
|
1458
|
+
}
|
|
1459
|
+
for (const text of newFollowUp) {
|
|
1460
|
+
void session.followUp(text);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
return removedText;
|
|
1464
|
+
},
|
|
1465
|
+
[session]
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
const retractMessage = useCallback((id: string) => {
|
|
1469
|
+
let retractedText: string | null = null;
|
|
1470
|
+
setMessages((prev) => {
|
|
1471
|
+
const idx = prev.findIndex((m) => m.id === id && m.type === "user");
|
|
1472
|
+
if (idx < 0) return prev;
|
|
1473
|
+
const msg = prev[idx];
|
|
1474
|
+
if (msg && msg.type === "user") {
|
|
1475
|
+
retractedText = msg.content;
|
|
1476
|
+
}
|
|
1477
|
+
return [...prev.slice(0, idx), ...prev.slice(idx + 1)];
|
|
1478
|
+
});
|
|
1479
|
+
return retractedText;
|
|
1480
|
+
}, []);
|
|
1481
|
+
|
|
1482
|
+
const addPlanMessage = useCallback(
|
|
1483
|
+
async (content: string) => {
|
|
1484
|
+
if (!session) return;
|
|
1485
|
+
// Remove any existing plan custom messages from the session so the new plan replaces the old one.
|
|
1486
|
+
const filtered = session.state.messages.filter((m) => !isCustomPlanMessage(m));
|
|
1487
|
+
if (filtered.length !== session.state.messages.length) {
|
|
1488
|
+
session.state.messages = filtered;
|
|
1489
|
+
}
|
|
1490
|
+
await session.sendCustomMessage(
|
|
1491
|
+
{ customType: "plan", content, display: true },
|
|
1492
|
+
{ triggerTurn: false }
|
|
1493
|
+
);
|
|
1494
|
+
// Update React state: replace any existing plan UI messages with the new one.
|
|
1495
|
+
setMessages((prev) => {
|
|
1496
|
+
const withoutOldPlan = prev.filter((m) => m.type !== "plan");
|
|
1497
|
+
return [...withoutOldPlan, { id: generateId("plan"), type: "plan", content }] as UIMessage[];
|
|
1498
|
+
});
|
|
1499
|
+
// Save snapshot since plan state changed.
|
|
1500
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[plan] addPlanMessage calling saveSnapshotIfChanged plan=${content.slice(0, 30)}\n`);
|
|
1501
|
+
saveSnapshotIfChanged(session, {
|
|
1502
|
+
tasks: globalTaskManager.listTasks(),
|
|
1503
|
+
planText: content,
|
|
1504
|
+
agentMode: getAgentMode(),
|
|
1505
|
+
activeTools: getActiveToolNamesForMode(getAgentMode()),
|
|
1506
|
+
});
|
|
1507
|
+
},
|
|
1508
|
+
[session]
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
// Sync agent mode changes to sessionStateRef for persistence
|
|
1512
|
+
const syncAgentMode = useCallback(
|
|
1513
|
+
(mode: "build" | "ask" | "plan") => {
|
|
1514
|
+
sessionStateRef.current = {
|
|
1515
|
+
...sessionStateRef.current,
|
|
1516
|
+
agentMode: mode,
|
|
1517
|
+
activeTools: getActiveToolNamesForMode(mode),
|
|
1518
|
+
};
|
|
1519
|
+
if (session) {
|
|
1520
|
+
session.setActiveToolsByName(sessionStateRef.current.activeTools);
|
|
1521
|
+
injectModeIntoSystemPrompt(session, mode);
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
[session]
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
return {
|
|
1528
|
+
session,
|
|
1529
|
+
messages,
|
|
1530
|
+
isStreaming,
|
|
1531
|
+
isReady,
|
|
1532
|
+
error,
|
|
1533
|
+
steeringMessages,
|
|
1534
|
+
followUpMessages,
|
|
1535
|
+
isConnectingMcp,
|
|
1536
|
+
mcpConnectionProgress,
|
|
1537
|
+
prompt,
|
|
1538
|
+
steer,
|
|
1539
|
+
followUp,
|
|
1540
|
+
abort,
|
|
1541
|
+
toggleCollapse,
|
|
1542
|
+
expandAll,
|
|
1543
|
+
collapseAll,
|
|
1544
|
+
clearMessages,
|
|
1545
|
+
removePendingMessage,
|
|
1546
|
+
retractMessage,
|
|
1547
|
+
switchSession,
|
|
1548
|
+
newSession,
|
|
1549
|
+
forkSession,
|
|
1550
|
+
sessionList,
|
|
1551
|
+
refreshSessionList,
|
|
1552
|
+
currentSessionId,
|
|
1553
|
+
saveCurrentState,
|
|
1554
|
+
sessionTitle,
|
|
1555
|
+
setSessionTitle: setSessionTitleWrapper,
|
|
1556
|
+
deleteSession,
|
|
1557
|
+
addPlanMessage,
|
|
1558
|
+
syncAgentMode,
|
|
1559
|
+
};
|
|
1560
|
+
}
|