wispy-cli 0.8.0 → 1.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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * core/subagents.mjs — Sub-agent orchestration for Wispy v0.9.0
3
+ *
4
+ * Class SubAgentManager:
5
+ * - async spawn(opts) → SubAgent
6
+ * - list() → SubAgent[]
7
+ * - get(id) → SubAgent
8
+ * - kill(id) → void
9
+ * - steer(id, message) → void
10
+ * - async waitFor(id, timeoutMs?) → Result
11
+ * - async waitForAll(ids) → Result[]
12
+ */
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+ import { WISPY_DIR } from "./config.mjs";
18
+
19
+ const SUBAGENTS_DIR = path.join(WISPY_DIR, "subagents");
20
+
21
+ function makeId() {
22
+ return `sa-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
23
+ }
24
+
25
+ export class SubAgent {
26
+ constructor({ id, task, label, model, timeout, workstream, status = "pending" }) {
27
+ this.id = id;
28
+ this.task = task;
29
+ this.label = label ?? `sub-agent-${id}`;
30
+ this.model = model ?? null;
31
+ this.timeout = timeout ?? 300_000; // ms
32
+ this.workstream = workstream ?? "default";
33
+ this.status = status; // pending → running → completed | failed | killed | timeout
34
+ this.result = null;
35
+ this.error = null;
36
+ this.createdAt = new Date().toISOString();
37
+ this.startedAt = null;
38
+ this.completedAt = null;
39
+ this._abortController = new AbortController();
40
+ this._steerMessages = []; // guidance queue
41
+ this._promise = null; // internal execution promise
42
+ }
43
+
44
+ toJSON() {
45
+ return {
46
+ id: this.id,
47
+ task: this.task,
48
+ label: this.label,
49
+ model: this.model,
50
+ timeout: this.timeout,
51
+ workstream: this.workstream,
52
+ status: this.status,
53
+ result: this.result,
54
+ error: this.error,
55
+ createdAt: this.createdAt,
56
+ startedAt: this.startedAt,
57
+ completedAt: this.completedAt,
58
+ };
59
+ }
60
+ }
61
+
62
+ export class SubAgentManager {
63
+ /**
64
+ * @param {import('./engine.mjs').WispyEngine} engine
65
+ * @param {import('./session.mjs').SessionManager} sessionManager
66
+ */
67
+ constructor(engine, sessionManager) {
68
+ this._engine = engine;
69
+ this._sessions = sessionManager;
70
+ this._agents = new Map(); // id → SubAgent
71
+ }
72
+
73
+ /**
74
+ * Spawn a new sub-agent.
75
+ * @param {object} opts
76
+ * @param {string} opts.task
77
+ * @param {string} [opts.label]
78
+ * @param {string} [opts.model]
79
+ * @param {number} [opts.timeout] - milliseconds (default 300_000)
80
+ * @param {string} [opts.workstream]
81
+ * @param {Function} [opts.onComplete] - callback(result)
82
+ * @param {Function} [opts.onNotify] - channel notification callback(type, text)
83
+ * @returns {Promise<SubAgent>}
84
+ */
85
+ async spawn(opts) {
86
+ const agent = new SubAgent({
87
+ id: makeId(),
88
+ task: opts.task,
89
+ label: opts.label,
90
+ model: opts.model,
91
+ timeout: opts.timeout ? opts.timeout * 1000 : 300_000,
92
+ workstream: opts.workstream ?? this._engine._activeWorkstream,
93
+ });
94
+
95
+ this._agents.set(agent.id, agent);
96
+
97
+ // Run async without awaiting
98
+ agent._promise = this._run(agent, opts).catch((err) => {
99
+ if (agent.status === "running" || agent.status === "pending") {
100
+ agent.status = "failed";
101
+ agent.error = err.message;
102
+ agent.completedAt = new Date().toISOString();
103
+ this._persist(agent);
104
+ opts.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${err.message}`);
105
+ }
106
+ });
107
+
108
+ return agent;
109
+ }
110
+
111
+ /**
112
+ * Internal: run the sub-agent's agentic loop.
113
+ */
114
+ async _run(agent, opts) {
115
+ agent.status = "running";
116
+ agent.startedAt = new Date().toISOString();
117
+
118
+ // Create an isolated session for this sub-agent
119
+ const session = this._sessions.create({ workstream: agent.workstream });
120
+
121
+ // Build initial messages
122
+ const systemPrompt = `You are Wispy 🌿 — a sub-agent handling a delegated task.
123
+ Be focused, thorough, and efficient. Complete the task fully.
124
+ Reply in the same language as the task. Sign off with 🌿.`;
125
+
126
+ const messages = [
127
+ { role: "system", content: systemPrompt },
128
+ { role: "user", content: agent.task },
129
+ ];
130
+
131
+ // Timeout logic
132
+ let timedOut = false;
133
+ const timeoutHandle = setTimeout(() => {
134
+ timedOut = true;
135
+ agent._abortController.abort();
136
+ }, agent.timeout);
137
+
138
+ try {
139
+ const MAX_ROUNDS = 15;
140
+ let round = 0;
141
+
142
+ while (round < MAX_ROUNDS) {
143
+ // Check if killed
144
+ if (agent.status === "killed") break;
145
+ if (timedOut) {
146
+ agent.status = "timeout";
147
+ agent.error = "Timed out";
148
+ agent.completedAt = new Date().toISOString();
149
+ await this._persist(agent);
150
+ opts?.onNotify?.("error", `⏰ Sub-agent '${agent.label}' timed out.`);
151
+ return;
152
+ }
153
+
154
+ // Inject any steering messages
155
+ while (agent._steerMessages.length > 0) {
156
+ const steerMsg = agent._steerMessages.shift();
157
+ messages.push({ role: "user", content: `[Guidance from orchestrator]: ${steerMsg}` });
158
+ }
159
+
160
+ // Call provider
161
+ const result = await this._engine.providers.chat(
162
+ messages,
163
+ this._engine.tools.getDefinitions(),
164
+ { model: agent.model }
165
+ );
166
+
167
+ if (result.type === "text") {
168
+ // Final answer
169
+ agent.result = result.text;
170
+ agent.status = "completed";
171
+ agent.completedAt = new Date().toISOString();
172
+ await this._persist(agent);
173
+
174
+ const summary = result.text.slice(0, 200).replace(/\n/g, " ");
175
+ opts?.onNotify?.("success", `✅ Sub-agent '${agent.label}' completed: ${summary}`);
176
+ opts?.onComplete?.(agent);
177
+ clearTimeout(timeoutHandle);
178
+ return;
179
+ }
180
+
181
+ // Handle tool calls
182
+ messages.push({ role: "assistant", toolCalls: result.calls, content: "" });
183
+
184
+ for (const call of result.calls) {
185
+ let toolResult;
186
+ try {
187
+ // Enforce per-tool timeout of 60s to prevent runaway tools
188
+ const TOOL_TIMEOUT_MS = 60_000;
189
+ toolResult = await Promise.race([
190
+ this._engine._executeTool(call.name, call.args, messages, session, {}),
191
+ new Promise((_, reject) =>
192
+ setTimeout(() => reject(new Error(`Tool '${call.name}' timed out`)), TOOL_TIMEOUT_MS)
193
+ ),
194
+ ]);
195
+ } catch (err) {
196
+ toolResult = { error: err.message, success: false };
197
+ }
198
+ messages.push({
199
+ role: "tool_result",
200
+ toolName: call.name,
201
+ toolUseId: call.id ?? call.name,
202
+ result: toolResult,
203
+ });
204
+ }
205
+
206
+ round++;
207
+ }
208
+
209
+ // Max rounds reached
210
+ agent.result = "(max rounds reached — partial work above)";
211
+ agent.status = "completed";
212
+ agent.completedAt = new Date().toISOString();
213
+ await this._persist(agent);
214
+ opts?.onNotify?.("success", `✅ Sub-agent '${agent.label}' completed (max rounds).`);
215
+ } catch (err) {
216
+ clearTimeout(timeoutHandle);
217
+ if (agent.status !== "killed") {
218
+ agent.status = "failed";
219
+ agent.error = err.message;
220
+ agent.completedAt = new Date().toISOString();
221
+ await this._persist(agent);
222
+ opts?.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${err.message}`);
223
+ }
224
+ } finally {
225
+ clearTimeout(timeoutHandle);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * List all sub-agents (active + recent in-memory).
231
+ */
232
+ list() {
233
+ return Array.from(this._agents.values());
234
+ }
235
+
236
+ /**
237
+ * Get a sub-agent by ID.
238
+ */
239
+ get(id) {
240
+ return this._agents.get(id) ?? null;
241
+ }
242
+
243
+ /**
244
+ * Kill (cancel) a running sub-agent.
245
+ */
246
+ kill(id) {
247
+ const agent = this._agents.get(id);
248
+ if (!agent) return;
249
+ if (agent.status === "running" || agent.status === "pending") {
250
+ agent.status = "killed";
251
+ agent.completedAt = new Date().toISOString();
252
+ agent._abortController.abort();
253
+ this._persist(agent).catch(() => {});
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Send guidance/steering to a running sub-agent.
259
+ */
260
+ steer(id, message) {
261
+ const agent = this._agents.get(id);
262
+ if (!agent) throw new Error(`Sub-agent not found: ${id}`);
263
+ if (agent.status !== "running" && agent.status !== "pending") {
264
+ throw new Error(`Sub-agent ${id} is not running (status: ${agent.status})`);
265
+ }
266
+ agent._steerMessages.push(message);
267
+ }
268
+
269
+ /**
270
+ * Wait for a specific sub-agent to complete.
271
+ * @param {string} id
272
+ * @param {number} [timeoutMs]
273
+ * @returns {Promise<{id, status, result, error}>}
274
+ */
275
+ async waitFor(id, timeoutMs) {
276
+ const agent = this._agents.get(id);
277
+ if (!agent) throw new Error(`Sub-agent not found: ${id}`);
278
+
279
+ if (agent.status === "completed" || agent.status === "failed" ||
280
+ agent.status === "killed" || agent.status === "timeout") {
281
+ return agent.toJSON();
282
+ }
283
+
284
+ if (!agent._promise) throw new Error(`Sub-agent ${id} has no active promise`);
285
+
286
+ if (timeoutMs) {
287
+ const timeoutPromise = new Promise((_, reject) =>
288
+ setTimeout(() => reject(new Error(`waitFor timed out after ${timeoutMs}ms`)), timeoutMs)
289
+ );
290
+ await Promise.race([agent._promise, timeoutPromise]);
291
+ } else {
292
+ await agent._promise;
293
+ }
294
+
295
+ return agent.toJSON();
296
+ }
297
+
298
+ /**
299
+ * Wait for multiple sub-agents to complete.
300
+ * @param {string[]} ids
301
+ * @returns {Promise<Array>}
302
+ */
303
+ async waitForAll(ids) {
304
+ return Promise.all(ids.map(id => this.waitFor(id)));
305
+ }
306
+
307
+ /**
308
+ * Persist a sub-agent's result to disk.
309
+ */
310
+ async _persist(agent) {
311
+ try {
312
+ await mkdir(SUBAGENTS_DIR, { recursive: true });
313
+ const filePath = path.join(SUBAGENTS_DIR, `${agent.id}.json`);
314
+ await writeFile(filePath, JSON.stringify(agent.toJSON(), null, 2) + "\n", "utf8");
315
+ } catch {
316
+ // Non-fatal
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Load persisted sub-agent from disk (for history).
322
+ */
323
+ async loadFromDisk(id) {
324
+ try {
325
+ const filePath = path.join(SUBAGENTS_DIR, `${id}.json`);
326
+ const data = JSON.parse(await readFile(filePath, "utf8"));
327
+ return data;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * List persisted sub-agent history from disk.
335
+ */
336
+ async listHistory(limit = 20) {
337
+ try {
338
+ const { readdir } = await import("node:fs/promises");
339
+ const files = await readdir(SUBAGENTS_DIR);
340
+ const jsonFiles = files.filter(f => f.endsWith(".json")).sort().reverse().slice(0, limit);
341
+ const results = [];
342
+ for (const f of jsonFiles) {
343
+ try {
344
+ const data = JSON.parse(await readFile(path.join(SUBAGENTS_DIR, f), "utf8"));
345
+ results.push(data);
346
+ } catch {}
347
+ }
348
+ return results;
349
+ } catch {
350
+ return [];
351
+ }
352
+ }
353
+ }
package/core/tools.mjs CHANGED
@@ -221,6 +221,60 @@ export class ToolRegistry {
221
221
  required: ["task"],
222
222
  },
223
223
  },
224
+ // ── Sub-agent tools (v0.9) ───────────────────────────────────────────────
225
+ {
226
+ name: "spawn_subagent",
227
+ description: "Spawn an isolated sub-agent to handle a task independently. Use for parallel work, long-running tasks, or delegating to specialized models.",
228
+ parameters: {
229
+ type: "object",
230
+ properties: {
231
+ task: { type: "string", description: "The task description/prompt for the sub-agent" },
232
+ label: { type: "string", description: "Human-readable label for the sub-agent" },
233
+ model: { type: "string", description: "Model override (e.g., 'flash' for cheap tasks, 'claude-opus-4-5' for complex)" },
234
+ timeout_seconds: { type: "number", description: "Max runtime in seconds (default 300)" },
235
+ },
236
+ required: ["task"],
237
+ },
238
+ },
239
+ {
240
+ name: "list_subagents",
241
+ description: "List all sub-agents (active and recent)",
242
+ parameters: { type: "object", properties: {}, required: [] },
243
+ },
244
+ {
245
+ name: "get_subagent_result",
246
+ description: "Get the result of a completed sub-agent",
247
+ parameters: {
248
+ type: "object",
249
+ properties: {
250
+ id: { type: "string", description: "Sub-agent ID" },
251
+ },
252
+ required: ["id"],
253
+ },
254
+ },
255
+ {
256
+ name: "kill_subagent",
257
+ description: "Cancel a running sub-agent",
258
+ parameters: {
259
+ type: "object",
260
+ properties: {
261
+ id: { type: "string", description: "Sub-agent ID" },
262
+ },
263
+ required: ["id"],
264
+ },
265
+ },
266
+ {
267
+ name: "steer_subagent",
268
+ description: "Send additional guidance to a running sub-agent",
269
+ parameters: {
270
+ type: "object",
271
+ properties: {
272
+ id: { type: "string", description: "Sub-agent ID" },
273
+ message: { type: "string", description: "Guidance message" },
274
+ },
275
+ required: ["id", "message"],
276
+ },
277
+ },
224
278
  ];
225
279
 
226
280
  for (const def of builtins) {
@@ -514,6 +568,12 @@ export class ToolRegistry {
514
568
  case "pipeline":
515
569
  case "spawn_async_agent":
516
570
  case "ralph_loop":
571
+ // Sub-agent tools (v0.9) — handled at engine level
572
+ case "spawn_subagent":
573
+ case "list_subagents":
574
+ case "get_subagent_result":
575
+ case "kill_subagent":
576
+ case "steer_subagent":
517
577
  return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
518
578
 
519
579
  default:
@@ -330,6 +330,18 @@ ${bold("Wispy Commands:")}
330
330
  ${cyan("/forget")} <key> Delete a memory file
331
331
  ${cyan("/memories")} List all memory files
332
332
  ${cyan("/recall")} <query> Search memories
333
+
334
+ ${bold("Sub-agent Commands (v0.9):")}
335
+ ${cyan("/agents")} List active/recent sub-agents
336
+ ${cyan("/agent")} <id> Show sub-agent details and result
337
+ ${cyan("/kill")} <id> Cancel a running sub-agent
338
+
339
+ ${bold("Permissions & Audit (v1.1):")}
340
+ ${cyan("/permissions")} Show current permission policies
341
+ ${cyan("/permit")} <tool> <level> Change policy (auto|notify|approve)
342
+ ${cyan("/audit")} Show last 10 audit events
343
+ ${cyan("/replay")} Replay current session steps
344
+
333
345
  ${cyan("/quit")} or ${cyan("/exit")} Exit
334
346
  `);
335
347
  return true;
@@ -614,6 +626,148 @@ ${bold("Wispy Commands:")}
614
626
  return true;
615
627
  }
616
628
 
629
+ // ── Sub-agent commands (v0.9) ──────────────────────────────────────────────
630
+
631
+ if (cmd === "/agents") {
632
+ const inMemory = engine.subagents.list();
633
+ const history = await engine.subagents.listHistory(20);
634
+ const inMemoryIds = new Set(inMemory.map(a => a.id));
635
+ const all = [
636
+ ...inMemory.map(a => a.toJSON()),
637
+ ...history.filter(h => !inMemoryIds.has(h.id)),
638
+ ].slice(0, 30);
639
+
640
+ if (all.length === 0) {
641
+ console.log(dim("No sub-agents yet. Ask wispy to spawn one!"));
642
+ } else {
643
+ console.log(bold(`\n🤖 Sub-agents (${all.length}):\n`));
644
+ for (const a of all) {
645
+ const icon = { completed: green("✓"), failed: red("✗"), running: cyan("⟳"), killed: dim("✕"), timeout: yellow("⏰"), pending: dim("…") }[a.status] ?? "?";
646
+ const elapsed = a.completedAt ? "" : a.startedAt ? ` ${dim(`(running ${Math.round((Date.now() - new Date(a.startedAt)) / 1000)}s)`)}` : "";
647
+ console.log(` ${icon} ${bold(a.id)} ${dim("·")} ${a.label}${elapsed}`);
648
+ console.log(` ${dim(`task: ${(a.task ?? "").slice(0, 70)}${(a.task?.length ?? 0) > 70 ? "…" : ""}`)}`);
649
+ if (a.result) console.log(` ${dim(`result: ${a.result.slice(0, 80)}${a.result.length > 80 ? "…" : ""}`)}`);
650
+ if (a.error) console.log(` ${red(`error: ${a.error.slice(0, 80)}`)}`);
651
+ console.log("");
652
+ }
653
+ }
654
+ return true;
655
+ }
656
+
657
+ if (cmd === "/agent") {
658
+ const id = parts[1];
659
+ if (!id) { console.log(yellow("Usage: /agent <id>")); return true; }
660
+
661
+ let agent = engine.subagents.get(id)?.toJSON();
662
+ if (!agent) agent = await engine.subagents.loadFromDisk(id);
663
+
664
+ if (!agent) {
665
+ console.log(red(`Sub-agent not found: ${id}`));
666
+ } else {
667
+ console.log(bold(`\n🤖 Sub-agent: ${agent.id}\n`));
668
+ console.log(` ${dim("Label:")} ${agent.label}`);
669
+ console.log(` ${dim("Status:")} ${agent.status}`);
670
+ console.log(` ${dim("Model:")} ${agent.model ?? "(default)"}`);
671
+ console.log(` ${dim("Created:")} ${agent.createdAt}`);
672
+ if (agent.startedAt) console.log(` ${dim("Started:")} ${agent.startedAt}`);
673
+ if (agent.completedAt) console.log(` ${dim("Done:")} ${agent.completedAt}`);
674
+ console.log(`\n ${bold("Task:")}\n ${dim(agent.task)}\n`);
675
+ if (agent.result) {
676
+ console.log(` ${bold("Result:")}\n ${agent.result.slice(0, 2000)}`);
677
+ }
678
+ if (agent.error) {
679
+ console.log(` ${bold("Error:")} ${red(agent.error)}`);
680
+ }
681
+ }
682
+ return true;
683
+ }
684
+
685
+ if (cmd === "/kill") {
686
+ const id = parts[1];
687
+ if (!id) { console.log(yellow("Usage: /kill <id>")); return true; }
688
+ const agent = engine.subagents.get(id);
689
+ if (!agent) { console.log(red(`Sub-agent not found: ${id}`)); return true; }
690
+ engine.subagents.kill(id);
691
+ console.log(green(`🛑 Sub-agent '${agent.label}' killed.`));
692
+ return true;
693
+ }
694
+
695
+ // ── Permission commands (v1.1) ────────────────────────────────────────────
696
+
697
+ if (cmd === "/permissions" || cmd === "/perms") {
698
+ await engine.permissions.load();
699
+ console.log(engine.permissions.formatTable());
700
+ return true;
701
+ }
702
+
703
+ if (cmd === "/permit") {
704
+ const tool = parts[1];
705
+ const level = parts[2];
706
+ if (!tool || !level) {
707
+ console.log(yellow("Usage: /permit <tool> <auto|notify|approve>"));
708
+ return true;
709
+ }
710
+ try {
711
+ engine.permissions.setPolicy(tool, level);
712
+ await engine.permissions.save();
713
+ console.log(green(`✅ ${tool} → ${level}`));
714
+ } catch (err) {
715
+ console.log(red(`Error: ${err.message}`));
716
+ }
717
+ return true;
718
+ }
719
+
720
+ // ── Audit commands (v1.1) ─────────────────────────────────────────────────
721
+
722
+ if (cmd === "/audit") {
723
+ const events = await engine.audit.getRecent(10);
724
+ if (events.length === 0) {
725
+ console.log(dim("No audit events yet."));
726
+ } else {
727
+ console.log(bold(`\n📋 Recent Events (${events.length}):\n`));
728
+ for (const evt of events) {
729
+ const ts = new Date(evt.timestamp).toLocaleTimeString();
730
+ const icons = {
731
+ tool_call: "🔧", tool_result: "✅", approval_requested: "⚠️ ",
732
+ approval_granted: "✅", approval_denied: "❌", message_sent: "🌿",
733
+ message_received: "👤", error: "🚨", subagent_spawned: "🤖",
734
+ };
735
+ const icon = icons[evt.type] ?? "•";
736
+ let detail = evt.tool ? ` ${cyan(evt.tool)}` : (evt.content ? ` ${dim(evt.content.slice(0, 60))}` : "");
737
+ console.log(` ${dim(ts)} ${icon} ${evt.type}${detail}`);
738
+ }
739
+ }
740
+ return true;
741
+ }
742
+
743
+ if (cmd === "/replay") {
744
+ // Replay current session
745
+ const sessionId = engine.sessions?.list?.()?.find(Boolean)?.id;
746
+ if (!sessionId) {
747
+ console.log(dim("No active session to replay."));
748
+ return true;
749
+ }
750
+ const steps = await engine.audit.getReplayTrace(sessionId);
751
+ if (steps.length === 0) {
752
+ console.log(dim("No events found for current session."));
753
+ } else {
754
+ console.log(bold(`\n🎬 Session Replay: ${dim(sessionId)}\n`));
755
+ for (const step of steps) {
756
+ const icons = {
757
+ user_message: "👤", assistant_message: "🌿", tool_call: "🔧",
758
+ tool_result: "✅", approval_requested: "⚠️ ", approval_granted: "✅",
759
+ approval_denied: "❌", subagent_spawned: "🤖",
760
+ };
761
+ const icon = icons[step.type] ?? "•";
762
+ let detail = "";
763
+ if (step.content) detail = dim(step.content.slice(0, 80));
764
+ if (step.tool) detail = `${cyan(step.tool)} ${dim(JSON.stringify(step.args ?? {}).slice(0, 50))}`;
765
+ console.log(` ${bold(`${step.step}.`)} ${icon} ${detail}`);
766
+ }
767
+ }
768
+ return true;
769
+ }
770
+
617
771
  if (cmd === "/quit" || cmd === "/exit") {
618
772
  console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
619
773
  engine.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.8.0",
3
+ "version": "1.1.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration and multi-channel bot support",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",