@meowlynxsea/koi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Agent Mode Manager
3
+ *
4
+ * Manages the three agent operating modes:
5
+ * • build — full tool access (default)
6
+ * • ask — read-only tools only
7
+ * • plan — no write/edit/bash tools; planning tools allowed
8
+ *
9
+ * Also provides the active tool name allowlist for each mode,
10
+ * used with AgentSession.setActiveToolsByName().
11
+ */
12
+
13
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
14
+ import { getAllMcpTools } from "../services/mcp/index.js";
15
+
16
+ export type AgentMode = "build" | "ask" | "plan";
17
+
18
+ let currentMode: AgentMode = "build";
19
+ let listeners: (() => void)[] = [];
20
+
21
+ function emit() {
22
+ for (const listener of listeners) {
23
+ try {
24
+ listener();
25
+ } catch {
26
+ // ignore
27
+ }
28
+ }
29
+ }
30
+
31
+ export function subscribeModeChanges(listener: () => void): () => void {
32
+ listeners.push(listener);
33
+ return () => {
34
+ listeners = listeners.filter((l) => l !== listener);
35
+ };
36
+ }
37
+
38
+ export function getAgentMode(): AgentMode {
39
+ return currentMode;
40
+ }
41
+
42
+ export function setAgentMode(mode: AgentMode): void {
43
+ if (currentMode === mode) return;
44
+ currentMode = mode;
45
+ emit();
46
+ }
47
+
48
+ export function cycleAgentMode(): AgentMode {
49
+ const order: AgentMode[] = ["build", "ask", "plan"];
50
+ const idx = order.indexOf(currentMode);
51
+ const next = order[(idx + 1) % order.length] ?? "build";
52
+ currentMode = next;
53
+ emit();
54
+ return next;
55
+ }
56
+
57
+ /**
58
+ * Inject mode awareness into the session's system prompt.
59
+ * Patches _baseSystemPrompt directly because Pi resets systemPrompt from it on every turn.
60
+ */
61
+ export function injectModeIntoSystemPrompt(session: AgentSession, mode: AgentMode): void {
62
+ const modeNotice =
63
+ mode === "plan"
64
+ ? "\n\n[AGENT MODE: Plan Mode. Write/edit/bash tools are DISABLED. You must NOT modify any files. Your task is to research, analyze, and formulate a detailed step-by-step plan. Use read-only tools to gather information. Once your plan is ready, you MUST call exitPlanMode with the complete plan to return to Build Mode.]"
65
+ : mode === "ask"
66
+ ? "\n\n[AGENT MODE: Ask Mode. Only read-only tools are available. You cannot modify files or execute commands.]"
67
+ : "\n\n[AGENT MODE: Build Mode. All tools are available.]";
68
+
69
+ const basePrompt = (session as unknown as Record<string, string>)["_baseSystemPrompt"] ?? "";
70
+ const modePattern = /\n\n\[AGENT MODE:.*?\]/s;
71
+ const cleanPrompt = basePrompt.replace(modePattern, "");
72
+ const patchedPrompt = cleanPrompt + modeNotice;
73
+ (session as unknown as Record<string, string>)["_baseSystemPrompt"] = patchedPrompt;
74
+ session.state.systemPrompt = patchedPrompt;
75
+ }
76
+
77
+
78
+
79
+ const ALL_TOOLS = [
80
+ "read",
81
+ "grep",
82
+ "glob",
83
+ "ls",
84
+ "bash",
85
+ "edit",
86
+ "write",
87
+ "webfetch",
88
+ "taskCreate",
89
+ "taskGet",
90
+ "taskList",
91
+ "taskUpdate",
92
+ "askUserQuestion",
93
+ "enterPlanMode",
94
+ "exitPlanMode",
95
+ "agent",
96
+ "CreateMonitor",
97
+ "CancelMonitor",
98
+ ];
99
+
100
+ const READONLY_TOOLS = [
101
+ "read",
102
+ "grep",
103
+ "glob",
104
+ "ls",
105
+ "webfetch",
106
+ "taskGet",
107
+ "taskList",
108
+ ];
109
+
110
+ const PLAN_TOOLS = [
111
+ "read",
112
+ "grep",
113
+ "glob",
114
+ "ls",
115
+ "webfetch",
116
+ "taskGet",
117
+ "taskList",
118
+ "taskCreate",
119
+ "taskUpdate",
120
+ "askUserQuestion",
121
+ "enterPlanMode",
122
+ "exitPlanMode",
123
+ ];
124
+
125
+ /**
126
+ * Get MCP tool names that should be included in the tool allowlist.
127
+ * MCP tools are included in all modes since they are user-configured and
128
+ * the user should have control over them.
129
+ */
130
+ function getMcpToolNames(): string[] {
131
+ try {
132
+ const mcpTools = getAllMcpTools();
133
+ // Filter to only return tools from connected servers
134
+ return mcpTools.map((tool) => tool.name);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ export function getActiveToolNamesForMode(mode: AgentMode): string[] {
141
+ // Get MCP tool names dynamically
142
+ const mcpToolNames = getMcpToolNames();
143
+
144
+ switch (mode) {
145
+ case "build": {
146
+ // Build mode: all built-in tools + MCP tools
147
+ const baseTools = ALL_TOOLS.filter((t) => t !== "exitPlanMode");
148
+ return [...baseTools, ...mcpToolNames];
149
+ }
150
+ case "ask":
151
+ // Ask mode: readonly tools + MCP tools
152
+ return [...READONLY_TOOLS, ...mcpToolNames];
153
+ case "plan": {
154
+ // Plan mode: readonly + task management tools + MCP tools (for read-only MCP tools)
155
+ const planTools = PLAN_TOOLS.filter((t) => t !== "enterPlanMode");
156
+ return [...planTools, ...mcpToolNames];
157
+ }
158
+ default: {
159
+ const baseTools = ALL_TOOLS.filter((t) => t !== "exitPlanMode");
160
+ return [...baseTools, ...mcpToolNames];
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Monitor Registry — 后台监控任务管理器
3
+ *
4
+ * Manages background process monitors that watch command output and notify
5
+ * the main agent when changes occur.
6
+ *
7
+ * Notification delivery:
8
+ * - Main agent busy → steer() to insert notification
9
+ * - Main agent idle → prompt() to trigger a new run
10
+ */
11
+
12
+ import { spawn, ChildProcess } from "child_process";
13
+ import { EventEmitter } from "events";
14
+ import { activeSessionRef } from "./hooks.js";
15
+
16
+ export type MonitorStatus = "running" | "completed" | "killed" | "error";
17
+
18
+ export interface MonitorEntry {
19
+ id: string;
20
+ sessionId: string;
21
+ description: string;
22
+ command: string;
23
+ status: MonitorStatus;
24
+ startTime: number;
25
+ endTime?: number;
26
+ exitCode?: number;
27
+ outputLines: string[];
28
+ lastOutput?: string;
29
+ error?: string;
30
+ }
31
+
32
+ type MonitorListener = (entries: MonitorEntry[]) => void;
33
+
34
+ /**
35
+ * Creates a monitor notification XML tag for internal LLM communication.
36
+ * These tags are filtered out of the UI but visible to the agent.
37
+ */
38
+ function createMonitorNotification(
39
+ monitorId: string,
40
+ type: "output" | "completed" | "error",
41
+ payload: string
42
+ ): string {
43
+ return `<monitor-notification>\n <monitor-id>${monitorId}</monitor-id>\n <type>${type}</type>\n <payload>${escapeXml(payload)}</payload>\n</monitor-notification>`;
44
+ }
45
+
46
+ function escapeXml(str: string): string {
47
+ return str
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;")
52
+ .replace(/'/g, "&apos;");
53
+ }
54
+
55
+ /**
56
+ * Generate a unique monitor ID.
57
+ */
58
+ function generateMonitorId(): string {
59
+ return `monitor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
60
+ }
61
+
62
+ class MonitorRegistryImpl extends EventEmitter {
63
+ private monitors = new Map<string, MonitorEntry>();
64
+ private processes = new Map<string, ChildProcess>();
65
+
66
+ /**
67
+ * Launch a new background monitor.
68
+ * Returns the monitor ID.
69
+ */
70
+ launch(sessionId: string, command: string, description: string = ""): string {
71
+ const id = generateMonitorId();
72
+
73
+ const entry: MonitorEntry = {
74
+ id,
75
+ sessionId,
76
+ description: description || `Monitor: ${command.slice(0, 40)}${command.length > 40 ? "…" : ""}`,
77
+ command,
78
+ status: "running",
79
+ startTime: Date.now(),
80
+ outputLines: [],
81
+ };
82
+
83
+ this.monitors.set(id, entry);
84
+ this.emit("change", this.getAll());
85
+
86
+ // Spawn the background process
87
+ const child = spawn("bash", ["-c", command], {
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ detached: false, // Keep in same process group so we can kill it
90
+ });
91
+
92
+ this.processes.set(id, child);
93
+
94
+ let stderrBuffer = "";
95
+
96
+ // Capture stdout line by line
97
+ child.stdout?.on("data", (chunk: Buffer) => {
98
+ const lines = chunk.toString().split("\n");
99
+ for (const line of lines) {
100
+ if (!line && lines.length === 1) continue; // Skip empty lines from partial chunks
101
+ const trimmed = line.trimEnd();
102
+ if (!trimmed) continue;
103
+
104
+ const monitor = this.monitors.get(id);
105
+ if (monitor) {
106
+ monitor.outputLines.push(trimmed);
107
+ monitor.lastOutput = trimmed;
108
+ }
109
+
110
+ const notification = createMonitorNotification(id, "output", trimmed);
111
+ this.notifyParent(notification);
112
+ this.emit("change", this.getAll());
113
+ }
114
+ });
115
+
116
+ // Capture stderr
117
+ child.stderr?.on("data", (chunk: Buffer) => {
118
+ stderrBuffer += chunk.toString();
119
+ });
120
+
121
+ // Handle process exit
122
+ child.on("close", (code: number | null) => {
123
+ this.processes.delete(id);
124
+
125
+ const monitor = this.monitors.get(id);
126
+ if (!monitor) return;
127
+
128
+ if (stderrBuffer.trim()) {
129
+ monitor.error = stderrBuffer.trim();
130
+ const notification = createMonitorNotification(id, "error", stderrBuffer.trim());
131
+ this.notifyParent(notification);
132
+ }
133
+
134
+ if (monitor.status === "running") {
135
+ monitor.status = code === 0 ? "completed" : "error";
136
+ monitor.exitCode = code ?? undefined;
137
+ monitor.endTime = Date.now();
138
+
139
+ const notification = createMonitorNotification(
140
+ id,
141
+ "completed",
142
+ `Exited with code ${code ?? "unknown"}`
143
+ );
144
+ this.notifyParent(notification);
145
+ }
146
+
147
+ this.emit("change", this.getAll());
148
+ });
149
+
150
+ child.on("error", (err: Error) => {
151
+ this.processes.delete(id);
152
+
153
+ const monitor = this.monitors.get(id);
154
+ if (monitor) {
155
+ monitor.status = "error";
156
+ monitor.error = err.message;
157
+ monitor.endTime = Date.now();
158
+
159
+ const notification = createMonitorNotification(id, "error", err.message);
160
+ this.notifyParent(notification);
161
+ }
162
+
163
+ this.emit("change", this.getAll());
164
+ });
165
+
166
+ return id;
167
+ }
168
+
169
+ /**
170
+ * Cancel a running monitor by killing its process.
171
+ * Returns true if the monitor was found and killed.
172
+ */
173
+ kill(id: string): boolean {
174
+ const child = this.processes.get(id);
175
+ if (!child) return false;
176
+
177
+ try {
178
+ // Kill the process group to ensure child processes are also terminated
179
+ process.kill(child.pid!, "SIGTERM");
180
+
181
+ // Give it a moment to clean up gracefully
182
+ setTimeout(() => {
183
+ const monitor = this.monitors.get(id);
184
+ if (monitor && monitor.status === "running") {
185
+ try {
186
+ process.kill(child.pid!, 0); // Check if still alive
187
+ } catch {
188
+ // Process already dead, already handled by 'close' event
189
+ return;
190
+ }
191
+ // Force kill if still alive
192
+ try {
193
+ process.kill(child.pid!, "SIGKILL");
194
+ } catch {
195
+ // ignore
196
+ }
197
+ }
198
+ }, 500);
199
+ } catch {
200
+ // Process might have already exited
201
+ }
202
+
203
+ const monitor = this.monitors.get(id);
204
+ if (monitor) {
205
+ monitor.status = "killed";
206
+ monitor.endTime = Date.now();
207
+ this.emit("change", this.getAll());
208
+ }
209
+
210
+ return true;
211
+ }
212
+
213
+ /**
214
+ * Get a single monitor entry by ID.
215
+ */
216
+ get(id: string): MonitorEntry | undefined {
217
+ return this.monitors.get(id);
218
+ }
219
+
220
+ /**
221
+ * Get all monitor entries.
222
+ */
223
+ getAll(): MonitorEntry[] {
224
+ return Array.from(this.monitors.values());
225
+ }
226
+
227
+ /**
228
+ * Get monitor entries for a specific session.
229
+ */
230
+ getBySession(sessionId: string): MonitorEntry[] {
231
+ return this.getAll().filter((m) => m.sessionId === sessionId);
232
+ }
233
+
234
+ /**
235
+ * Get only running monitors.
236
+ */
237
+ getRunning(): MonitorEntry[] {
238
+ return this.getAll().filter((m) => m.status === "running");
239
+ }
240
+
241
+ /**
242
+ * Get only running monitors for a specific session.
243
+ */
244
+ getRunningBySession(sessionId: string): MonitorEntry[] {
245
+ return this.getBySession(sessionId).filter((m) => m.status === "running");
246
+ }
247
+
248
+ /**
249
+ * Subscribe to monitor registry changes.
250
+ */
251
+ subscribe(listener: MonitorListener): () => void {
252
+ this.on("change", listener);
253
+ return () => this.off("change", listener);
254
+ }
255
+
256
+ /**
257
+ * Remove a monitor from the registry.
258
+ * Does not kill the process if it's still running.
259
+ */
260
+ remove(id: string): boolean {
261
+ const existed = this.monitors.has(id);
262
+ this.monitors.delete(id);
263
+ if (existed) this.emit("change", this.getAll());
264
+ return existed;
265
+ }
266
+
267
+ /**
268
+ * Clear all monitors.
269
+ */
270
+ clear(): void {
271
+ for (const [id, child] of this.processes) {
272
+ try {
273
+ process.kill(child.pid!, "SIGTERM");
274
+ } catch {
275
+ // ignore
276
+ }
277
+ this.monitors.delete(id);
278
+ }
279
+ this.processes.clear();
280
+ this.monitors.clear();
281
+ this.emit("change", this.getAll());
282
+ }
283
+
284
+ /**
285
+ * Notify the parent agent session of a monitor event.
286
+ * Uses steer() if the agent is busy, prompt() if idle.
287
+ */
288
+ private notifyParent(notification: string): void {
289
+ const parent = activeSessionRef.current;
290
+ if (!parent) {
291
+ console.warn("[MonitorRegistry] No active session to notify");
292
+ return;
293
+ }
294
+
295
+ if (parent.isStreaming) {
296
+ parent.steer(notification).catch(() => {
297
+ // Silently ignore if the parent session is no longer available
298
+ });
299
+ } else {
300
+ parent.prompt(notification).catch(() => {
301
+ // Silently ignore if the parent session is no longer available
302
+ });
303
+ }
304
+ }
305
+ }
306
+
307
+ // Global singleton
308
+ export const monitorRegistry = new MonitorRegistryImpl();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Permission UI Bridge
3
+ *
4
+ * Provides a decoupled way for tool execute() functions to request
5
+ * user confirmation via the TUI layer without depending on ExtensionContext.
6
+ *
7
+ * Usage:
8
+ * const result = await requestPermission({ toolName: "bash", args: { command: "rm -rf /" } });
9
+ * // result is true if user allowed, false if denied
10
+ */
11
+
12
+ export interface PermissionRequest {
13
+ id: string;
14
+ toolName: string;
15
+ args: unknown;
16
+ reason: string;
17
+ resolve: (allowed: boolean) => void;
18
+ }
19
+
20
+ let queue: PermissionRequest[] = [];
21
+ let listeners: (() => void)[] = [];
22
+ let yoloMode = false;
23
+
24
+ export function isYoloMode(): boolean {
25
+ return yoloMode;
26
+ }
27
+
28
+ export function setYoloMode(enabled: boolean): void {
29
+ yoloMode = enabled;
30
+ }
31
+
32
+ function emit() {
33
+ for (const listener of listeners) {
34
+ try {
35
+ listener();
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }
40
+ }
41
+
42
+ export function subscribePermissions(listener: () => void): () => void {
43
+ listeners.push(listener);
44
+ return () => {
45
+ listeners = listeners.filter((l) => l !== listener);
46
+ };
47
+ }
48
+
49
+ export function getPermissionQueue(): PermissionRequest[] {
50
+ return queue;
51
+ }
52
+
53
+ export function resolvePermission(id: string, allowed: boolean): void {
54
+ const request = queue.find((r) => r.id === id);
55
+ if (!request) return;
56
+ queue = queue.filter((r) => r.id !== id);
57
+ request.resolve(allowed);
58
+ emit();
59
+ }
60
+
61
+ export function requestPermission(params: {
62
+ toolName: string;
63
+ args: unknown;
64
+ reason: string;
65
+ }): Promise<boolean> {
66
+ return new Promise((resolve) => {
67
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
68
+ queue.push({ id, toolName: params.toolName, args: params.args, reason: params.reason, resolve });
69
+ emit();
70
+ });
71
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Plan Approval UI Bridge
3
+ *
4
+ * Decoupled way for the exitPlanMode tool to submit a plan for user approval
5
+ * via the TUI layer without depending on React context.
6
+ *
7
+ * Pattern mirrors permission-ui.ts.
8
+ */
9
+
10
+ export interface PlanApprovalResult {
11
+ approved: boolean;
12
+ comment?: string;
13
+ }
14
+
15
+ export interface PlanApprovalRequest {
16
+ id: string;
17
+ plan: string;
18
+ resolve: (result: PlanApprovalResult) => void;
19
+ }
20
+
21
+ let queue: PlanApprovalRequest[] = [];
22
+ let listeners: (() => void)[] = [];
23
+ let currentPlanText = "";
24
+
25
+ function emit() {
26
+ for (const listener of listeners) {
27
+ try {
28
+ listener();
29
+ } catch {
30
+ // ignore
31
+ }
32
+ }
33
+ }
34
+
35
+ export function subscribePlanApprovals(listener: () => void): () => void {
36
+ listeners.push(listener);
37
+ return () => {
38
+ listeners = listeners.filter((l) => l !== listener);
39
+ };
40
+ }
41
+
42
+ export function getPlanApprovalQueue(): PlanApprovalRequest[] {
43
+ return queue;
44
+ }
45
+
46
+ export function getCurrentPlanText(): string {
47
+ return currentPlanText;
48
+ }
49
+
50
+ export function setCurrentPlanText(text: string): void {
51
+ currentPlanText = text;
52
+ }
53
+
54
+ export function resolvePlanApproval(id: string, result: PlanApprovalResult): void {
55
+ const request = queue.find((r) => r.id === id);
56
+ if (!request) return;
57
+ queue = queue.filter((r) => r.id !== id);
58
+ request.resolve(result);
59
+ if (!result.approved) {
60
+ currentPlanText = "";
61
+ }
62
+ emit();
63
+ }
64
+
65
+ export function submitPlanForApproval(params: {
66
+ plan: string;
67
+ }): Promise<PlanApprovalResult> {
68
+ return new Promise((resolve) => {
69
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
70
+ currentPlanText = params.plan;
71
+ queue.push({ id, plan: params.plan, resolve });
72
+ emit();
73
+ });
74
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Question UI Bridge
3
+ *
4
+ * Decoupled way for the askUserQuestion tool to request user input
5
+ * via the TUI layer without depending on React context.
6
+ *
7
+ * Pattern mirrors permission-ui.ts.
8
+ */
9
+
10
+ export interface QuestionRequest {
11
+ id: string;
12
+ question: string;
13
+ options: string[];
14
+ resolve: (answer: string) => void;
15
+ }
16
+
17
+ let queue: QuestionRequest[] = [];
18
+ let listeners: (() => void)[] = [];
19
+
20
+ function emit() {
21
+ for (const listener of listeners) {
22
+ try {
23
+ listener();
24
+ } catch {
25
+ // ignore
26
+ }
27
+ }
28
+ }
29
+
30
+ export function subscribeQuestions(listener: () => void): () => void {
31
+ listeners.push(listener);
32
+ return () => {
33
+ listeners = listeners.filter((l) => l !== listener);
34
+ };
35
+ }
36
+
37
+ export function getQuestionQueue(): QuestionRequest[] {
38
+ return queue;
39
+ }
40
+
41
+ export function resolveQuestion(id: string, answer: string): void {
42
+ const request = queue.find((r) => r.id === id);
43
+ if (!request) return;
44
+ queue = queue.filter((r) => r.id !== id);
45
+ request.resolve(answer);
46
+ emit();
47
+ }
48
+
49
+ export function askUserQuestion(params: {
50
+ question: string;
51
+ options: string[];
52
+ }): Promise<string> {
53
+ return new Promise((resolve) => {
54
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
55
+ queue.push({ id, question: params.question, options: params.options, resolve });
56
+ emit();
57
+ });
58
+ }