@phren/agent 0.1.2 → 0.1.4

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 (48) hide show
  1. package/dist/agent-loop/index.js +214 -0
  2. package/dist/agent-loop/stream.js +124 -0
  3. package/dist/agent-loop/types.js +13 -0
  4. package/dist/agent-loop.js +7 -326
  5. package/dist/commands/info.js +146 -0
  6. package/dist/commands/memory.js +165 -0
  7. package/dist/commands/model.js +138 -0
  8. package/dist/commands/session.js +213 -0
  9. package/dist/commands.js +25 -297
  10. package/dist/config.js +6 -2
  11. package/dist/index.js +10 -4
  12. package/dist/mcp-client.js +11 -7
  13. package/dist/multi/multi-commands.js +170 -0
  14. package/dist/multi/multi-events.js +81 -0
  15. package/dist/multi/multi-render.js +146 -0
  16. package/dist/multi/pane.js +28 -0
  17. package/dist/multi/spawner.js +3 -2
  18. package/dist/multi/tui-multi.js +39 -454
  19. package/dist/permissions/allowlist.js +2 -2
  20. package/dist/permissions/shell-safety.js +8 -0
  21. package/dist/providers/anthropic.js +72 -33
  22. package/dist/providers/codex.js +121 -60
  23. package/dist/providers/openai-compat.js +6 -1
  24. package/dist/repl.js +2 -2
  25. package/dist/system-prompt.js +24 -26
  26. package/dist/tools/glob.js +30 -6
  27. package/dist/tools/shell.js +5 -2
  28. package/dist/tui/ansi.js +48 -0
  29. package/dist/tui/components/AgentMessage.js +5 -0
  30. package/dist/tui/components/App.js +70 -0
  31. package/dist/tui/components/Banner.js +44 -0
  32. package/dist/tui/components/ChatMessage.js +23 -0
  33. package/dist/tui/components/InputArea.js +23 -0
  34. package/dist/tui/components/Separator.js +7 -0
  35. package/dist/tui/components/StatusBar.js +25 -0
  36. package/dist/tui/components/SteerQueue.js +7 -0
  37. package/dist/tui/components/StreamingText.js +5 -0
  38. package/dist/tui/components/ThinkingIndicator.js +20 -0
  39. package/dist/tui/components/ToolCall.js +11 -0
  40. package/dist/tui/components/UserMessage.js +5 -0
  41. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  42. package/dist/tui/hooks/useSlashCommands.js +52 -0
  43. package/dist/tui/index.js +5 -0
  44. package/dist/tui/ink-entry.js +271 -0
  45. package/dist/tui/menu-mode.js +86 -0
  46. package/dist/tui/tool-render.js +43 -0
  47. package/dist/tui.js +378 -252
  48. package/package.json +9 -2
package/dist/commands.js CHANGED
@@ -1,17 +1,10 @@
1
- import { estimateMessageTokens } from "./context/token-counter.js";
2
- import { pruneMessages } from "./context/pruner.js";
3
- import { listPresets, loadPreset, savePreset, deletePreset, formatPreset } from "./multi/presets.js";
4
- import { renderMarkdown } from "./multi/markdown.js";
5
- import { showModelPicker } from "./multi/model-picker.js";
6
- import { formatProviderList, formatModelAddHelp, addCustomModel, removeCustomModel } from "./multi/provider-manager.js";
1
+ // Sub-module handlers
2
+ import { helpCommand, turnsCommand, clearCommand, cwdCommand, filesCommand, costCommand, planCommand, undoCommand, contextCommand } from "./commands/info.js";
3
+ import { sessionCommand, historyCommand, compactCommand, diffCommand, gitCommand } from "./commands/session.js";
4
+ import { memCommand, askCommand } from "./commands/memory.js";
5
+ import { modelCommand, providerCommand, presetCommand } from "./commands/model.js";
7
6
  const DIM = "\x1b[2m";
8
- const BOLD = "\x1b[1m";
9
- const CYAN = "\x1b[36m";
10
- const GREEN = "\x1b[32m";
11
- const RED = "\x1b[31m";
12
- const YELLOW = "\x1b[33m";
13
7
  const RESET = "\x1b[0m";
14
- const HISTORY_MAX_LINES = 5;
15
8
  export function createCommandContext(session, contextLimit) {
16
9
  return {
17
10
  session,
@@ -19,227 +12,33 @@ export function createCommandContext(session, contextLimit) {
19
12
  undoStack: [],
20
13
  };
21
14
  }
22
- /** Truncate text to N lines, appending [+M lines] if overflow. */
23
- function truncateText(text, maxLines) {
24
- const lines = text.split("\n");
25
- if (lines.length <= maxLines)
26
- return text;
27
- const overflow = lines.length - maxLines;
28
- return lines.slice(0, maxLines).join("\n") + `\n${DIM}[+${overflow} lines]${RESET}`;
29
- }
30
15
  /**
31
16
  * Try to handle a slash command. Returns true if the input was a command.
17
+ * Returns a Promise<boolean> for async commands like /ask.
32
18
  */
33
19
  export function handleCommand(input, ctx) {
34
20
  const parts = input.trim().split(/\s+/);
35
21
  const name = parts[0];
36
22
  switch (name) {
37
- case "/help":
38
- process.stderr.write(`${DIM}Commands:
39
- /help Show this help
40
- /model Interactive model + reasoning picker
41
- /model add <id> Add a custom model
42
- /model remove <id> Remove a custom model
43
- /provider Show configured providers + auth status
44
- /turns Show turn and tool call counts
45
- /clear Clear conversation history
46
- /cost Show token usage and estimated cost
47
- /plan Show conversation plan (tool calls so far)
48
- /undo Undo last user message and response
49
- /history [n|full] Show last N messages (default 10) with rich formatting
50
- /compact Compact conversation to save context space
51
- /mode Toggle input mode (steering ↔ queue)
52
- /spawn <name> <task> Spawn a background agent
53
- /agents List running agents
54
- /preset [name|save|delete|list] Config presets
55
- /exit Exit the REPL${RESET}\n`);
56
- return true;
57
- case "/model": {
58
- const sub = parts[1]?.toLowerCase();
59
- // /model add <id> [provider=X] [context=N] [reasoning=X]
60
- if (sub === "add") {
61
- const modelId = parts[2];
62
- if (!modelId) {
63
- process.stderr.write(formatModelAddHelp() + "\n");
64
- return true;
65
- }
66
- let provider = ctx.providerName ?? "openrouter";
67
- let contextWindow = 128_000;
68
- let reasoning = null;
69
- const reasoningRange = [];
70
- for (const arg of parts.slice(3)) {
71
- const [k, v] = arg.split("=", 2);
72
- if (k === "provider")
73
- provider = v;
74
- else if (k === "context")
75
- contextWindow = parseInt(v, 10) || 128_000;
76
- else if (k === "reasoning") {
77
- reasoning = v;
78
- reasoningRange.push("low", "medium", "high");
79
- if (v === "max")
80
- reasoningRange.push("max");
81
- }
82
- }
83
- addCustomModel(modelId, provider, { contextWindow, reasoning, reasoningRange });
84
- process.stderr.write(`${GREEN}→ Added ${modelId} to ${provider}${RESET}\n`);
85
- return true;
86
- }
87
- // /model remove <id>
88
- if (sub === "remove" || sub === "rm") {
89
- const modelId = parts[2];
90
- if (!modelId) {
91
- process.stderr.write(`${DIM}Usage: /model remove <model-id>${RESET}\n`);
92
- return true;
93
- }
94
- const ok = removeCustomModel(modelId);
95
- process.stderr.write(ok ? `${GREEN}→ Removed ${modelId}${RESET}\n` : `${DIM}Model "${modelId}" not found in custom models.${RESET}\n`);
96
- return true;
97
- }
98
- // /model (no sub) — interactive picker
99
- if (!ctx.providerName) {
100
- process.stderr.write(`${DIM}Provider not configured. Start with --provider to set one.${RESET}\n`);
101
- return true;
102
- }
103
- showModelPicker(ctx.providerName, ctx.currentModel, process.stdout).then((result) => {
104
- if (result && ctx.onModelChange) {
105
- ctx.onModelChange(result);
106
- const reasoningLabel = result.reasoning ? ` (reasoning: ${result.reasoning})` : "";
107
- process.stderr.write(`${GREEN}→ ${result.model}${reasoningLabel}${RESET}\n`);
108
- }
109
- else if (result) {
110
- process.stderr.write(`${DIM}Model selected: ${result.model} — restart to apply.${RESET}\n`);
111
- }
112
- });
113
- return true;
114
- }
115
- case "/provider": {
116
- process.stderr.write(formatProviderList());
117
- return true;
118
- }
119
- case "/turns": {
120
- const tokens = estimateMessageTokens(ctx.session.messages);
121
- const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
122
- const costLine = ctx.costTracker ? ` Cost: $${ctx.costTracker.totalCost.toFixed(4)}` : "";
123
- process.stderr.write(`${DIM}Turns: ${ctx.session.turns} Tool calls: ${ctx.session.toolCalls} ` +
124
- `Messages: ${ctx.session.messages.length} Tokens: ~${tokens} (${pct}%)${costLine}${RESET}\n`);
125
- return true;
126
- }
127
- case "/clear":
128
- ctx.session.messages.length = 0;
129
- ctx.session.turns = 0;
130
- ctx.session.toolCalls = 0;
131
- ctx.undoStack.length = 0;
132
- process.stderr.write(`${DIM}Conversation cleared.${RESET}\n`);
133
- return true;
134
- case "/cost": {
135
- const ct = ctx.costTracker;
136
- if (ct) {
137
- process.stderr.write(`${DIM}Tokens — input: ${ct.inputTokens} output: ${ct.outputTokens} est. cost: $${ct.totalCost.toFixed(4)}${RESET}\n`);
138
- }
139
- else {
140
- process.stderr.write(`${DIM}Cost tracking not available.${RESET}\n`);
141
- }
142
- return true;
143
- }
144
- case "/plan": {
145
- const tools = [];
146
- for (const msg of ctx.session.messages) {
147
- if (typeof msg.content !== "string") {
148
- for (const block of msg.content) {
149
- if (block.type === "tool_use") {
150
- tools.push(block.name);
151
- }
152
- }
153
- }
154
- }
155
- if (tools.length === 0) {
156
- process.stderr.write(`${DIM}No tool calls yet.${RESET}\n`);
157
- }
158
- else {
159
- process.stderr.write(`${DIM}Tool calls (${tools.length}): ${tools.join(" → ")}${RESET}\n`);
160
- }
161
- return true;
162
- }
163
- case "/undo": {
164
- if (ctx.session.messages.length < 2) {
165
- process.stderr.write(`${DIM}Nothing to undo.${RESET}\n`);
166
- return true;
167
- }
168
- // Remove messages back to the previous user message
169
- let removed = 0;
170
- while (ctx.session.messages.length > 0) {
171
- const last = ctx.session.messages.pop();
172
- removed++;
173
- if (last?.role === "user" && typeof last.content === "string")
174
- break;
175
- }
176
- process.stderr.write(`${DIM}Undid ${removed} messages.${RESET}\n`);
177
- return true;
178
- }
179
- case "/history": {
180
- const msgs = ctx.session.messages;
181
- if (msgs.length === 0) {
182
- process.stderr.write(`${DIM}No messages yet.${RESET}\n`);
183
- return true;
184
- }
185
- const arg = parts[1];
186
- const isFull = arg === "full";
187
- const count = isFull ? msgs.length : Math.min(parseInt(arg, 10) || 10, msgs.length);
188
- const slice = msgs.slice(-count);
189
- const tokens = estimateMessageTokens(msgs);
190
- const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
191
- process.stderr.write(`${DIM}── History (${slice.length}/${msgs.length} messages, ~${tokens} tokens, ${pct}% context) ──${RESET}\n`);
192
- for (const msg of slice) {
193
- if (msg.role === "user") {
194
- if (typeof msg.content === "string") {
195
- const truncated = truncateText(msg.content, isFull ? Infinity : HISTORY_MAX_LINES);
196
- process.stderr.write(`\n${CYAN}${BOLD}You:${RESET} ${truncated}\n`);
197
- }
198
- else {
199
- // Tool results
200
- for (const block of msg.content) {
201
- if (block.type === "tool_result") {
202
- const icon = block.is_error ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
203
- const preview = (block.content ?? "").slice(0, 80).replace(/\n/g, " ");
204
- process.stderr.write(`${DIM} ${icon} tool_result ${preview}${preview.length >= 80 ? "..." : ""}${RESET}\n`);
205
- }
206
- else if (block.type === "text") {
207
- process.stderr.write(`${DIM} ${block.text.slice(0, 100)}${RESET}\n`);
208
- }
209
- }
210
- }
211
- }
212
- else if (msg.role === "assistant") {
213
- if (typeof msg.content === "string") {
214
- const rendered = isFull ? renderMarkdown(msg.content) : truncateText(msg.content, HISTORY_MAX_LINES);
215
- process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
216
- }
217
- else {
218
- for (const block of msg.content) {
219
- if (block.type === "text") {
220
- const text = block.text;
221
- const rendered = isFull ? renderMarkdown(text) : truncateText(text, HISTORY_MAX_LINES);
222
- process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
223
- }
224
- else if (block.type === "tool_use") {
225
- const tb = block;
226
- const inputPreview = JSON.stringify(tb.input).slice(0, 60);
227
- process.stderr.write(`${YELLOW} ⚡ ${tb.name}${RESET}${DIM}(${inputPreview})${RESET}\n`);
228
- }
229
- }
230
- }
231
- }
232
- }
233
- process.stderr.write(`${DIM}── end ──${RESET}\n`);
234
- return true;
235
- }
236
- case "/compact": {
237
- const before = ctx.session.messages.length;
238
- ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
239
- const after = ctx.session.messages.length;
240
- process.stderr.write(`${DIM}Compacted: ${before} → ${after} messages.${RESET}\n`);
241
- return true;
242
- }
23
+ case "/help": return helpCommand(parts, ctx);
24
+ case "/turns": return turnsCommand(parts, ctx);
25
+ case "/clear": return clearCommand(parts, ctx);
26
+ case "/cwd": return cwdCommand(parts, ctx);
27
+ case "/files": return filesCommand(parts, ctx);
28
+ case "/cost": return costCommand(parts, ctx);
29
+ case "/plan": return planCommand(parts, ctx);
30
+ case "/undo": return undoCommand(parts, ctx);
31
+ case "/context": return contextCommand(parts, ctx);
32
+ case "/model": return modelCommand(parts, ctx);
33
+ case "/provider": return providerCommand(parts, ctx);
34
+ case "/preset": return presetCommand(parts, ctx);
35
+ case "/session": return sessionCommand(parts, ctx);
36
+ case "/history": return historyCommand(parts, ctx);
37
+ case "/compact": return compactCommand(parts, ctx);
38
+ case "/diff": return diffCommand(parts, ctx);
39
+ case "/git": return gitCommand(parts, ctx);
40
+ case "/mem": return memCommand(parts, ctx);
41
+ case "/ask": return askCommand(parts, ctx);
243
42
  case "/spawn": {
244
43
  if (!ctx.spawner) {
245
44
  process.stderr.write(`${DIM}Spawner not available. Start with --multi or --team to enable.${RESET}\n`);
@@ -275,77 +74,6 @@ export function handleCommand(input, ctx) {
275
74
  }
276
75
  return true;
277
76
  }
278
- case "/preset": {
279
- const sub = parts[1]?.toLowerCase();
280
- if (!sub || sub === "list") {
281
- const all = listPresets();
282
- if (all.length === 0) {
283
- process.stderr.write(`${DIM}No presets.${RESET}\n`);
284
- }
285
- else {
286
- const lines = all.map((p) => ` ${formatPreset(p.name, p.preset, p.builtin)}`);
287
- process.stderr.write(`${DIM}Presets:\n${lines.join("\n")}${RESET}\n`);
288
- }
289
- return true;
290
- }
291
- if (sub === "save") {
292
- // /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]
293
- const presetName = parts[2];
294
- if (!presetName) {
295
- process.stderr.write(`${DIM}Usage: /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]${RESET}\n`);
296
- return true;
297
- }
298
- const preset = {};
299
- for (const arg of parts.slice(3)) {
300
- const [k, v] = arg.split("=", 2);
301
- if (k === "provider")
302
- preset.provider = v;
303
- else if (k === "model")
304
- preset.model = v;
305
- else if (k === "permissions")
306
- preset.permissions = v;
307
- else if (k === "max-turns")
308
- preset.maxTurns = parseInt(v, 10) || undefined;
309
- else if (k === "budget")
310
- preset.budget = v === "none" ? null : parseFloat(v) || undefined;
311
- else if (k === "plan")
312
- preset.plan = true;
313
- }
314
- try {
315
- savePreset(presetName, preset);
316
- process.stderr.write(`${DIM}Saved preset "${presetName}".${RESET}\n`);
317
- }
318
- catch (err) {
319
- process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
320
- }
321
- return true;
322
- }
323
- if (sub === "delete") {
324
- const presetName = parts[2];
325
- if (!presetName) {
326
- process.stderr.write(`${DIM}Usage: /preset delete <name>${RESET}\n`);
327
- return true;
328
- }
329
- try {
330
- const ok = deletePreset(presetName);
331
- process.stderr.write(`${DIM}${ok ? `Deleted "${presetName}".` : `Preset "${presetName}" not found.`}${RESET}\n`);
332
- }
333
- catch (err) {
334
- process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
335
- }
336
- return true;
337
- }
338
- // /preset <name> — show preset details (use --preset <name> on CLI to apply at startup)
339
- const preset = loadPreset(sub);
340
- if (!preset) {
341
- process.stderr.write(`${DIM}Preset "${sub}" not found. Use /preset list to see available presets.${RESET}\n`);
342
- }
343
- else {
344
- const isBuiltin = ["fast", "careful", "yolo"].includes(sub);
345
- process.stderr.write(`${DIM}${formatPreset(sub, preset, isBuiltin)}\nUse: phren-agent --preset ${sub} <task>${RESET}\n`);
346
- }
347
- return true;
348
- }
349
77
  case "/exit":
350
78
  case "/quit":
351
79
  case "/q":
package/dist/config.js CHANGED
@@ -11,7 +11,8 @@ Options:
11
11
  --max-output <n> Max output tokens per response (default: auto per model)
12
12
  --budget <dollars> Max spend in USD (aborts when exceeded)
13
13
  --plan Plan mode: show plan before executing tools
14
- --permissions <mode> Permission mode: suggest, auto-confirm, full-auto (default: auto-confirm)
14
+ --permissions <mode> Permission mode: suggest (default), auto-confirm, full-auto
15
+ --yolo Full-auto permissions — no confirmations (alias for --permissions full-auto)
15
16
  --interactive, -i Interactive REPL mode (multi-turn conversation)
16
17
  --resume Resume last session's conversation
17
18
  --lint-cmd <cmd> Override auto-detected lint command
@@ -46,7 +47,7 @@ Examples:
46
47
  export function parseArgs(argv) {
47
48
  const args = {
48
49
  task: "",
49
- permissions: "auto-confirm",
50
+ permissions: "suggest",
50
51
  maxTurns: 50,
51
52
  budget: null,
52
53
  plan: false,
@@ -120,6 +121,9 @@ export function parseArgs(argv) {
120
121
  else if (arg === "--budget" && argv[i + 1]) {
121
122
  args.budget = parseFloat(argv[++i]) || null;
122
123
  }
124
+ else if (arg === "--yolo") {
125
+ args.permissions = "full-auto";
126
+ }
123
127
  else if (arg === "--permissions" && argv[i + 1]) {
124
128
  const mode = argv[++i];
125
129
  if (mode === "suggest" || mode === "auto-confirm" || mode === "full-auto") {
package/dist/index.js CHANGED
@@ -165,6 +165,7 @@ export async function runAgentCli(raw) {
165
165
  costTracker,
166
166
  plan: args.plan,
167
167
  lintTestConfig,
168
+ sessionId,
168
169
  };
169
170
  // Multi-agent TUI mode
170
171
  if (args.multi || args.team) {
@@ -183,12 +184,17 @@ export async function runAgentCli(raw) {
183
184
  mcpCleanup?.();
184
185
  return;
185
186
  }
186
- // Interactive mode — TUI if terminal, fallback to REPL if not
187
+ // Interactive mode — Ink TUI if available, legacy TUI fallback, REPL if not TTY
187
188
  if (args.interactive) {
188
189
  const isTTY = process.stdout.isTTY && process.stdin.isTTY;
189
- const session = isTTY
190
- ? await (await import("./tui.js")).startTui(agentConfig)
191
- : await (await import("./repl.js")).startRepl(agentConfig);
190
+ let session;
191
+ if (!isTTY) {
192
+ session = await (await import("./repl.js")).startRepl(agentConfig);
193
+ }
194
+ else {
195
+ // Ink TUI only — no legacy fallback
196
+ session = await (await import("./tui/ink-entry.js")).startInkTui(agentConfig);
197
+ }
192
198
  // Flush anti-patterns at session end
193
199
  if (phrenCtx) {
194
200
  try {
@@ -24,7 +24,8 @@ class McpConnection {
24
24
  try {
25
25
  const msg = JSON.parse(line);
26
26
  if (msg.id !== undefined && this.pending.has(msg.id)) {
27
- const { resolve, reject } = this.pending.get(msg.id);
27
+ const { resolve, reject, timer } = this.pending.get(msg.id);
28
+ clearTimeout(timer);
28
29
  this.pending.delete(msg.id);
29
30
  if (msg.error)
30
31
  reject(new Error(`MCP error: ${msg.error.message}`));
@@ -35,8 +36,10 @@ class McpConnection {
35
36
  catch { /* ignore non-JSON lines */ }
36
37
  });
37
38
  this.proc.on("error", (err) => {
38
- for (const { reject } of this.pending.values())
39
+ for (const { reject, timer } of this.pending.values()) {
40
+ clearTimeout(timer);
39
41
  reject(err);
42
+ }
40
43
  this.pending.clear();
41
44
  });
42
45
  }
@@ -44,15 +47,14 @@ class McpConnection {
44
47
  return new Promise((resolve, reject) => {
45
48
  const id = this.nextId++;
46
49
  const msg = { jsonrpc: "2.0", id, method, params };
47
- this.pending.set(id, { resolve, reject });
48
- this.proc.stdin.write(JSON.stringify(msg) + "\n");
49
- // Timeout after 30s
50
- setTimeout(() => {
50
+ const timer = setTimeout(() => {
51
51
  if (this.pending.has(id)) {
52
52
  this.pending.delete(id);
53
53
  reject(new Error(`MCP call ${method} timed out (30s)`));
54
54
  }
55
55
  }, 30_000);
56
+ this.pending.set(id, { resolve, reject, timer });
57
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
56
58
  });
57
59
  }
58
60
  async initialize() {
@@ -81,8 +83,10 @@ class McpConnection {
81
83
  }
82
84
  catch { /* ignore */ }
83
85
  this.rl.close();
84
- for (const { reject } of this.pending.values())
86
+ for (const { reject, timer } of this.pending.values()) {
87
+ clearTimeout(timer);
85
88
  reject(new Error("Connection closed"));
89
+ }
86
90
  this.pending.clear();
87
91
  }
88
92
  }
@@ -0,0 +1,170 @@
1
+ import { createPane, appendToPane, flushPartial } from "./pane.js";
2
+ import { s, statusColor } from "./multi-render.js";
3
+ function resolveAgentTarget(target, agentOrder, panes, spawner) {
4
+ // Try numeric index (1-based)
5
+ const idx = parseInt(target, 10);
6
+ if (!isNaN(idx) && idx >= 1 && idx <= agentOrder.length) {
7
+ return agentOrder[idx - 1];
8
+ }
9
+ // Try name match
10
+ for (const [id, pane] of panes) {
11
+ if (pane.name === target)
12
+ return id;
13
+ }
14
+ // Try agent ID
15
+ if (spawner.getAgent(target))
16
+ return target;
17
+ return null;
18
+ }
19
+ function appendToSystem(ctx, text) {
20
+ if (!ctx.selectedId || !ctx.panes.has(ctx.selectedId)) {
21
+ // Create a virtual system pane
22
+ const pane = createPane("_system", "system");
23
+ ctx.panes.set("_system", pane);
24
+ if (!ctx.agentOrder.includes("_system"))
25
+ ctx.agentOrder.push("_system");
26
+ ctx.setSelectedId("_system");
27
+ appendToPane(pane, text + "\n");
28
+ }
29
+ else {
30
+ const pane = ctx.panes.get(ctx.selectedId);
31
+ flushPartial(pane);
32
+ appendToPane(pane, text + "\n");
33
+ }
34
+ ctx.render();
35
+ }
36
+ export function handleSlashCommand(line, ctx) {
37
+ const parts = line.split(/\s+/);
38
+ const cmd = parts[0].toLowerCase();
39
+ if (cmd === "/spawn") {
40
+ const name = parts[1];
41
+ const task = parts.slice(2).join(" ");
42
+ if (!name || !task) {
43
+ appendToSystem(ctx, "Usage: /spawn <name> <task>");
44
+ return true;
45
+ }
46
+ const opts = {
47
+ task,
48
+ cwd: process.cwd(),
49
+ provider: ctx.config.provider.name,
50
+ permissions: "auto-confirm",
51
+ verbose: ctx.config.verbose,
52
+ };
53
+ const agentId = ctx.spawner.spawn(opts);
54
+ const pane = ctx.getOrCreatePane(agentId);
55
+ pane.name = name;
56
+ appendToPane(pane, s.cyan(`Spawned agent "${name}" (${agentId}): ${task}`) + "\n");
57
+ ctx.setSelectedId(agentId);
58
+ ctx.render();
59
+ return true;
60
+ }
61
+ if (cmd === "/list") {
62
+ const agents = ctx.spawner.listAgents();
63
+ if (agents.length === 0) {
64
+ appendToSystem(ctx, "No agents.");
65
+ }
66
+ else {
67
+ const lines = ["Agents:"];
68
+ for (let i = 0; i < agents.length; i++) {
69
+ const a = agents[i];
70
+ const pane = ctx.panes.get(a.id);
71
+ const name = pane?.name ?? a.id;
72
+ const color = statusColor(a.status);
73
+ const elapsed = a.finishedAt
74
+ ? `${((a.finishedAt - a.startedAt) / 1000).toFixed(1)}s`
75
+ : `${((Date.now() - a.startedAt) / 1000).toFixed(0)}s`;
76
+ lines.push(` ${i + 1}. ${name} [${color(a.status)}] ${s.dim(elapsed)} — ${a.task.slice(0, 50)}`);
77
+ }
78
+ appendToSystem(ctx, lines.join("\n"));
79
+ }
80
+ return true;
81
+ }
82
+ if (cmd === "/kill") {
83
+ const target = parts[1];
84
+ if (!target) {
85
+ appendToSystem(ctx, "Usage: /kill <name|index>");
86
+ return true;
87
+ }
88
+ const agentId = resolveAgentTarget(target, ctx.agentOrder, ctx.panes, ctx.spawner);
89
+ if (!agentId) {
90
+ appendToSystem(ctx, `Agent "${target}" not found.`);
91
+ return true;
92
+ }
93
+ const ok = ctx.spawner.cancel(agentId);
94
+ const pane = ctx.getOrCreatePane(agentId);
95
+ if (ok) {
96
+ appendToPane(pane, s.yellow("\n--- Cancelled ---\n"));
97
+ }
98
+ else {
99
+ appendToSystem(ctx, `Agent "${target}" is not running.`);
100
+ }
101
+ ctx.render();
102
+ return true;
103
+ }
104
+ if (cmd === "/broadcast") {
105
+ const msg = parts.slice(1).join(" ");
106
+ if (!msg) {
107
+ appendToSystem(ctx, "Usage: /broadcast <message>");
108
+ return true;
109
+ }
110
+ const agents = ctx.spawner.listAgents();
111
+ let sent = 0;
112
+ for (const a of agents) {
113
+ if (a.status === "running") {
114
+ const pane = ctx.getOrCreatePane(a.id);
115
+ appendToPane(pane, s.yellow(`[broadcast] ${msg}`) + "\n");
116
+ sent++;
117
+ }
118
+ }
119
+ appendToSystem(ctx, `Broadcast sent to ${sent} running agent(s).`);
120
+ return true;
121
+ }
122
+ if (cmd === "/msg") {
123
+ const target = parts[1];
124
+ const msg = parts.slice(2).join(" ");
125
+ if (!target || !msg) {
126
+ appendToSystem(ctx, "Usage: /msg <agent> <text>");
127
+ return true;
128
+ }
129
+ const agentId = resolveAgentTarget(target, ctx.agentOrder, ctx.panes, ctx.spawner);
130
+ if (!agentId) {
131
+ appendToSystem(ctx, `Agent "${target}" not found.`);
132
+ return true;
133
+ }
134
+ const ok = ctx.spawner.sendToAgent(agentId, msg, "user");
135
+ if (ok) {
136
+ const recipientPane = ctx.getOrCreatePane(agentId);
137
+ flushPartial(recipientPane);
138
+ appendToPane(recipientPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
139
+ if (ctx.selectedId && ctx.selectedId !== agentId && ctx.panes.has(ctx.selectedId)) {
140
+ const curPane = ctx.panes.get(ctx.selectedId);
141
+ flushPartial(curPane);
142
+ appendToPane(curPane, s.yellow(`[user -> ${recipientPane.name}] ${msg}`) + "\n");
143
+ }
144
+ }
145
+ else {
146
+ appendToSystem(ctx, `Agent "${target}" is not running.`);
147
+ }
148
+ ctx.render();
149
+ return true;
150
+ }
151
+ if (cmd === "/help") {
152
+ appendToSystem(ctx, [
153
+ "Commands:",
154
+ " /spawn <name> <task> — Spawn a new agent",
155
+ " /list — List all agents",
156
+ " /kill <name|index> — Terminate an agent",
157
+ " /msg <agent> <text> — Send direct message to an agent",
158
+ " /broadcast <msg> — Send to all running agents",
159
+ " /help — Show this help",
160
+ "",
161
+ "Keys:",
162
+ " 1-9 — Select agent by number",
163
+ " Ctrl+Left/Right — Cycle agents",
164
+ " PageUp/PageDown — Scroll output",
165
+ " Ctrl+D — Exit (kills all)",
166
+ ].join("\n"));
167
+ return true;
168
+ }
169
+ return false;
170
+ }