@simonfestl/husky-cli 1.5.0 → 1.6.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.
@@ -1,6 +1,10 @@
1
1
  import { Command } from "commander";
2
- import { AgentBrain } from "../lib/biz/agent-brain.js";
2
+ import { AgentBrain, AGENT_TYPES, isValidAgentType } from "../lib/biz/agent-brain.js";
3
3
  const DEFAULT_AGENT = process.env.HUSKY_AGENT_ID || 'default';
4
+ function createBrain(agentId, agentType) {
5
+ const validAgentType = isValidAgentType(agentType) ? agentType : undefined;
6
+ return new AgentBrain({ agentId, agentType: validAgentType });
7
+ }
4
8
  export const brainCommand = new Command("brain")
5
9
  .description("Agent memory and knowledge management");
6
10
  brainCommand
@@ -8,15 +12,17 @@ brainCommand
8
12
  .description("Store a memory")
9
13
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
10
14
  .option("-t, --tags <tags>", "Comma-separated tags")
15
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
11
16
  .option("--json", "Output as JSON")
12
17
  .action(async (content, options) => {
13
18
  try {
14
- const brain = new AgentBrain(options.agent);
19
+ const brain = createBrain(options.agent, options.agentType);
15
20
  const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : [];
16
- console.log(` Storing memory for agent: ${options.agent}...`);
21
+ const dbInfo = brain.getDatabaseInfo();
22
+ console.log(` Storing memory for agent: ${options.agent} (db: ${dbInfo.databaseName})...`);
17
23
  const id = await brain.remember(content, tags);
18
24
  if (options.json) {
19
- console.log(JSON.stringify({ success: true, id, agent: options.agent }));
25
+ console.log(JSON.stringify({ success: true, id, agent: options.agent, database: dbInfo.databaseName }));
20
26
  }
21
27
  else {
22
28
  console.log(` ✓ Memory stored: ${id}`);
@@ -33,14 +39,16 @@ brainCommand
33
39
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
34
40
  .option("-l, --limit <num>", "Max results", "5")
35
41
  .option("-m, --min-score <score>", "Minimum similarity score (0-1)", "0.5")
42
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
36
43
  .option("--json", "Output as JSON")
37
44
  .action(async (query, options) => {
38
45
  try {
39
- const brain = new AgentBrain(options.agent);
40
- console.log(` Searching memories for: "${query}"...`);
46
+ const brain = createBrain(options.agent, options.agentType);
47
+ const dbInfo = brain.getDatabaseInfo();
48
+ console.log(` Searching memories for: "${query}" (db: ${dbInfo.databaseName})...`);
41
49
  const results = await brain.recall(query, parseInt(options.limit, 10), parseFloat(options.minScore));
42
50
  if (options.json) {
43
- console.log(JSON.stringify({ success: true, query, results }));
51
+ console.log(JSON.stringify({ success: true, query, database: dbInfo.databaseName, results }));
44
52
  return;
45
53
  }
46
54
  console.log(`\n 🧠 Memories for "${query}" (${results.length} found)\n`);
@@ -64,16 +72,18 @@ brainCommand
64
72
  .description("List recent memories")
65
73
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
66
74
  .option("-l, --limit <num>", "Max results", "20")
75
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
67
76
  .option("--json", "Output as JSON")
68
77
  .action(async (options) => {
69
78
  try {
70
- const brain = new AgentBrain(options.agent);
79
+ const brain = createBrain(options.agent, options.agentType);
80
+ const dbInfo = brain.getDatabaseInfo();
71
81
  const memories = await brain.listMemories(parseInt(options.limit, 10));
72
82
  if (options.json) {
73
- console.log(JSON.stringify({ success: true, memories }));
83
+ console.log(JSON.stringify({ success: true, database: dbInfo.databaseName, memories }));
74
84
  return;
75
85
  }
76
- console.log(`\n 🧠 Memories for agent: ${options.agent} (${memories.length})\n`);
86
+ console.log(`\n 🧠 Memories for agent: ${options.agent} (db: ${dbInfo.databaseName}) (${memories.length})\n`);
77
87
  if (memories.length === 0) {
78
88
  console.log(" No memories stored yet.");
79
89
  return;
@@ -94,9 +104,10 @@ brainCommand
94
104
  .command("forget <id>")
95
105
  .description("Delete a memory")
96
106
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
107
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
97
108
  .action(async (memoryId, options) => {
98
109
  try {
99
- const brain = new AgentBrain(options.agent);
110
+ const brain = createBrain(options.agent, options.agentType);
100
111
  await brain.forget(memoryId);
101
112
  console.log(` ✓ Memory deleted: ${memoryId}`);
102
113
  }
@@ -109,16 +120,18 @@ brainCommand
109
120
  .command("stats")
110
121
  .description("Show memory statistics")
111
122
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
123
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
112
124
  .option("--json", "Output as JSON")
113
125
  .action(async (options) => {
114
126
  try {
115
- const brain = new AgentBrain(options.agent);
127
+ const brain = createBrain(options.agent, options.agentType);
128
+ const dbInfo = brain.getDatabaseInfo();
116
129
  const stats = await brain.stats();
117
130
  if (options.json) {
118
- console.log(JSON.stringify({ success: true, agent: options.agent, ...stats }));
131
+ console.log(JSON.stringify({ success: true, agent: options.agent, database: dbInfo.databaseName, ...stats }));
119
132
  return;
120
133
  }
121
- console.log(`\n 🧠 Brain Stats for: ${options.agent}`);
134
+ console.log(`\n 🧠 Brain Stats for: ${options.agent} (db: ${dbInfo.databaseName})`);
122
135
  console.log(` ────────────────────────────────`);
123
136
  console.log(` Total memories: ${stats.count}`);
124
137
  if (Object.keys(stats.tags).length > 0) {
@@ -140,17 +153,19 @@ brainCommand
140
153
  .description("Find memories by tags")
141
154
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
142
155
  .option("-l, --limit <num>", "Max results", "10")
156
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
143
157
  .option("--json", "Output as JSON")
144
158
  .action(async (tags, options) => {
145
159
  try {
146
- const brain = new AgentBrain(options.agent);
160
+ const brain = createBrain(options.agent, options.agentType);
161
+ const dbInfo = brain.getDatabaseInfo();
147
162
  const tagList = tags.split(",").map((t) => t.trim());
148
163
  const memories = await brain.recallByTags(tagList, parseInt(options.limit, 10));
149
164
  if (options.json) {
150
- console.log(JSON.stringify({ success: true, tags: tagList, memories }));
165
+ console.log(JSON.stringify({ success: true, tags: tagList, database: dbInfo.databaseName, memories }));
151
166
  return;
152
167
  }
153
- console.log(`\n 🏷️ Memories with tags: ${tagList.join(", ")} (${memories.length})\n`);
168
+ console.log(`\n 🏷️ Memories with tags: ${tagList.join(", ")} (db: ${dbInfo.databaseName}) (${memories.length})\n`);
154
169
  if (memories.length === 0) {
155
170
  console.log(" No memories found with these tags.");
156
171
  return;
@@ -165,4 +180,36 @@ brainCommand
165
180
  process.exit(1);
166
181
  }
167
182
  });
183
+ brainCommand
184
+ .command("info")
185
+ .description("Show current brain configuration")
186
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
187
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
188
+ .option("--json", "Output as JSON")
189
+ .action(async (options) => {
190
+ try {
191
+ const brain = createBrain(options.agent, options.agentType);
192
+ const dbInfo = brain.getDatabaseInfo();
193
+ if (options.json) {
194
+ console.log(JSON.stringify({
195
+ agent: options.agent,
196
+ agentType: dbInfo.agentType || null,
197
+ database: dbInfo.databaseName,
198
+ availableTypes: AGENT_TYPES
199
+ }));
200
+ return;
201
+ }
202
+ console.log(`\n 🧠 Brain Configuration`);
203
+ console.log(` ────────────────────────────────`);
204
+ console.log(` Agent ID: ${options.agent}`);
205
+ console.log(` Agent Type: ${dbInfo.agentType || '(not set - using default)'}`);
206
+ console.log(` Database: ${dbInfo.databaseName}`);
207
+ console.log(`\n Available agent types: ${AGENT_TYPES.join(", ")}`);
208
+ console.log("");
209
+ }
210
+ catch (error) {
211
+ console.error("Error:", error.message);
212
+ process.exit(1);
213
+ }
214
+ });
168
215
  export default brainCommand;
@@ -620,6 +620,228 @@ chatCommand
620
620
  await new Promise(() => { });
621
621
  });
622
622
  // ============================================
623
+ // AGENT QUESTION COMMANDS (prevents supervisor loop)
624
+ // ============================================
625
+ // Agent persona/role icons for Google Chat messages
626
+ const AGENT_PERSONA_ICONS = {
627
+ support: "🎧",
628
+ worker: "👷",
629
+ supervisor: "🎯",
630
+ reviewer: "🔍",
631
+ research: "🔬",
632
+ accounting: "📊",
633
+ marketing: "📢",
634
+ developer: "💻",
635
+ devops: "🔧",
636
+ default: "🤖",
637
+ };
638
+ chatCommand
639
+ .command("ask <question>")
640
+ .description("Ask a question to human via Google Chat (registers conversation for reply routing)")
641
+ .requiredOption("--space <id>", "Google Chat space ID (e.g., spaces/ABC123)")
642
+ .option("--agent-id <id>", "Agent ID (default: from env or hostname)")
643
+ .option("--vm-name <name>", "VM name (default: from env or hostname)")
644
+ .option("--session <name>", "Tmux session for reply routing", "main")
645
+ .option("--role <role>", "Agent role/persona (support, worker, supervisor, etc.)")
646
+ .option("--context <text>", "Additional context for the question")
647
+ .option("--task-id <id>", "Related task ID")
648
+ .option("--json", "Output as JSON")
649
+ .action(async (question, options) => {
650
+ const config = getConfig();
651
+ const huskyApiUrl = getHuskyApiUrl();
652
+ if (!huskyApiUrl) {
653
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
654
+ process.exit(1);
655
+ }
656
+ // Get agent/VM identity
657
+ const agentId = options.agentId || process.env.HUSKY_AGENT_ID || process.env.HUSKY_WORKER_ID || `agent-${process.pid}`;
658
+ const vmName = options.vmName || process.env.HUSKY_VM_NAME || process.env.HOSTNAME || "unknown-vm";
659
+ const tmuxSession = options.session;
660
+ const agentRole = options.role || process.env.HUSKY_AGENT_TYPE || process.env.HUSKY_AGENT_ROLE || "worker";
661
+ const taskId = options.taskId || process.env.HUSKY_TASK_ID;
662
+ // Build formatted message with metadata
663
+ const icon = AGENT_PERSONA_ICONS[agentRole] || AGENT_PERSONA_ICONS.default;
664
+ let formattedMessage = `${icon} *Agent Question*\n\n`;
665
+ formattedMessage += `*From:* ${agentId} (${agentRole})\n`;
666
+ formattedMessage += `*VM:* ${vmName} / session: ${tmuxSession}\n`;
667
+ if (taskId) {
668
+ formattedMessage += `*Task:* ${taskId}\n`;
669
+ }
670
+ formattedMessage += `\n---\n\n${question}`;
671
+ if (options.context) {
672
+ formattedMessage += `\n\n*Context:* ${options.context}`;
673
+ }
674
+ try {
675
+ // 1. Send message to Google Chat
676
+ const sendRes = await fetch(`${huskyApiUrl}/api/google-chat/send`, {
677
+ method: "POST",
678
+ headers: {
679
+ "Content-Type": "application/json",
680
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
681
+ },
682
+ body: JSON.stringify({
683
+ text: formattedMessage,
684
+ spaceName: options.space,
685
+ // Don't specify threadName - let Google Chat create a new thread
686
+ }),
687
+ });
688
+ if (!sendRes.ok) {
689
+ const error = await sendRes.text();
690
+ throw new Error(`Failed to send message: ${sendRes.status} - ${error}`);
691
+ }
692
+ const sendData = await sendRes.json();
693
+ const threadName = sendData.threadName || sendData.name;
694
+ if (!threadName) {
695
+ console.error("Warning: Could not get thread name from response. Reply routing may not work.");
696
+ }
697
+ // 2. Register conversation for reply routing (with full metadata)
698
+ const convRes = await fetch(`${huskyApiUrl}/api/agent-conversations`, {
699
+ method: "POST",
700
+ headers: {
701
+ "Content-Type": "application/json",
702
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
703
+ },
704
+ body: JSON.stringify({
705
+ agentId,
706
+ vmName,
707
+ tmuxSession,
708
+ spaceId: options.space,
709
+ threadName,
710
+ question,
711
+ status: "active",
712
+ // Additional metadata for routing and context
713
+ agentRole,
714
+ taskId: taskId || null,
715
+ context: options.context || null,
716
+ }),
717
+ });
718
+ let conversationId;
719
+ if (convRes.ok) {
720
+ const convData = await convRes.json();
721
+ conversationId = convData.id;
722
+ }
723
+ else {
724
+ console.error("Warning: Failed to register conversation. Reply may go to supervisor instead.");
725
+ }
726
+ if (options.json) {
727
+ console.log(JSON.stringify({
728
+ success: true,
729
+ agentId,
730
+ agentRole,
731
+ vmName,
732
+ tmuxSession,
733
+ threadName,
734
+ conversationId,
735
+ space: options.space,
736
+ question,
737
+ taskId: taskId || null,
738
+ }, null, 2));
739
+ }
740
+ else {
741
+ console.log("✅ Question sent to Google Chat");
742
+ console.log(` Agent: ${agentId} (${agentRole})`);
743
+ console.log(` VM: ${vmName} / session: ${tmuxSession}`);
744
+ if (taskId) {
745
+ console.log(` Task: ${taskId}`);
746
+ }
747
+ console.log(` Thread: ${threadName || "(unknown)"}`);
748
+ if (conversationId) {
749
+ console.log(` Conversation ID: ${conversationId}`);
750
+ }
751
+ console.log("\n When human replies, it will be routed to your tmux session.");
752
+ console.log(` To resolve conversation: husky chat resolve ${conversationId || threadName}`);
753
+ }
754
+ }
755
+ catch (error) {
756
+ console.error("Error asking question:", error);
757
+ process.exit(1);
758
+ }
759
+ });
760
+ chatCommand
761
+ .command("resolve <conversationId>")
762
+ .description("Mark a conversation as resolved (stops routing replies to agent)")
763
+ .action(async (conversationId) => {
764
+ const config = getConfig();
765
+ const huskyApiUrl = getHuskyApiUrl();
766
+ if (!huskyApiUrl) {
767
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
768
+ process.exit(1);
769
+ }
770
+ try {
771
+ const res = await fetch(`${huskyApiUrl}/api/agent-conversations/${conversationId}`, {
772
+ method: "PATCH",
773
+ headers: {
774
+ "Content-Type": "application/json",
775
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
776
+ },
777
+ body: JSON.stringify({ status: "resolved" }),
778
+ });
779
+ if (!res.ok) {
780
+ if (res.status === 404) {
781
+ console.error("Conversation not found.");
782
+ process.exit(1);
783
+ }
784
+ throw new Error(`API error: ${res.status}`);
785
+ }
786
+ console.log("✅ Conversation marked as resolved.");
787
+ console.log(" Future replies in this thread will go to supervisor.");
788
+ }
789
+ catch (error) {
790
+ console.error("Error resolving conversation:", error);
791
+ process.exit(1);
792
+ }
793
+ });
794
+ chatCommand
795
+ .command("conversations")
796
+ .description("List active agent conversations")
797
+ .option("--all", "Show all conversations (including resolved)")
798
+ .option("--agent <id>", "Filter by agent ID")
799
+ .option("--json", "Output as JSON")
800
+ .action(async (options) => {
801
+ const config = getConfig();
802
+ const huskyApiUrl = getHuskyApiUrl();
803
+ if (!huskyApiUrl) {
804
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
805
+ process.exit(1);
806
+ }
807
+ try {
808
+ const params = new URLSearchParams();
809
+ if (!options.all)
810
+ params.set("status", "active");
811
+ if (options.agent)
812
+ params.set("agentId", options.agent);
813
+ const res = await fetch(`${huskyApiUrl}/api/agent-conversations?${params}`, {
814
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
815
+ });
816
+ if (!res.ok) {
817
+ throw new Error(`API error: ${res.status}`);
818
+ }
819
+ const data = await res.json();
820
+ if (options.json) {
821
+ console.log(JSON.stringify(data, null, 2));
822
+ return;
823
+ }
824
+ if (!data.conversations || data.conversations.length === 0) {
825
+ console.log("No active conversations.");
826
+ return;
827
+ }
828
+ console.log("\n Agent Conversations");
829
+ console.log(" " + "─".repeat(60));
830
+ for (const conv of data.conversations) {
831
+ const time = new Date(conv.createdAt).toLocaleString();
832
+ const statusIcon = conv.status === "active" ? "🟢" : "⚪";
833
+ console.log(` ${statusIcon} [${conv.id.slice(0, 8)}] ${conv.agentId} @ ${conv.vmName}`);
834
+ console.log(` Q: "${conv.question.slice(0, 50)}${conv.question.length > 50 ? "..." : ""}"`);
835
+ console.log(` Created: ${time}`);
836
+ console.log("");
837
+ }
838
+ }
839
+ catch (error) {
840
+ console.error("Error fetching conversations:", error);
841
+ process.exit(1);
842
+ }
843
+ });
844
+ // ============================================
623
845
  // REVIEW COMMANDS (kept for backwards compatibility)
624
846
  // ============================================
625
847
  chatCommand
@@ -23,6 +23,7 @@ interface Config {
23
23
  gcpLocation?: string;
24
24
  gotessToken?: string;
25
25
  gotessBookId?: string;
26
+ agentType?: string;
26
27
  }
27
28
  export declare function getConfig(): Config;
28
29
  /**
@@ -158,6 +158,7 @@ configCommand
158
158
  "gcp-location": "gcpLocation",
159
159
  "gotess-token": "gotessToken",
160
160
  "gotess-book-id": "gotessBookId",
161
+ "agent-type": "agentType",
161
162
  };
162
163
  const configKey = keyMappings[key];
163
164
  if (!configKey) {
@@ -170,6 +171,7 @@ configCommand
170
171
  console.log(" Qdrant: qdrant-url, qdrant-api-key");
171
172
  console.log(" GCP: gcp-project-id, gcp-location");
172
173
  console.log(" Gotess: gotess-token, gotess-book-id");
174
+ console.log(" Brain: agent-type");
173
175
  process.exit(1);
174
176
  }
175
177
  // Validation for specific keys
@@ -180,6 +182,13 @@ configCommand
180
182
  process.exit(1);
181
183
  }
182
184
  }
185
+ if (key === "agent-type") {
186
+ const validTypes = ["support", "claude", "gotess", "supervisor", "worker"];
187
+ if (!validTypes.includes(value)) {
188
+ console.error(`Error: Invalid agent type. Must be one of: ${validTypes.join(", ")}`);
189
+ process.exit(1);
190
+ }
191
+ }
183
192
  // Set the value
184
193
  config[configKey] = value;
185
194
  saveConfig(config);
@@ -3,6 +3,7 @@ import { getConfig } from "./config.js";
3
3
  import * as readline from "readline";
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
6
+ import { spawnSync } from "child_process";
6
7
  import { DEFAULT_AGENT_CONFIGS, generateStartupScript, listDefaultAgentTypes, getDefaultAgentConfig, } from "../lib/agent-templates.js";
7
8
  import { requirePermission } from "../lib/permissions.js";
8
9
  export const vmCommand = new Command("vm").description("Manage VM sessions");
@@ -747,6 +748,340 @@ vmCommand
747
748
  const script = generateStartupScript(options.type, options.huskyUrl, options.huskyKey, options.project);
748
749
  console.log(script);
749
750
  });
751
+ // ============================================================================
752
+ // VM Monitoring Commands
753
+ // ============================================================================
754
+ // Patterns that indicate an agent is stuck
755
+ const STUCK_PATTERNS = [
756
+ { pattern: /Do you want to allow|Allow this action|allow this tool/i, reason: "Waiting for allow/deny prompt" },
757
+ { pattern: /\[y\/n\]|\[Y\/n\]|\(yes\/no\)/i, reason: "Waiting for yes/no confirmation" },
758
+ { pattern: /Press Enter to continue|press any key/i, reason: "Waiting for user input" },
759
+ { pattern: /Error:|error:|FAILED|failed/i, reason: "Error detected" },
760
+ { pattern: /permission denied|Permission denied/i, reason: "Permission denied error" },
761
+ { pattern: /timed out|timeout|Timeout/i, reason: "Timeout error" },
762
+ ];
763
+ // Helper to run SSH command on a VM using spawnSync (no shell injection)
764
+ function sshCommand(vmName, zone, command) {
765
+ try {
766
+ const result = spawnSync("gcloud", [
767
+ "compute", "ssh", vmName,
768
+ `--zone=${zone}`,
769
+ `--command=${command}`,
770
+ ], { encoding: "utf-8", timeout: 30000 });
771
+ if (result.status !== 0)
772
+ return null;
773
+ return result.stdout?.trim() || null;
774
+ }
775
+ catch {
776
+ return null;
777
+ }
778
+ }
779
+ // Get tmux sessions on a VM
780
+ function getTmuxSessions(vmName, zone) {
781
+ const result = sshCommand(vmName, zone, "tmux list-sessions -F '#{session_name}' 2>/dev/null || true");
782
+ if (!result)
783
+ return [];
784
+ return result.split("\n").filter(Boolean);
785
+ }
786
+ // Capture last lines from a tmux session
787
+ function captureTmuxOutput(vmName, zone, sessionName, lines = 50) {
788
+ return sshCommand(vmName, zone, `tmux capture-pane -t ${sessionName} -p -S -${lines} 2>/dev/null || true`);
789
+ }
790
+ // Check if output matches stuck patterns
791
+ function checkForStuckPatterns(output) {
792
+ for (const { pattern, reason } of STUCK_PATTERNS) {
793
+ if (pattern.test(output)) {
794
+ return { isStuck: true, reason };
795
+ }
796
+ }
797
+ return { isStuck: false };
798
+ }
799
+ // husky vm monitor
800
+ vmCommand
801
+ .command("monitor")
802
+ .description("Monitor agent status across running VMs")
803
+ .option("--vm <name>", "Monitor specific VM only")
804
+ .option("--watch", "Continuous monitoring (refresh every 10s)")
805
+ .option("--json", "Output as JSON")
806
+ .action(async (options) => {
807
+ const config = ensureConfig();
808
+ // Get running VMs from API
809
+ const getRunningVMs = async () => {
810
+ const url = new URL("/api/vm-sessions", config.apiUrl);
811
+ url.searchParams.set("vmStatus", "running");
812
+ const res = await fetch(url.toString(), {
813
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
814
+ });
815
+ if (!res.ok) {
816
+ throw new Error(`API error: ${res.status}`);
817
+ }
818
+ const data = await res.json();
819
+ return data.sessions || [];
820
+ };
821
+ const monitorVMs = async () => {
822
+ let sessions;
823
+ try {
824
+ sessions = await getRunningVMs();
825
+ }
826
+ catch (error) {
827
+ console.error("Error fetching VMs:", error);
828
+ return [];
829
+ }
830
+ // Filter by VM name if specified
831
+ if (options.vm) {
832
+ sessions = sessions.filter((s) => s.vmName === options.vm || s.name === options.vm || s.id === options.vm);
833
+ }
834
+ if (sessions.length === 0) {
835
+ console.log("\n No running VMs found.");
836
+ return [];
837
+ }
838
+ const results = [];
839
+ for (const session of sessions) {
840
+ const vmStatus = {
841
+ vmName: session.vmName,
842
+ zone: session.vmZone,
843
+ ip: session.vmIpAddress,
844
+ sessions: [],
845
+ isStuck: false,
846
+ };
847
+ // Get tmux sessions on this VM
848
+ const tmuxSessions = getTmuxSessions(session.vmName, session.vmZone);
849
+ if (tmuxSessions.length === 0) {
850
+ vmStatus.sessions.push({
851
+ sessionName: "(none)",
852
+ lastOutput: "No tmux sessions running",
853
+ isStuck: false,
854
+ });
855
+ }
856
+ else {
857
+ for (const tmuxSession of tmuxSessions) {
858
+ const output = captureTmuxOutput(session.vmName, session.vmZone, tmuxSession) || "";
859
+ const stuckCheck = checkForStuckPatterns(output);
860
+ const sessionStatus = {
861
+ sessionName: tmuxSession,
862
+ lastOutput: output.split("\n").slice(-10).join("\n"),
863
+ isStuck: stuckCheck.isStuck,
864
+ stuckReason: stuckCheck.reason,
865
+ };
866
+ vmStatus.sessions.push(sessionStatus);
867
+ if (stuckCheck.isStuck) {
868
+ vmStatus.isStuck = true;
869
+ vmStatus.stuckReason = `${tmuxSession}: ${stuckCheck.reason}`;
870
+ }
871
+ }
872
+ }
873
+ results.push(vmStatus);
874
+ }
875
+ return results;
876
+ };
877
+ // Initial check
878
+ const results = await monitorVMs();
879
+ if (options.json) {
880
+ console.log(JSON.stringify(results, null, 2));
881
+ if (!options.watch)
882
+ return;
883
+ }
884
+ else {
885
+ printMonitorResults(results);
886
+ }
887
+ // Watch mode
888
+ if (options.watch) {
889
+ console.log("\n--- Monitoring (Ctrl+C to stop, refreshing every 10s) ---\n");
890
+ const interval = setInterval(async () => {
891
+ const newResults = await monitorVMs();
892
+ if (options.json) {
893
+ console.log(JSON.stringify(newResults, null, 2));
894
+ }
895
+ else {
896
+ console.log("\x1b[2J\x1b[H"); // Clear screen
897
+ console.log(`VM Monitor - ${new Date().toLocaleTimeString()}`);
898
+ printMonitorResults(newResults);
899
+ }
900
+ }, 10000);
901
+ process.on("SIGINT", () => {
902
+ clearInterval(interval);
903
+ console.log("\nMonitoring stopped.");
904
+ process.exit(0);
905
+ });
906
+ await new Promise(() => { });
907
+ }
908
+ });
909
+ // husky vm unstuck <vmName>
910
+ vmCommand
911
+ .command("unstuck <vmName>")
912
+ .description("Try to unstick an agent by sending input")
913
+ .option("--session <name>", "Target specific tmux session", "main")
914
+ .option("--action <action>", "Action to send (see --list-actions)", "enter")
915
+ .option("--zone <zone>", "GCP zone", "europe-west1-b")
916
+ .option("--text <text>", "Send custom text (use with --action custom)")
917
+ .option("--list-actions", "Show all available actions")
918
+ .option("--json", "Output as JSON")
919
+ .action(async (vmName, options) => {
920
+ // All available actions for different AI agents
921
+ const actionKeys = {
922
+ // Basic actions
923
+ enter: "Enter",
924
+ yes: "y Enter",
925
+ no: "n Enter",
926
+ // Claude Code / OpenCode prompts
927
+ allow: "y Enter", // Allow single action
928
+ deny: "n Enter", // Deny action
929
+ "allow-all": "a Enter", // Allow all (OpenCode)
930
+ a: "a Enter", // Shorthand for allow-all
931
+ // Numbered menu selections (common in OpenCode)
932
+ "1": "1 Enter",
933
+ "2": "2 Enter",
934
+ "3": "3 Enter",
935
+ "4": "4 Enter",
936
+ "5": "5 Enter",
937
+ // Common responses
938
+ skip: "s Enter", // Skip
939
+ cancel: "Escape", // Cancel/escape
940
+ quit: "q Enter", // Quit
941
+ continue: "c Enter", // Continue
942
+ retry: "r Enter", // Retry
943
+ // Custom text (use with --text)
944
+ custom: "",
945
+ };
946
+ // Show available actions
947
+ if (options.listActions) {
948
+ console.log("\n Available unstuck actions:\n");
949
+ console.log(" Basic:");
950
+ console.log(" enter - Press Enter");
951
+ console.log(" yes / y - Send 'y' + Enter");
952
+ console.log(" no / n - Send 'n' + Enter");
953
+ console.log("\n AI Agent Prompts:");
954
+ console.log(" allow - Allow action (y + Enter)");
955
+ console.log(" deny - Deny action (n + Enter)");
956
+ console.log(" allow-all/a - Allow all (a + Enter) - OpenCode");
957
+ console.log("\n Menu Selections:");
958
+ console.log(" 1, 2, 3, 4, 5 - Send number + Enter");
959
+ console.log("\n Other:");
960
+ console.log(" skip - Skip (s + Enter)");
961
+ console.log(" cancel - Escape key");
962
+ console.log(" quit - Quit (q + Enter)");
963
+ console.log(" continue - Continue (c + Enter)");
964
+ console.log(" retry - Retry (r + Enter)");
965
+ console.log(" custom - Use with --text to send custom input");
966
+ console.log("\n Examples:");
967
+ console.log(" husky vm unstuck supervisor --action allow-all");
968
+ console.log(" husky vm unstuck worker-1 --action 2");
969
+ console.log(' husky vm unstuck support --action custom --text "my response"');
970
+ console.log("");
971
+ return;
972
+ }
973
+ // Handle custom text action
974
+ let keys;
975
+ if (options.action === "custom") {
976
+ if (!options.text) {
977
+ console.error("Error: --text is required when using --action custom");
978
+ process.exit(1);
979
+ }
980
+ // Escape special characters for tmux send-keys
981
+ const escapedText = options.text.replace(/"/g, '\\"').replace(/'/g, "\\'");
982
+ keys = `"${escapedText}" Enter`;
983
+ }
984
+ else {
985
+ keys = actionKeys[options.action] || "Enter";
986
+ if (!actionKeys[options.action]) {
987
+ console.warn(`Warning: Unknown action "${options.action}", sending Enter`);
988
+ }
989
+ }
990
+ console.log(`Sending "${options.action}" to ${vmName}:${options.session}...`);
991
+ try {
992
+ // First check if session exists
993
+ const sessions = getTmuxSessions(vmName, options.zone);
994
+ if (!sessions.includes(options.session)) {
995
+ console.error(`Error: Session "${options.session}" not found on ${vmName}`);
996
+ console.log(`Available sessions: ${sessions.join(", ") || "(none)"}`);
997
+ process.exit(1);
998
+ }
999
+ // Send keys to tmux session
1000
+ const result = spawnSync("gcloud", [
1001
+ "compute", "ssh", vmName,
1002
+ `--zone=${options.zone}`,
1003
+ `--command=tmux send-keys -t ${options.session} ${keys}`,
1004
+ ], { encoding: "utf-8", timeout: 30000 });
1005
+ if (result.status !== 0) {
1006
+ throw new Error(result.stderr || "Failed to send keys");
1007
+ }
1008
+ // Wait a moment and capture output
1009
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1010
+ const output = captureTmuxOutput(vmName, options.zone, options.session, 20);
1011
+ if (options.json) {
1012
+ console.log(JSON.stringify({
1013
+ success: true,
1014
+ action: options.action,
1015
+ vmName,
1016
+ session: options.session,
1017
+ lastOutput: output?.split("\n").slice(-10).join("\n"),
1018
+ }, null, 2));
1019
+ }
1020
+ else {
1021
+ console.log(`\nAction sent successfully!`);
1022
+ console.log(`\nLast output from ${options.session}:`);
1023
+ console.log("---");
1024
+ console.log(output?.split("\n").slice(-10).join("\n") || "(no output)");
1025
+ console.log("---");
1026
+ }
1027
+ }
1028
+ catch (error) {
1029
+ console.error("Error:", error);
1030
+ process.exit(1);
1031
+ }
1032
+ });
1033
+ // husky vm ssh <vmName>
1034
+ vmCommand
1035
+ .command("ssh <vmName>")
1036
+ .description("SSH into a VM (opens interactive shell)")
1037
+ .option("--zone <zone>", "GCP zone", "europe-west1-b")
1038
+ .option("--command <cmd>", "Run command instead of interactive shell")
1039
+ .action(async (vmName, options) => {
1040
+ if (options.command) {
1041
+ const result = sshCommand(vmName, options.zone, options.command);
1042
+ if (result !== null) {
1043
+ console.log(result);
1044
+ }
1045
+ else {
1046
+ console.error("Failed to execute command");
1047
+ process.exit(1);
1048
+ }
1049
+ }
1050
+ else {
1051
+ // Interactive SSH
1052
+ spawnSync("gcloud", ["compute", "ssh", vmName, `--zone=${options.zone}`], {
1053
+ stdio: "inherit",
1054
+ });
1055
+ }
1056
+ });
1057
+ function printMonitorResults(results) {
1058
+ console.log("\n VM MONITOR STATUS");
1059
+ console.log(" " + "=".repeat(80));
1060
+ for (const vm of results) {
1061
+ const stuckIndicator = vm.isStuck ? " [STUCK]" : "";
1062
+ console.log(`\n ${vm.vmName}${stuckIndicator}`);
1063
+ console.log(` Zone: ${vm.zone} | IP: ${vm.ip || "N/A"}`);
1064
+ console.log(" " + "-".repeat(60));
1065
+ for (const session of vm.sessions) {
1066
+ const sessionStuck = session.isStuck ? ` <- STUCK: ${session.stuckReason}` : "";
1067
+ console.log(`\n [${session.sessionName}]${sessionStuck}`);
1068
+ const lines = session.lastOutput.split("\n").slice(-5);
1069
+ for (const line of lines) {
1070
+ console.log(` ${line.substring(0, 76)}`);
1071
+ }
1072
+ }
1073
+ }
1074
+ const stuckVMs = results.filter((r) => r.isStuck);
1075
+ if (stuckVMs.length > 0) {
1076
+ console.log("\n " + "=".repeat(80));
1077
+ console.log(` WARNING: ${stuckVMs.length} VM(s) appear stuck:`);
1078
+ for (const vm of stuckVMs) {
1079
+ console.log(` - ${vm.vmName}: ${vm.stuckReason}`);
1080
+ }
1081
+ console.log(`\n To unstick, run: husky vm unstuck <vmName> --action allow`);
1082
+ }
1083
+ console.log("");
1084
+ }
750
1085
  // Print helpers
751
1086
  function printVMSessions(sessions, stats) {
752
1087
  if (sessions.length === 0) {
@@ -1,3 +1,5 @@
1
+ export declare const AGENT_TYPES: readonly ["support", "claude", "gotess", "supervisor", "worker"];
2
+ export type AgentType = typeof AGENT_TYPES[number];
1
3
  export interface Memory {
2
4
  id: string;
3
5
  agent: string;
@@ -12,12 +14,26 @@ export interface RecallResult {
12
14
  memory: Memory;
13
15
  score: number;
14
16
  }
17
+ export interface AgentBrainOptions {
18
+ agentId: string;
19
+ agentType?: AgentType;
20
+ projectId?: string;
21
+ }
22
+ export declare function isValidAgentType(value: string | undefined): value is AgentType;
23
+ export declare function getAgentType(): AgentType | undefined;
24
+ export declare function getDatabaseName(agentType?: AgentType): string;
15
25
  export declare class AgentBrain {
16
26
  private db;
17
27
  private embeddings;
18
28
  private agentId;
29
+ private agentType?;
19
30
  private collectionPath;
20
- constructor(agentId: string, projectId?: string);
31
+ private databaseName;
32
+ constructor(agentIdOrOptions: string | AgentBrainOptions, projectId?: string);
33
+ getDatabaseInfo(): {
34
+ agentType?: AgentType;
35
+ databaseName: string;
36
+ };
21
37
  remember(content: string, tags?: string[], metadata?: Record<string, unknown>): Promise<string>;
22
38
  recall(query: string, limit?: number, minScore?: number): Promise<RecallResult[]>;
23
39
  recallByTags(tags: string[], limit?: number): Promise<Memory[]>;
@@ -3,24 +3,85 @@ import { getFirestore, Timestamp } from 'firebase-admin/firestore';
3
3
  import { EmbeddingService } from './embeddings.js';
4
4
  import { getConfig } from '../../commands/config.js';
5
5
  const BRAIN_COLLECTION = 'agent_brains';
6
+ const DEFAULT_DATABASE = '(default)';
7
+ export const AGENT_TYPES = ['support', 'claude', 'gotess', 'supervisor', 'worker'];
8
+ const AGENT_DB_MAP = {
9
+ support: 'support-brain-db',
10
+ claude: 'claude-brain-db',
11
+ gotess: 'gotess-brain-db',
12
+ supervisor: 'supervisor-brain-db',
13
+ worker: 'worker-brain-db',
14
+ };
15
+ const firestoreCache = new Map();
16
+ let firebaseInitialized = false;
17
+ export function isValidAgentType(value) {
18
+ return value !== undefined && AGENT_TYPES.includes(value);
19
+ }
20
+ export function getAgentType() {
21
+ const envType = process.env.HUSKY_AGENT_TYPE;
22
+ if (isValidAgentType(envType)) {
23
+ return envType;
24
+ }
25
+ const config = getConfig();
26
+ const configType = config.agentType;
27
+ if (isValidAgentType(configType)) {
28
+ return configType;
29
+ }
30
+ return undefined;
31
+ }
32
+ export function getDatabaseName(agentType) {
33
+ if (!agentType) {
34
+ return DEFAULT_DATABASE;
35
+ }
36
+ return AGENT_DB_MAP[agentType];
37
+ }
6
38
  export class AgentBrain {
7
39
  db;
8
40
  embeddings;
9
41
  agentId;
42
+ agentType;
10
43
  collectionPath;
11
- constructor(agentId, projectId) {
12
- this.agentId = agentId;
44
+ databaseName;
45
+ constructor(agentIdOrOptions, projectId) {
46
+ let options;
47
+ if (typeof agentIdOrOptions === 'string') {
48
+ options = { agentId: agentIdOrOptions, projectId };
49
+ }
50
+ else {
51
+ options = agentIdOrOptions;
52
+ }
13
53
  const config = getConfig();
14
- const gcpProject = projectId || config.gcpProjectId || process.env.GOOGLE_CLOUD_PROJECT || 'tigerv0';
15
- if (getApps().length === 0) {
54
+ this.agentId = options.agentId;
55
+ this.agentType = options.agentType || getAgentType();
56
+ this.databaseName = getDatabaseName(this.agentType);
57
+ const gcpProject = options.projectId || config.gcpProjectId || process.env.GOOGLE_CLOUD_PROJECT || 'tigerv0';
58
+ if (!firebaseInitialized && getApps().length === 0) {
16
59
  initializeApp({ projectId: gcpProject });
60
+ firebaseInitialized = true;
17
61
  }
18
- this.db = getFirestore();
62
+ const cacheKey = `${gcpProject}:${this.databaseName}`;
63
+ let db = firestoreCache.get(cacheKey);
64
+ if (!db) {
65
+ if (this.databaseName === DEFAULT_DATABASE) {
66
+ db = getFirestore();
67
+ }
68
+ else {
69
+ db = getFirestore(this.databaseName);
70
+ }
71
+ firestoreCache.set(cacheKey, db);
72
+ }
73
+ this.db = db;
19
74
  this.embeddings = new EmbeddingService({
20
75
  projectId: gcpProject,
21
76
  location: config.gcpLocation || 'europe-west1'
22
77
  });
23
- this.collectionPath = `${BRAIN_COLLECTION}/${agentId}/memories`;
78
+ this.collectionPath = `${BRAIN_COLLECTION}/${this.agentId}/memories`;
79
+ }
80
+ getDatabaseInfo() {
81
+ return {
82
+ agentType: this.agentType,
83
+ databaseName: this.databaseName,
84
+ };
24
85
  }
25
86
  async remember(content, tags = [], metadata) {
26
87
  const embedding = await this.embeddings.embed(content);
@@ -65,6 +126,9 @@ export class AgentBrain {
65
126
  .slice(0, limit);
66
127
  }
67
128
  async recallByTags(tags, limit = 10) {
129
+ if (tags.length === 0) {
130
+ return [];
131
+ }
68
132
  const snapshot = await this.db
69
133
  .collection(this.collectionPath)
70
134
  .where('tags', 'array-contains-any', tags)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {