@phren/agent 0.0.1
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/dist/agent-loop.js +328 -0
- package/dist/bin.js +3 -0
- package/dist/checkpoint.js +103 -0
- package/dist/commands.js +292 -0
- package/dist/config.js +139 -0
- package/dist/context/pruner.js +62 -0
- package/dist/context/token-counter.js +28 -0
- package/dist/cost.js +71 -0
- package/dist/index.js +284 -0
- package/dist/mcp-client.js +168 -0
- package/dist/memory/anti-patterns.js +69 -0
- package/dist/memory/auto-capture.js +72 -0
- package/dist/memory/context-flush.js +24 -0
- package/dist/memory/context.js +170 -0
- package/dist/memory/error-recovery.js +58 -0
- package/dist/memory/project-context.js +77 -0
- package/dist/memory/session.js +100 -0
- package/dist/multi/agent-colors.js +41 -0
- package/dist/multi/child-entry.js +173 -0
- package/dist/multi/coordinator.js +263 -0
- package/dist/multi/diff-renderer.js +175 -0
- package/dist/multi/markdown.js +96 -0
- package/dist/multi/presets.js +107 -0
- package/dist/multi/progress.js +32 -0
- package/dist/multi/spawner.js +219 -0
- package/dist/multi/tui-multi.js +626 -0
- package/dist/multi/types.js +7 -0
- package/dist/permissions/allowlist.js +61 -0
- package/dist/permissions/checker.js +111 -0
- package/dist/permissions/prompt.js +190 -0
- package/dist/permissions/sandbox.js +95 -0
- package/dist/permissions/shell-safety.js +74 -0
- package/dist/permissions/types.js +2 -0
- package/dist/plan.js +38 -0
- package/dist/providers/anthropic.js +170 -0
- package/dist/providers/codex-auth.js +197 -0
- package/dist/providers/codex.js +265 -0
- package/dist/providers/ollama.js +142 -0
- package/dist/providers/openai-compat.js +163 -0
- package/dist/providers/openrouter.js +116 -0
- package/dist/providers/resolve.js +39 -0
- package/dist/providers/retry.js +55 -0
- package/dist/providers/types.js +2 -0
- package/dist/repl.js +180 -0
- package/dist/spinner.js +46 -0
- package/dist/system-prompt.js +31 -0
- package/dist/tools/edit-file.js +31 -0
- package/dist/tools/git.js +98 -0
- package/dist/tools/glob.js +65 -0
- package/dist/tools/grep.js +108 -0
- package/dist/tools/lint-test.js +76 -0
- package/dist/tools/phren-finding.js +35 -0
- package/dist/tools/phren-search.js +44 -0
- package/dist/tools/phren-tasks.js +71 -0
- package/dist/tools/read-file.js +44 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/shell.js +48 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write-file.js +27 -0
- package/dist/tui.js +451 -0
- package/package.json +39 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiplexed TUI for multi-agent orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Full alternate-screen terminal with:
|
|
5
|
+
* - Top bar: agent tabs with status color coding
|
|
6
|
+
* - Main area: scrollback buffer for the selected agent
|
|
7
|
+
* - Bottom bar: input line + keyboard hints
|
|
8
|
+
*
|
|
9
|
+
* Keyboard:
|
|
10
|
+
* 1-9 Select agent pane by index
|
|
11
|
+
* Ctrl+Left/Right Cycle between agent panes
|
|
12
|
+
* Enter Send input / execute command
|
|
13
|
+
* /spawn <n> <t> Spawn a new agent
|
|
14
|
+
* /list List all agents
|
|
15
|
+
* /kill <name> Terminate an agent
|
|
16
|
+
* /broadcast <msg> Send message to all agents
|
|
17
|
+
* Ctrl+D Exit (kills all agents)
|
|
18
|
+
*/
|
|
19
|
+
import * as readline from "node:readline";
|
|
20
|
+
import { getAgentStyle, formatAgentName } from "./agent-colors.js";
|
|
21
|
+
import { decodeDiffPayload, renderInlineDiff, DIFF_MARKER } from "./diff-renderer.js";
|
|
22
|
+
// ── ANSI helpers (mirrors tui.ts pattern) ────────────────────────────────────
|
|
23
|
+
const ESC = "\x1b[";
|
|
24
|
+
const s = {
|
|
25
|
+
reset: `${ESC}0m`,
|
|
26
|
+
bold: (t) => `${ESC}1m${t}${ESC}0m`,
|
|
27
|
+
dim: (t) => `${ESC}2m${t}${ESC}0m`,
|
|
28
|
+
cyan: (t) => `${ESC}36m${t}${ESC}0m`,
|
|
29
|
+
green: (t) => `${ESC}32m${t}${ESC}0m`,
|
|
30
|
+
yellow: (t) => `${ESC}33m${t}${ESC}0m`,
|
|
31
|
+
red: (t) => `${ESC}31m${t}${ESC}0m`,
|
|
32
|
+
gray: (t) => `${ESC}90m${t}${ESC}0m`,
|
|
33
|
+
white: (t) => `${ESC}37m${t}${ESC}0m`,
|
|
34
|
+
bgGreen: (t) => `${ESC}42m${t}${ESC}0m`,
|
|
35
|
+
bgRed: (t) => `${ESC}41m${t}${ESC}0m`,
|
|
36
|
+
bgGray: (t) => `${ESC}100m${t}${ESC}0m`,
|
|
37
|
+
bgCyan: (t) => `${ESC}46m${t}${ESC}0m`,
|
|
38
|
+
bgYellow: (t) => `${ESC}43m${t}${ESC}0m`,
|
|
39
|
+
invert: (t) => `${ESC}7m${t}${ESC}0m`,
|
|
40
|
+
};
|
|
41
|
+
function stripAnsi(t) {
|
|
42
|
+
return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
43
|
+
}
|
|
44
|
+
function cols() {
|
|
45
|
+
return process.stdout.columns || 80;
|
|
46
|
+
}
|
|
47
|
+
function rows() {
|
|
48
|
+
return process.stdout.rows || 24;
|
|
49
|
+
}
|
|
50
|
+
// ── Pane buffer ──────────────────────────────────────────────────────────────
|
|
51
|
+
const MAX_SCROLLBACK = 1000;
|
|
52
|
+
let nextPaneIndex = 0;
|
|
53
|
+
function createPane(agentId, name) {
|
|
54
|
+
return { agentId, name, index: nextPaneIndex++, lines: [], partial: "" };
|
|
55
|
+
}
|
|
56
|
+
function appendToPane(pane, text) {
|
|
57
|
+
// Merge with partial line buffer
|
|
58
|
+
const combined = pane.partial + text;
|
|
59
|
+
const parts = combined.split("\n");
|
|
60
|
+
// Everything except the last segment is a complete line
|
|
61
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
62
|
+
pane.lines.push(parts[i]);
|
|
63
|
+
}
|
|
64
|
+
pane.partial = parts[parts.length - 1];
|
|
65
|
+
// Enforce scrollback cap
|
|
66
|
+
if (pane.lines.length > MAX_SCROLLBACK) {
|
|
67
|
+
pane.lines.splice(0, pane.lines.length - MAX_SCROLLBACK);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function flushPartial(pane) {
|
|
71
|
+
if (pane.partial) {
|
|
72
|
+
pane.lines.push(pane.partial);
|
|
73
|
+
pane.partial = "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// ── Status color ─────────────────────────────────────────────────────────────
|
|
77
|
+
function statusColor(status) {
|
|
78
|
+
switch (status) {
|
|
79
|
+
case "starting": return s.yellow;
|
|
80
|
+
case "running": return s.green;
|
|
81
|
+
case "done": return s.gray;
|
|
82
|
+
case "error": return s.red;
|
|
83
|
+
case "cancelled": return s.gray;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function statusBg(status, selected) {
|
|
87
|
+
if (selected)
|
|
88
|
+
return s.invert;
|
|
89
|
+
switch (status) {
|
|
90
|
+
case "running": return s.bgGreen;
|
|
91
|
+
case "error": return s.bgRed;
|
|
92
|
+
default: return s.bgGray;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── Tool call formatting ─────────────────────────────────────────────────────
|
|
96
|
+
function formatToolStart(toolName, input) {
|
|
97
|
+
const preview = JSON.stringify(input).slice(0, 60);
|
|
98
|
+
return s.dim(` > ${toolName}(${preview})...`);
|
|
99
|
+
}
|
|
100
|
+
function formatToolEnd(toolName, input, output, isError, durationMs) {
|
|
101
|
+
const dur = durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
102
|
+
const icon = isError ? s.red("x") : s.green("ok");
|
|
103
|
+
const preview = JSON.stringify(input).slice(0, 50);
|
|
104
|
+
const header = s.dim(` ${toolName}(${preview})`) + ` ${icon} ${s.dim(dur)}`;
|
|
105
|
+
const outputLines = output.split("\n").slice(0, 4);
|
|
106
|
+
const w = cols();
|
|
107
|
+
const body = outputLines.map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
|
|
108
|
+
const more = output.split("\n").length > 4 ? s.dim(` | ... (${output.split("\n").length} lines)`) : "";
|
|
109
|
+
return `${header}\n${body}${more ? "\n" + more : ""}`;
|
|
110
|
+
}
|
|
111
|
+
// ── Main TUI ─────────────────────────────────────────────────────────────────
|
|
112
|
+
export async function startMultiTui(spawner, config) {
|
|
113
|
+
const w = process.stdout;
|
|
114
|
+
const panes = new Map();
|
|
115
|
+
let selectedId = null;
|
|
116
|
+
let inputLine = "";
|
|
117
|
+
let scrollOffset = 0;
|
|
118
|
+
// Ordered list of agent IDs for tab navigation
|
|
119
|
+
const agentOrder = [];
|
|
120
|
+
// ── Pane management ────────────────────────────────────────────────────
|
|
121
|
+
function getOrCreatePane(agentId) {
|
|
122
|
+
let pane = panes.get(agentId);
|
|
123
|
+
if (!pane) {
|
|
124
|
+
const agent = spawner.getAgent(agentId);
|
|
125
|
+
const name = agent?.task.slice(0, 20) ?? agentId;
|
|
126
|
+
pane = createPane(agentId, name);
|
|
127
|
+
panes.set(agentId, pane);
|
|
128
|
+
if (!agentOrder.includes(agentId)) {
|
|
129
|
+
agentOrder.push(agentId);
|
|
130
|
+
}
|
|
131
|
+
// Auto-select first agent
|
|
132
|
+
if (!selectedId) {
|
|
133
|
+
selectedId = agentId;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return pane;
|
|
137
|
+
}
|
|
138
|
+
function selectAgent(agentId) {
|
|
139
|
+
if (panes.has(agentId)) {
|
|
140
|
+
selectedId = agentId;
|
|
141
|
+
scrollOffset = 0;
|
|
142
|
+
render();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function selectByIndex(index) {
|
|
146
|
+
if (index >= 0 && index < agentOrder.length) {
|
|
147
|
+
selectAgent(agentOrder[index]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function cycleAgent(direction) {
|
|
151
|
+
if (agentOrder.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
const currentIdx = selectedId ? agentOrder.indexOf(selectedId) : -1;
|
|
154
|
+
let next = currentIdx + direction;
|
|
155
|
+
if (next < 0)
|
|
156
|
+
next = agentOrder.length - 1;
|
|
157
|
+
if (next >= agentOrder.length)
|
|
158
|
+
next = 0;
|
|
159
|
+
selectAgent(agentOrder[next]);
|
|
160
|
+
}
|
|
161
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
162
|
+
function renderTopBar() {
|
|
163
|
+
const w_ = cols();
|
|
164
|
+
const agents = spawner.listAgents();
|
|
165
|
+
const tabs = [];
|
|
166
|
+
for (let i = 0; i < agents.length; i++) {
|
|
167
|
+
const a = agents[i];
|
|
168
|
+
const isSel = a.id === selectedId;
|
|
169
|
+
const pane = panes.get(a.id);
|
|
170
|
+
const paneIdx = pane?.index ?? i;
|
|
171
|
+
const stColor = statusColor(a.status);
|
|
172
|
+
const agentLabel = formatAgentName(pane?.name ?? a.task.slice(0, 12), paneIdx);
|
|
173
|
+
const statusTag = stColor(a.status);
|
|
174
|
+
const raw = ` ${i + 1}:${stripAnsi(agentLabel)} [${stripAnsi(statusTag)}] `;
|
|
175
|
+
const colored = ` ${i + 1}:${agentLabel} [${statusTag}] `;
|
|
176
|
+
const tab = isSel ? s.invert(raw) : colored;
|
|
177
|
+
tabs.push(tab);
|
|
178
|
+
}
|
|
179
|
+
if (agents.length === 0) {
|
|
180
|
+
tabs.push(s.dim(" no agents "));
|
|
181
|
+
}
|
|
182
|
+
const title = s.bold(" phren-multi ");
|
|
183
|
+
const tabStr = tabs.join(s.dim("|"));
|
|
184
|
+
const line = title + s.dim("|") + tabStr;
|
|
185
|
+
const pad = Math.max(0, w_ - stripAnsi(line).length);
|
|
186
|
+
return s.invert(stripAnsi(title)) + s.dim("|") + tabStr + " ".repeat(pad);
|
|
187
|
+
}
|
|
188
|
+
function renderMainArea() {
|
|
189
|
+
const availRows = rows() - 3; // top bar + bottom bar + input line
|
|
190
|
+
if (availRows < 1)
|
|
191
|
+
return [];
|
|
192
|
+
if (!selectedId || !panes.has(selectedId)) {
|
|
193
|
+
const emptyMsg = s.dim(" No agent selected. Use /spawn <name> <task> to create one.");
|
|
194
|
+
const lines = [emptyMsg];
|
|
195
|
+
while (lines.length < availRows)
|
|
196
|
+
lines.push("");
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
const pane = panes.get(selectedId);
|
|
200
|
+
// Include partial line if any
|
|
201
|
+
const allLines = [...pane.lines];
|
|
202
|
+
if (pane.partial)
|
|
203
|
+
allLines.push(pane.partial);
|
|
204
|
+
// Apply scroll offset
|
|
205
|
+
const totalLines = allLines.length;
|
|
206
|
+
let start = Math.max(0, totalLines - availRows - scrollOffset);
|
|
207
|
+
let end = start + availRows;
|
|
208
|
+
if (end > totalLines) {
|
|
209
|
+
end = totalLines;
|
|
210
|
+
start = Math.max(0, end - availRows);
|
|
211
|
+
}
|
|
212
|
+
const visible = allLines.slice(start, end);
|
|
213
|
+
const w_ = cols();
|
|
214
|
+
const output = [];
|
|
215
|
+
const paneStyle = getAgentStyle(pane.index);
|
|
216
|
+
const linePrefix = paneStyle.color(paneStyle.icon) + " ";
|
|
217
|
+
const prefixLen = 2; // icon + space
|
|
218
|
+
for (const line of visible) {
|
|
219
|
+
output.push(linePrefix + line.slice(0, w_ - prefixLen));
|
|
220
|
+
}
|
|
221
|
+
// Pad remaining rows
|
|
222
|
+
while (output.length < availRows)
|
|
223
|
+
output.push("");
|
|
224
|
+
return output;
|
|
225
|
+
}
|
|
226
|
+
function renderBottomBar() {
|
|
227
|
+
const w_ = cols();
|
|
228
|
+
const agentCount = spawner.listAgents().length;
|
|
229
|
+
const runningCount = spawner.getAgentsByStatus("running").length;
|
|
230
|
+
const left = ` Agents: ${agentCount} (${runningCount} running)`;
|
|
231
|
+
const right = `1-9:select Ctrl+</>:cycle /spawn /list /kill /broadcast Ctrl+D:exit `;
|
|
232
|
+
const pad = Math.max(0, w_ - left.length - right.length);
|
|
233
|
+
return s.invert(left + " ".repeat(pad) + right);
|
|
234
|
+
}
|
|
235
|
+
function renderInputLine() {
|
|
236
|
+
const prompt = s.cyan("multi> ");
|
|
237
|
+
return prompt + inputLine;
|
|
238
|
+
}
|
|
239
|
+
function render() {
|
|
240
|
+
// Hide cursor, move to top, clear screen
|
|
241
|
+
w.write(`${ESC}?25l${ESC}H${ESC}2J`);
|
|
242
|
+
// Top bar
|
|
243
|
+
w.write(renderTopBar());
|
|
244
|
+
w.write("\n");
|
|
245
|
+
// Main area
|
|
246
|
+
const mainLines = renderMainArea();
|
|
247
|
+
for (const line of mainLines) {
|
|
248
|
+
w.write(line + "\n");
|
|
249
|
+
}
|
|
250
|
+
// Bottom bar
|
|
251
|
+
w.write(renderBottomBar());
|
|
252
|
+
w.write("\n");
|
|
253
|
+
// Input line
|
|
254
|
+
w.write(renderInputLine());
|
|
255
|
+
// Show cursor
|
|
256
|
+
w.write(`${ESC}?25h`);
|
|
257
|
+
}
|
|
258
|
+
// ── Spawner event wiring ───────────────────────────────────────────────
|
|
259
|
+
spawner.on("text_delta", (agentId, text) => {
|
|
260
|
+
const pane = getOrCreatePane(agentId);
|
|
261
|
+
appendToPane(pane, text);
|
|
262
|
+
if (agentId === selectedId)
|
|
263
|
+
render();
|
|
264
|
+
});
|
|
265
|
+
spawner.on("text_block", (agentId, text) => {
|
|
266
|
+
const pane = getOrCreatePane(agentId);
|
|
267
|
+
appendToPane(pane, text + "\n");
|
|
268
|
+
if (agentId === selectedId)
|
|
269
|
+
render();
|
|
270
|
+
});
|
|
271
|
+
spawner.on("tool_start", (agentId, toolName, input) => {
|
|
272
|
+
const pane = getOrCreatePane(agentId);
|
|
273
|
+
flushPartial(pane);
|
|
274
|
+
appendToPane(pane, formatToolStart(toolName, input) + "\n");
|
|
275
|
+
if (agentId === selectedId)
|
|
276
|
+
render();
|
|
277
|
+
});
|
|
278
|
+
spawner.on("tool_end", (agentId, toolName, input, output, isError, durationMs) => {
|
|
279
|
+
const pane = getOrCreatePane(agentId);
|
|
280
|
+
flushPartial(pane);
|
|
281
|
+
const diffData = (toolName === "edit_file" || toolName === "write_file") ? decodeDiffPayload(output) : null;
|
|
282
|
+
const cleanOutput = diffData ? output.slice(0, output.indexOf(DIFF_MARKER)) : output;
|
|
283
|
+
appendToPane(pane, formatToolEnd(toolName, input, cleanOutput, isError, durationMs) + "\n");
|
|
284
|
+
if (diffData) {
|
|
285
|
+
appendToPane(pane, renderInlineDiff(diffData.oldContent, diffData.newContent, diffData.filePath) + "\n");
|
|
286
|
+
}
|
|
287
|
+
if (agentId === selectedId)
|
|
288
|
+
render();
|
|
289
|
+
});
|
|
290
|
+
spawner.on("status", (agentId, message) => {
|
|
291
|
+
const pane = getOrCreatePane(agentId);
|
|
292
|
+
appendToPane(pane, s.dim(message) + "\n");
|
|
293
|
+
if (agentId === selectedId)
|
|
294
|
+
render();
|
|
295
|
+
});
|
|
296
|
+
spawner.on("done", (agentId, result) => {
|
|
297
|
+
const pane = getOrCreatePane(agentId);
|
|
298
|
+
flushPartial(pane);
|
|
299
|
+
const style = getAgentStyle(pane.index);
|
|
300
|
+
appendToPane(pane, "\n" + style.color(`--- ${style.icon} Agent completed ---`) + "\n");
|
|
301
|
+
appendToPane(pane, s.dim(` Turns: ${result.turns} Tool calls: ${result.toolCalls}${result.totalCost ? ` Cost: ${result.totalCost}` : ""}`) + "\n");
|
|
302
|
+
render();
|
|
303
|
+
});
|
|
304
|
+
spawner.on("error", (agentId, error) => {
|
|
305
|
+
const pane = getOrCreatePane(agentId);
|
|
306
|
+
flushPartial(pane);
|
|
307
|
+
const style = getAgentStyle(pane.index);
|
|
308
|
+
appendToPane(pane, "\n" + style.color(`--- ${style.icon} Error: ${error} ---`) + "\n");
|
|
309
|
+
render();
|
|
310
|
+
});
|
|
311
|
+
spawner.on("exit", (agentId, code) => {
|
|
312
|
+
const pane = getOrCreatePane(agentId);
|
|
313
|
+
if (code !== null && code !== 0) {
|
|
314
|
+
appendToPane(pane, s.dim(` Process exited with code ${code}`) + "\n");
|
|
315
|
+
}
|
|
316
|
+
render();
|
|
317
|
+
});
|
|
318
|
+
spawner.on("message", (from, to, content) => {
|
|
319
|
+
// Show in sender's pane
|
|
320
|
+
const senderPane = panes.get(from);
|
|
321
|
+
if (senderPane) {
|
|
322
|
+
flushPartial(senderPane);
|
|
323
|
+
const toName = panes.get(to)?.name ?? to;
|
|
324
|
+
appendToPane(senderPane, s.yellow(`[${senderPane.name} -> ${toName}] ${content}`) + "\n");
|
|
325
|
+
}
|
|
326
|
+
// Show in recipient's pane
|
|
327
|
+
const recipientPane = panes.get(to);
|
|
328
|
+
if (recipientPane) {
|
|
329
|
+
flushPartial(recipientPane);
|
|
330
|
+
const fromName = senderPane?.name ?? from;
|
|
331
|
+
appendToPane(recipientPane, s.yellow(`[${fromName} -> ${recipientPane.name}] ${content}`) + "\n");
|
|
332
|
+
}
|
|
333
|
+
if (from === selectedId || to === selectedId)
|
|
334
|
+
render();
|
|
335
|
+
});
|
|
336
|
+
// ── Slash command handling ─────────────────────────────────────────────
|
|
337
|
+
function handleSlashCommand(line) {
|
|
338
|
+
const parts = line.split(/\s+/);
|
|
339
|
+
const cmd = parts[0].toLowerCase();
|
|
340
|
+
if (cmd === "/spawn") {
|
|
341
|
+
const name = parts[1];
|
|
342
|
+
const task = parts.slice(2).join(" ");
|
|
343
|
+
if (!name || !task) {
|
|
344
|
+
appendToSystem("Usage: /spawn <name> <task>");
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
const opts = {
|
|
348
|
+
task,
|
|
349
|
+
cwd: process.cwd(),
|
|
350
|
+
provider: config.provider.name,
|
|
351
|
+
permissions: "auto-confirm",
|
|
352
|
+
verbose: config.verbose,
|
|
353
|
+
};
|
|
354
|
+
const agentId = spawner.spawn(opts);
|
|
355
|
+
const pane = getOrCreatePane(agentId);
|
|
356
|
+
pane.name = name;
|
|
357
|
+
appendToPane(pane, s.cyan(`Spawned agent "${name}" (${agentId}): ${task}`) + "\n");
|
|
358
|
+
selectAgent(agentId);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
if (cmd === "/list") {
|
|
362
|
+
const agents = spawner.listAgents();
|
|
363
|
+
if (agents.length === 0) {
|
|
364
|
+
appendToSystem("No agents.");
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const lines = ["Agents:"];
|
|
368
|
+
for (let i = 0; i < agents.length; i++) {
|
|
369
|
+
const a = agents[i];
|
|
370
|
+
const pane = panes.get(a.id);
|
|
371
|
+
const name = pane?.name ?? a.id;
|
|
372
|
+
const color = statusColor(a.status);
|
|
373
|
+
const elapsed = a.finishedAt
|
|
374
|
+
? `${((a.finishedAt - a.startedAt) / 1000).toFixed(1)}s`
|
|
375
|
+
: `${((Date.now() - a.startedAt) / 1000).toFixed(0)}s`;
|
|
376
|
+
lines.push(` ${i + 1}. ${name} [${color(a.status)}] ${s.dim(elapsed)} — ${a.task.slice(0, 50)}`);
|
|
377
|
+
}
|
|
378
|
+
appendToSystem(lines.join("\n"));
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
if (cmd === "/kill") {
|
|
383
|
+
const target = parts[1];
|
|
384
|
+
if (!target) {
|
|
385
|
+
appendToSystem("Usage: /kill <name|index>");
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
const agentId = resolveAgentTarget(target);
|
|
389
|
+
if (!agentId) {
|
|
390
|
+
appendToSystem(`Agent "${target}" not found.`);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
const ok = spawner.cancel(agentId);
|
|
394
|
+
const pane = getOrCreatePane(agentId);
|
|
395
|
+
if (ok) {
|
|
396
|
+
appendToPane(pane, s.yellow("\n--- Cancelled ---\n"));
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
appendToSystem(`Agent "${target}" is not running.`);
|
|
400
|
+
}
|
|
401
|
+
render();
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (cmd === "/broadcast") {
|
|
405
|
+
const msg = parts.slice(1).join(" ");
|
|
406
|
+
if (!msg) {
|
|
407
|
+
appendToSystem("Usage: /broadcast <message>");
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
const agents = spawner.listAgents();
|
|
411
|
+
let sent = 0;
|
|
412
|
+
for (const a of agents) {
|
|
413
|
+
if (a.status === "running") {
|
|
414
|
+
const pane = getOrCreatePane(a.id);
|
|
415
|
+
appendToPane(pane, s.yellow(`[broadcast] ${msg}`) + "\n");
|
|
416
|
+
sent++;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
appendToSystem(`Broadcast sent to ${sent} running agent(s).`);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
if (cmd === "/msg") {
|
|
423
|
+
const target = parts[1];
|
|
424
|
+
const msg = parts.slice(2).join(" ");
|
|
425
|
+
if (!target || !msg) {
|
|
426
|
+
appendToSystem("Usage: /msg <agent> <text>");
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
const agentId = resolveAgentTarget(target);
|
|
430
|
+
if (!agentId) {
|
|
431
|
+
appendToSystem(`Agent "${target}" not found.`);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
const ok = spawner.sendToAgent(agentId, msg, "user");
|
|
435
|
+
if (ok) {
|
|
436
|
+
const recipientPane = getOrCreatePane(agentId);
|
|
437
|
+
flushPartial(recipientPane);
|
|
438
|
+
appendToPane(recipientPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
|
|
439
|
+
if (selectedId && selectedId !== agentId && panes.has(selectedId)) {
|
|
440
|
+
const curPane = panes.get(selectedId);
|
|
441
|
+
flushPartial(curPane);
|
|
442
|
+
appendToPane(curPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
appendToSystem(`Agent "${target}" is not running.`);
|
|
447
|
+
}
|
|
448
|
+
render();
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (cmd === "/help") {
|
|
452
|
+
appendToSystem([
|
|
453
|
+
"Commands:",
|
|
454
|
+
" /spawn <name> <task> — Spawn a new agent",
|
|
455
|
+
" /list — List all agents",
|
|
456
|
+
" /kill <name|index> — Terminate an agent",
|
|
457
|
+
" /msg <agent> <text> — Send direct message to an agent",
|
|
458
|
+
" /broadcast <msg> — Send to all running agents",
|
|
459
|
+
" /help — Show this help",
|
|
460
|
+
"",
|
|
461
|
+
"Keys:",
|
|
462
|
+
" 1-9 — Select agent by number",
|
|
463
|
+
" Ctrl+Left/Right — Cycle agents",
|
|
464
|
+
" PageUp/PageDown — Scroll output",
|
|
465
|
+
" Ctrl+D — Exit (kills all)",
|
|
466
|
+
].join("\n"));
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
function resolveAgentTarget(target) {
|
|
472
|
+
// Try numeric index (1-based)
|
|
473
|
+
const idx = parseInt(target, 10);
|
|
474
|
+
if (!isNaN(idx) && idx >= 1 && idx <= agentOrder.length) {
|
|
475
|
+
return agentOrder[idx - 1];
|
|
476
|
+
}
|
|
477
|
+
// Try name match
|
|
478
|
+
for (const [id, pane] of panes) {
|
|
479
|
+
if (pane.name === target)
|
|
480
|
+
return id;
|
|
481
|
+
}
|
|
482
|
+
// Try agent ID
|
|
483
|
+
if (spawner.getAgent(target))
|
|
484
|
+
return target;
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
function appendToSystem(text) {
|
|
488
|
+
if (!selectedId || !panes.has(selectedId)) {
|
|
489
|
+
// Create a virtual system pane
|
|
490
|
+
const pane = createPane("_system", "system");
|
|
491
|
+
panes.set("_system", pane);
|
|
492
|
+
if (!agentOrder.includes("_system"))
|
|
493
|
+
agentOrder.push("_system");
|
|
494
|
+
selectedId = "_system";
|
|
495
|
+
appendToPane(pane, text + "\n");
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
const pane = panes.get(selectedId);
|
|
499
|
+
flushPartial(pane);
|
|
500
|
+
appendToPane(pane, text + "\n");
|
|
501
|
+
}
|
|
502
|
+
render();
|
|
503
|
+
}
|
|
504
|
+
// ── Terminal setup ─────────────────────────────────────────────────────
|
|
505
|
+
// Enter alternate screen
|
|
506
|
+
w.write("\x1b[?1049h");
|
|
507
|
+
if (process.stdin.isTTY) {
|
|
508
|
+
readline.emitKeypressEvents(process.stdin);
|
|
509
|
+
process.stdin.setRawMode(true);
|
|
510
|
+
}
|
|
511
|
+
function cleanup() {
|
|
512
|
+
w.write("\x1b[?1049l"); // leave alternate screen
|
|
513
|
+
w.write(`${ESC}?25h`); // show cursor
|
|
514
|
+
if (process.stdin.isTTY) {
|
|
515
|
+
try {
|
|
516
|
+
process.stdin.setRawMode(false);
|
|
517
|
+
}
|
|
518
|
+
catch { }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// ── Promise-based lifecycle ────────────────────────────────────────────
|
|
522
|
+
return new Promise((resolve) => {
|
|
523
|
+
async function shutdown() {
|
|
524
|
+
cleanup();
|
|
525
|
+
w.write(s.dim("Shutting down agents...\n"));
|
|
526
|
+
await spawner.shutdown();
|
|
527
|
+
w.write(s.dim("All agents stopped.\n"));
|
|
528
|
+
resolve();
|
|
529
|
+
}
|
|
530
|
+
// ── Keypress handler ─────────────────────────────────────────────────
|
|
531
|
+
process.stdin.on("keypress", (_ch, key) => {
|
|
532
|
+
if (!key)
|
|
533
|
+
return;
|
|
534
|
+
// Ctrl+D — exit
|
|
535
|
+
if (key.ctrl && key.name === "d") {
|
|
536
|
+
shutdown();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Ctrl+C — clear input or exit if empty
|
|
540
|
+
if (key.ctrl && key.name === "c") {
|
|
541
|
+
if (inputLine.length > 0) {
|
|
542
|
+
inputLine = "";
|
|
543
|
+
render();
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
shutdown();
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Number keys 1-9 — select agent
|
|
551
|
+
if (!key.ctrl && !key.meta && key.sequence && /^[1-9]$/.test(key.sequence) && inputLine.length === 0) {
|
|
552
|
+
selectByIndex(parseInt(key.sequence, 10) - 1);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// Ctrl+Left/Right — cycle agents
|
|
556
|
+
if (key.ctrl && key.name === "left") {
|
|
557
|
+
cycleAgent(-1);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (key.ctrl && key.name === "right") {
|
|
561
|
+
cycleAgent(1);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// Page Up/Down — scroll
|
|
565
|
+
if (key.name === "pageup") {
|
|
566
|
+
const availRows = rows() - 3;
|
|
567
|
+
scrollOffset = Math.min(scrollOffset + Math.floor(availRows / 2), MAX_SCROLLBACK);
|
|
568
|
+
render();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (key.name === "pagedown") {
|
|
572
|
+
const availRows = rows() - 3;
|
|
573
|
+
scrollOffset = Math.max(0, scrollOffset - Math.floor(availRows / 2));
|
|
574
|
+
render();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
// Enter — submit input
|
|
578
|
+
if (key.name === "return") {
|
|
579
|
+
const line = inputLine.trim();
|
|
580
|
+
inputLine = "";
|
|
581
|
+
if (!line) {
|
|
582
|
+
render();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (line.startsWith("/")) {
|
|
586
|
+
if (handleSlashCommand(line)) {
|
|
587
|
+
render();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Send as text to currently selected agent's pane
|
|
592
|
+
if (selectedId && panes.has(selectedId)) {
|
|
593
|
+
const pane = panes.get(selectedId);
|
|
594
|
+
appendToPane(pane, s.cyan(`> ${line}`) + "\n");
|
|
595
|
+
}
|
|
596
|
+
render();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Backspace
|
|
600
|
+
if (key.name === "backspace") {
|
|
601
|
+
if (inputLine.length > 0) {
|
|
602
|
+
inputLine = inputLine.slice(0, -1);
|
|
603
|
+
render();
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Regular character input
|
|
608
|
+
if (key.sequence && !key.ctrl && !key.meta) {
|
|
609
|
+
inputLine += key.sequence;
|
|
610
|
+
render();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
// Handle terminal resize
|
|
614
|
+
process.stdout.on("resize", () => render());
|
|
615
|
+
// Initial render
|
|
616
|
+
render();
|
|
617
|
+
// Register panes for any agents that already exist
|
|
618
|
+
for (const agent of spawner.listAgents()) {
|
|
619
|
+
getOrCreatePane(agent.id);
|
|
620
|
+
}
|
|
621
|
+
if (agentOrder.length > 0) {
|
|
622
|
+
selectedId = agentOrder[0];
|
|
623
|
+
}
|
|
624
|
+
render();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped allowlist for tool permissions.
|
|
3
|
+
*
|
|
4
|
+
* Tracks tool+pattern combos that the user has approved via "allow-session" (s)
|
|
5
|
+
* in the permission prompt. Checked before mode-based rules so approved tools
|
|
6
|
+
* skip the interactive prompt for the rest of the session.
|
|
7
|
+
*/
|
|
8
|
+
const sessionAllowlist = [];
|
|
9
|
+
/**
|
|
10
|
+
* Extract a matchable pattern from tool input.
|
|
11
|
+
* - File tools: the path argument
|
|
12
|
+
* - Shell: first token of the command (the binary)
|
|
13
|
+
* - Other tools: "*" (wildcard — allow all invocations)
|
|
14
|
+
*/
|
|
15
|
+
export function extractPattern(toolName, input) {
|
|
16
|
+
if (toolName === "shell") {
|
|
17
|
+
const cmd = (input.command || "").trim();
|
|
18
|
+
// Use the first token (the binary) as the pattern
|
|
19
|
+
return cmd.split(/\s+/)[0] || "*";
|
|
20
|
+
}
|
|
21
|
+
const filePath = input.path || input.file_path || "";
|
|
22
|
+
if (filePath)
|
|
23
|
+
return filePath;
|
|
24
|
+
return "*";
|
|
25
|
+
}
|
|
26
|
+
/** Check if a tool call is in the session allowlist. */
|
|
27
|
+
export function isAllowed(toolName, input) {
|
|
28
|
+
if (sessionAllowlist.length === 0)
|
|
29
|
+
return false;
|
|
30
|
+
const pattern = extractPattern(toolName, input);
|
|
31
|
+
return sessionAllowlist.some((entry) => {
|
|
32
|
+
if (entry.toolName !== toolName)
|
|
33
|
+
return false;
|
|
34
|
+
if (entry.pattern === "*")
|
|
35
|
+
return true;
|
|
36
|
+
// For file paths: exact match or child path
|
|
37
|
+
if (pattern.startsWith(entry.pattern))
|
|
38
|
+
return true;
|
|
39
|
+
// For shell commands: match the binary name
|
|
40
|
+
return entry.pattern === pattern;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Add a tool+pattern to the session allowlist. */
|
|
44
|
+
export function addAllow(toolName, input, scope) {
|
|
45
|
+
if (scope === "once")
|
|
46
|
+
return; // "once" approvals don't persist
|
|
47
|
+
const pattern = scope === "tool" ? "*" : extractPattern(toolName, input);
|
|
48
|
+
// Avoid duplicates
|
|
49
|
+
const exists = sessionAllowlist.some((e) => e.toolName === toolName && e.pattern === pattern);
|
|
50
|
+
if (!exists) {
|
|
51
|
+
sessionAllowlist.push({ toolName, pattern });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Clear the session allowlist (e.g., on session reset). */
|
|
55
|
+
export function clearAllowlist() {
|
|
56
|
+
sessionAllowlist.length = 0;
|
|
57
|
+
}
|
|
58
|
+
/** Get a snapshot of the current allowlist (for display). */
|
|
59
|
+
export function getAllowlist() {
|
|
60
|
+
return sessionAllowlist;
|
|
61
|
+
}
|