@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Task Manager
|
|
3
|
+
*
|
|
4
|
+
* Replaces the global in-memory task Map with per-session isolated storage.
|
|
5
|
+
* Each session's tasks are persisted to ~/.config/koi/sessions/<id>/tasks.json.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "koi");
|
|
14
|
+
const KOI_SESSIONS_DIR = path.join(CONFIG_DIR, "sessions");
|
|
15
|
+
|
|
16
|
+
type TaskStatus = "pending" | "in_progress" | "completed";
|
|
17
|
+
type TaskPriority = "high" | "medium" | "low";
|
|
18
|
+
|
|
19
|
+
export interface Task {
|
|
20
|
+
id: string;
|
|
21
|
+
content: string;
|
|
22
|
+
status: TaskStatus;
|
|
23
|
+
priority: TaskPriority;
|
|
24
|
+
blockedBy: string[];
|
|
25
|
+
blocks: string[];
|
|
26
|
+
createdAt: number;
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
/** Fork source task ID (null for original tasks) */
|
|
29
|
+
forkedFrom: string | null;
|
|
30
|
+
/** Timestamp when this task was forked */
|
|
31
|
+
forkedAt: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* File System Helpers
|
|
36
|
+
*
|
|
37
|
+
* All fs operations are wrapped with silent error handling so a corrupted tasks.json
|
|
38
|
+
* or missing directory never crashes the agent loop.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
function ensureDir(dir: string): void {
|
|
42
|
+
if (!fs.existsSync(dir)) {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getTasksPath(sessionId: string): string {
|
|
48
|
+
return path.join(KOI_SESSIONS_DIR, sessionId, "tasks.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeReadTasks(filePath: string): Task[] | null {
|
|
52
|
+
try {
|
|
53
|
+
if (!fs.existsSync(filePath)) return null;
|
|
54
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
55
|
+
return JSON.parse(raw) as Task[];
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeWriteTasks(filePath: string, tasks: Task[]): void {
|
|
62
|
+
try {
|
|
63
|
+
ensureDir(path.dirname(filePath));
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(tasks, null, 2) + "\n", { mode: 0o600 });
|
|
65
|
+
} catch {
|
|
66
|
+
// Silently ignore write errors
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeDeleteFile(filePath: string): void {
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Task Update Helpers
|
|
80
|
+
*
|
|
81
|
+
* updateStringArray applies add/remove delta operations on id arrays (blockedBy, blocks)
|
|
82
|
+
* without mutating the original reference until the final assignment.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
function updateStringArray(current: string[], add?: string[], remove?: string[]): string[] {
|
|
86
|
+
let result = [...current];
|
|
87
|
+
if (add) {
|
|
88
|
+
for (const id of add) {
|
|
89
|
+
if (!result.includes(id)) result.push(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (remove) {
|
|
93
|
+
result = result.filter((id) => !remove.includes(id));
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function applyTaskUpdates(
|
|
99
|
+
task: Task,
|
|
100
|
+
updates: Partial<Pick<Task, "content" | "status" | "priority">> & {
|
|
101
|
+
addBlockedBy?: string[];
|
|
102
|
+
removeBlockedBy?: string[];
|
|
103
|
+
addBlocks?: string[];
|
|
104
|
+
removeBlocks?: string[];
|
|
105
|
+
}
|
|
106
|
+
): void {
|
|
107
|
+
if (updates.content !== undefined) task.content = updates.content;
|
|
108
|
+
if (updates.status !== undefined) task.status = updates.status;
|
|
109
|
+
if (updates.priority !== undefined) task.priority = updates.priority;
|
|
110
|
+
|
|
111
|
+
task.blockedBy = updateStringArray(task.blockedBy, updates.addBlockedBy, updates.removeBlockedBy);
|
|
112
|
+
task.blocks = updateStringArray(task.blocks, updates.addBlocks, updates.removeBlocks);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* SessionTaskManager
|
|
117
|
+
*
|
|
118
|
+
* Per-session in-memory task storage with JSON persistence.
|
|
119
|
+
* When no session is active, tasks land in a transient "__transient__" store
|
|
120
|
+
* so the API surface never returns undefined/null unexpectedly.
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
export class SessionTaskManager {
|
|
124
|
+
private stores = new Map<string, Map<string, Task>>();
|
|
125
|
+
private activeSessionId: string | null = null;
|
|
126
|
+
|
|
127
|
+
setActiveSession(sessionId: string): void {
|
|
128
|
+
this.activeSessionId = sessionId;
|
|
129
|
+
if (!this.stores.has(sessionId)) {
|
|
130
|
+
this.load(sessionId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getCurrentSessionId(): string | null {
|
|
135
|
+
return this.activeSessionId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private getStore(): Map<string, Task> {
|
|
139
|
+
if (!this.activeSessionId) {
|
|
140
|
+
const transientId = "__transient__";
|
|
141
|
+
if (!this.stores.has(transientId)) {
|
|
142
|
+
this.stores.set(transientId, new Map());
|
|
143
|
+
}
|
|
144
|
+
return this.stores.get(transientId)!;
|
|
145
|
+
}
|
|
146
|
+
if (!this.stores.has(this.activeSessionId)) {
|
|
147
|
+
this.stores.set(this.activeSessionId, new Map());
|
|
148
|
+
}
|
|
149
|
+
return this.stores.get(this.activeSessionId)!;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
generateTaskId(): string {
|
|
153
|
+
return `task-${randomUUID()}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
createTask(
|
|
157
|
+
content: string,
|
|
158
|
+
priority: TaskPriority = "medium",
|
|
159
|
+
blockedBy: string[] = [],
|
|
160
|
+
blocks: string[] = []
|
|
161
|
+
): Task {
|
|
162
|
+
const id = this.generateTaskId();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const task: Task = {
|
|
165
|
+
id,
|
|
166
|
+
content,
|
|
167
|
+
status: "pending",
|
|
168
|
+
priority,
|
|
169
|
+
blockedBy: [...blockedBy],
|
|
170
|
+
blocks: [...blocks],
|
|
171
|
+
createdAt: now,
|
|
172
|
+
updatedAt: now,
|
|
173
|
+
forkedFrom: null,
|
|
174
|
+
forkedAt: null,
|
|
175
|
+
};
|
|
176
|
+
this.getStore().set(id, task);
|
|
177
|
+
this.saveActive();
|
|
178
|
+
return task;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getTask(taskId: string): Task | undefined {
|
|
182
|
+
return this.getStore().get(taskId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
listTasks(status?: TaskStatus): Task[] {
|
|
186
|
+
let all = Array.from(this.getStore().values());
|
|
187
|
+
if (status) {
|
|
188
|
+
all = all.filter((t) => t.status === status);
|
|
189
|
+
}
|
|
190
|
+
const statusOrder = { in_progress: 0, pending: 1, completed: 2 };
|
|
191
|
+
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
192
|
+
all.sort((a, b) => {
|
|
193
|
+
const s = statusOrder[a.status] - statusOrder[b.status];
|
|
194
|
+
if (s !== 0) return s;
|
|
195
|
+
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
|
196
|
+
});
|
|
197
|
+
return all;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
updateTask(
|
|
201
|
+
taskId: string,
|
|
202
|
+
updates: Parameters<typeof applyTaskUpdates>[1]
|
|
203
|
+
): Task | null {
|
|
204
|
+
const task = this.getStore().get(taskId);
|
|
205
|
+
if (!task) return null;
|
|
206
|
+
|
|
207
|
+
applyTaskUpdates(task, updates);
|
|
208
|
+
task.updatedAt = Date.now();
|
|
209
|
+
this.saveActive();
|
|
210
|
+
return task;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
deleteTask(taskId: string): boolean {
|
|
214
|
+
const ok = this.getStore().delete(taskId);
|
|
215
|
+
if (ok) this.saveActive();
|
|
216
|
+
return ok;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Replace all tasks in the active session store.
|
|
221
|
+
* Used when restoring from a snapshot.
|
|
222
|
+
*/
|
|
223
|
+
setTasks(tasks: Task[]): void {
|
|
224
|
+
const store = this.getStore();
|
|
225
|
+
store.clear();
|
|
226
|
+
for (const task of tasks) {
|
|
227
|
+
store.set(task.id, task);
|
|
228
|
+
}
|
|
229
|
+
this.saveActive();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
load(sessionId: string): void {
|
|
233
|
+
const tasksArray = safeReadTasks(getTasksPath(sessionId));
|
|
234
|
+
if (!tasksArray) {
|
|
235
|
+
this.stores.set(sessionId, new Map());
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const map = new Map<string, Task>();
|
|
239
|
+
for (const t of tasksArray) {
|
|
240
|
+
map.set(t.id, t);
|
|
241
|
+
}
|
|
242
|
+
this.stores.set(sessionId, map);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
save(sessionId: string): void {
|
|
246
|
+
const store = this.stores.get(sessionId);
|
|
247
|
+
if (!store) return;
|
|
248
|
+
safeWriteTasks(getTasksPath(sessionId), Array.from(store.values()));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
saveActive(): void {
|
|
252
|
+
if (this.activeSessionId) {
|
|
253
|
+
this.save(this.activeSessionId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
clearSession(sessionId: string): void {
|
|
258
|
+
this.stores.delete(sessionId);
|
|
259
|
+
safeDeleteFile(getTasksPath(sessionId));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fork all tasks for a new session.
|
|
264
|
+
* Creates new task IDs and sets fork metadata to track the fork relationship.
|
|
265
|
+
* Returns a map of old task IDs to new task IDs for updating blockedBy/blocks references.
|
|
266
|
+
*/
|
|
267
|
+
forkTasks(): Map<string, string> {
|
|
268
|
+
const currentStore = this.getStore();
|
|
269
|
+
const oldToNewIdMap = new Map<string, string>();
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
|
|
272
|
+
// Collect existing tasks before mutating the store
|
|
273
|
+
const existingTasks = Array.from(currentStore.values());
|
|
274
|
+
|
|
275
|
+
// First pass: create new IDs
|
|
276
|
+
for (const task of existingTasks) {
|
|
277
|
+
oldToNewIdMap.set(task.id, this.generateTaskId());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Clear the store so only forked tasks remain
|
|
281
|
+
currentStore.clear();
|
|
282
|
+
|
|
283
|
+
// Second pass: create forked tasks with updated references
|
|
284
|
+
for (const task of existingTasks) {
|
|
285
|
+
const newId = oldToNewIdMap.get(task.id)!;
|
|
286
|
+
const forkedTask: Task = {
|
|
287
|
+
...task,
|
|
288
|
+
id: newId,
|
|
289
|
+
forkedFrom: task.id,
|
|
290
|
+
forkedAt: now,
|
|
291
|
+
// Update blockedBy references to new IDs
|
|
292
|
+
blockedBy: task.blockedBy.map(id => oldToNewIdMap.get(id) ?? id),
|
|
293
|
+
// Update blocks references to new IDs
|
|
294
|
+
blocks: task.blocks.map(id => oldToNewIdMap.get(id) ?? id),
|
|
295
|
+
};
|
|
296
|
+
currentStore.set(newId, forkedTask);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.saveActive();
|
|
300
|
+
return oldToNewIdMap;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Global singleton instance
|
|
305
|
+
export const globalTaskManager = new SessionTaskManager();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Session Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for agent session management.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refresh MCP tools in the agent session.
|
|
11
|
+
* Call this after MCP connections change (connect/disconnect).
|
|
12
|
+
*/
|
|
13
|
+
export async function refreshMcpTools(session: AgentSession | null): Promise<void> {
|
|
14
|
+
if (!session) return;
|
|
15
|
+
|
|
16
|
+
// Re-initialize MCP connections
|
|
17
|
+
const { disconnectAllMcpServers, initializeMcpConnections } = await import("../services/mcp/index.js");
|
|
18
|
+
await disconnectAllMcpServers();
|
|
19
|
+
await initializeMcpConnections();
|
|
20
|
+
|
|
21
|
+
// Get current mode and update active tools
|
|
22
|
+
const { getAgentMode, getActiveToolNamesForMode } = await import("./mode.js");
|
|
23
|
+
const mode = getAgentMode();
|
|
24
|
+
const activeTools = getActiveToolNamesForMode(mode);
|
|
25
|
+
|
|
26
|
+
session.setActiveToolsByName(activeTools);
|
|
27
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async Subagent Registry
|
|
3
|
+
*
|
|
4
|
+
* Manages background (fire-and-forget) subagents. Each async agent gets a
|
|
5
|
+
* unique ID, runs in the background, and notifies the parent session via
|
|
6
|
+
* followUp() when it completes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Agent } from "@mariozechner/pi-agent-core";
|
|
10
|
+
import { activeSessionRef } from "./hooks.js";
|
|
11
|
+
import { runSubagent, type SubagentConfig } from "./subagent.js";
|
|
12
|
+
|
|
13
|
+
export interface AsyncSubagentEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
description: string;
|
|
16
|
+
status: "running" | "completed" | "failed" | "killed";
|
|
17
|
+
result?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
startTime: number;
|
|
20
|
+
endTime?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class SubagentRegistry {
|
|
24
|
+
private entries = new Map<string, AsyncSubagentEntry>();
|
|
25
|
+
private runningAgents = new Map<string, Agent>();
|
|
26
|
+
private listeners: (() => void)[] = [];
|
|
27
|
+
|
|
28
|
+
private emit() {
|
|
29
|
+
for (const listener of this.listeners) {
|
|
30
|
+
try {
|
|
31
|
+
listener();
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
subscribe(listener: () => void): () => void {
|
|
39
|
+
this.listeners.push(listener);
|
|
40
|
+
return () => {
|
|
41
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get(id: string): AsyncSubagentEntry | undefined {
|
|
46
|
+
return this.entries.get(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getAll(): AsyncSubagentEntry[] {
|
|
50
|
+
return Array.from(this.entries.values()).sort(
|
|
51
|
+
(a, b) => b.startTime - a.startTime
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Launch a subagent in the background and return its ID immediately.
|
|
57
|
+
*/
|
|
58
|
+
async launch(config: SubagentConfig): Promise<string> {
|
|
59
|
+
const id = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
60
|
+
|
|
61
|
+
const entry: AsyncSubagentEntry = {
|
|
62
|
+
id,
|
|
63
|
+
description: config.description,
|
|
64
|
+
status: "running",
|
|
65
|
+
startTime: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
this.entries.set(id, entry);
|
|
68
|
+
|
|
69
|
+
// Fire-and-forget — do not await
|
|
70
|
+
void this.runInBackground(id, config);
|
|
71
|
+
this.emit();
|
|
72
|
+
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async runInBackground(
|
|
77
|
+
id: string,
|
|
78
|
+
config: SubagentConfig
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const entry = this.entries.get(id);
|
|
81
|
+
if (!entry) return;
|
|
82
|
+
|
|
83
|
+
let agentRef: Agent | undefined;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = await runSubagent(config, (agent) => {
|
|
87
|
+
agentRef = agent;
|
|
88
|
+
this.runningAgents.set(id, agent);
|
|
89
|
+
});
|
|
90
|
+
entry.status = "completed";
|
|
91
|
+
entry.result = result;
|
|
92
|
+
entry.endTime = Date.now();
|
|
93
|
+
this.emit();
|
|
94
|
+
this.notifyParent(id, "completed", result);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
// If the agent was explicitly killed, override status
|
|
98
|
+
if (entry.status === "killed") {
|
|
99
|
+
entry.error = message;
|
|
100
|
+
entry.endTime = Date.now();
|
|
101
|
+
this.emit();
|
|
102
|
+
this.notifyParent(id, "killed", message);
|
|
103
|
+
} else {
|
|
104
|
+
entry.status = "failed";
|
|
105
|
+
entry.error = message;
|
|
106
|
+
entry.endTime = Date.now();
|
|
107
|
+
this.emit();
|
|
108
|
+
this.notifyParent(id, "failed", message);
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
if (agentRef) {
|
|
112
|
+
this.runningAgents.delete(id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private notifyParent(
|
|
118
|
+
agentId: string,
|
|
119
|
+
status: string,
|
|
120
|
+
summary: string
|
|
121
|
+
): void {
|
|
122
|
+
const parent = activeSessionRef.current;
|
|
123
|
+
if (!parent) return;
|
|
124
|
+
|
|
125
|
+
const truncated =
|
|
126
|
+
summary.length > 500 ? summary.slice(0, 500) + "..." : summary;
|
|
127
|
+
const notification = [
|
|
128
|
+
`<task-notification>`,
|
|
129
|
+
` <task-id>${agentId}</task-id>`,
|
|
130
|
+
` <status>${status}</status>`,
|
|
131
|
+
` <summary>${truncated}</summary>`,
|
|
132
|
+
`</task-notification>`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
|
|
135
|
+
// If the parent is still running, inject as steer so it gets processed
|
|
136
|
+
// at the end of the current turn. If idle, prompt immediately to trigger
|
|
137
|
+
// a new run right away.
|
|
138
|
+
if (parent.isStreaming) {
|
|
139
|
+
parent.steer(notification).catch(() => {
|
|
140
|
+
// Silently ignore if the parent session is no longer available
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
parent.prompt(notification).catch(() => {
|
|
144
|
+
// Silently ignore if the parent session is no longer available
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Abort a running async subagent by ID.
|
|
151
|
+
* Returns true if the agent was found and aborted.
|
|
152
|
+
*/
|
|
153
|
+
kill(id: string): boolean {
|
|
154
|
+
const agent = this.runningAgents.get(id);
|
|
155
|
+
if (!agent) {
|
|
156
|
+
// Agent may have finished already; mark as killed if still running in
|
|
157
|
+
// our record.
|
|
158
|
+
const entry = this.entries.get(id);
|
|
159
|
+
if (entry && entry.status === "running") {
|
|
160
|
+
entry.status = "killed";
|
|
161
|
+
entry.endTime = Date.now();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const entry = this.entries.get(id);
|
|
167
|
+
if (entry) {
|
|
168
|
+
entry.status = "killed";
|
|
169
|
+
}
|
|
170
|
+
agent.abort();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Global singleton registry for async subagents. */
|
|
176
|
+
export const subagentRegistry = new SubagentRegistry();
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Runner
|
|
3
|
+
*
|
|
4
|
+
* Creates a lightweight child Agent by directly instantiating pi-agent-core's Agent
|
|
5
|
+
* class, copying the parent session's runtime config (streamFn, getApiKey, model,
|
|
6
|
+
* systemPrompt) but with an isolated message history and filtered tool set.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Agent } from "@mariozechner/pi-agent-core";
|
|
10
|
+
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
|
11
|
+
import type { UserMessage, AssistantMessage } from "@mariozechner/pi-ai";
|
|
12
|
+
import { activeSessionRef } from "./hooks.js";
|
|
13
|
+
|
|
14
|
+
export type SubagentType = "explore" | "plan";
|
|
15
|
+
|
|
16
|
+
export interface SubagentConfig {
|
|
17
|
+
description: string;
|
|
18
|
+
prompt: string;
|
|
19
|
+
subagentType?: SubagentType;
|
|
20
|
+
runInBackground?: boolean;
|
|
21
|
+
maxTurns?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_TURNS = 50;
|
|
25
|
+
|
|
26
|
+
/** Tools that no subagent should ever see. */
|
|
27
|
+
const DISALLOWED_TOOLS = new Set([
|
|
28
|
+
"agent",
|
|
29
|
+
"askUserQuestion",
|
|
30
|
+
"exitPlanMode",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** Read-only tool set for explore agents. */
|
|
34
|
+
const READONLY_TOOL_NAMES = new Set([
|
|
35
|
+
"read",
|
|
36
|
+
"grep",
|
|
37
|
+
"glob",
|
|
38
|
+
"ls",
|
|
39
|
+
"webfetch",
|
|
40
|
+
"taskGet",
|
|
41
|
+
"taskList",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Planning tool set for plan agents. */
|
|
45
|
+
const PLAN_TOOL_NAMES = new Set([
|
|
46
|
+
"read",
|
|
47
|
+
"grep",
|
|
48
|
+
"glob",
|
|
49
|
+
"ls",
|
|
50
|
+
"webfetch",
|
|
51
|
+
"taskGet",
|
|
52
|
+
"taskList",
|
|
53
|
+
"taskCreate",
|
|
54
|
+
"taskUpdate",
|
|
55
|
+
"enterPlanMode",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function filterTools(
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
parentTools: AgentTool<any>[],
|
|
61
|
+
subagentType?: SubagentType
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
): AgentTool<any>[] {
|
|
64
|
+
let allowedNames: Set<string> | null = null;
|
|
65
|
+
if (subagentType === "explore") {
|
|
66
|
+
allowedNames = READONLY_TOOL_NAMES;
|
|
67
|
+
} else if (subagentType === "plan") {
|
|
68
|
+
allowedNames = PLAN_TOOL_NAMES;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parentTools.filter((tool) => {
|
|
72
|
+
if (DISALLOWED_TOOLS.has(tool.name)) return false;
|
|
73
|
+
if (allowedNames && !allowedNames.has(tool.name)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildSystemPrompt(
|
|
79
|
+
parentSystemPrompt: string,
|
|
80
|
+
subagentType?: SubagentType
|
|
81
|
+
): string {
|
|
82
|
+
if (subagentType === "explore") {
|
|
83
|
+
return (
|
|
84
|
+
parentSystemPrompt +
|
|
85
|
+
"\n\n[SUBAGENT MODE: Explore]\n" +
|
|
86
|
+
"You are a read-only exploration subagent. " +
|
|
87
|
+
"You cannot modify files or execute shell commands. " +
|
|
88
|
+
"Your sole purpose is to gather information and report findings concisely."
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (subagentType === "plan") {
|
|
92
|
+
return (
|
|
93
|
+
parentSystemPrompt +
|
|
94
|
+
"\n\n[SUBAGENT MODE: Plan]\n" +
|
|
95
|
+
"You are a planning subagent. " +
|
|
96
|
+
"You can use read-only tools and task management tools to research and formulate plans. " +
|
|
97
|
+
"You cannot modify files or execute shell commands. " +
|
|
98
|
+
"Your output should be a detailed, actionable step-by-step plan."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return parentSystemPrompt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractResult(messages: AgentMessage[]): string {
|
|
105
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
106
|
+
const msg = messages[i];
|
|
107
|
+
if (
|
|
108
|
+
msg &&
|
|
109
|
+
typeof msg === "object" &&
|
|
110
|
+
"role" in msg &&
|
|
111
|
+
msg.role === "assistant"
|
|
112
|
+
) {
|
|
113
|
+
const assistant = msg as AssistantMessage;
|
|
114
|
+
let text = "";
|
|
115
|
+
for (const block of assistant.content) {
|
|
116
|
+
if (block.type === "text") {
|
|
117
|
+
text += block.text;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return text.trim() || "[Agent completed with no text output]";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return "[Agent completed with no assistant message]";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run a subagent synchronously and return its final text output.
|
|
128
|
+
*
|
|
129
|
+
* @param onAgentCreated Optional callback invoked immediately after the Agent
|
|
130
|
+
* instance is created. Used by the async registry to hold a reference for
|
|
131
|
+
* abort/kill operations.
|
|
132
|
+
*/
|
|
133
|
+
export async function runSubagent(
|
|
134
|
+
config: SubagentConfig,
|
|
135
|
+
onAgentCreated?: (agent: Agent) => void
|
|
136
|
+
): Promise<string> {
|
|
137
|
+
const parentSession = activeSessionRef.current;
|
|
138
|
+
if (!parentSession) {
|
|
139
|
+
throw new Error("No active session available to spawn subagent");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parentAgent = parentSession.agent;
|
|
143
|
+
const parentState = parentSession.state;
|
|
144
|
+
|
|
145
|
+
const tools = filterTools(parentState.tools, config.subagentType);
|
|
146
|
+
const systemPrompt = buildSystemPrompt(
|
|
147
|
+
parentState.systemPrompt,
|
|
148
|
+
config.subagentType
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const userMessage: UserMessage = {
|
|
152
|
+
role: "user",
|
|
153
|
+
content: config.prompt,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const agent = new Agent({
|
|
158
|
+
streamFn: parentAgent.streamFn,
|
|
159
|
+
getApiKey: parentAgent.getApiKey,
|
|
160
|
+
convertToLlm: parentAgent.convertToLlm,
|
|
161
|
+
transformContext: parentAgent.transformContext,
|
|
162
|
+
thinkingBudgets: parentAgent.thinkingBudgets,
|
|
163
|
+
transport: parentAgent.transport,
|
|
164
|
+
toolExecution: parentAgent.toolExecution,
|
|
165
|
+
maxRetryDelayMs: parentAgent.maxRetryDelayMs,
|
|
166
|
+
initialState: {
|
|
167
|
+
systemPrompt,
|
|
168
|
+
model: parentState.model,
|
|
169
|
+
thinkingLevel: parentState.thinkingLevel,
|
|
170
|
+
tools,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
onAgentCreated?.(agent);
|
|
175
|
+
|
|
176
|
+
let turnCount = 0;
|
|
177
|
+
const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
178
|
+
const unsubscribe = agent.subscribe((event, _signal) => {
|
|
179
|
+
if (event.type === "turn_start") {
|
|
180
|
+
turnCount++;
|
|
181
|
+
if (turnCount > maxTurns) {
|
|
182
|
+
agent.abort();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await agent.prompt(userMessage);
|
|
189
|
+
await agent.waitForIdle();
|
|
190
|
+
return extractResult(agent.state.messages);
|
|
191
|
+
} finally {
|
|
192
|
+
unsubscribe();
|
|
193
|
+
}
|
|
194
|
+
}
|