@harbinger-ai/harbinger 0.1.4 → 0.1.5

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.
@@ -986,6 +986,169 @@ export async function createAgentJob(agentId, prompt) {
986
986
  }
987
987
  }
988
988
 
989
+ // ─────────────────────────────────────────────────────────────────────────────
990
+ // Agent-Program Assignment actions
991
+ // ─────────────────────────────────────────────────────────────────────────────
992
+
993
+ /**
994
+ * Get program assignments for an agent.
995
+ * Stored in settings table: type='agent_assignment', key=agentId.
996
+ */
997
+ export async function getAgentAssignments(agentId) {
998
+ await requireAuth();
999
+ try {
1000
+ const { getDb } = await import('../db/index.js');
1001
+ const { settings } = await import('../db/schema.js');
1002
+ const { eq, and } = await import('drizzle-orm');
1003
+ const db = getDb();
1004
+ const row = db.select().from(settings)
1005
+ .where(and(eq(settings.type, 'agent_assignment'), eq(settings.key, agentId)))
1006
+ .get();
1007
+ if (!row) return { programs: [], targets: [] };
1008
+ return JSON.parse(row.value);
1009
+ } catch {
1010
+ return { programs: [], targets: [] };
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Get assignments for ALL agents at once.
1016
+ */
1017
+ export async function getAllAgentAssignments() {
1018
+ await requireAuth();
1019
+ try {
1020
+ const { getDb } = await import('../db/index.js');
1021
+ const { settings } = await import('../db/schema.js');
1022
+ const { eq } = await import('drizzle-orm');
1023
+ const db = getDb();
1024
+ const rows = db.select().from(settings)
1025
+ .where(eq(settings.type, 'agent_assignment'))
1026
+ .all();
1027
+ const result = {};
1028
+ for (const row of rows) {
1029
+ try { result[row.key] = JSON.parse(row.value); } catch {}
1030
+ }
1031
+ return result;
1032
+ } catch {
1033
+ return {};
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Assign programs to an agent.
1039
+ */
1040
+ export async function assignProgramsToAgent(agentId, programIds) {
1041
+ await requireAuth();
1042
+ try {
1043
+ const { getDb } = await import('../db/index.js');
1044
+ const { settings } = await import('../db/schema.js');
1045
+ const { eq, and } = await import('drizzle-orm');
1046
+ const { randomUUID } = await import('crypto');
1047
+ const db = getDb();
1048
+
1049
+ const existing = db.select().from(settings)
1050
+ .where(and(eq(settings.type, 'agent_assignment'), eq(settings.key, agentId)))
1051
+ .get();
1052
+
1053
+ const current = existing ? JSON.parse(existing.value) : { programs: [], targets: [] };
1054
+ current.programs = [...new Set([...current.programs, ...programIds])];
1055
+ const value = JSON.stringify(current);
1056
+ const now = Date.now();
1057
+
1058
+ if (existing) {
1059
+ db.update(settings).set({ value, updatedAt: now })
1060
+ .where(and(eq(settings.type, 'agent_assignment'), eq(settings.key, agentId))).run();
1061
+ } else {
1062
+ db.insert(settings).values({
1063
+ id: randomUUID(), type: 'agent_assignment', key: agentId,
1064
+ value, createdAt: now, updatedAt: now,
1065
+ }).run();
1066
+ }
1067
+ return { success: true, assignments: current };
1068
+ } catch (err) {
1069
+ return { error: err.message };
1070
+ }
1071
+ }
1072
+
1073
+ /**
1074
+ * Remove a program from an agent's assignments.
1075
+ */
1076
+ export async function unassignProgramFromAgent(agentId, programId) {
1077
+ await requireAuth();
1078
+ try {
1079
+ const { getDb } = await import('../db/index.js');
1080
+ const { settings } = await import('../db/schema.js');
1081
+ const { eq, and } = await import('drizzle-orm');
1082
+ const db = getDb();
1083
+
1084
+ const existing = db.select().from(settings)
1085
+ .where(and(eq(settings.type, 'agent_assignment'), eq(settings.key, agentId)))
1086
+ .get();
1087
+ if (!existing) return { success: true };
1088
+
1089
+ const current = JSON.parse(existing.value);
1090
+ current.programs = current.programs.filter(id => id !== programId);
1091
+ db.update(settings).set({ value: JSON.stringify(current), updatedAt: Date.now() })
1092
+ .where(and(eq(settings.type, 'agent_assignment'), eq(settings.key, agentId))).run();
1093
+ return { success: true, assignments: current };
1094
+ } catch (err) {
1095
+ return { error: err.message };
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Get programs with target counts for the assignment wizard.
1101
+ * Returns slimmed-down program list with counts.
1102
+ */
1103
+ export async function getProgramsForAssignment() {
1104
+ await requireAuth();
1105
+ try {
1106
+ const { getDb } = await import('../db/index.js');
1107
+ const db = getDb();
1108
+ const rows = db.prepare(`
1109
+ SELECT p.id, p.name, p.platform, p.status, p.url,
1110
+ p.min_bounty, p.max_bounty,
1111
+ COUNT(t.id) as target_count,
1112
+ SUM(CASE WHEN t.status = 'in_scope' THEN 1 ELSE 0 END) as in_scope_count
1113
+ FROM programs p
1114
+ LEFT JOIN targets t ON t.program_id = p.id
1115
+ GROUP BY p.id
1116
+ ORDER BY target_count DESC, p.name ASC
1117
+ `).all();
1118
+ return rows.map(r => ({
1119
+ id: r.id, name: r.name, platform: r.platform, status: r.status, url: r.url,
1120
+ minBounty: r.min_bounty, maxBounty: r.max_bounty,
1121
+ targetCount: r.target_count, inScopeCount: r.in_scope_count,
1122
+ }));
1123
+ } catch (err) {
1124
+ return [];
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Get targets for a list of program IDs (for agent deep dive).
1130
+ */
1131
+ export async function getTargetsForPrograms(programIds) {
1132
+ await requireAuth();
1133
+ if (!Array.isArray(programIds) || programIds.length === 0) return [];
1134
+ try {
1135
+ const { getDb } = await import('../db/index.js');
1136
+ const db = getDb();
1137
+ const placeholders = programIds.map(() => '?').join(',');
1138
+ const rows = db.prepare(`
1139
+ SELECT t.*, p.name as program_name, p.platform as program_platform
1140
+ FROM targets t
1141
+ JOIN programs p ON p.id = t.program_id
1142
+ WHERE t.program_id IN (${placeholders})
1143
+ AND t.status = 'in_scope'
1144
+ ORDER BY p.name, t.type, t.value
1145
+ `).all(...programIds);
1146
+ return rows;
1147
+ } catch {
1148
+ return [];
1149
+ }
1150
+ }
1151
+
989
1152
  // ─────────────────────────────────────────────────────────────────────────────
990
1153
  // Swarm actions
991
1154
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2,8 +2,22 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect } from "react";
4
4
  import { motion, AnimatePresence } from "framer-motion";
5
- import { UsersIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, PlusIcon, PencilIcon, CheckIcon, XIcon, SearchIcon, CpuIcon, PackageIcon, DatabaseIcon, FolderIcon, ClockIcon, SlidersIcon, TagIcon, HashIcon, PlayIcon } from "./icons.js";
6
- import { getAgentProfilesWithStatus, getAgentProfile, updateAgentFile, createAgent, createAgentJob, getAvailableMcpTools, getLlmProviders } from "../actions.js";
5
+ import { UsersIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, PlusIcon, PencilIcon, CheckIcon, XIcon, SearchIcon, CpuIcon, PackageIcon, DatabaseIcon, FolderIcon, ClockIcon, SlidersIcon, TagIcon, HashIcon, PlayIcon, CrosshairIcon, ShieldIcon, GlobeIcon, FilterIcon } from "./icons.js";
6
+ import {
7
+ getAgentProfilesWithStatus,
8
+ getAgentProfile,
9
+ updateAgentFile,
10
+ createAgent,
11
+ createAgentJob,
12
+ getAvailableMcpTools,
13
+ getLlmProviders,
14
+ getAgentAssignments,
15
+ getAllAgentAssignments,
16
+ assignProgramsToAgent,
17
+ unassignProgramFromAgent,
18
+ getProgramsForAssignment,
19
+ getTargetsForPrograms
20
+ } from "../actions.js";
7
21
  const PROVIDER_MODELS = {
8
22
  anthropic: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001"],
9
23
  openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o1-mini", "o3-mini"],
@@ -86,10 +100,11 @@ Describe the agent's primary objective here.
86
100
  - Step 2
87
101
  - Step 3`
88
102
  };
89
- function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
103
+ function AgentCard({ agent, onViewProfile, onAssignTask, onAssignPrograms, assignments, index }) {
90
104
  const codename = agent.codename || agent.name || agent.id;
91
105
  const initial = codename.charAt(0).toUpperCase();
92
106
  const isActive = agent.status === "active";
107
+ const programCount = assignments?.programs?.length || 0;
93
108
  return /* @__PURE__ */ jsx(
94
109
  motion.div,
95
110
  {
@@ -100,7 +115,7 @@ function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
100
115
  children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 p-4", children: [
101
116
  /* @__PURE__ */ jsx("div", { className: "shrink-0 w-12 h-12 rounded-lg bg-[--cyan]/10 border border-[--cyan]/20 flex items-center justify-center", children: /* @__PURE__ */ jsx("span", { className: "text-xl font-mono font-bold text-[--cyan]", children: initial }) }),
102
117
  /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
103
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
118
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [
104
119
  /* @__PURE__ */ jsxs("p", { className: "text-sm font-mono font-semibold text-foreground", children: [
105
120
  "@",
106
121
  codename.toUpperCase()
@@ -111,25 +126,43 @@ function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
111
126
  " job",
112
127
  agent.activeJobs !== 1 ? "s" : "",
113
128
  " running"
129
+ ] }),
130
+ programCount > 0 && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-orange-500/10 text-orange-400 border border-orange-500/20 px-2 py-0.5 text-[9px] font-mono font-medium", children: [
131
+ /* @__PURE__ */ jsx(CrosshairIcon, { size: 8 }),
132
+ " ",
133
+ programCount,
134
+ " program",
135
+ programCount !== 1 ? "s" : ""
114
136
  ] })
115
137
  ] }),
116
138
  agent.role && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mt-0.5 font-mono truncate", children: agent.role }),
117
139
  agent.specialization && /* @__PURE__ */ jsx("p", { className: "text-[10px] text-muted-foreground/70 mt-0.5 font-mono truncate", children: agent.specialization })
118
140
  ] }),
119
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [
141
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 shrink-0", children: [
120
142
  /* @__PURE__ */ jsx(
121
143
  "button",
122
144
  {
123
145
  onClick: () => onViewProfile(agent.id),
124
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors",
146
+ className: "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors",
125
147
  children: "View"
126
148
  }
127
149
  ),
150
+ /* @__PURE__ */ jsxs(
151
+ "button",
152
+ {
153
+ onClick: () => onAssignPrograms(agent),
154
+ className: "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium border border-orange-500/20 text-orange-400 hover:bg-orange-500/10 transition-colors",
155
+ children: [
156
+ /* @__PURE__ */ jsx(CrosshairIcon, { size: 10 }),
157
+ " Programs"
158
+ ]
159
+ }
160
+ ),
128
161
  /* @__PURE__ */ jsx(
129
162
  "button",
130
163
  {
131
164
  onClick: () => onAssignTask(agent),
132
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors",
165
+ className: "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors",
133
166
  children: "Assign Task"
134
167
  }
135
168
  )
@@ -345,6 +378,350 @@ function AssignTaskDialog({ agent, onClose }) {
345
378
  }
346
379
  );
347
380
  }
381
+ function ProgramAssignmentWizard({ agent, currentAssignments, onClose, onAssigned }) {
382
+ const [programs, setPrograms] = useState([]);
383
+ const [loading, setLoading] = useState(true);
384
+ const [search, setSearch] = useState("");
385
+ const [selected, setSelected] = useState(new Set(currentAssignments?.programs || []));
386
+ const [saving, setSaving] = useState(false);
387
+ const [step, setStep] = useState("select");
388
+ const [deepDiveProgram, setDeepDiveProgram] = useState(null);
389
+ const [deepDiveTargets, setDeepDiveTargets] = useState([]);
390
+ const [loadingTargets, setLoadingTargets] = useState(false);
391
+ const [filter, setFilter] = useState("all");
392
+ useEffect(() => {
393
+ getProgramsForAssignment().then((p) => {
394
+ setPrograms(p);
395
+ setLoading(false);
396
+ });
397
+ }, []);
398
+ const codename = agent.codename || agent.name || agent.id;
399
+ function toggleProgram(id) {
400
+ const next = new Set(selected);
401
+ if (next.has(id)) next.delete(id);
402
+ else next.add(id);
403
+ setSelected(next);
404
+ }
405
+ async function handleSave() {
406
+ setSaving(true);
407
+ const ids = [...selected];
408
+ await assignProgramsToAgent(agent.id, ids);
409
+ setSaving(false);
410
+ onAssigned();
411
+ onClose();
412
+ }
413
+ async function handleDeepDive(program) {
414
+ setDeepDiveProgram(program);
415
+ setStep("deepdive");
416
+ setLoadingTargets(true);
417
+ const targets = await getTargetsForPrograms([program.id]);
418
+ setDeepDiveTargets(targets);
419
+ setLoadingTargets(false);
420
+ }
421
+ async function handleRemoveProgram(programId) {
422
+ await unassignProgramFromAgent(agent.id, programId);
423
+ const next = new Set(selected);
424
+ next.delete(programId);
425
+ setSelected(next);
426
+ onAssigned();
427
+ }
428
+ const filtered = programs.filter((p) => {
429
+ if (search) {
430
+ const q = search.toLowerCase();
431
+ if (!p.name.toLowerCase().includes(q) && !p.platform.toLowerCase().includes(q)) return false;
432
+ }
433
+ if (filter === "with-targets" && p.targetCount === 0) return false;
434
+ if (filter === "bounty" && (!p.maxBounty || p.maxBounty === 0)) return false;
435
+ return true;
436
+ });
437
+ const sorted = [...filtered].sort((a, b) => {
438
+ const aS = selected.has(a.id) ? 1 : 0;
439
+ const bS = selected.has(b.id) ? 1 : 0;
440
+ if (bS !== aS) return bS - aS;
441
+ return b.targetCount - a.targetCount;
442
+ });
443
+ const selectedPrograms = programs.filter((p) => selected.has(p.id));
444
+ const totalTargets = selectedPrograms.reduce((sum, p) => sum + p.targetCount, 0);
445
+ return /* @__PURE__ */ jsxs(
446
+ motion.div,
447
+ {
448
+ initial: { opacity: 0, scale: 0.95 },
449
+ animate: { opacity: 1, scale: 1 },
450
+ exit: { opacity: 0, scale: 0.95 },
451
+ className: "fixed inset-0 z-50 flex items-center justify-center p-4",
452
+ children: [
453
+ /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/60", onClick: onClose }),
454
+ /* @__PURE__ */ jsxs("div", { className: "relative w-full max-w-2xl max-h-[85vh] rounded-lg border border-orange-500/20 bg-[--card] shadow-2xl flex flex-col overflow-hidden", children: [
455
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between p-4 border-b border-white/[0.06] shrink-0", children: [
456
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
457
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
458
+ /* @__PURE__ */ jsx("div", { className: "w-2 h-2 rounded-full bg-[#ff5f57]" }),
459
+ /* @__PURE__ */ jsx("div", { className: "w-2 h-2 rounded-full bg-[#febc2e]" }),
460
+ /* @__PURE__ */ jsx("div", { className: "w-2 h-2 rounded-full bg-[#28c840]" })
461
+ ] }),
462
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px] font-medium text-orange-400 uppercase tracking-wider ml-1", children: step === "deepdive" ? `Deep Dive \u2014 ${deepDiveProgram?.name}` : step === "review" ? `Review \u2014 @${codename.toUpperCase()}` : `Assign Programs \u2014 @${codename.toUpperCase()}` })
463
+ ] }),
464
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
465
+ step !== "select" && /* @__PURE__ */ jsx(
466
+ "button",
467
+ {
468
+ onClick: () => setStep("select"),
469
+ className: "text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors",
470
+ children: "\u2190 Back"
471
+ }
472
+ ),
473
+ /* @__PURE__ */ jsx("button", { onClick: onClose, className: "text-muted-foreground hover:text-foreground", children: /* @__PURE__ */ jsx(XIcon, { size: 14 }) })
474
+ ] })
475
+ ] }),
476
+ step === "select" && /* @__PURE__ */ jsxs(Fragment, { children: [
477
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-2.5 border-b border-white/[0.06] bg-black/20 shrink-0", children: [
478
+ /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-muted-foreground", children: [
479
+ selected.size,
480
+ " program",
481
+ selected.size !== 1 ? "s" : "",
482
+ " selected"
483
+ ] }),
484
+ /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-orange-400", children: [
485
+ totalTargets,
486
+ " targets"
487
+ ] }),
488
+ /* @__PURE__ */ jsx("div", { className: "flex-1" }),
489
+ /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1", children: ["all", "with-targets", "bounty"].map((f) => /* @__PURE__ */ jsx(
490
+ "button",
491
+ {
492
+ onClick: () => setFilter(f),
493
+ className: `px-2 py-0.5 text-[9px] font-mono rounded-full border transition-colors ${filter === f ? "bg-orange-500/10 text-orange-400 border-orange-500/20" : "border-white/[0.06] text-muted-foreground hover:text-foreground"}`,
494
+ children: f === "all" ? "All" : f === "with-targets" ? "Has Targets" : "Bounty"
495
+ },
496
+ f
497
+ )) })
498
+ ] }),
499
+ /* @__PURE__ */ jsx("div", { className: "px-4 pt-3 pb-2 shrink-0", children: /* @__PURE__ */ jsxs("div", { className: "relative", children: [
500
+ /* @__PURE__ */ jsx("div", { className: "absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground", children: /* @__PURE__ */ jsx(SearchIcon, { size: 14 }) }),
501
+ /* @__PURE__ */ jsx(
502
+ "input",
503
+ {
504
+ placeholder: "Search programs...",
505
+ value: search,
506
+ onChange: (e) => setSearch(e.target.value),
507
+ className: "w-full text-sm border border-white/[0.06] rounded-md pl-9 pr-3 py-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-orange-500/40 focus:ring-1 focus:ring-orange-500/20 transition-colors",
508
+ autoFocus: true
509
+ }
510
+ )
511
+ ] }) }),
512
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto px-4 py-2 min-h-0", children: loading ? /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: [...Array(5)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-14 animate-shimmer rounded-lg border border-white/[0.06]" }, i)) }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
513
+ sorted.map((p) => {
514
+ const isSelected = selected.has(p.id);
515
+ return /* @__PURE__ */ jsxs(
516
+ "div",
517
+ {
518
+ className: `flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-all ${isSelected ? "border-orange-500/30 bg-orange-500/5" : "border-white/[0.06] hover:border-white/[0.12]"}`,
519
+ onClick: () => toggleProgram(p.id),
520
+ children: [
521
+ /* @__PURE__ */ jsx("div", { className: `w-5 h-5 rounded border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "bg-orange-500 border-orange-500" : "border-white/[0.15]"}`, children: isSelected && /* @__PURE__ */ jsx(CheckIcon, { size: 12, className: "text-white" }) }),
522
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
523
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
524
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-mono font-medium truncate", children: p.name }),
525
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-white/[0.04] px-1.5 py-0 text-[9px] font-mono text-muted-foreground border border-white/[0.06] shrink-0", children: p.platform })
526
+ ] }),
527
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mt-0.5", children: [
528
+ /* @__PURE__ */ jsxs("span", { className: "text-[9px] font-mono text-muted-foreground", children: [
529
+ p.targetCount,
530
+ " targets"
531
+ ] }),
532
+ p.inScopeCount > 0 && /* @__PURE__ */ jsxs("span", { className: "text-[9px] font-mono text-green-500", children: [
533
+ p.inScopeCount,
534
+ " in scope"
535
+ ] }),
536
+ p.maxBounty > 0 && /* @__PURE__ */ jsxs("span", { className: "text-[9px] font-mono text-yellow-500", children: [
537
+ "$",
538
+ p.minBounty || 0,
539
+ "\u2013$",
540
+ p.maxBounty
541
+ ] })
542
+ ] })
543
+ ] }),
544
+ /* @__PURE__ */ jsxs(
545
+ "button",
546
+ {
547
+ onClick: (e) => {
548
+ e.stopPropagation();
549
+ handleDeepDive(p);
550
+ },
551
+ className: "shrink-0 inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-orange-400 border border-white/[0.06] hover:border-orange-500/20 transition-colors",
552
+ children: [
553
+ /* @__PURE__ */ jsx(SearchIcon, { size: 10 }),
554
+ " Deep Dive"
555
+ ]
556
+ }
557
+ )
558
+ ]
559
+ },
560
+ p.id
561
+ );
562
+ }),
563
+ sorted.length === 0 && /* @__PURE__ */ jsx("div", { className: "text-center py-8", children: /* @__PURE__ */ jsx("p", { className: "text-sm font-mono text-muted-foreground", children: "No programs found" }) })
564
+ ] }) }),
565
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-t border-white/[0.06] shrink-0 bg-black/20", children: [
566
+ /* @__PURE__ */ jsx(
567
+ "button",
568
+ {
569
+ onClick: () => setStep("review"),
570
+ disabled: selected.size === 0,
571
+ className: "text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50",
572
+ children: "Review Selection \u2192"
573
+ }
574
+ ),
575
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
576
+ /* @__PURE__ */ jsx(
577
+ "button",
578
+ {
579
+ onClick: onClose,
580
+ className: "inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground",
581
+ children: "Cancel"
582
+ }
583
+ ),
584
+ /* @__PURE__ */ jsxs(
585
+ "button",
586
+ {
587
+ onClick: handleSave,
588
+ disabled: saving || selected.size === 0,
589
+ className: "inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors disabled:opacity-50",
590
+ children: [
591
+ saving ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }) : /* @__PURE__ */ jsx(CheckIcon, { size: 12 }),
592
+ "Assign ",
593
+ selected.size,
594
+ " Program",
595
+ selected.size !== 1 ? "s" : ""
596
+ ]
597
+ }
598
+ )
599
+ ] })
600
+ ] })
601
+ ] }),
602
+ step === "review" && /* @__PURE__ */ jsxs(Fragment, { children: [
603
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 overflow-y-auto px-4 py-4 min-h-0", children: [
604
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3 mb-4", children: [
605
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center p-3 rounded-lg border border-orange-500/20 bg-[--card]", children: [
606
+ /* @__PURE__ */ jsx("span", { className: "text-xl font-semibold text-orange-400 font-mono", children: selected.size }),
607
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-[9px] text-muted-foreground uppercase tracking-wider mt-0.5", children: "Programs" })
608
+ ] }),
609
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center p-3 rounded-lg border border-orange-500/20 bg-[--card]", children: [
610
+ /* @__PURE__ */ jsx("span", { className: "text-xl font-semibold text-orange-400 font-mono", children: totalTargets }),
611
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-[9px] text-muted-foreground uppercase tracking-wider mt-0.5", children: "Total Targets" })
612
+ ] })
613
+ ] }),
614
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px] font-medium text-orange-400 uppercase tracking-wider", children: "Assigned Programs" }),
615
+ /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2 mt-2", children: selectedPrograms.map((p) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 p-3 rounded-lg border border-orange-500/10 bg-orange-500/5", children: [
616
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
617
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-mono font-medium", children: p.name }),
618
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mt-0.5", children: [
619
+ /* @__PURE__ */ jsx("span", { className: "text-[10px] font-mono text-muted-foreground", children: p.platform }),
620
+ /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-green-500", children: [
621
+ p.targetCount,
622
+ " targets"
623
+ ] }),
624
+ p.maxBounty > 0 && /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-yellow-500", children: [
625
+ "$",
626
+ p.minBounty || 0,
627
+ "\u2013$",
628
+ p.maxBounty
629
+ ] })
630
+ ] })
631
+ ] }),
632
+ /* @__PURE__ */ jsx(
633
+ "button",
634
+ {
635
+ onClick: () => handleDeepDive(p),
636
+ className: "shrink-0 text-[10px] font-mono text-muted-foreground hover:text-orange-400 transition-colors",
637
+ children: "Deep Dive"
638
+ }
639
+ ),
640
+ /* @__PURE__ */ jsx(
641
+ "button",
642
+ {
643
+ onClick: () => handleRemoveProgram(p.id),
644
+ className: "shrink-0 text-muted-foreground hover:text-[--destructive] transition-colors",
645
+ children: /* @__PURE__ */ jsx(XIcon, { size: 12 })
646
+ }
647
+ )
648
+ ] }, p.id)) })
649
+ ] }),
650
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-end px-4 py-3 border-t border-white/[0.06] shrink-0", children: /* @__PURE__ */ jsxs(
651
+ "button",
652
+ {
653
+ onClick: handleSave,
654
+ disabled: saving,
655
+ className: "inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors disabled:opacity-50",
656
+ children: [
657
+ saving ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }) : /* @__PURE__ */ jsx(CheckIcon, { size: 12 }),
658
+ " Save Assignments"
659
+ ]
660
+ }
661
+ ) })
662
+ ] }),
663
+ step === "deepdive" && deepDiveProgram && /* @__PURE__ */ jsxs(Fragment, { children: [
664
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3 border-b border-white/[0.06] bg-black/20 shrink-0", children: [
665
+ /* @__PURE__ */ jsxs("div", { className: "flex-1", children: [
666
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-mono font-medium", children: deepDiveProgram.name }),
667
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mt-0.5", children: [
668
+ /* @__PURE__ */ jsx("span", { className: "text-[10px] font-mono text-muted-foreground", children: deepDiveProgram.platform }),
669
+ /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-green-500", children: [
670
+ deepDiveProgram.targetCount,
671
+ " targets (",
672
+ deepDiveProgram.inScopeCount,
673
+ " in scope)"
674
+ ] }),
675
+ deepDiveProgram.maxBounty > 0 && /* @__PURE__ */ jsxs("span", { className: "text-[10px] font-mono text-yellow-500", children: [
676
+ "$",
677
+ deepDiveProgram.minBounty || 0,
678
+ "\u2013$",
679
+ deepDiveProgram.maxBounty
680
+ ] }),
681
+ deepDiveProgram.url && /* @__PURE__ */ jsx("a", { href: deepDiveProgram.url, target: "_blank", rel: "noopener", className: "text-[10px] font-mono text-[--cyan] hover:underline", children: "View on Platform \u2192" })
682
+ ] })
683
+ ] }),
684
+ !selected.has(deepDiveProgram.id) ? /* @__PURE__ */ jsxs(
685
+ "button",
686
+ {
687
+ onClick: () => {
688
+ toggleProgram(deepDiveProgram.id);
689
+ },
690
+ className: "inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors",
691
+ children: [
692
+ /* @__PURE__ */ jsx(PlusIcon, { size: 12 }),
693
+ " Assign to Agent"
694
+ ]
695
+ }
696
+ ) : /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-orange-500/10 text-orange-400 border border-orange-500/20 px-2 py-0.5 text-[10px] font-mono", children: [
697
+ /* @__PURE__ */ jsx(CheckIcon, { size: 10 }),
698
+ " Assigned"
699
+ ] })
700
+ ] }),
701
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto px-4 py-3 min-h-0", children: loadingTargets ? /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: [...Array(5)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-10 animate-shimmer rounded-lg border border-white/[0.06]" }, i)) }) : deepDiveTargets.length === 0 ? /* @__PURE__ */ jsx("div", { className: "text-center py-8", children: /* @__PURE__ */ jsx("p", { className: "text-sm font-mono text-muted-foreground", children: "No in-scope targets found for this program" }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
702
+ (() => {
703
+ const types = {};
704
+ deepDiveTargets.forEach((t) => {
705
+ types[t.type] = (types[t.type] || 0) + 1;
706
+ });
707
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5 mb-3", children: Object.entries(types).sort((a, b) => b[1] - a[1]).map(([type, count]) => /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center rounded-full bg-white/[0.04] px-2 py-0.5 text-[9px] font-mono border border-white/[0.06]", children: [
708
+ type,
709
+ " ",
710
+ /* @__PURE__ */ jsx("span", { className: "text-[--cyan] ml-1", children: count })
711
+ ] }, type)) });
712
+ })(),
713
+ /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", children: deepDiveTargets.map((t, i) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md border border-white/[0.04] hover:border-white/[0.08] transition-colors", children: [
714
+ /* @__PURE__ */ jsx("span", { className: `inline-flex items-center rounded-full px-1.5 py-0 text-[8px] font-mono border shrink-0 ${t.type === "domain" ? "bg-blue-500/10 text-blue-400 border-blue-500/20" : t.type === "wildcard" ? "bg-purple-500/10 text-purple-400 border-purple-500/20" : t.type === "url" ? "bg-green-500/10 text-green-400 border-green-500/20" : t.type === "ip" || t.type === "cidr" ? "bg-orange-500/10 text-orange-400 border-orange-500/20" : "bg-white/[0.04] text-muted-foreground border-white/[0.06]"}`, children: t.type }),
715
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-mono text-foreground/90 flex-1 truncate", children: t.value }),
716
+ t.notes && /* @__PURE__ */ jsx("span", { className: "text-[9px] font-mono text-muted-foreground/60 shrink-0 truncate max-w-[120px]", children: t.notes })
717
+ ] }, t.id || i)) })
718
+ ] }) })
719
+ ] })
720
+ ] })
721
+ ]
722
+ }
723
+ );
724
+ }
348
725
  function TabBtn({ active, onClick, icon: Icon, label }) {
349
726
  return /* @__PURE__ */ jsxs(
350
727
  "button",
@@ -798,17 +1175,30 @@ function AgentsPage() {
798
1175
  const [refreshing, setRefreshing] = useState(false);
799
1176
  const [viewingProfile, setViewingProfile] = useState(null);
800
1177
  const [assigningTask, setAssigningTask] = useState(null);
1178
+ const [assigningPrograms, setAssigningPrograms] = useState(null);
801
1179
  const [showCreate, setShowCreate] = useState(false);
802
1180
  const [search, setSearch] = useState("");
1181
+ const [allAssignments, setAllAssignments] = useState({});
803
1182
  async function load() {
804
1183
  try {
805
- const a = await getAgentProfilesWithStatus();
1184
+ const [a, assignments] = await Promise.all([
1185
+ getAgentProfilesWithStatus(),
1186
+ getAllAgentAssignments()
1187
+ ]);
806
1188
  setAgents(a);
1189
+ setAllAssignments(assignments || {});
807
1190
  } catch {
808
1191
  }
809
1192
  setLoading(false);
810
1193
  setRefreshing(false);
811
1194
  }
1195
+ async function refreshAssignments() {
1196
+ try {
1197
+ const assignments = await getAllAgentAssignments();
1198
+ setAllAssignments(assignments || {});
1199
+ } catch {
1200
+ }
1201
+ }
812
1202
  useEffect(() => {
813
1203
  load();
814
1204
  }, []);
@@ -908,9 +1298,29 @@ function AgentsPage() {
908
1298
  ] }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center py-12 text-center", children: [
909
1299
  /* @__PURE__ */ jsx("div", { className: "rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4", children: /* @__PURE__ */ jsx(SearchIcon, { size: 24 }) }),
910
1300
  /* @__PURE__ */ jsx("p", { className: "text-sm font-mono text-muted-foreground", children: "No agents match your search." })
911
- ] }) : filtered.map((agent, i) => /* @__PURE__ */ jsx(AgentCard, { agent, onViewProfile: setViewingProfile, onAssignTask: setAssigningTask, index: i }, agent.id)) }),
1301
+ ] }) : filtered.map((agent, i) => /* @__PURE__ */ jsx(
1302
+ AgentCard,
1303
+ {
1304
+ agent,
1305
+ onViewProfile: setViewingProfile,
1306
+ onAssignTask: setAssigningTask,
1307
+ onAssignPrograms: setAssigningPrograms,
1308
+ assignments: allAssignments[agent.id],
1309
+ index: i
1310
+ },
1311
+ agent.id
1312
+ )) }),
912
1313
  /* @__PURE__ */ jsx(AnimatePresence, { children: viewingProfile && /* @__PURE__ */ jsx("div", { className: "mt-4", children: /* @__PURE__ */ jsx(AgentProfilePanel, { agentId: viewingProfile, onClose: () => setViewingProfile(null) }) }) }),
913
- /* @__PURE__ */ jsx(AnimatePresence, { children: assigningTask && /* @__PURE__ */ jsx(AssignTaskDialog, { agent: assigningTask, onClose: () => setAssigningTask(null) }) })
1314
+ /* @__PURE__ */ jsx(AnimatePresence, { children: assigningTask && /* @__PURE__ */ jsx(AssignTaskDialog, { agent: assigningTask, onClose: () => setAssigningTask(null) }) }),
1315
+ /* @__PURE__ */ jsx(AnimatePresence, { children: assigningPrograms && /* @__PURE__ */ jsx(
1316
+ ProgramAssignmentWizard,
1317
+ {
1318
+ agent: assigningPrograms,
1319
+ currentAssignments: allAssignments[assigningPrograms.id],
1320
+ onClose: () => setAssigningPrograms(null),
1321
+ onAssigned: refreshAssignments
1322
+ }
1323
+ ) })
914
1324
  ] });
915
1325
  }
916
1326
  export {
@@ -2,8 +2,13 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import { motion, AnimatePresence } from 'framer-motion';
5
- import { UsersIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, PlusIcon, PencilIcon, CheckIcon, XIcon, SearchIcon, CpuIcon, PackageIcon, DatabaseIcon, FolderIcon, ClockIcon, SlidersIcon, TagIcon, HashIcon, PlayIcon } from './icons.js';
6
- import { getAgentProfilesWithStatus, getAgentProfile, updateAgentFile, createAgent, createAgentJob, getAvailableMcpTools, getLlmProviders } from '../actions.js';
5
+ import { UsersIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, PlusIcon, PencilIcon, CheckIcon, XIcon, SearchIcon, CpuIcon, PackageIcon, DatabaseIcon, FolderIcon, ClockIcon, SlidersIcon, TagIcon, HashIcon, PlayIcon, CrosshairIcon, ShieldIcon, GlobeIcon, FilterIcon } from './icons.js';
6
+ import {
7
+ getAgentProfilesWithStatus, getAgentProfile, updateAgentFile, createAgent, createAgentJob,
8
+ getAvailableMcpTools, getLlmProviders,
9
+ getAgentAssignments, getAllAgentAssignments, assignProgramsToAgent,
10
+ unassignProgramFromAgent, getProgramsForAssignment, getTargetsForPrograms,
11
+ } from '../actions.js';
7
12
 
8
13
  const PROVIDER_MODELS = {
9
14
  anthropic: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-haiku-4-5-20251001'],
@@ -24,10 +29,11 @@ const SOUL_TEMPLATES = {
24
29
 
25
30
  // ─── Agent Card ──────────────────────────────────────────────────────────────
26
31
 
27
- function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
32
+ function AgentCard({ agent, onViewProfile, onAssignTask, onAssignPrograms, assignments, index }) {
28
33
  const codename = agent.codename || agent.name || agent.id;
29
34
  const initial = codename.charAt(0).toUpperCase();
30
35
  const isActive = agent.status === 'active';
36
+ const programCount = assignments?.programs?.length || 0;
31
37
 
32
38
  return (
33
39
  <motion.div
@@ -43,7 +49,7 @@ function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
43
49
  <span className="text-xl font-mono font-bold text-[--cyan]">{initial}</span>
44
50
  </div>
45
51
  <div className="flex-1 min-w-0">
46
- <div className="flex items-center gap-2">
52
+ <div className="flex items-center gap-2 flex-wrap">
47
53
  <p className="text-sm font-mono font-semibold text-foreground">@{codename.toUpperCase()}</p>
48
54
  <div className={`w-2 h-2 rounded-full shrink-0 ${isActive ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
49
55
  {isActive && (
@@ -51,17 +57,26 @@ function AgentCard({ agent, onViewProfile, onAssignTask, index }) {
51
57
  {agent.activeJobs} job{agent.activeJobs !== 1 ? 's' : ''} running
52
58
  </span>
53
59
  )}
60
+ {programCount > 0 && (
61
+ <span className="inline-flex items-center gap-1 rounded-full bg-orange-500/10 text-orange-400 border border-orange-500/20 px-2 py-0.5 text-[9px] font-mono font-medium">
62
+ <CrosshairIcon size={8} /> {programCount} program{programCount !== 1 ? 's' : ''}
63
+ </span>
64
+ )}
54
65
  </div>
55
66
  {agent.role && <p className="text-xs text-muted-foreground mt-0.5 font-mono truncate">{agent.role}</p>}
56
67
  {agent.specialization && <p className="text-[10px] text-muted-foreground/70 mt-0.5 font-mono truncate">{agent.specialization}</p>}
57
68
  </div>
58
- <div className="flex items-center gap-2 shrink-0">
69
+ <div className="flex items-center gap-1.5 shrink-0">
59
70
  <button onClick={() => onViewProfile(agent.id)}
60
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors">
71
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors">
61
72
  View
62
73
  </button>
74
+ <button onClick={() => onAssignPrograms(agent)}
75
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium border border-orange-500/20 text-orange-400 hover:bg-orange-500/10 transition-colors">
76
+ <CrosshairIcon size={10} /> Programs
77
+ </button>
63
78
  <button onClick={() => onAssignTask(agent)}
64
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors">
79
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors">
65
80
  Assign Task
66
81
  </button>
67
82
  </div>
@@ -242,6 +257,339 @@ function AssignTaskDialog({ agent, onClose }) {
242
257
  );
243
258
  }
244
259
 
260
+ // ─── Program Assignment Wizard ───────────────────────────────────────────────
261
+
262
+ function ProgramAssignmentWizard({ agent, currentAssignments, onClose, onAssigned }) {
263
+ const [programs, setPrograms] = useState([]);
264
+ const [loading, setLoading] = useState(true);
265
+ const [search, setSearch] = useState('');
266
+ const [selected, setSelected] = useState(new Set(currentAssignments?.programs || []));
267
+ const [saving, setSaving] = useState(false);
268
+ const [step, setStep] = useState('select'); // 'select' | 'review' | 'deepdive'
269
+ const [deepDiveProgram, setDeepDiveProgram] = useState(null);
270
+ const [deepDiveTargets, setDeepDiveTargets] = useState([]);
271
+ const [loadingTargets, setLoadingTargets] = useState(false);
272
+ const [filter, setFilter] = useState('all'); // 'all', 'with-targets', 'bounty'
273
+
274
+ useEffect(() => {
275
+ getProgramsForAssignment().then(p => { setPrograms(p); setLoading(false); });
276
+ }, []);
277
+
278
+ const codename = agent.codename || agent.name || agent.id;
279
+
280
+ function toggleProgram(id) {
281
+ const next = new Set(selected);
282
+ if (next.has(id)) next.delete(id);
283
+ else next.add(id);
284
+ setSelected(next);
285
+ }
286
+
287
+ async function handleSave() {
288
+ setSaving(true);
289
+ const ids = [...selected];
290
+ await assignProgramsToAgent(agent.id, ids);
291
+ setSaving(false);
292
+ onAssigned();
293
+ onClose();
294
+ }
295
+
296
+ async function handleDeepDive(program) {
297
+ setDeepDiveProgram(program);
298
+ setStep('deepdive');
299
+ setLoadingTargets(true);
300
+ const targets = await getTargetsForPrograms([program.id]);
301
+ setDeepDiveTargets(targets);
302
+ setLoadingTargets(false);
303
+ }
304
+
305
+ async function handleRemoveProgram(programId) {
306
+ await unassignProgramFromAgent(agent.id, programId);
307
+ const next = new Set(selected);
308
+ next.delete(programId);
309
+ setSelected(next);
310
+ onAssigned();
311
+ }
312
+
313
+ // Filter programs
314
+ const filtered = programs.filter(p => {
315
+ if (search) {
316
+ const q = search.toLowerCase();
317
+ if (!p.name.toLowerCase().includes(q) && !p.platform.toLowerCase().includes(q)) return false;
318
+ }
319
+ if (filter === 'with-targets' && p.targetCount === 0) return false;
320
+ if (filter === 'bounty' && (!p.maxBounty || p.maxBounty === 0)) return false;
321
+ return true;
322
+ });
323
+
324
+ // Sort: selected first, then by target count
325
+ const sorted = [...filtered].sort((a, b) => {
326
+ const aS = selected.has(a.id) ? 1 : 0;
327
+ const bS = selected.has(b.id) ? 1 : 0;
328
+ if (bS !== aS) return bS - aS;
329
+ return b.targetCount - a.targetCount;
330
+ });
331
+
332
+ const selectedPrograms = programs.filter(p => selected.has(p.id));
333
+ const totalTargets = selectedPrograms.reduce((sum, p) => sum + p.targetCount, 0);
334
+
335
+ return (
336
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
337
+ className="fixed inset-0 z-50 flex items-center justify-center p-4">
338
+ <div className="absolute inset-0 bg-black/60" onClick={onClose} />
339
+ <div className="relative w-full max-w-2xl max-h-[85vh] rounded-lg border border-orange-500/20 bg-[--card] shadow-2xl flex flex-col overflow-hidden">
340
+ {/* Header */}
341
+ <div className="flex items-center justify-between p-4 border-b border-white/[0.06] shrink-0">
342
+ <div className="flex items-center gap-2">
343
+ <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ff5f57]" /><div className="w-2 h-2 rounded-full bg-[#febc2e]" /><div className="w-2 h-2 rounded-full bg-[#28c840]" /></div>
344
+ <span className="font-mono text-[10px] font-medium text-orange-400 uppercase tracking-wider ml-1">
345
+ {step === 'deepdive'
346
+ ? `Deep Dive — ${deepDiveProgram?.name}`
347
+ : step === 'review'
348
+ ? `Review — @${codename.toUpperCase()}`
349
+ : `Assign Programs — @${codename.toUpperCase()}`}
350
+ </span>
351
+ </div>
352
+ <div className="flex items-center gap-2">
353
+ {step !== 'select' && (
354
+ <button onClick={() => setStep('select')}
355
+ className="text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors">
356
+ ← Back
357
+ </button>
358
+ )}
359
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground"><XIcon size={14} /></button>
360
+ </div>
361
+ </div>
362
+
363
+ {/* Step: Select programs */}
364
+ {step === 'select' && (
365
+ <>
366
+ {/* Selection summary bar */}
367
+ <div className="flex items-center gap-3 px-4 py-2.5 border-b border-white/[0.06] bg-black/20 shrink-0">
368
+ <span className="text-[10px] font-mono text-muted-foreground">
369
+ {selected.size} program{selected.size !== 1 ? 's' : ''} selected
370
+ </span>
371
+ <span className="text-[10px] font-mono text-orange-400">{totalTargets} targets</span>
372
+ <div className="flex-1" />
373
+ {/* Filters */}
374
+ <div className="flex items-center gap-1">
375
+ {['all', 'with-targets', 'bounty'].map(f => (
376
+ <button key={f} onClick={() => setFilter(f)}
377
+ className={`px-2 py-0.5 text-[9px] font-mono rounded-full border transition-colors ${
378
+ filter === f
379
+ ? 'bg-orange-500/10 text-orange-400 border-orange-500/20'
380
+ : 'border-white/[0.06] text-muted-foreground hover:text-foreground'
381
+ }`}>
382
+ {f === 'all' ? 'All' : f === 'with-targets' ? 'Has Targets' : 'Bounty'}
383
+ </button>
384
+ ))}
385
+ </div>
386
+ </div>
387
+
388
+ {/* Search */}
389
+ <div className="px-4 pt-3 pb-2 shrink-0">
390
+ <div className="relative">
391
+ <div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={14} /></div>
392
+ <input placeholder="Search programs..." value={search} onChange={e => setSearch(e.target.value)}
393
+ className="w-full text-sm border border-white/[0.06] rounded-md pl-9 pr-3 py-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-orange-500/40 focus:ring-1 focus:ring-orange-500/20 transition-colors" autoFocus />
394
+ </div>
395
+ </div>
396
+
397
+ {/* Program list */}
398
+ <div className="flex-1 overflow-y-auto px-4 py-2 min-h-0">
399
+ {loading ? (
400
+ <div className="flex flex-col gap-2">
401
+ {[...Array(5)].map((_, i) => <div key={i} className="h-14 animate-shimmer rounded-lg border border-white/[0.06]" />)}
402
+ </div>
403
+ ) : (
404
+ <div className="flex flex-col gap-1.5">
405
+ {sorted.map(p => {
406
+ const isSelected = selected.has(p.id);
407
+ return (
408
+ <div key={p.id}
409
+ className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-all ${
410
+ isSelected ? 'border-orange-500/30 bg-orange-500/5' : 'border-white/[0.06] hover:border-white/[0.12]'
411
+ }`}
412
+ onClick={() => toggleProgram(p.id)}
413
+ >
414
+ <div className={`w-5 h-5 rounded border flex items-center justify-center shrink-0 transition-colors ${
415
+ isSelected ? 'bg-orange-500 border-orange-500' : 'border-white/[0.15]'
416
+ }`}>
417
+ {isSelected && <CheckIcon size={12} className="text-white" />}
418
+ </div>
419
+ <div className="flex-1 min-w-0">
420
+ <div className="flex items-center gap-2">
421
+ <p className="text-xs font-mono font-medium truncate">{p.name}</p>
422
+ <span className="inline-flex items-center rounded-full bg-white/[0.04] px-1.5 py-0 text-[9px] font-mono text-muted-foreground border border-white/[0.06] shrink-0">
423
+ {p.platform}
424
+ </span>
425
+ </div>
426
+ <div className="flex items-center gap-3 mt-0.5">
427
+ <span className="text-[9px] font-mono text-muted-foreground">{p.targetCount} targets</span>
428
+ {p.inScopeCount > 0 && <span className="text-[9px] font-mono text-green-500">{p.inScopeCount} in scope</span>}
429
+ {p.maxBounty > 0 && <span className="text-[9px] font-mono text-yellow-500">${p.minBounty || 0}–${p.maxBounty}</span>}
430
+ </div>
431
+ </div>
432
+ <button
433
+ onClick={(e) => { e.stopPropagation(); handleDeepDive(p); }}
434
+ className="shrink-0 inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-orange-400 border border-white/[0.06] hover:border-orange-500/20 transition-colors"
435
+ >
436
+ <SearchIcon size={10} /> Deep Dive
437
+ </button>
438
+ </div>
439
+ );
440
+ })}
441
+ {sorted.length === 0 && (
442
+ <div className="text-center py-8">
443
+ <p className="text-sm font-mono text-muted-foreground">No programs found</p>
444
+ </div>
445
+ )}
446
+ </div>
447
+ )}
448
+ </div>
449
+
450
+ {/* Footer */}
451
+ <div className="flex items-center justify-between px-4 py-3 border-t border-white/[0.06] shrink-0 bg-black/20">
452
+ <button onClick={() => setStep('review')} disabled={selected.size === 0}
453
+ className="text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50">
454
+ Review Selection →
455
+ </button>
456
+ <div className="flex items-center gap-2">
457
+ <button onClick={onClose}
458
+ className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground">Cancel</button>
459
+ <button onClick={handleSave} disabled={saving || selected.size === 0}
460
+ className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors disabled:opacity-50">
461
+ {saving ? <SpinnerIcon size={12} /> : <CheckIcon size={12} />}
462
+ Assign {selected.size} Program{selected.size !== 1 ? 's' : ''}
463
+ </button>
464
+ </div>
465
+ </div>
466
+ </>
467
+ )}
468
+
469
+ {/* Step: Review */}
470
+ {step === 'review' && (
471
+ <>
472
+ <div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
473
+ <div className="grid grid-cols-2 gap-3 mb-4">
474
+ <div className="flex flex-col items-center p-3 rounded-lg border border-orange-500/20 bg-[--card]">
475
+ <span className="text-xl font-semibold text-orange-400 font-mono">{selected.size}</span>
476
+ <span className="font-mono text-[9px] text-muted-foreground uppercase tracking-wider mt-0.5">Programs</span>
477
+ </div>
478
+ <div className="flex flex-col items-center p-3 rounded-lg border border-orange-500/20 bg-[--card]">
479
+ <span className="text-xl font-semibold text-orange-400 font-mono">{totalTargets}</span>
480
+ <span className="font-mono text-[9px] text-muted-foreground uppercase tracking-wider mt-0.5">Total Targets</span>
481
+ </div>
482
+ </div>
483
+
484
+ <span className="font-mono text-[10px] font-medium text-orange-400 uppercase tracking-wider">Assigned Programs</span>
485
+ <div className="flex flex-col gap-2 mt-2">
486
+ {selectedPrograms.map(p => (
487
+ <div key={p.id} className="flex items-center gap-3 p-3 rounded-lg border border-orange-500/10 bg-orange-500/5">
488
+ <div className="flex-1 min-w-0">
489
+ <p className="text-sm font-mono font-medium">{p.name}</p>
490
+ <div className="flex items-center gap-3 mt-0.5">
491
+ <span className="text-[10px] font-mono text-muted-foreground">{p.platform}</span>
492
+ <span className="text-[10px] font-mono text-green-500">{p.targetCount} targets</span>
493
+ {p.maxBounty > 0 && <span className="text-[10px] font-mono text-yellow-500">${p.minBounty || 0}–${p.maxBounty}</span>}
494
+ </div>
495
+ </div>
496
+ <button onClick={() => handleDeepDive(p)}
497
+ className="shrink-0 text-[10px] font-mono text-muted-foreground hover:text-orange-400 transition-colors">
498
+ Deep Dive
499
+ </button>
500
+ <button onClick={() => handleRemoveProgram(p.id)}
501
+ className="shrink-0 text-muted-foreground hover:text-[--destructive] transition-colors"><XIcon size={12} /></button>
502
+ </div>
503
+ ))}
504
+ </div>
505
+ </div>
506
+ <div className="flex items-center justify-end px-4 py-3 border-t border-white/[0.06] shrink-0">
507
+ <button onClick={handleSave} disabled={saving}
508
+ className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors disabled:opacity-50">
509
+ {saving ? <SpinnerIcon size={12} /> : <CheckIcon size={12} />} Save Assignments
510
+ </button>
511
+ </div>
512
+ </>
513
+ )}
514
+
515
+ {/* Step: Deep Dive into a program's targets */}
516
+ {step === 'deepdive' && deepDiveProgram && (
517
+ <>
518
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.06] bg-black/20 shrink-0">
519
+ <div className="flex-1">
520
+ <p className="text-sm font-mono font-medium">{deepDiveProgram.name}</p>
521
+ <div className="flex items-center gap-3 mt-0.5">
522
+ <span className="text-[10px] font-mono text-muted-foreground">{deepDiveProgram.platform}</span>
523
+ <span className="text-[10px] font-mono text-green-500">{deepDiveProgram.targetCount} targets ({deepDiveProgram.inScopeCount} in scope)</span>
524
+ {deepDiveProgram.maxBounty > 0 && <span className="text-[10px] font-mono text-yellow-500">${deepDiveProgram.minBounty || 0}–${deepDiveProgram.maxBounty}</span>}
525
+ {deepDiveProgram.url && <a href={deepDiveProgram.url} target="_blank" rel="noopener" className="text-[10px] font-mono text-[--cyan] hover:underline">View on Platform →</a>}
526
+ </div>
527
+ </div>
528
+ {!selected.has(deepDiveProgram.id) ? (
529
+ <button onClick={() => { toggleProgram(deepDiveProgram.id); }}
530
+ className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20 hover:bg-orange-500 hover:text-white transition-colors">
531
+ <PlusIcon size={12} /> Assign to Agent
532
+ </button>
533
+ ) : (
534
+ <span className="inline-flex items-center gap-1 rounded-full bg-orange-500/10 text-orange-400 border border-orange-500/20 px-2 py-0.5 text-[10px] font-mono">
535
+ <CheckIcon size={10} /> Assigned
536
+ </span>
537
+ )}
538
+ </div>
539
+
540
+ <div className="flex-1 overflow-y-auto px-4 py-3 min-h-0">
541
+ {loadingTargets ? (
542
+ <div className="flex flex-col gap-2">
543
+ {[...Array(5)].map((_, i) => <div key={i} className="h-10 animate-shimmer rounded-lg border border-white/[0.06]" />)}
544
+ </div>
545
+ ) : deepDiveTargets.length === 0 ? (
546
+ <div className="text-center py-8">
547
+ <p className="text-sm font-mono text-muted-foreground">No in-scope targets found for this program</p>
548
+ </div>
549
+ ) : (
550
+ <>
551
+ {/* Type summary */}
552
+ {(() => {
553
+ const types = {};
554
+ deepDiveTargets.forEach(t => { types[t.type] = (types[t.type] || 0) + 1; });
555
+ return (
556
+ <div className="flex flex-wrap gap-1.5 mb-3">
557
+ {Object.entries(types).sort((a, b) => b[1] - a[1]).map(([type, count]) => (
558
+ <span key={type} className="inline-flex items-center rounded-full bg-white/[0.04] px-2 py-0.5 text-[9px] font-mono border border-white/[0.06]">
559
+ {type} <span className="text-[--cyan] ml-1">{count}</span>
560
+ </span>
561
+ ))}
562
+ </div>
563
+ );
564
+ })()}
565
+
566
+ <div className="flex flex-col gap-1">
567
+ {deepDiveTargets.map((t, i) => (
568
+ <div key={t.id || i} className="flex items-center gap-2 p-2 rounded-md border border-white/[0.04] hover:border-white/[0.08] transition-colors">
569
+ <span className={`inline-flex items-center rounded-full px-1.5 py-0 text-[8px] font-mono border shrink-0 ${
570
+ t.type === 'domain' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
571
+ : t.type === 'wildcard' ? 'bg-purple-500/10 text-purple-400 border-purple-500/20'
572
+ : t.type === 'url' ? 'bg-green-500/10 text-green-400 border-green-500/20'
573
+ : t.type === 'ip' || t.type === 'cidr' ? 'bg-orange-500/10 text-orange-400 border-orange-500/20'
574
+ : 'bg-white/[0.04] text-muted-foreground border-white/[0.06]'
575
+ }`}>
576
+ {t.type}
577
+ </span>
578
+ <span className="text-xs font-mono text-foreground/90 flex-1 truncate">{t.value}</span>
579
+ {t.notes && <span className="text-[9px] font-mono text-muted-foreground/60 shrink-0 truncate max-w-[120px]">{t.notes}</span>}
580
+ </div>
581
+ ))}
582
+ </div>
583
+ </>
584
+ )}
585
+ </div>
586
+ </>
587
+ )}
588
+ </div>
589
+ </motion.div>
590
+ );
591
+ }
592
+
245
593
  // ─── Tab Button ──────────────────────────────────────────────────────────────
246
594
 
247
595
  function TabBtn({ active, onClick, icon: Icon, label }) {
@@ -741,18 +1089,31 @@ export function AgentsPage() {
741
1089
  const [refreshing, setRefreshing] = useState(false);
742
1090
  const [viewingProfile, setViewingProfile] = useState(null);
743
1091
  const [assigningTask, setAssigningTask] = useState(null);
1092
+ const [assigningPrograms, setAssigningPrograms] = useState(null);
744
1093
  const [showCreate, setShowCreate] = useState(false);
745
1094
  const [search, setSearch] = useState('');
1095
+ const [allAssignments, setAllAssignments] = useState({});
746
1096
 
747
1097
  async function load() {
748
1098
  try {
749
- const a = await getAgentProfilesWithStatus();
1099
+ const [a, assignments] = await Promise.all([
1100
+ getAgentProfilesWithStatus(),
1101
+ getAllAgentAssignments(),
1102
+ ]);
750
1103
  setAgents(a);
1104
+ setAllAssignments(assignments || {});
751
1105
  } catch {}
752
1106
  setLoading(false);
753
1107
  setRefreshing(false);
754
1108
  }
755
1109
 
1110
+ async function refreshAssignments() {
1111
+ try {
1112
+ const assignments = await getAllAgentAssignments();
1113
+ setAllAssignments(assignments || {});
1114
+ } catch {}
1115
+ }
1116
+
756
1117
  useEffect(() => { load(); }, []);
757
1118
 
758
1119
  const activeCount = agents.filter(a => a.status === 'active').length;
@@ -848,7 +1209,10 @@ export function AgentsPage() {
848
1209
  </div>
849
1210
  ) : (
850
1211
  filtered.map((agent, i) => (
851
- <AgentCard key={agent.id} agent={agent} onViewProfile={setViewingProfile} onAssignTask={setAssigningTask} index={i} />
1212
+ <AgentCard key={agent.id} agent={agent}
1213
+ onViewProfile={setViewingProfile} onAssignTask={setAssigningTask}
1214
+ onAssignPrograms={setAssigningPrograms} assignments={allAssignments[agent.id]}
1215
+ index={i} />
852
1216
  ))
853
1217
  )}
854
1218
  </div>
@@ -864,6 +1228,18 @@ export function AgentsPage() {
864
1228
  <AnimatePresence>
865
1229
  {assigningTask && <AssignTaskDialog agent={assigningTask} onClose={() => setAssigningTask(null)} />}
866
1230
  </AnimatePresence>
1231
+
1232
+ {/* Program assignment wizard */}
1233
+ <AnimatePresence>
1234
+ {assigningPrograms && (
1235
+ <ProgramAssignmentWizard
1236
+ agent={assigningPrograms}
1237
+ currentAssignments={allAssignments[assigningPrograms.id]}
1238
+ onClose={() => setAssigningPrograms(null)}
1239
+ onAssigned={refreshAssignments}
1240
+ />
1241
+ )}
1242
+ </AnimatePresence>
867
1243
  </>
868
1244
  );
869
1245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harbinger-ai/harbinger",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {